SLOPSHOPPER

code-wrapped

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

newpaneguardcommandtoast
v0.1.0MITupdated 2026-10-09lakmadev/code-wrapped
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · code-wrapped
│ ┃ ✨ Your Claude Code Wrapped ✕ › fix the failing auth╭────────────────────────────────────────────╮ │ ┃ Your coding personality │ code-wrapped │ │ ┃ The Shell Wizard 🧙 ⏺ Read(src/auth.ts) │ 🏆 Achievement unlocked: Hello, Claude. │ │ ┃ ⎿ Read 6 lines │ Finished your first turn. (/wrapped) │ │ ┃ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ⏺ Update(src/auth.ts) ╰────────────────────────────────────────────╯ │ ┃ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ⎿ Added 2 lines, removed 1 line │ ┃ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ⏺ Bash(bun test) │ ┃ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ⎿ 3 pass, 1 fail │ ┃ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ 1 turns · 0h with Claude · +16 −1 lines · 3 │ ┃ files › /wrapped │ ┃ 🔥 1-day streak · busiest day 2025-10-09 (1 │ ┃ turns) · peak hour 08:00 │ ┃ │ ┃ Top files │ ┃ ████████████████ 1 /work/app/src/auth.ts │ ┃ ████████████████ 1 /work/app/src/audit.ts │ ┃ ████████████████ 1 /work/app/src/cache.ts │ ┃ │ ┃ Favourite tools │ ┃ ████████████████ 4 Bash │ ┃ ████████ 2 Write │ ┃ ████ 1 Read ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · ✨ Your Claude Code Wrapped
Your coding personality The Shell Wizard 🧙 ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ 1 turns · 0h with Claude · +16 −1 lines · 3 files 🔥 1-day streak · busiest day 2025-10-09 (1 turns) · peak hour 08:00 Top files ████████████████ 1 /work/app/src/auth.ts ████████████████ 1 /work/app/src/audit.ts ████████████████ 1 /work/app/src/cache.ts Favourite tools ████████████████ 4 Bash ████████ 2 Write ████ 1 Read ████ 1 Grep ████ 1 Edit Achievements 1/14 🏆 Hello, Claude · Finished your first turn 🔒 Night Owl 🔒 Early Bird 🔒 Weekend Warrior 🔒 Marathon 🔒 Centurion 🔒 Pair for Life 🔒 Big Bang 🔒 Marie Kondo 🔒 On Fire 🔒 Unstoppable 🔒 Error Collector 🔒 Shell Wizard 🔒 Polyglot [ Copy share text ]
README

code-wrapped

Your Claude Code Wrapped: run /wrapped for a pane with your year in code.

code-wrapped pane (sample data)

  • GitHub-style activity heatmap: a pixel grid in the terminal, an SVG with hover tooltips in the desktop app.
  • Turns, hours with Claude, lines added and removed, files touched, streak, busiest day, peak hour.
  • Top files and favourite tools as bar charts.
  • Your coding personality: The Night Owl 🦉, The Refactorer 🧹, The Shell Wizard 🧙, The Detective 🔎, The Builder 🏗️, The Daredevil 🔥 or The Steady Shipper 🚢.
  • 14 achievements that pop up as toasts the moment you earn them: Night Owl, Early Bird, Weekend Warrior, Marathon, Centurion, Big Bang, Marie Kondo, On Fire, Unstoppable, Shell Wizard, Polyglot…
  • Copy share text puts a one-line summary on your clipboard for sharing.

Install

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/code-wrapped
/plugin install code-wrapped@code-wrapped

/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.

Part of lakmadev/claude-mods, a pack of eleven Claude Code mods. MIT licensed.

Source 2 files
hooks/register.tsx 191 lines
1import 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}
191
hooks/stats.ts 135 lines
1export 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