Your Claude Code year in one shareable card

Your Claude Code year in one shareable card.

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.
/wrapped export saves a 1200×630 SVG, ready for a post.| Command | What |
|---|---|
/wrapped | This year's card: prompts, turns, tokens, tool calls, active days, streak, top tools, peak hour, most edited file |
/wrapped month / /wrapped all | Other ranges |
/wrapped export | Writes claude-wrapped-<year>.svg (1200×630) to the current folder — share it |
/wrapped reset | Clears 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.
/plugin marketplace add Ron-Nefyodov/wrapped-mod /plugin install wrapped@wrapped
Or try it locally: claude --plugin-dir .
npm test # claude plugin test . claude plugin validate .
hooks/register.js 138 lines1import { 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}
138hooks/card.js 133 lines1import { 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}
133hooks/commands.js 46 lines1export 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}
46hooks/stats.js 233 lines1import { 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}
233hooks/stats-store.js 19 lines1import { 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}
19hooks/svg.js 133 lines1import { 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, '&')
22 .replace(/</g, '<')
23 .replace(/>/g, '>')
24 .replace(/"/g, '"')
25 .replace(/'/g, ''');
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}
133hooks/format.js 62 lines1const 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}
62hooks/persona.js 36 lines1const 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