Your Claude Code Wrapped: activity heatmap, top files, coding personality and unlockable achievements.

Your Claude Code Wrapped: run /wrapped for a pane with your year in code.
Requires Claude Code 2.1.293 or later: mods are an early-access feature and gain events with each release. Check with claude --version; update with claude update.
/plugin marketplace add lakmadev/claude-mods
/plugin install code-wrapped@claude-mods
/wrapped shows as many weeks as fit (up to 26); /wrapped 52 asks for a year. Stats start counting from install and stay on your machine (Claude Code's plugin store), kept for 400 days.
hooks/register.tsx 191 lines1import type { EngineInterface, Register } from 'claude-code'
2
3import { ACHIEVEMENTS, bump, dateKey, emptyDay, heatmap, mergeDay, newlyUnlocked, streak, summarize } from './stats'
4import type { Day, Summary } from './stats'
5
6const PANE = 'wrapped'
7const DAY_MS = 86_400_000
8const LEVELS = [0x2d333b, 0x0e4429, 0x006d32, 0x26a641, 0x39d353]
9const lineCount = (text: string) => (text === '' ? 0 : text.split('\n').length)
10
11let pending: Day = emptyDay()
12let weeksAsked: number | undefined
13
14async function loadDays($: EngineInterface, sinceMs: number): Promise<Record<string, Day>> {
15 const since = `day:${dateKey(sinceMs)}`
16 const days: Record<string, Day> = {}
17 for (const key of await $.store.keys()) {
18 if (key.startsWith('day:') && key >= since) days[key.slice(4)] = (await $.store.get(key)) as Day
19 }
20 return days
21}
22
23export function shareText(s: Summary, weeks: number, streakDays: number): string {
24 return `My Claude Code Wrapped (last ${weeks} weeks): ${s.turns} turns, ${s.hours}h, +${s.added.toLocaleString('en-US')}/−${s.removed.toLocaleString('en-US')} lines across ${s.files} files, ${streakDays}-day streak. I'm ${s.personality}. Get yours: github.com/lakmadev/claude-mods`
25}
26
27// Cells for the terminal Raster: one ■ per day plus a gap, weekday rows, week columns.
28function heatCells(grid: ReturnType<typeof heatmap>): string {
29 const columns = grid.length * 2
30 const view = new DataView(new ArrayBuffer(columns * 7 * 12))
31 for (let row = 0; row < 7; row++) {
32 for (let col = 0; col < columns; col++) {
33 const cell = grid[Math.floor(col / 2)]![row]!
34 const isGap = col % 2 === 1 || cell.isFuture
35 const at = (row * columns + col) * 12
36 view.setUint32(at, isGap ? 0x20 : 0x25a0, true)
37 view.setUint32(at + 4, isGap ? 0x01000000 : LEVELS[cell.level]!, true)
38 view.setUint32(at + 8, 0x01000000, true)
39 }
40 }
41 return toBase64(new Uint8Array(view.buffer))
42}
43
44function heatSvg(grid: ReturnType<typeof heatmap>): string {
45 const step = 14
46 const rects = grid.flatMap((week, w) =>
47 week.filter(d => !d.isFuture).map((d, row) =>
48 `<rect x="${w * step}" y="${row * step}" width="11" height="11" rx="2" fill="#${LEVELS[d.level]!.toString(16).padStart(6, '0')}"><title>${d.date}: ${d.turns} turns</title></rect>`,
49 ),
50 )
51 return `<svg xmlns="http://www.w3.org/2000/svg" width="${grid.length * step}" height="${7 * step}">${rects.join('')}</svg>`
52}
53
54const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
55function toBase64(bytes: Uint8Array): string {
56 let out = ''
57 for (let i = 0; i < bytes.length; i += 3) {
58 const n = (bytes[i]! << 16) | ((bytes[i + 1] ?? 0) << 8) | (bytes[i + 2] ?? 0)
59 out += B64[(n >> 18) & 63]! + B64[(n >> 12) & 63]!
60 out += i + 1 < bytes.length ? B64[(n >> 6) & 63]! : '='
61 out += i + 2 < bytes.length ? B64[n & 63]! : '='
62 }
63 return out
64}
65
66const bar = (value: number, max: number, width = 16) => '█'.repeat(Math.max(1, Math.round((value / Math.max(1, max)) * width)))
67const shortPath = (path: string) => (path.length > 40 ? `…${path.slice(-39)}` : path)
68
69export const register: Register = on => {
70 on('session.start', async ($, e, next) => {
71 await $.command.register({ name: 'wrapped', description: 'Your Claude Code Wrapped: heatmap, stats, achievements', argumentHint: '[weeks]' })
72 const cutoff = `day:${dateKey((await $.clock.now()) - 400 * DAY_MS)}`
73 for (const key of await $.store.keys()) if (key.startsWith('day:') && key < cutoff) await $.store.delete(key)
74 return next(e)
75 })
76
77 on('tool.call', async ($, e, next) => {
78 const ran = await next(e)
79 if (ran.deny !== undefined) return ran
80 pending.tools = bump(pending.tools, String(e.tool))
81 if (ran.isError) pending.errors += 1
82 else if (e.tool === 'Edit') {
83 pending.added += lineCount(e.new_string)
84 pending.removed += lineCount(e.old_string)
85 pending.files = bump(pending.files, e.file_path)
86 } else if (e.tool === 'Write') {
87 pending.added += lineCount(e.content)
88 pending.files = bump(pending.files, e.file_path)
89 }
90 return ran
91 })
92
93 on('turn.complete', async ($, e, next) => {
94 const done = await next(e)
95 if (e.agentId !== undefined) return done
96 const now = await $.clock.now()
97 const delta = { ...pending, turns: 1, seconds: Math.round(e.durationMs / 1000), hours: pending.hours.map((h, i) => (i === new Date(now).getHours() ? h + 1 : h)) }
98 pending = emptyDay()
99
100 const key = `day:${dateKey(now)}`
101 const today = mergeDay(((await $.store.get(key)) as Day | undefined) ?? emptyDay(), delta)
102 await $.store.set(key, today)
103 const totalTurns = (((await $.store.get('totalTurns')) as number | undefined) ?? 0) + 1
104 await $.store.set('totalTurns', totalTurns)
105
106 const active = new Set((await $.store.keys()).filter(k => k.startsWith('day:')).map(k => k.slice(4)))
107 const unlocked = ((await $.store.get('achievements')) as Record<string, number> | undefined) ?? {}
108 const fresh = newlyUnlocked({ today, turnSeconds: delta.seconds, at: now, totalTurns, streak: streak(active, now) }, unlocked)
109 if (fresh.length) {
110 await $.store.set('achievements', { ...unlocked, ...Object.fromEntries(fresh.map(a => [a.id, now])) })
111 for (const a of fresh) $.ui.toast(`🏆 Achievement unlocked: ${a.title}. ${a.desc}. (/wrapped)`, { timeoutMs: 6000 })
112 }
113 return done
114 })
115
116 on('command.run', { command: 'wrapped' }, async ($, e) => {
117 const weeks = Number.parseInt(e.args, 10)
118 weeksAsked = weeks > 0 ? Math.min(weeks, 52) : undefined
119 await $.ui.open({ id: PANE, title: '✨ Your Claude Code Wrapped' })
120 return {}
121 })
122
123 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
124 const { Box, Text, Button } = $.ui.resolve(e)
125 const now = await $.clock.now()
126 const fit = Math.floor(((e.props.bodyColumns ?? 60) - 2) / 2)
127 const weeks = weeksAsked ?? Math.max(4, Math.min(26, e.surface === 'terminal' ? fit : 26))
128 const days = await loadDays($, now - weeks * 7 * DAY_MS)
129 const s = summarize(days)
130 const active = new Set((await $.store.keys()).filter(k => k.startsWith('day:')).map(k => k.slice(4)))
131 const streakDays = streak(active, now)
132 const unlocked = ((await $.store.get('achievements')) as Record<string, number> | undefined) ?? {}
133 const grid = heatmap(days, now, weeks)
134
135 if (s.turns === 0) return <Text dimColor>No turns recorded yet. Finish a few turns with Claude, then run /wrapped again.</Text>
136
137 const maxFile = s.topFiles[0]?.[1] ?? 1
138 const maxTool = s.topTools[0]?.[1] ?? 1
139 // Choose by surface: every table lists Raster, but only the terminal draws it.
140 let heat
141 if (e.surface === 'terminal') {
142 const { Raster } = $.ui.resolve(e)
143 heat = <Raster key="heat" columns={weeks * 2} rows={7} cells={heatCells(grid)} />
144 } else {
145 const { Svg } = $.ui.resolve(e)
146 heat = <Svg source={heatSvg(grid)} alt={`Activity heatmap, ${s.turns} turns over ${weeks} weeks`} isInteractive />
147 }
148
149 return (
150 <Box flexDirection="column" gap={1}>
151 <Box flexDirection="column">
152 <Text dimColor>Your coding personality</Text>
153 <Text bold color="claude">{s.personality}</Text>
154 </Box>
155 {heat}
156 <Box flexDirection="column">
157 <Text>
158 <Text bold>{s.turns}</Text> turns · <Text bold>{s.hours}h</Text> with Claude · <Text color="diffAdded">+{s.added}</Text> <Text color="diffRemoved">−{s.removed}</Text> lines · <Text bold>{s.files}</Text> files
159 </Text>
160 <Text dimColor>
161 🔥 {streakDays}-day streak{s.busiest ? ` · busiest day ${s.busiest[0]} (${s.busiest[1]} turns)` : ''} · peak hour {String(s.peakHour).padStart(2, '0')}:00
162 </Text>
163 </Box>
164 <Box flexDirection="column">
165 <Text bold>Top files</Text>
166 {s.topFiles.map(([path, n]) => (
167 <Text>
168 <Text color="success">{bar(n, maxFile)}</Text> {n} {shortPath(path)}
169 </Text>
170 ))}
171 </Box>
172 <Box flexDirection="column">
173 <Text bold>Favourite tools</Text>
174 {s.topTools.map(([tool, n]) => (
175 <Text>
176 <Text color="suggestion">{bar(n, maxTool)}</Text> {n} {tool}
177 </Text>
178 ))}
179 </Box>
180 <Box flexDirection="column">
181 <Text bold>Achievements {Object.keys(unlocked).length}/{ACHIEVEMENTS.length}</Text>
182 {ACHIEVEMENTS.map(a =>
183 a.id in unlocked ? <Text>🏆 {a.title} <Text dimColor>· {a.desc}</Text></Text> : <Text dimColor>🔒 {a.title}</Text>,
184 )}
185 </Box>
186 <Button key="copy" label="Copy share text" variant="primary" onPress={() => $.ui.copy({ text: shareText(s, weeks, streakDays), surface: e.surface })} />
187 </Box>
188 )
189 })
190}
191hooks/stats.ts 135 lines1export type Day = {
2 turns: number
3 seconds: number
4 added: number
5 removed: number
6 errors: number
7 hours: number[]
8 tools: Record<string, number>
9 files: Record<string, number>
10}
11
12export const emptyDay = (): Day => ({ turns: 0, seconds: 0, added: 0, removed: 0, errors: 0, hours: Array(24).fill(0), tools: {}, files: {} })
13
14const pad = (n: number) => String(n).padStart(2, '0')
15export const dateKey = (ms: number) => {
16 const d = new Date(ms)
17 return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
18}
19
20export const bump = (record: Record<string, number>, key: string, by = 1) => ({ ...record, [key]: (record[key] ?? 0) + by })
21
22export function mergeDay(day: Day, delta: Day): Day {
23 let tools = day.tools
24 for (const [k, v] of Object.entries(delta.tools)) tools = bump(tools, k, v)
25 let files = day.files
26 for (const [k, v] of Object.entries(delta.files)) files = bump(files, k, v)
27 // ponytail: per-day file map capped at 300 keys; busiest files win.
28 files = Object.fromEntries(Object.entries(files).sort((a, b) => b[1] - a[1]).slice(0, 300))
29 return {
30 turns: day.turns + delta.turns,
31 seconds: day.seconds + delta.seconds,
32 added: day.added + delta.added,
33 removed: day.removed + delta.removed,
34 errors: day.errors + delta.errors,
35 hours: day.hours.map((h, i) => h + (delta.hours[i] ?? 0)),
36 tools,
37 files,
38 }
39}
40
41// Consecutive days with at least one turn, ending today or yesterday.
42export function streak(activeDates: ReadonlySet<string>, now: number): number {
43 let ms = activeDates.has(dateKey(now)) ? now : now - 86_400_000
44 let count = 0
45 while (activeDates.has(dateKey(ms))) {
46 count += 1
47 ms -= 86_400_000
48 }
49 return count
50}
51
52export type Facts = { today: Day; turnSeconds: number; at: number; totalTurns: number; streak: number }
53
54export const ACHIEVEMENTS: { id: string; title: string; desc: string; test: (f: Facts) => boolean }[] = [
55 { id: 'hello', title: 'Hello, Claude', desc: 'Finished your first turn', test: f => f.totalTurns >= 1 },
56 { id: 'night-owl', title: 'Night Owl', desc: 'Coded between midnight and 5am', test: f => new Date(f.at).getHours() < 5 },
57 { id: 'early-bird', title: 'Early Bird', desc: 'Coded between 5 and 7am', test: f => [5, 6].includes(new Date(f.at).getHours()) },
58 { id: 'weekend', title: 'Weekend Warrior', desc: 'Coded on a Saturday or Sunday', test: f => [0, 6].includes(new Date(f.at).getDay()) },
59 { id: 'marathon', title: 'Marathon', desc: 'One turn ran 10+ minutes', test: f => f.turnSeconds >= 600 },
60 { id: 'centurion', title: 'Centurion', desc: '100 turns together', test: f => f.totalTurns >= 100 },
61 { id: 'pair-for-life', title: 'Pair for Life', desc: '1,000 turns together', test: f => f.totalTurns >= 1000 },
62 { id: 'big-bang', title: 'Big Bang', desc: 'Added 1,000+ lines in a day', test: f => f.today.added >= 1000 },
63 { id: 'kondo', title: 'Marie Kondo', desc: 'Deleted 300+ lines in a day, more than you added', test: f => f.today.removed >= 300 && f.today.removed > f.today.added },
64 { id: 'on-fire', title: 'On Fire', desc: 'A 7-day streak', test: f => f.streak >= 7 },
65 { id: 'unstoppable', title: 'Unstoppable', desc: 'A 30-day streak', test: f => f.streak >= 30 },
66 { id: 'error-collector', title: 'Error Collector', desc: '10 failed tool calls in a day', test: f => f.today.errors >= 10 },
67 { id: 'shell-wizard', title: 'Shell Wizard', desc: '50 shell commands in a day', test: f => (f.today.tools.Bash ?? 0) >= 50 },
68 { id: 'polyglot', title: 'Polyglot', desc: 'Touched 5 file types in a day', test: f => new Set(Object.keys(f.today.files).map(p => p.split('.').pop())).size >= 5 },
69]
70
71export function newlyUnlocked(facts: Facts, unlocked: Readonly<Record<string, number>>) {
72 return ACHIEVEMENTS.filter(a => !(a.id in unlocked) && a.test(facts))
73}
74
75export type Summary = {
76 turns: number
77 hours: number
78 added: number
79 removed: number
80 files: number
81 errors: number
82 topFiles: [string, number][]
83 topTools: [string, number][]
84 busiest: [string, number] | undefined
85 peakHour: number
86 nightShare: number
87 personality: string
88}
89
90export function summarize(days: Readonly<Record<string, Day>>): Summary {
91 const all = Object.values(days).reduce(mergeDay, emptyDay())
92 const top = (record: Record<string, number>) => Object.entries(record).sort((a, b) => b[1] - a[1]).slice(0, 5)
93 const nightTurns = [22, 23, 0, 1, 2, 3, 4].reduce((sum, h) => sum + all.hours[h]!, 0)
94 const nightShare = all.turns ? nightTurns / all.turns : 0
95 const topTool = top(all.tools)[0]?.[0]
96 const busiest = Object.entries(days).sort((a, b) => b[1].turns - a[1].turns)[0]
97 const personality =
98 nightShare >= 0.4 ? 'The Night Owl 🦉'
99 : all.removed > all.added && all.removed > 200 ? 'The Refactorer 🧹'
100 : topTool === 'Bash' ? 'The Shell Wizard 🧙'
101 : ['Read', 'Grep', 'Glob'].includes(topTool ?? '') ? 'The Detective 🔎'
102 : all.added > 2000 ? 'The Builder 🏗️'
103 : all.turns && all.errors / all.turns > 1 ? 'The Daredevil 🔥'
104 : 'The Steady Shipper 🚢'
105 return {
106 turns: all.turns,
107 hours: Math.round((all.seconds / 3600) * 10) / 10,
108 added: all.added,
109 removed: all.removed,
110 files: Object.keys(all.files).length,
111 errors: all.errors,
112 topFiles: top(all.files),
113 topTools: top(all.tools),
114 busiest: busiest && [busiest[0], busiest[1].turns],
115 peakHour: all.hours.indexOf(Math.max(...all.hours)),
116 nightShare,
117 personality,
118 }
119}
120
121// GitHub-style: one column per week (Sunday first), weeks ending with this one.
122export function heatmap(days: Readonly<Record<string, Day>>, now: number, weeks: number): { date: string; turns: number; level: number; isFuture: boolean }[][] {
123 const today = new Date(now)
124 const start = new Date(today.getFullYear(), today.getMonth(), today.getDate() - today.getDay() - (weeks - 1) * 7)
125 const max = Math.max(1, ...Object.values(days).map(d => d.turns))
126 return Array.from({ length: weeks }, (_, w) =>
127 Array.from({ length: 7 }, (_, d) => {
128 const at = new Date(start.getFullYear(), start.getMonth(), start.getDate() + w * 7 + d).getTime()
129 const date = dateKey(at)
130 const turns = days[date]?.turns ?? 0
131 return { date, turns, level: turns === 0 ? 0 : Math.ceil((turns / max) * 4), isFuture: at > now }
132 }),
133 )
134}
135