SLOPSHOPPER

wrapped

Your Claude Code year in one shareable card

newpaneguardcommandprompt
★ 1v0.1.0MITupdated 2026-10-15Ron-Nefyodov/wrapped-mod
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · wrapped
│ ┃ Wrapped · 2025 ✕ › fix the failing auth test and add an audit log call │ ┃ ✦ CLAUDE CODE WRAPPED 2025 │ ┃ ⏺ Read(src/auth.ts) │ ┃ The Operator ⎿ Read 6 lines │ ┃ Early bird · peak 08:00 ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ 1 1 ⏺ Bash(bun test) │ ┃ prompts turns ⎿ 3 pass, 1 fail │ ┃ │ ┃ 7,880 9 ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ tokens tool calls │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ 1 1d │ ┃ active days longest streak › /wrapped │ ┃ │ ┃ TOP TOOLS │ ┃ Bash ████████████████████████████████████ │ ┃ Write ████████████████████ │ ┃ Edit ██████████ │ ┃ │ ┃ WHEN YOU CODE │ ┃ ██ │ ┃ │ ┃ 0 6 12 18 │ ┃ │ ┃ MOST EDITED audit.ts · 1 edit ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Wrapped · 2025
✦ CLAUDE CODE WRAPPED 2025 The Operator Early bird · peak 08:00 1 1 prompts turns 7,880 9 tokens tool calls 1 1d active days longest streak TOP TOOLS Bash ████████████████████████████████████████ 4 Write ████████████████████ 2 Edit ██████████ 1 WHEN YOU CODE ██ 0 6 12 18 MOST EDITED audit.ts · 1 edit 1 session /wrapped [year|month|all] · /wrapped export · Esc closes
README

Wrapped

Your Claude Code year in one shareable card.

Wrapped card: prompts, tokens, tool calls, top tools, when you code

Why

You spend hours a day with Claude Code. Wrapped shows you what that looks like: how much you ask, which tools do the work, which file you never stop editing, and when you code best.

  • One card. Every number on one screen, for this month, this year or all time.
  • Shareable. /wrapped export saves a 1200×630 SVG, ready for a post.
  • Fun. A title like The Refactorer and a persona like Night owl.
  • Private. Stats are counted on your machine. Nothing leaves it.

Use

CommandWhat
/wrappedThis year's card: prompts, turns, tokens, tool calls, active days, streak, top tools, peak hour, most edited file
/wrapped month / /wrapped allOther ranges
/wrapped exportWrites claude-wrapped-<year>.svg (1200×630) to the current folder — share it
/wrapped resetClears stats (asks you to confirm)

You also get a title (The Refactorer, The Explorer, The Operator, …) and a persona from your peak hour (Early bird, Night owl, …).

All stats stay on your machine, in the mod's local store.

Install

/plugin marketplace add Ron-Nefyodov/wrapped-mod /plugin install wrapped@wrapped

Or try it locally: claude --plugin-dir .

Develop

npm test # claude plugin test . claude plugin validate .

