Spotify Wrapped for your Claude Code life: prompts, tool calls, lines written, streaks, your coding persona, and a share card you can copy.

Spotify Wrapped for your Claude Code life: your stats, your streaks, your coding persona, and a share card that's one keypress away.
<img src="../../../assets/screens/wrapped.svg" alt="wrapped in a real Claude Code session" width="100%">
The capture above is from a fresh install. After a few months, a card looks more like this (illustrative numbers):
✦ CLAUDE CODE WRAPPED · 2026 ✦
You are 🦉 The Night Owl
Your best ideas show up after dark. Peak hour: 11pm.
1,284 312 96h
prompts sessions with Claude
18,422 41,200 128
tool calls lines written commits
Top tools
Bash ████████████████████████████████ 6,201
Edit █████████████████████▏ 4,102
Read ███████████████▊ 3,050
Grep ██████▏ 1,200
Write ████▏ 800
🔥 14-day streak · best 31
🕐 busiest hour 11pm
⏱ longest turn 42m
📁 top project api-server
💸 $412.80 of tokens
[ Copy share card ] [ Close ]
Run /wrapped for the card, then press c to copy a compact version for Slack or X:
✦ My Claude Code Wrapped · 2026 ✦
🦉 The Night Owl
1,284 prompts · 312 sessions · 96h with Claude
18,422 tool calls · 41,200 lines written · 128 commits
Top tools: Bash · Edit · Read
🔥 Longest streak: 31 days
made with wrapped · github.com/Singh-AP/awesome-claude-mods
/wrapped 2025, /wrapped all/wrapped text prints it. Cost stays off the card unless you turn shareCost on./plugin marketplace add Singh-AP/awesome-claude-mods
/plugin install wrapped@awesome-claude-mods
Requires Claude Code 2.1.287 or later. It counts from the moment it's installed; it can't see sessions from before.
| Command | What it does | |
|---|---|---|
/wrapped | Opens this year's card in a pane (c copies, x or Esc closes) | |
/wrapped 2025 · /wrapped all | Another year, or your whole history | |
| `/wrapped text [year\ | all]` | Prints the share card into the transcript |
/wrapped reset | Wipes every stat, after asking |
| Option | Default | What it does |
|---|---|---|
shareCost | false | Adds 💸 $… of tokens to the copied share card. The pane always shows cost. |
| Event / API | Why |
|---|---|
prompt.submit | Counts your prompts (typed, Remote Control or SDK, but not plugin or notification prompts) and the hour you sent them |
tool.call | After next(e): counts the call by tool, failures, lines from Edit/Write, file hashes, successful git commits |
turn.complete | Main-loop turns, their duration, the day and the project; then writes this session's record |
session.start · session.end | Registers /wrapped, folds old records, and writes the record on exit |
$.session.usage() | Cost, counted as the ledger's rise so a reload never double-counts |
$.store | s:<session id> per session, y:<year> for folded history |
ui.render on Pane · $.ui.copy | The card and the share button |
The counting, streak and persona logic (hooks/stats.ts) is pure TypeScript with no $.
claude plugin test mods/fun/wrapped # 30 tests
Edit's replacement text and each Write's content, so it measures output, not net change.hooks/register.tsx 318 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderSurface } from 'claude-code'
3
4import type { Summary } from '../types'
5import {
6 absorb,
7 asTally,
8 bar,
9 costDelta,
10 dayKey,
11 duration,
12 emptyTally,
13 hashPath,
14 hourLabel,
15 hourOf,
16 isCommit,
17 lineCount,
18 merge,
19 num,
20 shareCard,
21 summarize,
22 yearOf,
23 type Tally,
24} from './stats'
25
26const PANE = 'wrapped'
27const summary = atom({ plugin: 'wrapped', key: 'summary' } as const, null)
28
29// Session records kept whole before old ones are folded into their year.
30const KEEP_SESSIONS = 60
31const FOLD_AT = 120
32
33// What this session counted since the last write; a reload loses at most a turn of it.
34let fresh: Tally = emptyTally(Number.MAX_SAFE_INTEGER)
35let project = ''
36let costSeen = 0
37let shareCost = false
38
39const isHuman = (kind: string) => kind === 'composer' || kind === 'bridge' || kind === 'sdk'
40
41/** Adds what this session counted to its own record. Only this session writes that key. */
42async function flush($: EngineInterface) {
43 const now = await $.clock.now()
44 const key = `s:${await $.session.id()}`
45 const stored = asTally(await $.store.get(key), now)
46 const merged = absorb(stored, fresh)
47 merged.lastAt = now
48 const usage = await $.session.usage()
49 const reading = usage.cost?.usd
50 if (typeof reading === 'number') {
51 merged.cost = stored.cost + fresh.cost + costDelta(costSeen, reading)
52 merged.costSeen = reading
53 costSeen = reading
54 }
55 fresh = emptyTally(Number.MAX_SAFE_INTEGER)
56 await $.store.set(key, merged)
57}
58
59/** Folds the oldest session records into one record per year, so the store stays small. */
60async function fold($: EngineInterface) {
61 const now = await $.clock.now()
62 const current = `s:${await $.session.id()}`
63 const keys = (await $.store.keys()).filter(k => k.startsWith('s:') && k !== current)
64 if (keys.length < FOLD_AT) return
65 const records: { key: string; tally: Tally }[] = []
66 for (const key of keys) records.push({ key, tally: asTally(await $.store.get(key), now) })
67 records.sort((a, b) => a.tally.lastAt - b.tally.lastAt)
68 const old = records.slice(0, records.length - KEEP_SESSIONS)
69 const years = new Map<string, Tally>()
70 for (const { tally } of old) {
71 const year = yearOf(tally.firstAt)
72 const into = years.get(year) ?? asTally(await $.store.get(`y:${year}`), tally.firstAt)
73 years.set(year, merge(into, tally))
74 }
75 for (const [year, tally] of years) await $.store.set(`y:${year}`, tally)
76 for (const { key } of old) await $.store.delete(key)
77}
78
79/** Sums every record for `year` ('all' for a lifetime). */
80async function gather($: EngineInterface, year: string): Promise<Summary> {
81 await flush($)
82 const now = await $.clock.now()
83 let total: Tally | undefined
84 for (const key of await $.store.keys()) {
85 const isSession = key.startsWith('s:')
86 if (!isSession && !key.startsWith('y:')) continue
87 const tally = asTally(await $.store.get(key), now)
88 if (year !== 'all' && (isSession ? yearOf(tally.firstAt) : key.slice(2)) !== year) continue
89 total = total === undefined ? tally : merge(total, tally)
90 }
91 const label = year === 'all' ? 'Claude Code Wrapped · all time' : `Claude Code Wrapped · ${year}`
92 return summarize(total ?? emptyTally(now), label, dayKey(now))
93}
94
95/** `''` this year, `all`, or a four-digit year; undefined for anything else. */
96async function yearFrom($: EngineInterface, arg: string): Promise<string | undefined> {
97 if (arg === '') return yearOf(await $.clock.now())
98 if (arg === 'all' || /^\d{4}$/.test(arg)) return arg
99 return undefined
100}
101
102async function copyCard($: EngineInterface, surface: RenderSurface) {
103 const shown = await read($, summary)
104 if (shown === null) return
105 const copied = await $.ui.copy({ text: shareCard(shown, shareCost), surface })
106 $.ui.toast(copied.isCopied ? 'Share card copied. Paste it anywhere! ✨' : `Could not copy: ${copied.reason}`)
107}
108
109export const register: Register = (on, options) => {
110 shareCost = options.shareCost === true
111
112 on('session.start', async ($, e, next) => {
113 await $.command.register({
114 name: 'wrapped',
115 description: 'Your Claude Code year in review. /wrapped [year|all] · text · reset',
116 argumentHint: '[year | all | text [year|all] | reset]',
117 })
118 const root = await $.session.root()
119 project = root.split(/[\\/]/).filter(Boolean).pop() ?? root
120 const now = await $.clock.now()
121 const own = asTally(await $.store.get(`s:${await $.session.id()}`), now)
122 costSeen = own.costSeen
123 await fold($)
124 return next(e)
125 })
126
127 on('prompt.submit', async ($, e, next) => {
128 if (isHuman(e.origin.kind)) {
129 fresh.prompts += 1
130 fresh.hours[hourOf(await $.clock.now())]! += 1
131 }
132 return next(e)
133 })
134
135 on('tool.call', async ($, e, next) => {
136 const ran = await next(e)
137 if (ran.deny !== undefined) return ran
138 fresh.tools[e.tool] = (fresh.tools[e.tool] ?? 0) + 1
139 const isError = ran.isError === true
140 if (isError) {
141 fresh.failed += 1
142 return ran
143 }
144 if (e.tool === 'Edit') {
145 fresh.lines += lineCount(e.new_string)
146 fresh.files.push(hashPath(e.file_path))
147 } else if (e.tool === 'Write') {
148 fresh.lines += lineCount(e.content)
149 fresh.files.push(hashPath(e.file_path))
150 } else if (e.tool === 'Bash' && isCommit(e.command, ran.text ?? '', isError)) {
151 fresh.commits += 1
152 }
153 return ran
154 })
155
156 on('turn.complete', async ($, e, next) => {
157 if (e.agentId !== undefined) return next(e)
158 const today = dayKey(await $.clock.now())
159 fresh.turns += 1
160 fresh.turnMs += e.durationMs
161 fresh.longestTurnMs = Math.max(fresh.longestTurnMs, e.durationMs)
162 fresh.days[today] = (fresh.days[today] ?? 0) + 1
163 if (project !== '') fresh.projects[project] = (fresh.projects[project] ?? 0) + 1
164 await flush($)
165 return next(e)
166 })
167
168 on('session.end', async ($, e, next) => {
169 await flush($)
170 return next(e)
171 })
172
173 on('command.run', { command: 'wrapped' }, async ($, e) => {
174 const [verb = '', arg = ''] = e.args.trim().toLowerCase().split(/\s+/)
175
176 if (verb === 'reset') {
177 let answer = ''
178 try {
179 answer = await $.ui.ask('Wipe every wrapped stat on this machine? This cannot be undone.', {
180 header: 'wrapped',
181 options: ['Wipe my stats', 'Keep them'],
182 })
183 } catch {
184 // Nobody to ask: keep everything.
185 }
186 if (answer !== 'Wipe my stats') return { text: 'wrapped: kept your stats.' }
187 for (const key of await $.store.keys()) await $.store.delete(key)
188 fresh = emptyTally(Number.MAX_SAFE_INTEGER)
189 costSeen = 0
190 await update($, summary, () => null)
191 return { text: 'wrapped: all stats wiped. Counting starts fresh from now.' }
192 }
193
194 if (verb === 'text') {
195 const year = await yearFrom($, arg)
196 if (year === undefined) return { text: 'Usage: /wrapped text [year|all]' }
197 const shown = await gather($, year)
198 if (shown.isEmpty) return { text: `${shown.label}: nothing counted yet. wrapped counts from the day it was installed.` }
199 return { text: shareCard(shown, shareCost) }
200 }
201
202 const year = await yearFrom($, verb)
203 if (year === undefined) return { text: 'Usage: /wrapped [year|all] · /wrapped text [year|all] · /wrapped reset' }
204 const shown = await gather($, year)
205 await update($, summary, () => shown)
206 const opened = await $.ui.open({ id: PANE, title: 'Wrapped', focus: true, closeOnEscape: true })
207 return opened.isPlaced ? {} : { text: shareCard(shown, shareCost) }
208 })
209
210 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
211 const { Box, Text, Button } = $.ui.resolve(e)
212 const shown = await read($, summary)
213 const width = Math.max(24, Math.min(e.props.bodyColumns, 72))
214
215 if (shown === null || shown.isEmpty) {
216 return (
217 <Box flexDirection="column" gap={1} width={width}>
218 <Text bold color="#c084fc">
219 ✦ {shown?.label ?? 'Claude Code Wrapped'} ✦
220 </Text>
221 <Text>Nothing counted yet. wrapped keeps score from the day it was installed: come back after a few turns.</Text>
222 <Button key="close" label="Close" role="dismiss" hotkey="x" onPress={() => $.ui.close({ id: PANE })} />
223 </Box>
224 )
225 }
226
227 const perRow = width >= 54 ? 3 : 2
228 const cell = Math.floor(width / perRow)
229 const headline: [string, string][] = [
230 [num(shown.prompts), 'prompts'],
231 [num(shown.sessions), 'sessions'],
232 [duration(shown.turnMs), 'with Claude'],
233 [num(shown.toolCalls), 'tool calls'],
234 [num(shown.lines), 'lines written'],
235 [num(shown.commits), 'commits'],
236 ]
237 const rows: [string, string][][] = []
238 for (let i = 0; i < headline.length; i += perRow) rows.push(headline.slice(i, i + perRow))
239
240 const maxCount = shown.topTools[0]?.count ?? 0
241 const nameWidth = Math.max(4, ...shown.topTools.map(t => t.name.length))
242 const barWidth = Math.max(4, width - nameWidth - 10)
243 const facts = [
244 shown.streak.longest > 0 ? `🔥 ${shown.streak.current}-day streak · best ${shown.streak.longest}` : '',
245 shown.busiestHour >= 0 ? `🕐 busiest hour ${hourLabel(shown.busiestHour)}` : '',
246 shown.longestTurnMs > 0 ? `⏱ longest turn ${duration(shown.longestTurnMs)}` : '',
247 shown.topProject !== '' ? `📁 top project ${shown.topProject}` : '',
248 shown.files > 0 ? `📄 ${num(shown.files)} files touched` : '',
249 shown.cost > 0 ? `💸 $${shown.cost.toFixed(2)} of tokens` : '',
250 ].filter(f => f !== '')
251
252 return (
253 <Box flexDirection="column" width={width}>
254 <Text bold color="#c084fc">
255 ✦ {shown.label.toUpperCase()} ✦
256 </Text>
257 <Text> </Text>
258 <Box flexDirection="row" gap={1}>
259 <Text>You are</Text>
260 <Text bold color="#fbbf24">
261 {shown.persona.emoji} {shown.persona.title}
262 </Text>
263 </Box>
264 <Text dimColor wrap="wrap">
265 {shown.persona.blurb}
266 </Text>
267 <Text> </Text>
268 {rows.map(row => (
269 <Box flexDirection="column" marginBottom={1}>
270 <Box flexDirection="row">
271 {row.map(([value]) => (
272 <Box width={cell}>
273 <Text bold color="#34d399">
274 {value}
275 </Text>
276 </Box>
277 ))}
278 </Box>
279 <Box flexDirection="row">
280 {row.map(([, name]) => (
281 <Box width={cell}>
282 <Text dimColor>{name}</Text>
283 </Box>
284 ))}
285 </Box>
286 </Box>
287 ))}
288 {shown.topTools.length > 0 && <Text bold>Top tools</Text>}
289 {shown.topTools.map(tool => (
290 <Box flexDirection="row" gap={1}>
291 <Box width={nameWidth}>
292 <Text>{tool.name}</Text>
293 </Box>
294 <Text color="#60a5fa">{bar(tool.count, maxCount, barWidth)}</Text>
295 <Text dimColor>{num(tool.count)}</Text>
296 </Box>
297 ))}
298 <Text> </Text>
299 {facts.map(fact => (
300 <Text wrap="truncate-end">{fact}</Text>
301 ))}
302 <Text> </Text>
303 <Box flexDirection="row" gap={2}>
304 <Button
305 key="copy"
306 label="Copy share card"
307 hotkey="c"
308 variant="primary"
309 autoFocus
310 onPress={press => copyCard($, press.surface)}
311 />
312 <Button key="close" label="Close" role="dismiss" hotkey="x" onPress={() => $.ui.close({ id: PANE })} />
313 </Box>
314 </Box>
315 )
316 })
317}
318hooks/stats.ts 307 lines1// Counting and summing a Claude Code life: no `$`, so tests import it directly.
2
3import type { Persona, Summary } from '../types'
4
5/**
6 * One session's counters, kept under `s:<session id>` in the store. Only that
7 * session writes its key, so parallel sessions never race; old sessions are
8 * folded into one record per year (`y:<year>`).
9 */
10export type Tally = {
11 v: 1
12 firstAt: number
13 lastAt: number
14 sessions: number
15 prompts: number
16 turns: number
17 turnMs: number
18 longestTurnMs: number
19 tools: Record<string, number>
20 failed: number
21 /** Short hashes of the files edited, never the paths themselves. */
22 files: string[]
23 lines: number
24 commits: number
25 /** Prompts per local hour of day, 0 to 23. */
26 hours: number[]
27 /** Turns per local day, `YYYY-MM-DD`. */
28 days: Record<string, number>
29 /** Turns per project folder name. */
30 projects: Record<string, number>
31 /** US dollars, as the session's cost ledger counts them. */
32 cost: number
33 /** The ledger's last reading, so a reload never counts the same dollars twice. */
34 costSeen: number
35}
36
37// Unique-file hashes kept: per session, and per folded year.
38const MAX_FILES = 500
39const MAX_YEAR_FILES = 20_000
40
41export function emptyTally(now: number): Tally {
42 return {
43 v: 1,
44 firstAt: now,
45 lastAt: now,
46 sessions: 0,
47 prompts: 0,
48 turns: 0,
49 turnMs: 0,
50 longestTurnMs: 0,
51 tools: {},
52 failed: 0,
53 files: [],
54 lines: 0,
55 commits: 0,
56 hours: Array.from({ length: 24 }, () => 0),
57 days: {},
58 projects: {},
59 cost: 0,
60 costSeen: 0,
61 }
62}
63
64function addCounts(a: Record<string, number>, b: Record<string, number>): Record<string, number> {
65 const out = { ...a }
66 for (const [key, n] of Object.entries(b)) out[key] = (out[key] ?? 0) + n
67 return out
68}
69
70/** Reads a stored value as a Tally, filling what an older or damaged record lacks. */
71export function asTally(value: unknown, now: number): Tally {
72 const base = emptyTally(now)
73 if (typeof value !== 'object' || value === null) return base
74 const t = { ...base, ...(value as Partial<Tally>) }
75 t.hours = Array.from({ length: 24 }, (_, i) => Number(t.hours?.[i] ?? 0))
76 t.files = Array.isArray(t.files) ? t.files : []
77 return t
78}
79
80/** Adds two tallies: counts summed, records kept, unique files unioned. */
81export function merge(a: Tally, b: Tally): Tally {
82 const files = new Set(a.files)
83 for (const f of b.files) {
84 if (files.size >= MAX_YEAR_FILES) break
85 files.add(f)
86 }
87 return {
88 v: 1,
89 firstAt: Math.min(a.firstAt, b.firstAt),
90 lastAt: Math.max(a.lastAt, b.lastAt),
91 sessions: a.sessions + b.sessions,
92 prompts: a.prompts + b.prompts,
93 turns: a.turns + b.turns,
94 turnMs: a.turnMs + b.turnMs,
95 longestTurnMs: Math.max(a.longestTurnMs, b.longestTurnMs),
96 tools: addCounts(a.tools, b.tools),
97 failed: a.failed + b.failed,
98 files: [...files],
99 lines: a.lines + b.lines,
100 commits: a.commits + b.commits,
101 hours: a.hours.map((n, i) => n + (b.hours[i] ?? 0)),
102 days: addCounts(a.days, b.days),
103 projects: addCounts(a.projects, b.projects),
104 cost: a.cost + b.cost,
105 costSeen: 0,
106 }
107}
108
109/** Adds a session's fresh counts to its stored record. */
110export function absorb(stored: Tally, fresh: Tally): Tally {
111 const merged = merge(stored, fresh)
112 merged.files = merged.files.slice(0, MAX_FILES)
113 merged.sessions = 1
114 merged.costSeen = stored.costSeen
115 return merged
116}
117
118/** The dollars a ledger reading adds: the rise since the last one, or all of it after a reset. */
119export function costDelta(seen: number, reading: number): number {
120 return reading >= seen ? reading - seen : reading
121}
122
123const pad = (n: number) => String(n).padStart(2, '0')
124
125export function dayKey(ms: number): string {
126 const d = new Date(ms)
127 return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
128}
129
130export function hourOf(ms: number): number {
131 return new Date(ms).getHours()
132}
133
134export function yearOf(ms: number): string {
135 return String(new Date(ms).getFullYear())
136}
137
138/** FNV-1a, 32 bits: enough to count unique files without keeping their paths. */
139export function hashPath(path: string): string {
140 let h = 0x811c9dc5
141 for (let i = 0; i < path.length; i++) {
142 h ^= path.charCodeAt(i)
143 h = Math.imul(h, 0x01000193) >>> 0
144 }
145 return h.toString(36)
146}
147
148export function lineCount(text: string): number {
149 if (text === '') return 0
150 return text.split('\n').length - (text.endsWith('\n') ? 1 : 0)
151}
152
153export function isCommit(command: string, output: string, isError: boolean): boolean {
154 if (isError) return false
155 if (!/\bgit\b(\s+-[Cc]\s+\S+)*\s+commit\b/.test(command) || /--dry-run/.test(command)) return false
156 return !/nothing to commit|no changes added to commit/i.test(output)
157}
158
159/** Current and longest runs of consecutive active days; today may still be ahead. */
160export function streaks(days: readonly string[], today: string): { current: number; longest: number } {
161 const toNum = (key: string) => Math.round(Date.UTC(+key.slice(0, 4), +key.slice(5, 7) - 1, +key.slice(8, 10)) / 86_400_000)
162 const set = new Set(days.map(toNum))
163 const sorted = [...set].sort((a, b) => a - b)
164 let longest = 0
165 let run = 0
166 let prev = Number.NaN
167 for (const n of sorted) {
168 run = n === prev + 1 ? run + 1 : 1
169 longest = Math.max(longest, run)
170 prev = n
171 }
172 let cursor = toNum(today)
173 if (!set.has(cursor)) cursor -= 1
174 let current = 0
175 while (set.has(cursor)) {
176 current++
177 cursor--
178 }
179 return { current, longest }
180}
181
182export function personaOf(t: Tally): Persona {
183 const calls = Object.values(t.tools).reduce((a, b) => a + b, 0)
184 const share = (...names: string[]) => (calls === 0 ? 0 : names.reduce((a, n) => a + (t.tools[n] ?? 0), 0) / calls)
185 const busiest = busiestHour(t)
186 const hasHours = t.hours.some(n => n > 0)
187
188 if (hasHours && t.prompts >= 5 && (busiest >= 22 || busiest < 4)) {
189 return { emoji: '🦉', title: 'The Night Owl', blurb: `Your best ideas show up after dark. Peak hour: ${hourLabel(busiest)}.` }
190 }
191 if (hasHours && t.prompts >= 5 && busiest >= 5 && busiest <= 7) {
192 return { emoji: '🐦', title: 'The Early Bird', blurb: `Shipping before the coffee kicks in. Peak hour: ${hourLabel(busiest)}.` }
193 }
194 if (t.longestTurnMs > 15 * 60_000) {
195 return { emoji: '🏃', title: 'The Marathoner', blurb: `You hand Claude the big jobs. Longest turn: ${duration(t.longestTurnMs)}.` }
196 }
197 if (calls >= 20 && share('Bash') >= 0.4) {
198 return { emoji: '🧙', title: 'The Shell Wizard', blurb: 'The terminal is your wand. Most of your calls are Bash.' }
199 }
200 if (calls >= 20 && share('Edit', 'MultiEdit', 'Write', 'NotebookEdit') >= 0.35) {
201 return { emoji: '🔧', title: 'The Refactorer', blurb: 'No file is ever finished. You edit relentlessly.' }
202 }
203 if (calls >= 20 && share('Read', 'Grep', 'Glob') >= 0.5) {
204 return { emoji: '📚', title: 'The Reader', blurb: 'You understand before you change. Reading leads every session.' }
205 }
206 if (calls >= 20 && share('Agent', 'Task') >= 0.1) {
207 return { emoji: '🎼', title: 'The Conductor', blurb: 'Why do it yourself? You run an orchestra of subagents.' }
208 }
209 if (calls >= 20 && share('WebFetch', 'WebSearch') >= 0.15) {
210 return { emoji: '🌊', title: 'The Researcher', blurb: 'Half the internet has passed through your sessions.' }
211 }
212 if (t.commits >= 25) {
213 return { emoji: '🚢', title: 'The Shipper', blurb: `Talk is cheap: ${t.commits} commits and counting.` }
214 }
215 return { emoji: '✨', title: 'The Collaborator', blurb: 'A little of everything, with Claude at your side.' }
216}
217
218export function busiestHour(t: Tally): number {
219 let best = 0
220 t.hours.forEach((n, i) => {
221 if (n > (t.hours[best] ?? 0)) best = i
222 })
223 return best
224}
225
226export function hourLabel(hour: number): string {
227 const h = hour % 12 === 0 ? 12 : hour % 12
228 return `${h}${hour < 12 ? 'am' : 'pm'}`
229}
230
231export function duration(ms: number): string {
232 if (ms < 59_500) return `${Math.round(ms / 1000)}s`
233 const minutes = Math.round(ms / 60_000)
234 if (minutes < 60) return `${minutes}m`
235 const hours = minutes / 60
236 return hours < 10 ? `${hours.toFixed(1).replace(/\.0$/, '')}h` : `${Math.round(hours)}h`
237}
238
239export function num(n: number): string {
240 return Math.round(n).toLocaleString('en-US')
241}
242
243/** `1 commit`, `2 commits`, `1 line written`: the count, then the noun it takes. */
244export function count(n: number, one: string, many: string): string {
245 return `${num(n)} ${Math.round(n) === 1 ? one : many}`
246}
247
248function top(counts: Record<string, number>, n: number): { name: string; count: number }[] {
249 return Object.entries(counts)
250 .map(([name, count]) => ({ name, count }))
251 .sort((a, b) => b.count - a.count || a.name.localeCompare(b.name))
252 .slice(0, n)
253}
254
255/** Everything the card shows, worked out once. */
256export function summarize(t: Tally, label: string, today: string): Summary {
257 const calls = Object.values(t.tools).reduce((a, b) => a + b, 0)
258 const days = Object.keys(t.days)
259 return {
260 label,
261 isEmpty: t.turns === 0 && t.prompts === 0 && calls === 0,
262 prompts: t.prompts,
263 sessions: t.sessions,
264 turns: t.turns,
265 turnMs: t.turnMs,
266 toolCalls: calls,
267 failed: t.failed,
268 files: t.files.length,
269 lines: t.lines,
270 commits: t.commits,
271 cost: t.cost,
272 activeDays: days.length,
273 streak: streaks(days, today),
274 topTools: top(t.tools, 5),
275 topProject: top(t.projects, 1)[0]?.name ?? '',
276 busiestHour: t.hours.some(n => n > 0) ? busiestHour(t) : -1,
277 longestTurnMs: t.longestTurnMs,
278 firstSeen: t.firstAt,
279 persona: personaOf(t),
280 }
281}
282
283const PARTIALS = ['', '▏', '▎', '▍', '▌', '▋', '▊', '▉']
284
285/** A bar of `width` cells for `value` out of `max`, smooth to an eighth of a cell. */
286export function bar(value: number, max: number, width: number): string {
287 if (max <= 0 || width <= 0) return ''
288 const eighths = Math.round((value / max) * width * 8)
289 return '█'.repeat(Math.floor(eighths / 8)) + PARTIALS[eighths % 8]
290}
291
292export const SHARE_FOOTER = 'made with wrapped · github.com/Singh-AP/awesome-claude-mods'
293
294export function shareCard(s: Summary, withCost: boolean): string {
295 const lines = [
296 `✦ My ${s.label} ✦`,
297 `${s.persona.emoji} ${s.persona.title}`,
298 `${count(s.prompts, 'prompt', 'prompts')} · ${count(s.sessions, 'session', 'sessions')} · ${duration(s.turnMs)} with Claude`,
299 `${count(s.toolCalls, 'tool call', 'tool calls')} · ${count(s.lines, 'line written', 'lines written')} · ${count(s.commits, 'commit', 'commits')}`,
300 ]
301 if (s.topTools.length > 0) lines.push(`Top tools: ${s.topTools.slice(0, 3).map(t => t.name).join(' · ')}`)
302 if (s.streak.longest > 1) lines.push(`🔥 Longest streak: ${s.streak.longest} days`)
303 if (withCost && s.cost > 0) lines.push(`💸 $${s.cost.toFixed(2)} of tokens`)
304 lines.push(SHARE_FOOTER)
305 return lines.join('\n')
306}
307types/index.d.ts 34 lines1export type Persona = { emoji: string; title: string; blurb: string }
2
3/** What the card shows: a year's (or a lifetime's) stats, worked out once. */
4export type Summary = {
5 label: string
6 isEmpty: boolean
7 prompts: number
8 sessions: number
9 turns: number
10 turnMs: number
11 toolCalls: number
12 failed: number
13 files: number
14 lines: number
15 commits: number
16 cost: number
17 activeDays: number
18 streak: { current: number; longest: number }
19 topTools: { name: string; count: number }[]
20 topProject: string
21 busiestHour: number
22 longestTurnMs: number
23 firstSeen: number
24 persona: Persona
25}
26
27declare module 'claude-code' {
28 interface PluginState {
29 wrapped: {
30 summary: Summary | null
31 }
32 }
33}
34