SLOPSHOPPER

jr-wrapped

/jrwrapped: your month with Claude Code, Wrapped-style: prompts, hours, streak, busiest hour, top tools and projects, a persona, and a share card saved as SVG…

newpaneguardcommandtoastprompt
★ 2v0.1.0MITupdated 2026-10-15jrlprost/claude-code-mods/plugins/jr-wrapped
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · jr-wrapped
│ ┃ jr wrapped ✕ › fix the failing auth test and add an audit log call │ ┃ JR MODS · CLAUDE WRAPPED │ ┃ OCTOBER 2025 ⏺ Read(src/auth.ts) │ ┃ The Shell Whisperer · You speak fluent ⎿ Read 6 lines │ ┃ terminal. ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ 1 0h 1 ⏺ Bash(bun test) │ ┃ prompts Claude working acti ⎿ 3 pass, 1 fail │ ┃ │ ┃ TOP TOOLS TOP PROJECTS ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ 1. Bash 4 1. app 1 prompts │ ┃ 2. Write 2 ✻ Worked for 42s · done 4:20 PM │ ┃ 3. Read 1 │ ┃ › /jrwrapped │ ┃ [ Save the card to Downloads ] ⎿ jr-wrapped: jr wrapped · October 2025 │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · jr wrapped
JR MODS · CLAUDE WRAPPED OCTOBER 2025 The Shell Whisperer · You speak fluent terminal. 1 0h 1 prompts Claude working active days TOP TOOLS TOP PROJECTS 1. Bash 4 1. app 1 prompts 2. Write 2 3. Read 1 [ Save the card to Downloads ]
README

jr-wrapped

/jrwrapped sums up your month with Claude Code, Wrapped-style, and saves a card you can share.

JR MODS · CLAUDE WRAPPED
OCTOBER 2026
The Night Owl · Your best ideas arrive after dark.

1,204           38h              21            9d               23:00          $64.20
prompts         Claude working   active days   longest streak   busiest hour   spent

TOP TOOLS            TOP PROJECTS
1. Edit 412          1. tokenkarma 640 prompts
2. Bash 380          2. acme 301 prompts
3. Read 220          3. northwind 120 prompts

[ Save the card to Downloads ]

The persona comes from how the month went: the hour you work most, the tool Claude used most, how many projects you touched, how many days you showed up.

Commands

Command
/jrwrappedThis month
/jrwrapped lastLast month
/jrwrapped 2026-09 or /jrwrapped sepA given month
/jrwrapped exportSave the card as ~/Downloads/claude-wrapped-<month>.svg, plus .png when rsvg-convert or ImageMagick is installed

In the pane, e saves the card.

The mod records from the moment it is installed; earlier months stay empty.

Install

/plugin install jr-wrapped --marketplace jrlprost/claude-code-mods

Access

Records prompts, turns, tool names and cost in the plugin's own store. Reads the session's cost and project folder. Writes the card to ~/Downloads and runs rsvg-convert or magick to make the PNG, when asked. Sends nothing over the network.

