SLOPSHOPPER

wrapped

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

newpaneguardcommandtoastprompt
★ 1v1.0.0MITupdated 2026-10-04Singh-AP/awesome-claude-mods/mods/fun/wrapped
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · wrapped
│ ┃ Wrapped ✕ › fix the failing auth test and add an audit log call │ ┃ ✦ CLAUDE CODE WRAPPED · 2025 ✦ │ ┃ ⏺ Read(src/auth.ts) │ ┃ You are ✨ The Collaborator ⎿ Read 6 lines │ ┃ A little of everything, with Claude at your ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ 1 1 42s ⏺ Bash(bun test) │ ┃ prompts sessions with Cla ⎿ 3 pass, 1 fail │ ┃ │ ┃ 9 14 0 ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ tool calls lines written commits │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ Top tools │ ┃ Bash ██████████████████████████████████████ › /wrapped │ ┃ Write ████████████████████▌ 2 │ ┃ Edit ██████████▎ 1 │ ┃ Grep ██████████▎ 1 │ ┃ Read ██████████▎ 1 │ ┃ │ ┃ 🔥 1-day streak · best 1 │ ┃ 🕐 busiest hour 1am │ ┃ ⏱ longest turn 42s │ ┃ 📁 top project app │ ┃ 📄 3 files touched │ ┃ 💸 $0.42 of tokens │ ┃ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Wrapped
✦ CLAUDE CODE WRAPPED · 2025 ✦ You are ✨ The Collaborator A little of everything, with Claude at your side. 1 1 42s prompts sessions with Claude 9 14 0 tool calls lines written commits Top tools Bash █████████████████████████████████████████ 4 Write ████████████████████▌ 2 Edit ██████████▎ 1 Grep ██████████▎ 1 Read ██████████▎ 1 🔥 1-day streak · best 1 🕐 busiest hour 1am ⏱ longest turn 42s 📁 top project app 📄 3 files touched 💸 $0.42 of tokens [ Copy share card ] [ Close ]
README

🎁 wrapped

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

Features

  • Counts what matters: sessions, your prompts, turns and time with Claude, tool calls by tool, failed calls, unique files edited, lines written, commits, cost
  • Streaks: your current and longest run of days with Claude Code
  • A coding persona from your habits: 🦉 Night Owl, 🐦 Early Bird, 🏃 Marathoner, 🧙 Shell Wizard, 🔧 Refactorer, 📚 Reader, 🎼 Conductor, 🌊 Researcher, 🚢 Shipper, or ✨ Collaborator
  • Any year or all time: /wrapped 2025, /wrapped all
  • Share card: one button copies it, and /wrapped text prints it. Cost stays off the card unless you turn shareCost on.
  • Private by design: aggregate counters only. No prompts, no transcripts, no file paths (files are counted by hash). Nothing leaves your machine unless you paste the card.
  • Safe with parallel sessions: each session writes only its own record. Old records fold into one per year, so the store stays small.

Install

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

Commands

CommandWhat it does
/wrappedOpens this year's card in a pane (c copies, x or Esc closes)
/wrapped 2025 · /wrapped allAnother year, or your whole history
`/wrapped text [year\all]`Prints the share card into the transcript
/wrapped resetWipes every stat, after asking

Configuration

OptionDefaultWhat it does
shareCostfalseAdds 💸 $… of tokens to the copied share card. The pane always shows cost.

How it works

Event / APIWhy
prompt.submitCounts your prompts (typed, Remote Control or SDK, but not plugin or notification prompts) and the hour you sent them
tool.callAfter next(e): counts the call by tool, failures, lines from Edit/Write, file hashes, successful git commits
turn.completeMain-loop turns, their duration, the day and the project; then writes this session's record
session.start · session.endRegisters /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
$.stores:<session id> per session, y:<year> for folded history
ui.render on Pane · $.ui.copyThe card and the share button

The counting, streak and persona logic (hooks/stats.ts) is pure TypeScript with no $.

Test it

claude plugin test mods/fun/wrapped   # 30 tests

Limitations

  • "Lines written" counts the lines in each Edit's replacement text and each Write's content, so it measures output, not net change.
  • Unique files are counted by hash, capped at 500 per session and 20,000 per folded year, so huge years undercount slightly.
  • Counts made in the last moments before a crash may be lost. Records are written at the end of each turn and on exit.
  • Hours and days use the machine's local time zone.
Source 3 files
hooks/register.tsx 318 lines
1import { 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}
318
hooks/stats.ts 307 lines
1// 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}
307
types/index.d.ts 34 lines
1export 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