Source 8 files
hooks/register.js 138 lines
1import { drawWrappedPane } from './card.js';
2import { exportFileName, HELP_TEXT, isResetArmed, joinPath, normalizeRange, parseArgs, RANGE_KEY, RESET_KEY } from './commands.js';
3import {
4  editedPathOf, normalizeStats, recordPrompt, recordSessionMinutes, recordSessionStart, recordTool, recordTurn,
5  SESSION_KEY, STATS_KEY, summarize,
6} from './stats.js';
7import { enqueue, updateStats } from './stats-store.js';
8import { buildSvg } from './svg.js';
9
10const WRAPPED_PANE_ID = 'wrapped';
11const WRAPPED_COMMAND = {
12  name: 'wrapped',
13  description: 'Your Claude Code year in one card',
14  argumentHint: '[month|all|export|reset]',
15};
16
17export function register(on) {
18  on('session.start', async ($, e, next) => {
19    const result = await next(e);
20    try {
21      await $.command.register(WRAPPED_COMMAND);
22      const now = await $.clock.now();
23      await $.store.set(SESSION_KEY, now);
24      await updateStats({ get: (k) => $.store.get(k), set: (k, v) => $.store.set(k, v) }, (s) => recordSessionStart(s, now));
25    } catch {
26      // Stats are best effort; never block a session.
27    }
28    return result;
29  }).catch(($, e, next) => next(e));
30
31  on('session.end', async ($, e, next) => {
32    const result = await next(e);
33    try {
34      const startedAt = await $.store.get(SESSION_KEY);
35      if (typeof startedAt === 'number') {
36        const now = await $.clock.now();
37        await updateStats({ get: (k) => $.store.get(k), set: (k, v) => $.store.set(k, v) }, (s) => recordSessionMinutes(s, startedAt, now));
38        await $.store.delete(SESSION_KEY);
39      }
40    } catch {
41      // best effort
42    }
43    return result;
44  }).catch(($, e, next) => next(e));
45
46  on('prompt.submit', async ($, e, next) => {
47    const result = await next(e);
48    if (e.origin.kind === 'composer') {
49      try {
50        if (!result?.drop) {
51          const now = await $.clock.now();
52          await updateStats({ get: (k) => $.store.get(k), set: (k, v) => $.store.set(k, v) }, (s) => recordPrompt(s, now));
53        }
54      } catch {
55        // best effort
56      }
57    }
58    return result;
59  }).catch(($, e, next) => next(e));
60
61  on('turn.complete', async ($, e, next) => {
62    const result = await next(e);
63    try {
64      const now = await $.clock.now();
65      await updateStats({ get: (k) => $.store.get(k), set: (k, v) => $.store.set(k, v) }, (s) => recordTurn(s, now, e.usage, e.agentId === undefined));
66    } catch {
67      // best effort
68    }
69    return result;
70  }).catch(($, e, next) => next(e));
71
72  on('tool.call', async ($, e, next) => {
73    const result = await next(e);
74    try {
75      if (!result?.deny) {
76        const now = await $.clock.now();
77        const path = result?.isError ? undefined : editedPathOf(e);
78        await updateStats({ get: (k) => $.store.get(k), set: (k, v) => $.store.set(k, v) }, (s) => recordTool(s, now, e.tool, path));
79      }
80    } catch {
81      // best effort
82    }
83    return result;
84  }).catch(($, e, next) => next(e));
85
86  on('command.run', { command: 'wrapped' }, async ($, e, next) => {
87    const { action, range } = parseArgs(e.args);
88    const now = await $.clock.now();
89
90    if (action === 'help') return { text: HELP_TEXT };
91
92    if (action === 'reset') {
93      await $.store.set(RESET_KEY, now);
94      return { text: 'This deletes all Wrapped stats. To confirm, run /wrapped reset confirm within 60 seconds.' };
95    }
96
97    if (action === 'reset-confirm') {
98      if (!isResetArmed(await $.store.get(RESET_KEY), now)) {
99        return { text: 'Nothing to confirm. Run /wrapped reset first.' };
100      }
101      await enqueue(() => $.store.delete(STATS_KEY));
102      await $.store.delete(RESET_KEY);
103      return { text: 'Wrapped stats cleared.' };
104    }
105
106    const summary = summarize(normalizeStats(await $.store.get(STATS_KEY)), range, now);
107
108    if (action === 'export') {
109      const path = joinPath(await $.session.cwd(), exportFileName(range, now));
110      try {
111        await $.fs.write(path, buildSvg(summary, now));
112      } catch (error) {
113        return { text: `Could not save the Wrapped card to ${path}: ${error?.message ?? error}`, exitCode: 1 };
114      }
115      return { text: `Wrapped card saved: ${path}` };
116    }
117
118    await $.store.set(RANGE_KEY, range);
119    const opened = await $.ui.open({ id: WRAPPED_PANE_ID, title: `Wrapped · ${summary.label}`, focus: true, closeOnEscape: true, rows: 28 });
120    $.ui.invalidate('ui.render');
121    if (opened && opened.isPlaced === false) return { text: 'Wrapped needs more room. Make the terminal bigger and run /wrapped again.' };
122    return {};
123  }).catch(() => ({ text: 'Wrapped hit an error. Try again.', exitCode: 1 }));
124
125  on('ui.render', { component: 'Pane', requestId: 'wrapped' }, async ($, e, next) => {
126    if (e.surface !== 'terminal') return next(e);
127    try {
128      const range = normalizeRange(await $.store.get(RANGE_KEY));
129      const stats = normalizeStats(await $.store.get(STATS_KEY));
130      const summary = summarize(stats, range, await $.clock.now());
131      return drawWrappedPane(await $.ui.resolve(e), summary, e.props.bodyColumns);
132    } catch {
133      return next(e);
134    }
135  });
136
137}
138
hooks/card.js 133 lines
1import { bar, compact, duration, hourLabel, pad, padStart, plural, sparkline } from './format.js';
2
3export const ACCENT = 'claude';
4
5function tilesPerRow(width) {
6  if (width >= 120) return 6;
7  if (width >= 60) return 3;
8  if (width >= 40) return 2;
9  return 1;
10}
11
12export function statTilesOf(summary) {
13  return [
14    { id: 'prompts', value: compact(summary.prompts), label: 'prompts' },
15    { id: 'turns', value: compact(summary.turns), label: 'turns' },
16    { id: 'tokens', value: compact(summary.tokens), label: 'tokens' },
17    { id: 'tools', value: compact(summary.toolCalls), label: 'tool calls' },
18    { id: 'days', value: compact(summary.activeDays), label: 'active days' },
19    { id: 'streak', value: `${compact(summary.longestStreak)}d`, label: 'longest streak' },
20  ];
21}
22
23export function toolRowsOf(topTools, width) {
24  const nameWidth = Math.min(16, Math.max(4, ...topTools.map((tool) => tool.name.length)));
25  const counts = topTools.map((tool) => compact(tool.count));
26  const countWidth = Math.max(1, ...counts.map((count) => count.length));
27  const barWidth = Math.max(4, Math.min(40, width - nameWidth - countWidth - 6));
28  const max = topTools[0]?.count ?? 0;
29  return topTools.map((tool, i) => ({
30    name: pad(tool.name, nameWidth),
31    bar: pad(bar(tool.count, max, barWidth), barWidth),
32    count: padStart(counts[i], countWidth),
33  }));
34}
35
36export function hourAxisOf(cellWidth) {
37  const axis = Array.from({ length: 24 * cellWidth }, () => ' ');
38  for (const hour of [0, 6, 12, 18]) {
39    String(hour).split('').forEach((char, i) => { axis[hour * cellWidth + i] = char; });
40  }
41  return axis.join('').trimEnd();
42}
43
44export function drawWrappedPane({ Box, Text }, summary, columns) {
45  const width = Math.max(20, columns - 2);
46  const heading = (label) => Text({ bold: true, dimColor: true, children: [label] });
47  const header = Box({
48    key: 'wrapped-header', flexDirection: 'row', justifyContent: 'space-between',
49    children: [
50      Text({ bold: true, color: ACCENT, children: ['✦ CLAUDE CODE WRAPPED'] }),
51      Text({ bold: true, children: [summary.label] }),
52    ],
53  });
54  const footer = Text({ dimColor: true, children: ['/wrapped [year|month|all] · /wrapped export · Esc closes'] });
55
56  if (summary.isEmpty) {
57    return Box({
58      key: 'wrapped-card', flexDirection: 'column', paddingX: 1,
59      children: [
60        header,
61        Box({ key: 'wrapped-empty', marginY: 1, children: [Text({ children: [`No activity yet for ${summary.label}. Use Claude Code a bit and check back.`] })] }),
62        footer,
63      ],
64    });
65  }
66
67  const subtitle = [summary.persona, summary.peakHour === null ? null : `peak ${hourLabel(summary.peakHour)}`]
68    .filter(Boolean).join(' · ');
69  const identity = Box({
70    key: 'wrapped-identity', flexDirection: 'column', marginTop: 1,
71    children: [
72      Text({ bold: true, children: [summary.title] }),
73      Text({ color: ACCENT, children: [subtitle] }),
74    ],
75  });
76
77  const perRow = tilesPerRow(width);
78  const tileWidth = Math.floor(width / perRow);
79  const tiles = statTilesOf(summary).map((tile) => Box({
80    key: `wrapped-stat-${tile.id}`, flexDirection: 'column', width: tileWidth,
81    children: [
82      Text({ bold: true, color: ACCENT, children: [tile.value] }),
83      Text({ dimColor: true, children: [tile.label] }),
84    ],
85  }));
86  const rows = [];
87  for (let i = 0; i < tiles.length; i += perRow) {
88    rows.push(Box({ flexDirection: 'row', children: tiles.slice(i, i + perRow) }));
89  }
90  const stats = Box({ key: 'wrapped-stats', flexDirection: 'column', marginTop: 1, rowGap: 1, children: rows });
91
92  const toolLines = summary.topTools.length === 0
93    ? [Text({ dimColor: true, children: ['No tool calls yet'] })]
94    : toolRowsOf(summary.topTools, width).map((row) => Box({
95      flexDirection: 'row',
96      children: [
97        Text({ children: [`${row.name}  `] }),
98        Text({ color: ACCENT, children: [row.bar] }),
99        Text({ bold: true, children: [`  ${row.count}`] }),
100      ],
101    }));
102  const tools = Box({ key: 'wrapped-tools', flexDirection: 'column', marginTop: 1, children: [heading('TOP TOOLS'), ...toolLines] });
103
104  const cellWidth = width >= 50 ? 2 : 1;
105  const spark = sparkline(summary.hours).split('').map((char) => char.repeat(cellWidth)).join('');
106  const hours = Box({
107    key: 'wrapped-hours', flexDirection: 'column', marginTop: 1,
108    children: [
109      heading('WHEN YOU CODE'),
110      Text({ color: ACCENT, children: [spark] }),
111      Text({ dimColor: true, children: [hourAxisOf(cellWidth)] }),
112    ],
113  });
114
115  const fileText = summary.topFile
116    ? [Text({ bold: true, children: [summary.topFile.name] }), Text({ dimColor: true, children: [` · ${plural(summary.topFile.count, 'edit')}`] })]
117    : [Text({ dimColor: true, children: ['no edits yet'] })];
118  const file = Box({
119    key: 'wrapped-file', flexDirection: 'row', marginTop: 1,
120    children: [Text({ bold: true, dimColor: true, children: ['MOST EDITED  '] }), ...fileText],
121  });
122
123  const extras = [plural(summary.sessions, 'session')];
124  if (summary.minutes > 0) extras.push(`${duration(summary.minutes)} in sessions`);
125  if (summary.currentStreak > 1) extras.push(`${summary.currentStreak}-day streak going`);
126  const meta = Box({ key: 'wrapped-meta', children: [Text({ dimColor: true, children: [extras.join(' · ')] })] });
127
128  return Box({
129    key: 'wrapped-card', flexDirection: 'column', paddingX: 1,
130    children: [header, identity, stats, tools, hours, file, meta, Box({ marginTop: 1, children: [footer] })],
131  });
132}
133
hooks/commands.js 46 lines
1export const RANGES = ['year', 'month', 'all'];
2export const RESET_WINDOW_MS = 60000;
3export const RANGE_KEY = 'wrapped-range';
4export const RESET_KEY = 'wrapped-reset-armed';
5
6export const HELP_TEXT = [
7  'Usage:',
8  '  /wrapped              this year',
9  '  /wrapped month        this month',
10  '  /wrapped all          all time',
11  '  /wrapped export [range]  save an SVG card in this folder',
12  '  /wrapped reset        clear all stats (asks first)',
13].join('\n');
14
15export function parseArgs(args) {
16  const words = String(args ?? '').trim().toLowerCase().split(/\s+/).filter(Boolean);
17  const [first, second] = words;
18  if (!first) return { action: 'open', range: 'year' };
19  if (RANGES.includes(first)) return { action: 'open', range: first };
20  if (first === 'export') return { action: 'export', range: RANGES.includes(second) ? second : 'year' };
21  if (first === 'reset') {
22    const confirmed = second === 'confirm' || second === 'yes';
23    return { action: confirmed ? 'reset-confirm' : 'reset', range: 'year' };
24  }
25  return { action: 'help', range: 'year' };
26}
27
28export function normalizeRange(value) {
29  return RANGES.includes(value) ? value : 'year';
30}
31
32export function exportFileName(range, nowMs) {
33  const date = new Date(nowMs);
34  if (range === 'all') return 'claude-wrapped-all.svg';
35  if (range === 'month') return `claude-wrapped-${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}.svg`;
36  return `claude-wrapped-${date.getFullYear()}.svg`;
37}
38
39export function joinPath(dir, name) {
40  return dir.endsWith('/') || dir.endsWith('\\') ? `${dir}${name}` : `${dir}/${name}`;
41}
42
43export function isResetArmed(armedAt, nowMs) {
44  return typeof armedAt === 'number' && nowMs - armedAt >= 0 && nowMs - armedAt < RESET_WINDOW_MS;
45}
46
hooks/stats.js 233 lines
1import { basename } from './format.js';
2import { personaOf, titleOf } from './persona.js';
3
4export const STATS_KEY = 'wrapped-stats';
5export const SESSION_KEY = 'wrapped-session-start';
6export const MAX_FILES_PER_MONTH = 50;
7export const MAX_TOOLS_PER_DAY = 40;
8export const MAX_DAYS = 1100;
9export const MAX_SESSION_MINUTES = 12 * 60;
10export const EDIT_TOOLS = { Edit: 'file_path', MultiEdit: 'file_path', Write: 'file_path', NotebookEdit: 'notebook_path' };
11
12const DAY_MS = 86400000;
13const MONTHS = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
14
15function emptyDay() {
16  return { sessions: 0, prompts: 0, turns: 0, inputTokens: 0, outputTokens: 0, minutes: 0, tools: {}, hours: {} };
17}
18
19export function emptyStats() {
20  return { v: 1, days: {}, files: {}, archive: emptyDay() };
21}
22
23const isObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
24
25function normalizeDay(raw) {
26  if (!isObject(raw)) return null;
27  return {
28    ...emptyDay(),
29    ...raw,
30    tools: isObject(raw.tools) ? raw.tools : {},
31    hours: isObject(raw.hours) ? raw.hours : {},
32  };
33}
34
35export function normalizeStats(raw) {
36  if (!isObject(raw) || raw.v !== 1 || !isObject(raw.days)) return emptyStats();
37  const days = {};
38  for (const [key, day] of Object.entries(raw.days)) {
39    const normalized = normalizeDay(day);
40    if (normalized) days[key] = normalized;
41  }
42  return {
43    v: 1,
44    days,
45    files: isObject(raw.files) ? raw.files : {},
46    archive: normalizeDay(raw.archive) ?? emptyDay(),
47  };
48}
49
50const two = (n) => String(n).padStart(2, '0');
51
52export function dayKeyOf(ms) {
53  const date = new Date(ms);
54  return `${date.getFullYear()}-${two(date.getMonth() + 1)}-${two(date.getDate())}`;
55}
56
57export function monthKeyOf(ms) {
58  return dayKeyOf(ms).slice(0, 7);
59}
60
61function addInto(target, source) {
62  for (const field of ['sessions', 'prompts', 'turns', 'inputTokens', 'outputTokens', 'minutes']) {
63    target[field] += source[field] ?? 0;
64  }
65  for (const [name, count] of Object.entries(source.tools ?? {})) target.tools[name] = (target.tools[name] ?? 0) + count;
66  for (const [hour, count] of Object.entries(source.hours ?? {})) target.hours[hour] = (target.hours[hour] ?? 0) + count;
67}
68
69function prune(stats) {
70  const keys = Object.keys(stats.days).sort();
71  for (const key of keys.slice(0, Math.max(0, keys.length - MAX_DAYS))) {
72    addInto(stats.archive, stats.days[key]);
73    delete stats.days[key];
74  }
75}
76
77function dayOf(stats, ms) {
78  const key = dayKeyOf(ms);
79  if (!stats.days[key]) {
80    stats.days[key] = emptyDay();
81    prune(stats);
82  }
83  return stats.days[key];
84}
85
86export function recordSessionStart(stats, ms) {
87  dayOf(stats, ms).sessions += 1;
88  return stats;
89}
90
91export function recordSessionMinutes(stats, startMs, endMs) {
92  const minutes = Math.min(MAX_SESSION_MINUTES, Math.max(0, Math.round((endMs - startMs) / 60000)));
93  if (minutes > 0) dayOf(stats, startMs).minutes += minutes;
94  return stats;
95}
96
97export function recordPrompt(stats, ms) {
98  const day = dayOf(stats, ms);
99  const hour = String(new Date(ms).getHours());
100  day.prompts += 1;
101  day.hours[hour] = (day.hours[hour] ?? 0) + 1;
102  return stats;
103}
104
105export function recordTurn(stats, ms, usage, isMainLoop) {
106  const day = dayOf(stats, ms);
107  if (isMainLoop) day.turns += 1;
108  if (usage) {
109    day.inputTokens += (usage.input_tokens ?? 0) + (usage.cache_creation_input_tokens ?? 0);
110    day.outputTokens += usage.output_tokens ?? 0;
111  }
112  return stats;
113}
114
115function capFiles(files) {
116  const entries = Object.entries(files);
117  if (entries.length <= MAX_FILES_PER_MONTH) return files;
118  entries.sort((a, b) => b[1] - a[1]);
119  return Object.fromEntries(entries.slice(0, MAX_FILES_PER_MONTH));
120}
121
122export function recordTool(stats, ms, name, filePath) {
123  const day = dayOf(stats, ms);
124  const known = name in day.tools || Object.keys(day.tools).length < MAX_TOOLS_PER_DAY;
125  const toolName = known ? name : 'Other';
126  day.tools[toolName] = (day.tools[toolName] ?? 0) + 1;
127  if (typeof filePath === 'string' && filePath) {
128    const month = monthKeyOf(ms);
129    const files = stats.files[month] ?? {};
130    if (filePath in files || Object.keys(files).length < MAX_FILES_PER_MONTH) {
131      files[filePath] = (files[filePath] ?? 0) + 1;
132    } else {
133      // Space-saving: the new path takes the least edited slot, so a hot new file can still climb.
134      const [minPath, minCount] = Object.entries(files).sort((a, b) => a[1] - b[1])[0];
135      delete files[minPath];
136      files[filePath] = minCount + 1;
137    }
138    stats.files[month] = capFiles(files);
139  }
140  return stats;
141}
142
143export function editedPathOf(e) {
144  const field = EDIT_TOOLS[e.tool];
145  const value = field ? e[field] : undefined;
146  return typeof value === 'string' ? value : undefined;
147}
148
149function prefixOf(range, nowMs) {
150  if (range === 'all') return '';
151  return range === 'month' ? monthKeyOf(nowMs) : dayKeyOf(nowMs).slice(0, 4);
152}
153
154function labelOf(range, nowMs) {
155  const date = new Date(nowMs);
156  if (range === 'all') return 'All time';
157  if (range === 'month') return `${MONTHS[date.getMonth()]} ${date.getFullYear()}`;
158  return String(date.getFullYear());
159}
160
161const isActive = (day) => day.sessions + day.prompts + day.turns + Object.keys(day.tools).length > 0;
162
163const dayNumberOf = (key) => {
164  const [y, m, d] = key.split('-').map(Number);
165  return Math.round(Date.UTC(y, m - 1, d) / DAY_MS);
166};
167
168function streaksOf(activeKeys, nowMs) {
169  const numbers = activeKeys.map(dayNumberOf).sort((a, b) => a - b);
170  let longest = 0;
171  let run = 0;
172  for (let i = 0; i < numbers.length; i++) {
173    run = i > 0 && numbers[i] === numbers[i - 1] + 1 ? run + 1 : 1;
174    longest = Math.max(longest, run);
175  }
176  const today = dayNumberOf(dayKeyOf(nowMs));
177  const last = numbers[numbers.length - 1];
178  const current = last === today || last === today - 1 ? run : 0;
179  return { longest, current };
180}
181
182export function summarize(stats, range, nowMs) {
183  const prefix = prefixOf(range, nowMs);
184  const total = emptyDay();
185  const activeKeys = [];
186  for (const [key, day] of Object.entries(stats.days)) {
187    if (!key.startsWith(prefix)) continue;
188    addInto(total, day);
189    if (isActive(day)) activeKeys.push(key);
190  }
191  if (range === 'all') addInto(total, stats.archive);
192
193  const fileCounts = {};
194  for (const [month, files] of Object.entries(stats.files)) {
195    if (!month.startsWith(prefix.slice(0, 7))) continue;
196    for (const [path, count] of Object.entries(files)) fileCounts[path] = (fileCounts[path] ?? 0) + count;
197  }
198  const [topPath] = Object.entries(fileCounts).sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
199
200  const tools = Object.entries(total.tools)
201    .map(([name, count]) => ({ name, count }))
202    .sort((a, b) => b.count - a.count || a.name.localeCompare(b.name));
203  const hours = Array.from({ length: 24 }, (_, hour) => total.hours[String(hour)] ?? 0);
204  const peak = Math.max(...hours);
205  const peakHour = peak > 0 ? hours.indexOf(peak) : null;
206  const streaks = streaksOf(activeKeys, nowMs);
207  const toolCalls = tools.reduce((sum, tool) => sum + tool.count, 0);
208
209  return {
210    range,
211    label: labelOf(range, nowMs),
212    sessions: total.sessions,
213    prompts: total.prompts,
214    turns: total.turns,
215    inputTokens: total.inputTokens,
216    outputTokens: total.outputTokens,
217    tokens: total.inputTokens + total.outputTokens,
218    minutes: total.minutes,
219    toolCalls,
220    tools,
221    topTools: tools.slice(0, 3),
222    hours,
223    peakHour,
224    activeDays: activeKeys.length,
225    longestStreak: streaks.longest,
226    currentStreak: streaks.current,
227    topFile: topPath ? { name: basename(topPath[0]), count: topPath[1] } : null,
228    persona: personaOf(peakHour),
229    title: titleOf(total.tools),
230    isEmpty: total.sessions + total.prompts + total.turns + toolCalls === 0,
231  };
232}
233
hooks/stats-store.js 19 lines
1import { normalizeStats, STATS_KEY } from './stats.js';
2
3// One write at a time, so parallel tool calls never lose an increment.
4let writes = Promise.resolve();
5
6export function enqueue(task) {
7  const run = writes.then(task);
8  writes = run.catch(() => undefined);
9  return run;
10}
11
12export function updateStats({ get, set }, change) {
13  return enqueue(async () => {
14    const stats = normalizeStats(await get(STATS_KEY));
15    change(stats);
16    await set(STATS_KEY, stats);
17  });
18}
19
hooks/svg.js 133 lines
1import { compact, duration, hourLabel, plural } from './format.js';
2
3export const WIDTH = 1200;
4export const HEIGHT = 630;
5
6const COLORS = {
7  bgTop: '#21170f',
8  bgBottom: '#0f0b08',
9  accent: '#D97757',
10  accentSoft: '#F0C9A8',
11  text: '#F5EFE8',
12  muted: '#A89A8E',
13  panel: '#ffffff',
14};
15const SANS = "font-family=\"-apple-system, BlinkMacSystemFont, 'Segoe UI', Inter, Helvetica, Arial, sans-serif\"";
16const MONO = "font-family=\"ui-monospace, SFMono-Regular, Menlo, Consolas, monospace\"";
17const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
18
19export function escapeXml(text) {
20  return String(text)
21    .replace(/&/g, '&amp;')
22    .replace(/</g, '&lt;')
23    .replace(/>/g, '&gt;')
24    .replace(/"/g, '&quot;')
25    .replace(/'/g, '&apos;');
26}
27
28function clip(text, max) {
29  const value = String(text);
30  return value.length > max ? `${value.slice(0, max - 1)}…` : value;
31}
32
33function text(x, y, content, attrs = '') {
34  return `<text x="${x}" y="${y}" ${SANS} ${attrs}>${escapeXml(content)}</text>`;
35}
36
37function statTiles(summary) {
38  const tiles = [
39    [compact(summary.prompts), 'prompts'],
40    [compact(summary.turns), 'turns'],
41    [compact(summary.tokens), 'tokens'],
42    [compact(summary.toolCalls), 'tool calls'],
43    [compact(summary.activeDays), 'active days'],
44    [`${compact(summary.longestStreak)}d`, 'longest streak'],
45  ];
46  return tiles.map(([value, label], i) => {
47    const x = 64 + (i % 3) * 190;
48    const y = 345 + Math.floor(i / 3) * 125;
49    return [
50      text(x, y, value, `font-size="54" font-weight="800" fill="${COLORS.text}"`),
51      text(x, y + 30, label.toUpperCase(), `font-size="15" font-weight="600" letter-spacing="2" fill="${COLORS.muted}"`),
52    ].join('');
53  }).join('\n  ');
54}
55
56function toolBars(summary) {
57  const top = summary.topTools;
58  if (top.length === 0) return text(692, 214, 'No tool calls yet', `font-size="20" fill="${COLORS.muted}"`);
59  const max = top[0].count;
60  return top.map((tool, i) => {
61    const y = 214 + i * 44;
62    const width = Math.max(4, Math.round((tool.count / max) * 190));
63    return [
64      text(692, y, clip(tool.name, 12), `font-size="20" font-weight="600" fill="${COLORS.text}"`),
65      `<rect x="830" y="${y - 16}" width="${width}" height="18" rx="9" fill="${COLORS.accent}" fill-opacity="${i === 0 ? 1 : 0.6}"/>`,
66      text(1104, y, compact(tool.count), `font-size="20" font-weight="700" fill="${COLORS.accentSoft}" text-anchor="end"`),
67    ].join('');
68  }).join('\n  ');
69}
70
71function hourBars(summary) {
72  const max = Math.max(1, ...summary.hours);
73  const base = 470;
74  const bars = summary.hours.map((count, hour) => {
75    const height = count > 0 ? Math.max(4, Math.round((count / max) * 76)) : 2;
76    const isPeak = hour === summary.peakHour;
77    return `<rect class="hour" x="${692 + hour * 17.5}" y="${base - height}" width="12" height="${height}" rx="3" fill="${COLORS.accent}" fill-opacity="${isPeak ? 1 : count > 0 ? 0.5 : 0.15}"/>`;
78  });
79  const ticks = [0, 6, 12, 18].map((hour) =>
80    text(692 + hour * 17.5, base + 22, hourLabel(hour), `font-size="13" fill="${COLORS.muted}"`));
81  return [...bars, ...ticks].join('\n  ');
82}
83
84function footer(summary, nowMs) {
85  const date = new Date(nowMs);
86  const left = [plural(summary.sessions, 'session'), summary.minutes > 0 ? `${duration(summary.minutes)} in sessions` : null]
87    .filter(Boolean).join('  ·  ');
88  return [
89    text(64, 590, left, `font-size="16" fill="${COLORS.muted}"`),
90    text(1136, 590, `generated ${date.getDate()} ${MONTHS[date.getMonth()]} ${date.getFullYear()}  ·  /wrapped`, `font-size="16" fill="${COLORS.muted}" text-anchor="end"`),
91  ].join('\n  ');
92}
93
94export function buildSvg(summary, nowMs) {
95  const subtitle = summary.peakHour === null
96    ? summary.persona
97    : `${summary.persona}  ·  peak hour ${hourLabel(summary.peakHour)}`;
98  const file = summary.topFile
99    ? `<text x="692" y="550" ${MONO} font-size="20" fill="${COLORS.text}">${escapeXml(clip(summary.topFile.name, 30))}</text>`
100      + text(1104, 550, plural(summary.topFile.count, 'edit'), `font-size="16" fill="${COLORS.muted}" text-anchor="end"`)
101    : text(692, 550, 'No edits yet', `font-size="20" fill="${COLORS.muted}"`);
102
103  return `<svg xmlns="http://www.w3.org/2000/svg" width="${WIDTH}" height="${HEIGHT}" viewBox="0 0 ${WIDTH} ${HEIGHT}">
104  <defs>
105    <linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
106      <stop offset="0" stop-color="${COLORS.bgTop}"/>
107      <stop offset="1" stop-color="${COLORS.bgBottom}"/>
108    </linearGradient>
109    <radialGradient id="glow" cx="0.5" cy="0.5" r="0.5">
110      <stop offset="0" stop-color="${COLORS.accent}" stop-opacity="0.35"/>
111      <stop offset="1" stop-color="${COLORS.accent}" stop-opacity="0"/>
112    </radialGradient>
113  </defs>
114  <rect width="${WIDTH}" height="${HEIGHT}" fill="url(#bg)"/>
115  <circle cx="1080" cy="40" r="320" fill="url(#glow)"/>
116  <rect x="64" y="56" width="40" height="4" rx="2" fill="${COLORS.accent}"/>
117  ${text(64, 92, 'CLAUDE CODE WRAPPED', `font-size="18" font-weight="700" letter-spacing="5" fill="${COLORS.accent}"`)}
118  ${text(1136, 92, summary.label, `font-size="18" font-weight="700" letter-spacing="2" fill="${COLORS.muted}" text-anchor="end"`)}
119  ${text(64, 186, summary.title, `font-size="68" font-weight="800" fill="${COLORS.text}"`)}
120  ${text(64, 232, subtitle, `font-size="26" font-weight="500" fill="${COLORS.accentSoft}"`)}
121  ${statTiles(summary)}
122  <rect x="660" y="132" width="476" height="440" rx="20" fill="${COLORS.panel}" fill-opacity="0.045" stroke="${COLORS.panel}" stroke-opacity="0.08"/>
123  ${text(692, 172, 'TOP TOOLS', `font-size="14" font-weight="700" letter-spacing="3" fill="${COLORS.muted}"`)}
124  ${toolBars(summary)}
125  ${text(692, 372, 'WHEN YOU CODE', `font-size="14" font-weight="700" letter-spacing="3" fill="${COLORS.muted}"`)}
126  ${hourBars(summary)}
127  ${text(692, 522, 'MOST EDITED', `font-size="14" font-weight="700" letter-spacing="3" fill="${COLORS.muted}"`)}
128  ${file}
129  ${footer(summary, nowMs)}
130</svg>
131`;
132}
133
hooks/format.js 62 lines
1const EIGHTHS = ['', '▏', '▎', '▍', '▌', '▋', '▊', '▉'];
2const LEVELS = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
3const UNITS = [[1e9, 'B'], [1e6, 'M'], [1e3, 'K']];
4
5function withCommas(n) {
6  return String(Math.round(n)).replace(/\B(?=(\d{3})+(?!\d))/g, ',');
7}
8
9export function compact(n) {
10  const value = Number(n) || 0;
11  if (value < 10000) return withCommas(value);
12  for (const [size, unit] of UNITS) {
13    if (value >= size) return `${(value / size).toFixed(1).replace(/\.0$/, '')}${unit}`;
14  }
15  return withCommas(value);
16}
17
18export function bar(value, max, width) {
19  if (max <= 0 || value <= 0 || width <= 0) return '';
20  const eighths = Math.max(1, Math.round((Math.min(value, max) / max) * width * 8));
21  return '█'.repeat(Math.floor(eighths / 8)) + EIGHTHS[eighths % 8];
22}
23
24export function sparkline(values) {
25  const max = Math.max(0, ...values);
26  return values
27    .map((v) => (v > 0 && max > 0 ? LEVELS[Math.max(0, Math.ceil((v / max) * LEVELS.length) - 1)] : ' '))
28    .join('');
29}
30
31export function hourLabel(hour) {
32  return `${String(hour).padStart(2, '0')}:00`;
33}
34
35export function basename(path) {
36  const parts = String(path).split(/[\\/]/);
37  return parts[parts.length - 1] || String(path);
38}
39
40export function duration(minutes) {
41  const total = Math.round(minutes);
42  if (total < 60) return `${total}m`;
43  const hours = Math.floor(total / 60);
44  const rest = total % 60;
45  if (hours >= 100 || rest === 0) return `${hours}h`;
46  return `${hours}h ${rest}m`;
47}
48
49export function pad(text, width) {
50  const value = String(text);
51  return value.length >= width ? value.slice(0, width) : value + ' '.repeat(width - value.length);
52}
53
54export function padStart(text, width) {
55  const value = String(text);
56  return value.length >= width ? value : ' '.repeat(width - value.length) + value;
57}
58
59export function plural(count, word) {
60  return `${compact(count)} ${count === 1 ? word : `${word}s`}`;
61}
62
hooks/persona.js 36 lines
1const TITLE_GROUPS = [
2  ['The Refactorer', ['Edit', 'MultiEdit']],
3  ['The Explorer', ['Read', 'Grep', 'Glob']],
4  ['The Operator', ['Bash']],
5  ['The Author', ['Write']],
6  ['The Delegator', ['Agent', 'Task']],
7];
8
9export const FALLBACK_TITLE = 'The Generalist';
10
11export function personaOf(peakHour) {
12  if (peakHour === null || peakHour === undefined) return 'Fresh start';
13  if (peakHour >= 5 && peakHour < 11) return 'Early bird';
14  if (peakHour >= 11 && peakHour < 17) return 'Day shift';
15  if (peakHour >= 17 && peakHour < 23) return 'Evening coder';
16  return 'Night owl';
17}
18
19export function titleOf(tools) {
20  const groupOf = (name) => TITLE_GROUPS.find(([, names]) => names.includes(name))?.[0] ?? null;
21  const totals = new Map();
22  for (const [name, count] of Object.entries(tools)) {
23    const key = groupOf(name) ?? `other:${name}`;
24    totals.set(key, (totals.get(key) ?? 0) + count);
25  }
26  let best = null;
27  let bestCount = 0;
28  for (const [key, count] of totals) {
29    if (count > bestCount) {
30      best = key;
31      bestCount = count;
32    }
33  }
34  return best && !best.startsWith('other:') ? best : FALLBACK_TITLE;
35}
36