Source 6 files
hooks/register.tsx 158 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import { loadDays, primeCost, recordPrompt, recordTool, recordTurn } from './activity'
5import { gradientText } from './jr/draw'
6import { GRADIENTS, paint } from './jr/gradient'
7import { hourLabel, hours, parseMonth, toSvg, wrapMonth } from './wrap'
8
9const PANE = 'jr-wrapped'
10const view = atom({ plugin: 'jr-wrapped', key: 'view' } as const, null)
11const ready = { isCommand: false }
12
13async function ensureCommand($: EngineInterface) {
14  if (ready.isCommand) return
15  ready.isCommand = true
16  await $.command.register({ name: 'jrwrapped', description: 'Your month with Claude, Wrapped-style; /jrwrapped last, /jrwrapped 2026-09, /jrwrapped export' })
17}
18
19async function build($: EngineInterface, arg: string) {
20  const now = await $.clock.now()
21  const { year, month } = parseMonth(arg, now)
22  const days = await loadDays($, new Date(year, month, 1).getTime(), new Date(year, month + 1, 0, 12).getTime())
23  const w = wrapMonth(days, year, month)
24  await update($, view, () => w)
25  return w
26}
27
28/** Writes the card as SVG, then as PNG when rsvg-convert or ImageMagick can draw it. */
29async function exportCard($: EngineInterface) {
30  const w = await read($, view)
31  if (!w) return 'Open /jrwrapped first.'
32  const home = (await $.env.get('HOME')) ?? '.'
33  const base = `${home}/Downloads/claude-wrapped-${w.month}`
34  await $.fs.write(`${base}.svg`, toSvg(w))
35  for (const argv of [
36    ['rsvg-convert', '-o', `${base}.png`, `${base}.svg`],
37    ['magick', `${base}.svg`, `${base}.png`],
38  ]) {
39    try {
40      const { exitCode } = await $.process.run(argv)
41      // ImageMagick can exit 0 without writing when it lacks a font: check the file.
42      if (exitCode === 0 && (await $.fs.exists(`${base}.png`))) return `Saved ${base}.png and .svg`
43    } catch {
44      // Not installed: try the next converter.
45    }
46  }
47  return `Saved ${base}.svg`
48}
49
50export const register: Register = on => {
51  on('session.start', async ($, e, next) => {
52    const started = await next(e)
53    await ensureCommand($)
54    await primeCost($)
55    return started
56  })
57
58  on('prompt.submit', async ($, e, next) => {
59    await ensureCommand($)
60    if (!e.command && !e.isMeta) {
61      await primeCost($)
62      await recordPrompt($)
63    }
64    return next(e)
65  })
66
67  on('turn.complete', async ($, e, next) => {
68    const done = await next(e)
69    if (e.agentId === undefined) await recordTurn($, e.durationMs)
70    return done
71  })
72
73  on('tool.call', async ($, e, next) => {
74    const ran = await next(e)
75    await recordTool($, String(e.tool))
76    return ran
77  })
78
79  on('command.run', { command: 'jrwrapped' }, async ($, e) => {
80    if (e.args.trim() === 'export') {
81      if (!(await read($, view))) await build($, '')
82      return { text: `jr wrapped · ${await exportCard($)}` }
83    }
84    const w = await build($, e.args)
85    await $.ui.open({ id: PANE, title: 'jr wrapped', focus: true, closeOnEscape: true, rows: 24 })
86    return { text: `jr wrapped · ${w.label}` }
87  })
88
89  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
90    const { Box, Text, Button } = $.ui.resolve(e)
91    const w = await read($, view)
92    if (!w) return <Text dimColor>Wrapping…</Text>
93    const stat = (value: string, label: string) => (
94      <Box flexDirection="column" width={18}>
95        <Text bold>{value}</Text>
96        <Text dimColor>{label}</Text>
97      </Box>
98    )
99    return (
100      <Box flexDirection="column" rowGap={1}>
101        <Box flexDirection="column">
102          <Text dimColor>{'JR MODS · CLAUDE WRAPPED'}</Text>
103          <Text bold>
104            {paint(w.label.toUpperCase(), GRADIENTS.brand).map(({ char, color }) => (
105              <Text color={color}>{char}</Text>
106            ))}
107          </Text>
108          <Text>
109            {gradientText(Text, w.persona.title, ['#ff5ca8', '#ffb86b'])}
110            <Text dimColor>{` · ${w.persona.line}`}</Text>
111          </Text>
112        </Box>
113        <Box columnGap={2} flexWrap="wrap">
114          {stat(w.prompts.toLocaleString('en-US'), 'prompts')}
115          {stat(hours(w.activeMs), 'Claude working')}
116          {stat(String(w.activeDays), 'active days')}
117          {stat(`${w.longestStreak}d`, 'longest streak')}
118          {stat(hourLabel(w.busiestHour), 'busiest hour')}
119          {stat(`$${w.costUsd.toFixed(2)}`, 'spent')}
120        </Box>
121        <Box columnGap={6} flexWrap="wrap">
122          <Box flexDirection="column">
123            <Text dimColor>{'TOP TOOLS'}</Text>
124            {w.topTools.length === 0 && <Text dimColor>—</Text>}
125            {w.topTools.map((t, i) => (
126              <Text>
127                <Text color="#ff5ca8" bold>{`${i + 1}. `}</Text>
128                {`${t.name} `}
129                <Text dimColor>{String(t.count)}</Text>
130              </Text>
131            ))}
132          </Box>
133          <Box flexDirection="column">
134            <Text dimColor>{'TOP PROJECTS'}</Text>
135            {w.topProjects.length === 0 && <Text dimColor>—</Text>}
136            {w.topProjects.map((p, i) => (
137              <Text>
138                <Text color="#ffb86b" bold>{`${i + 1}. `}</Text>
139                {`${p.name} `}
140                <Text dimColor>{`${p.prompts} prompts`}</Text>
141              </Text>
142            ))}
143          </Box>
144        </Box>
145        <Button
146          key="export"
147          label="Save the card to Downloads"
148          hotkey="e"
149          variant="primary"
150          onPress={async () => {
151            $.ui.toast(`jr wrapped · ${await exportCard($)}`)
152          }}
153        />
154      </Box>
155    )
156  })
157}
158
hooks/activity.ts 128 lines
1// Activity history shared by jr-week, jr-heatmap and jr-wrapped. Each mod keeps its own copy
2// of this file and of its records (a mod's store is its own); the code is identical across them.
3import type { EngineInterface } from 'claude-code'
4
5export type ProjectStats = { prompts: number; turns: number; activeMs: number; costUsd: number }
6
7export type DayStats = {
8  prompts: number
9  turns: number
10  /** Time Claude spent working (sum of turn durations). */
11  activeMs: number
12  costUsd: number
13  tools: Record<string, number>
14  /** Prompts per hour of the day, 24 entries. */
15  hours: number[]
16  projects: Record<string, ProjectStats>
17}
18
19/** Days of one month, keyed YYYY-MM-DD (local time). */
20export type MonthActivity = Record<string, DayStats>
21
22const pad = (n: number) => String(n).padStart(2, '0')
23
24export const dayKey = (t: number) => {
25  const d = new Date(t)
26  return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
27}
28
29export const monthKey = (t: number) => dayKey(t).slice(0, 7)
30
31export const emptyDay = (): DayStats => ({ prompts: 0, turns: 0, activeMs: 0, costUsd: 0, tools: {}, hours: Array.from({ length: 24 }, () => 0), projects: {} })
32
33const project = (day: DayStats, name: string) => (day.projects[name] ??= { prompts: 0, turns: 0, activeMs: 0, costUsd: 0 })
34
35export const addPrompt = (day: DayStats, at: number, name: string) => {
36  day.prompts += 1
37  day.hours[new Date(at).getHours()] = (day.hours[new Date(at).getHours()] ?? 0) + 1
38  project(day, name).prompts += 1
39}
40
41export const addTurn = (day: DayStats, durationMs: number, costUsd: number, name: string) => {
42  day.turns += 1
43  day.activeMs += durationMs
44  day.costUsd += costUsd
45  const p = project(day, name)
46  p.turns += 1
47  p.activeMs += durationMs
48  p.costUsd += costUsd
49}
50
51export const addTool = (day: DayStats, tool: string) => {
52  day.tools[tool] = (day.tools[tool] ?? 0) + 1
53}
54
55/** Every day from `from` to `to` (inclusive, local), oldest first. */
56export const daysBetween = (from: number, to: number) => {
57  const out: string[] = []
58  const d = new Date(from)
59  d.setHours(12, 0, 0, 0)
60  while (d.getTime() <= to + 12 * 3_600_000 && out.length < 400) {
61    out.push(dayKey(d.getTime()))
62    d.setDate(d.getDate() + 1)
63  }
64  return out
65}
66
67// ── Recording: one live record per session, in the mod's own store ──────────────────────────
68
69const STORE_PREFIX = 'activity:'
70const live: { project: string; lastCostUsd: number | null } = { project: '', lastCostUsd: null }
71
72export async function currentProject($: EngineInterface) {
73  if (!live.project) {
74    const root = await $.session.root()
75    live.project = root.split('/').pop() || root
76  }
77  return live.project
78}
79
80async function withDay($: EngineInterface, at: number, change: (day: DayStats) => void) {
81  const key = STORE_PREFIX + monthKey(at)
82  const month = ((await $.store.get(key)) as MonthActivity | undefined) ?? {}
83  const day = month[dayKey(at)] ?? emptyDay()
84  change(day)
85  month[dayKey(at)] = day
86  await $.store.set(key, month)
87}
88
89export async function recordPrompt($: EngineInterface) {
90  const at = await $.clock.now()
91  const name = await currentProject($)
92  await withDay($, at, day => addPrompt(day, at, name))
93}
94
95/** The session's cost so far, minus what was already counted: the cost of this turn. */
96export async function recordTurn($: EngineInterface, durationMs: number) {
97  const at = await $.clock.now()
98  const name = await currentProject($)
99  const usage = await $.session.usage()
100  const total = usage.cost?.usd ?? 0
101  const delta = live.lastCostUsd === null ? 0 : Math.max(0, total - live.lastCostUsd)
102  live.lastCostUsd = total
103  await withDay($, at, day => addTurn(day, durationMs, delta, name))
104}
105
106export async function primeCost($: EngineInterface) {
107  if (live.lastCostUsd !== null) return
108  const usage = await $.session.usage()
109  live.lastCostUsd = usage.cost?.usd ?? 0
110}
111
112export async function recordTool($: EngineInterface, tool: string) {
113  const at = await $.clock.now()
114  await withDay($, at, day => addTool(day, tool))
115}
116
117/** The recorded days between two instants, missing days filled empty. */
118export async function loadDays($: EngineInterface, from: number, to: number) {
119  const months = new Map<string, MonthActivity>()
120  const out: Record<string, DayStats> = {}
121  for (const key of daysBetween(from, to)) {
122    const m = key.slice(0, 7)
123    if (!months.has(m)) months.set(m, ((await $.store.get(STORE_PREFIX + m)) as MonthActivity | undefined) ?? {})
124    out[key] = months.get(m)![key] ?? emptyDay()
125  }
126  return out
127}
128
hooks/jr/draw.tsx 35 lines
1// Drawing helpers shared by every jr mod. Synced into each plugin's hooks/jr/.
2import type { Elements } from 'claude-code'
3
4import { bar, paint } from './gradient'
5
6type TextElement = Elements['terminal']['Text']
7
8/** `text` in a gradient, one color per character, bold by default. */
9export const gradientText = (Text: TextElement, text: string, stops: readonly string[], bold = true) => (
10  <Text bold={bold}>
11    {paint(text, stops).map(({ char, color }) => (
12      <Text color={color}>{char}</Text>
13    ))}
14  </Text>
15)
16
17/** A gauge: filled cells in the gradient, empty cells dim. */
18export const gauge = (Text: TextElement, ratio: number, width: number, stops: readonly string[]) => (
19  <Text>
20    {bar(ratio, width, stops).map(cell =>
21      cell.empty ? (
22        <Text color={cell.color} dimColor>
23          {cell.char}
24        </Text>
25      ) : (
26        <Text color={cell.color}>{cell.char}</Text>
27      ),
28    )}
29  </Text>
30)
31
32/** The jr tag that opens every band line: a small gradient label. */
33export const tag = (Text: TextElement, label: string, stops: readonly string[]) =>
34  gradientText(Text, `${label} `, stops)
35
hooks/jr/gradient.ts 75 lines
1// Shared look of every jr mod: one palette, gradients drawn one cell at a time.
2// Synced into each plugin's hooks/jr/ by scripts/sync-shared.sh: edit it here only.
3
4export type Rgb = readonly [number, number, number]
5
6const hexToRgb = (hex: string): Rgb => {
7  const n = Number.parseInt(hex.replace('#', ''), 16)
8  return [(n >> 16) & 255, (n >> 8) & 255, n & 255]
9}
10
11const rgbToHex = ([r, g, b]: Rgb) =>
12  `#${[r, g, b].map(v => Math.round(v).toString(16).padStart(2, '0')).join('')}`
13
14export const GRADIENTS = {
15  // The jr signature: violet to pink to amber.
16  brand: ['#7f5af0', '#ff5ca8', '#ffb86b'],
17  // Calm to alarm, for gauges where full is bad.
18  heat: ['#2cb67d', '#f4d35e', '#ff8c42', '#ef4565'],
19  // Cool, for neutral figures.
20  ice: ['#3da9fc', '#7f5af0'],
21  // Money and growth.
22  money: ['#2cb67d', '#a8e6cf'],
23} as const satisfies Record<string, readonly string[]>
24
25export type GradientName = keyof typeof GRADIENTS
26
27/** The color at `t` (0..1) along the stops. */
28export const colorAt = (stops: readonly string[], t: number): string => {
29  if (stops.length === 1) return stops[0]!
30  const clamped = Math.min(1, Math.max(0, t))
31  const span = clamped * (stops.length - 1)
32  const i = Math.min(stops.length - 2, Math.floor(span))
33  const a = hexToRgb(stops[i]!)
34  const b = hexToRgb(stops[i + 1]!)
35  const local = span - i
36  return rgbToHex([0, 1, 2].map(k => a[k]! + (b[k]! - a[k]!) * local) as unknown as Rgb)
37}
38
39/** One color per character of `text`, spread across the stops. */
40export const paint = (text: string, stops: readonly string[]) => {
41  const chars = [...text]
42  return chars.map((char, i) => ({ char, color: colorAt(stops, chars.length > 1 ? i / (chars.length - 1) : 0) }))
43}
44
45const EIGHTHS = ['', '▏', '▎', '▍', '▌', '▋', '▊', '▉']
46
47/**
48 * A bar `width` cells wide filled to `ratio` (0..1), with eighth-cell precision.
49 * Each filled cell takes the gradient color of its position along the whole bar,
50 * so a nearly full heat bar ends red and a low one stays green.
51 */
52export const bar = (ratio: number, width: number, stops: readonly string[]) => {
53  const filled = Math.min(1, Math.max(0, ratio)) * width
54  const full = Math.floor(filled)
55  const partial = EIGHTHS[Math.round((filled - full) * 7)] ?? ''
56  const cells: { char: string; color: string; empty?: true }[] = []
57  for (let i = 0; i < width; i++) {
58    const color = colorAt(stops, width > 1 ? i / (width - 1) : 0)
59    if (i < full) cells.push({ char: '█', color })
60    else if (i === full && partial) cells.push({ char: partial, color })
61    else cells.push({ char: '·', color, empty: true })
62  }
63  return cells
64}
65
66export const formatTokens = (n: number) =>
67  n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)}M` : n >= 10_000 ? `${Math.round(n / 1000)}k` : n >= 1000 ? `${(n / 1000).toFixed(1)}k` : `${n}`
68
69export const formatDuration = (ms: number) => {
70  const minutes = Math.max(0, Math.round(ms / 60_000))
71  const h = Math.floor(minutes / 60)
72  const m = minutes % 60
73  return h > 0 ? `${h}h${String(m).padStart(2, '0')}` : `${m}min`
74}
75
hooks/wrap.ts 135 lines
1import type { DayStats } from './activity'
2import { emptyDay } from './activity'
3
4export type Wrapped = {
5  month: string
6  label: string
7  prompts: number
8  turns: number
9  activeMs: number
10  costUsd: number
11  activeDays: number
12  longestStreak: number
13  busiestHour: number | null
14  busiestDay: { key: string; prompts: number } | null
15  topTools: { name: string; count: number }[]
16  topProjects: { name: string; prompts: number }[]
17  persona: { title: string; line: string }
18}
19
20const MONTHS = ['january', 'february', 'march', 'april', 'may', 'june', 'july', 'august', 'september', 'october', 'november', 'december']
21
22/** `''` (this month), `last`, `2026-10`, `oct`, `october` → { year, month (0-based) }. */
23export const parseMonth = (arg: string, now: number) => {
24  const d = new Date(now)
25  const a = arg.trim().toLowerCase()
26  if (!a) return { year: d.getFullYear(), month: d.getMonth() }
27  if (a === 'last') return d.getMonth() === 0 ? { year: d.getFullYear() - 1, month: 11 } : { year: d.getFullYear(), month: d.getMonth() - 1 }
28  const iso = /^(\d{4})-(\d{1,2})$/.exec(a)
29  if (iso) return { year: Number(iso[1]), month: Math.min(11, Math.max(0, Number(iso[2]) - 1)) }
30  const i = MONTHS.findIndex(m => m.startsWith(a.slice(0, 3)))
31  if (i >= 0) return { year: i > d.getMonth() ? d.getFullYear() - 1 : d.getFullYear(), month: i }
32  return { year: d.getFullYear(), month: d.getMonth() }
33}
34
35const top = <T>(entries: [string, number][], n: number, make: (name: string, v: number) => T) =>
36  entries.filter(([, v]) => v > 0).sort((a, b) => b[1] - a[1]).slice(0, n).map(([k, v]) => make(k, v))
37
38/** A playful title from how the month went. */
39export const personaOf = (w: Pick<Wrapped, 'busiestHour' | 'topTools' | 'topProjects' | 'prompts' | 'activeDays'>, projectCount: number) => {
40  const tool = w.topTools[0]?.name ?? ''
41  if (w.prompts === 0) return { title: 'The Sleeper', line: 'A quiet month. Claude missed you.' }
42  if (w.busiestHour !== null && (w.busiestHour >= 22 || w.busiestHour < 5)) return { title: 'The Night Owl', line: 'Your best ideas arrive after dark.' }
43  if (projectCount >= 6) return { title: 'The Juggler', line: `${projectCount} projects in one month. Respect.` }
44  if (/^(Bash|PowerShell)$/.test(tool)) return { title: 'The Shell Whisperer', line: 'You speak fluent terminal.' }
45  if (/^(Edit|Write|MultiEdit|NotebookEdit)$/.test(tool)) return { title: 'The Code Sculptor', line: 'Edit after edit, you shape things.' }
46  if (/^(Read|Grep|Glob)$/.test(tool)) return { title: 'The Explorer', line: 'You read the map before you move.' }
47  if (/^(WebFetch|WebSearch)$/.test(tool) || tool.startsWith('mcp__')) return { title: 'The Researcher', line: 'The whole internet is your library.' }
48  if (w.activeDays >= 20) return { title: 'The Marathoner', line: 'Showing up, day after day.' }
49  return { title: 'The Steady Builder', line: 'One prompt at a time, it adds up.' }
50}
51
52export const wrapMonth = (days: Record<string, DayStats>, year: number, month: number): Wrapped => {
53  const prefix = `${year}-${String(month + 1).padStart(2, '0')}`
54  // Every day of the month, recorded or not, so a gap breaks a streak.
55  const length = new Date(year, month + 1, 0).getDate()
56  const keys = Array.from({ length }, (_, i) => `${prefix}-${String(i + 1).padStart(2, '0')}`)
57  const tools = new Map<string, number>()
58  const projects = new Map<string, number>()
59  const hours = Array.from({ length: 24 }, () => 0)
60  let prompts = 0
61  let turns = 0
62  let activeMs = 0
63  let costUsd = 0
64  let activeDays = 0
65  let run = 0
66  let longestStreak = 0
67  let busiestDay: Wrapped['busiestDay'] = null
68  for (const k of keys) {
69    const d = days[k] ?? emptyDay()
70    prompts += d.prompts
71    turns += d.turns
72    activeMs += d.activeMs
73    costUsd += d.costUsd
74    if (d.prompts > 0) activeDays += 1
75    run = d.prompts > 0 ? run + 1 : 0
76    longestStreak = Math.max(longestStreak, run)
77    if (d.prompts > (busiestDay?.prompts ?? 0)) busiestDay = { key: k, prompts: d.prompts }
78    d.hours.forEach((n, h) => (hours[h]! += n))
79    for (const [t, n] of Object.entries(d.tools)) tools.set(t, (tools.get(t) ?? 0) + n)
80    for (const [p, s] of Object.entries(d.projects)) projects.set(p, (projects.get(p) ?? 0) + s.prompts)
81  }
82  const peak = Math.max(...hours)
83  const base = {
84    month: prefix,
85    label: new Date(year, month, 1).toLocaleDateString('en-GB', { month: 'long', year: 'numeric' }),
86    prompts,
87    turns,
88    activeMs,
89    costUsd,
90    activeDays,
91    longestStreak,
92    busiestHour: peak > 0 ? hours.indexOf(peak) : null,
93    busiestDay,
94    topTools: top([...tools.entries()], 3, (name, count) => ({ name, count })),
95    topProjects: top([...projects.entries()], 3, (name, prompts) => ({ name, prompts })),
96  }
97  return { ...base, persona: personaOf(base, [...projects.values()].filter(v => v > 0).length) }
98}
99
100export const hours = (ms: number) => `${Math.round(ms / 3_600_000)}h`
101export const hourLabel = (h: number | null) => (h === null ? '—' : `${String(h).padStart(2, '0')}:00`)
102
103const esc = (s: string) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
104
105/** A 1080×1350 share card in the jr colours. */
106export const toSvg = (w: Wrapped) => {
107  const stat = (x: number, y: number, value: string, label: string) =>
108    `<text x="${x}" y="${y}" font-size="72" font-weight="700" fill="#fffffe">${esc(value)}</text><text x="${x}" y="${y + 44}" font-size="26" fill="#94a1b2">${esc(label)}</text>`
109  const list = (y: number, title: string, rows: string[]) =>
110    `<text x="90" y="${y}" font-size="26" fill="#94a1b2" letter-spacing="3">${esc(title.toUpperCase())}</text>` +
111    rows.map((r, i) => `<text x="90" y="${y + 54 + i * 50}" font-size="38" fill="#fffffe">${i + 1}. ${esc(r)}</text>`).join('')
112  return `<svg xmlns="http://www.w3.org/2000/svg" width="1080" height="1350" viewBox="0 0 1080 1350" font-family="Helvetica, Arial, sans-serif">
113  <defs>
114    <linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
115      <stop offset="0" stop-color="#7f5af0"/><stop offset="0.55" stop-color="#ff5ca8"/><stop offset="1" stop-color="#ffb86b"/>
116    </linearGradient>
117  </defs>
118  <rect width="1080" height="1350" fill="#16161a"/>
119  <rect x="0" y="0" width="1080" height="360" fill="url(#g)"/>
120  <text x="90" y="130" font-size="34" fill="#16161a" letter-spacing="6">JR MODS · CLAUDE WRAPPED</text>
121  <text x="90" y="240" font-size="96" font-weight="700" fill="#16161a">${esc(w.label)}</text>
122  <text x="90" y="310" font-size="34" fill="#16161a">${esc(w.persona.title)} · ${esc(w.persona.line)}</text>
123  ${stat(90, 500, w.prompts.toLocaleString('en-US'), 'prompts')}
124  ${stat(420, 500, hours(w.activeMs), 'Claude working')}
125  ${stat(750, 500, String(w.activeDays), 'active days')}
126  ${stat(90, 680, `${w.longestStreak}d`, 'longest streak')}
127  ${stat(420, 680, hourLabel(w.busiestHour), 'busiest hour')}
128  ${stat(750, 680, `$${Math.round(w.costUsd)}`, 'spent')}
129  ${list(860, 'Top tools', w.topTools.map(t => `${t.name} · ${t.count}`))}
130  ${list(1090, 'Top projects', w.topProjects.map(p => `${p.name} · ${p.prompts} prompts`))}
131  <text x="990" y="1310" text-anchor="end" font-size="22" fill="#5c5f77">github.com/jrlprost/claude-code-mods</text>
132</svg>
133`
134}
135
types/index.d.ts 22 lines
1export type JrWrapped = {
2  month: string
3  label: string
4  prompts: number
5  turns: number
6  activeMs: number
7  costUsd: number
8  activeDays: number
9  longestStreak: number
10  busiestHour: number | null
11  busiestDay: { key: string; prompts: number } | null
12  topTools: { name: string; count: number }[]
13  topProjects: { name: string; prompts: number }[]
14  persona: { title: string; line: string }
15}
16
17declare module 'claude-code' {
18  interface PluginState {
19    'jr-wrapped': { view: JrWrapped | null }
20  }
21}
22