/jrweek: your week with Claude Code, per day and per project: time Claude worked, prompts and cost, with gradient bars and a Markdown export.

/jrweek shows your week with Claude Code: for each day and each project, how long Claude worked, how many prompts you sent, and what it cost.
jr week 12 Oct – 18 Oct [This week] [Last week] [Export]
Claude working time prompts cost
Mon ████████████████·········· 2h40 34 $4.12
Tue ██████████················ 1h38 21 $2.60
...
Total 11h05 143 $17.90
──────────────────────────────────────────────
tokenkarma ███████████ 6h12 80 $10.40
acme ██████····· 3h01 41 $5.02
| Command | |
|---|---|
/jrweek | Open the week |
In the pane: t this week, l last week, e export the shown week as Markdown to ~/Downloads.
The mod records from the moment it is installed; earlier weeks stay empty.
/plugin install jr-week --marketplace jrlprost/claude-code-mods
Records prompts, turns, tool names and cost in the plugin's own store. Reads the session's cost and project folder. Writes the export to ~/Downloads when asked. Sends nothing over the network.
hooks/register.tsx 124 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import { loadDays, primeCost, recordPrompt, recordTool, recordTurn } from './activity'
5import { gauge, gradientText } from './jr/draw'
6import { GRADIENTS } from './jr/gradient'
7import { hours, toMarkdown, usd, weekReport, weekStart } from './week'
8
9const PANE = 'jr-week'
10const DAY = 86_400_000
11const view = atom({ plugin: 'jr-week', key: 'view' } as const, null)
12const ready = { isCommand: false }
13
14async function ensureCommand($: EngineInterface) {
15 if (ready.isCommand) return
16 ready.isCommand = true
17 await $.command.register({ name: 'jrweek', description: 'This week with Claude: time, prompts and cost per day and project' })
18}
19
20async function refresh($: EngineInterface, back: number) {
21 const now = await $.clock.now()
22 const from = weekStart(now, back)
23 const days = await loadDays($, from, from + 6 * DAY)
24 await update($, view, () => ({ back, report: weekReport(days, from) }))
25}
26
27async function exportWeek($: EngineInterface) {
28 const v = await read($, view)
29 if (!v) return
30 const home = (await $.env.get('HOME')) ?? '.'
31 const file = `${home}/Downloads/jr-week-${new Date(v.report.from).toISOString().slice(0, 10)}.md`
32 await $.fs.write(file, toMarkdown(v.report))
33 $.ui.toast(`jr week · exported ${file}`)
34}
35
36export const register: Register = on => {
37 on('session.start', async ($, e, next) => {
38 const started = await next(e)
39 await ensureCommand($)
40 await primeCost($)
41 return started
42 })
43
44 on('prompt.submit', async ($, e, next) => {
45 await ensureCommand($)
46 if (!e.command && !e.isMeta) {
47 await primeCost($)
48 await recordPrompt($)
49 }
50 return next(e)
51 })
52
53 on('turn.complete', async ($, e, next) => {
54 const done = await next(e)
55 if (e.agentId === undefined) await recordTurn($, e.durationMs)
56 return done
57 })
58
59 on('tool.call', async ($, e, next) => {
60 const ran = await next(e)
61 await recordTool($, String(e.tool))
62 return ran
63 })
64
65 on('command.run', { command: 'jrweek' }, async $ => {
66 await refresh($, 0)
67 await $.ui.open({ id: PANE, title: 'jr week', focus: true, closeOnEscape: true, rows: 20 })
68 return { text: 'jr week opened.' }
69 })
70
71 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
72 const { Box, Text, Button } = $.ui.resolve(e)
73 const v = await read($, view)
74 if (!v) return <Text dimColor>Loading the week…</Text>
75 const r = v.report
76 const width = Math.max(50, e.props.bodyColumns ?? 80)
77 const barWidth = Math.max(8, Math.min(30, width - 40))
78 const mostDay = Math.max(1, ...r.days.map(d => d.activeMs))
79 const mostProject = Math.max(1, ...r.projects.map(p => p.activeMs))
80 return (
81 <Box flexDirection="column">
82 <Box justifyContent="space-between">
83 <Text>
84 {gradientText(Text, 'jr week', GRADIENTS.brand)}
85 <Text dimColor>{` ${r.label}`}</Text>
86 </Text>
87 <Box columnGap={1}>
88 <Button key="this" label="This week" hotkey="t" variant={v.back === 0 ? 'primary' : 'secondary'} onPress={() => refresh($, 0)} />
89 <Button key="last" label="Last week" hotkey="l" variant={v.back === 1 ? 'primary' : 'secondary'} onPress={() => refresh($, 1)} />
90 <Button key="export" label="Export" hotkey="e" onPress={() => exportWeek($)} />
91 </Box>
92 </Box>
93 <Text dimColor>{`${''.padEnd(5)}${'Claude working'.padEnd(barWidth + 1)}${'time'.padStart(6)} prompts cost`}</Text>
94 {r.days.map(d => (
95 <Text>
96 <Text bold>{d.name.padEnd(5)}</Text>
97 {gauge(Text, d.activeMs / mostDay, barWidth, GRADIENTS.brand)}
98 <Text>{` ${hours(d.activeMs).padStart(6)}`}</Text>
99 <Text dimColor>{` ${String(d.prompts).padStart(7)}`}</Text>
100 <Text color="#2cb67d">{` ${usd(d.costUsd).padStart(7)}`}</Text>
101 </Text>
102 ))}
103 <Text>
104 <Text bold>{'Total'.padEnd(5 + barWidth)}</Text>
105 <Text bold>{` ${hours(r.total.activeMs).padStart(6)}`}</Text>
106 <Text bold>{` ${String(r.total.prompts).padStart(7)}`}</Text>
107 <Text color="#2cb67d" bold>{` ${usd(r.total.costUsd).padStart(7)}`}</Text>
108 </Text>
109 <Text dimColor>{'─'.repeat(Math.min(width, barWidth + 32))}</Text>
110 {r.projects.length === 0 && <Text dimColor>No activity recorded this week yet.</Text>}
111 {r.projects.slice(0, 8).map(p => (
112 <Text wrap="truncate-end">
113 <Text bold>{p.name.slice(0, 18).padEnd(19)}</Text>
114 {gauge(Text, p.activeMs / mostProject, Math.max(6, barWidth - 14), GRADIENTS.ice)}
115 <Text>{` ${hours(p.activeMs).padStart(6)}`}</Text>
116 <Text dimColor>{` ${String(p.prompts).padStart(7)}`}</Text>
117 <Text color="#2cb67d">{` ${usd(p.costUsd).padStart(7)}`}</Text>
118 </Text>
119 ))}
120 </Box>
121 )
122 })
123}
124hooks/activity.ts 128 lines1// 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}
128hooks/jr/draw.tsx 35 lines1// 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)
35hooks/jr/gradient.ts 75 lines1// 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}
75hooks/week.ts 78 lines1import type { DayStats } from './activity'
2import { dayKey } from './activity'
3
4const DAY = 86_400_000
5const NAMES = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
6
7/** Monday 00:00 local of the week containing `now`, `back` weeks earlier. */
8export const weekStart = (now: number, back = 0) => {
9 const d = new Date(now)
10 d.setHours(0, 0, 0, 0)
11 d.setDate(d.getDate() - ((d.getDay() + 6) % 7) - back * 7)
12 return d.getTime()
13}
14
15export type Totals = { activeMs: number; prompts: number; costUsd: number }
16export type WeekReport = {
17 from: number
18 label: string
19 days: (Totals & { key: string; name: string })[]
20 projects: (Totals & { name: string })[]
21 total: Totals
22}
23
24const zero = (): Totals => ({ activeMs: 0, prompts: 0, costUsd: 0 })
25
26export const weekReport = (days: Record<string, DayStats>, from: number): WeekReport => {
27 const total = zero()
28 const projects = new Map<string, Totals>()
29 const rows = NAMES.map((name, i) => {
30 const key = dayKey(from + i * DAY + 12 * 3_600_000)
31 const d = days[key]
32 const row = { key, name, activeMs: d?.activeMs ?? 0, prompts: d?.prompts ?? 0, costUsd: d?.costUsd ?? 0 }
33 total.activeMs += row.activeMs
34 total.prompts += row.prompts
35 total.costUsd += row.costUsd
36 for (const [p, s] of Object.entries(d?.projects ?? {})) {
37 const acc = projects.get(p) ?? zero()
38 acc.activeMs += s.activeMs
39 acc.prompts += s.prompts
40 acc.costUsd += s.costUsd
41 projects.set(p, acc)
42 }
43 return row
44 })
45 const end = new Date(from + 6 * DAY)
46 const start = new Date(from)
47 const fmt = (d: Date) => d.toLocaleDateString('en-GB', { day: 'numeric', month: 'short' })
48 return {
49 from,
50 label: `${fmt(start)} – ${fmt(end)}`,
51 days: rows,
52 projects: [...projects.entries()].map(([name, t]) => ({ name, ...t })).filter(p => p.prompts + p.activeMs > 0).sort((a, b) => b.activeMs - a.activeMs || b.prompts - a.prompts),
53 total,
54 }
55}
56
57export const hours = (ms: number) => {
58 const m = Math.round(ms / 60_000)
59 return `${Math.floor(m / 60)}h${String(m % 60).padStart(2, '0')}`
60}
61
62export const usd = (n: number) => `$${n.toFixed(2)}`
63
64export const toMarkdown = (r: WeekReport) =>
65 [
66 `# Week ${r.label}`,
67 '',
68 '| Day | Claude working | Prompts | Cost |',
69 '| --- | --- | --- | --- |',
70 ...r.days.map(d => `| ${d.name} | ${hours(d.activeMs)} | ${d.prompts} | ${usd(d.costUsd)} |`),
71 `| **Total** | **${hours(r.total.activeMs)}** | **${r.total.prompts}** | **${usd(r.total.costUsd)}** |`,
72 '',
73 '| Project | Claude working | Prompts | Cost |',
74 '| --- | --- | --- | --- |',
75 ...r.projects.map(p => `| ${p.name} | ${hours(p.activeMs)} | ${p.prompts} | ${usd(p.costUsd)} |`),
76 '',
77 ].join('\n')
78types/index.d.ts 19 lines1export type JrWeekTotals = { activeMs: number; prompts: number; costUsd: number }
2
3export type JrWeekView = {
4 back: number
5 report: {
6 from: number
7 label: string
8 days: (JrWeekTotals & { key: string; name: string })[]
9 projects: (JrWeekTotals & { name: string })[]
10 total: JrWeekTotals
11 }
12}
13
14declare module 'claude-code' {
15 interface PluginState {
16 'jr-week': { view: JrWeekView | null }
17 }
18}
19