/report: tasks Claude finished today, this week, month or year, the time it worked, what the work was, which models did it, the API-equivalent cost, your…

Personal Claude Code mods, built or vendored at a reviewed commit. Each mod lives in its own folder and is listed in .claude-plugin/marketplace.json.
hooks/register.tsx 329 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Day, Kind, Period, Report } from '../types'
5
6// /report: what Claude did for you today, this week, month or year. Each
7// main-loop turn that ends with an answer is a task: its length, the kind of
8// work (by the most telling tool it used), the model that answered and the
9// status line's cost across it are added to that day's totals in $.store.
10// Time saved and its value are estimates from two settings in /config.
11// Observers only: every event passes through unchanged.
12// $.state, $.store, $.clock, $.session.usage and $.ui only: no files,
13// network, processes or model calls.
14
15const PANE = 'todays-report'
16const report = atom({ plugin: 'todays-report', key: 'report' } as const, null)
17const PERIODS: Period[] = ['today', 'week', 'month', 'year', 'all']
18const PERIOD_DAYS: Record<Period, number> = { today: 1, week: 7, month: 30, year: 365, all: Infinity }
19const PERIOD_LABEL: Record<Period, string> = { today: 'Today', week: 'Last 7 days', month: 'Last 30 days', year: 'Last 365 days', all: 'All time' }
20const KIND_LABEL: Record<Kind, string> = {
21 email: 'Email',
22 calendar: 'Calendar',
23 code: 'Code',
24 docs: 'Documents',
25 design: 'Design',
26 research: 'Research',
27 commands: 'Commands',
28 agents: 'Agents',
29 reading: 'Reading files',
30 other: 'Other tools',
31 chat: 'Chat (no tools)',
32}
33// a task is the most telling kind of tool it used, in this order
34const PRIORITY: Kind[] = ['email', 'calendar', 'code', 'docs', 'design', 'research', 'commands', 'agents', 'reading', 'other']
35const DOC_EXT = /\.(md|mdx|txt|rtf|docx?|odt|pdf|csv|xlsx?|pptx?|html?)$/i
36const BAR = 24
37const DEFAULT_MINUTES = 10
38const DEFAULT_RATE = 50
39
40/** What kind of work one tool call is. */
41export function kindOf(tool: string, input: Record<string, unknown> = {}): Exclude<Kind, 'chat'> {
42 if (/gmail|outlook|mail/i.test(tool)) return 'email'
43 if (/calendar/i.test(tool)) return 'calendar'
44 if (tool === 'Edit' || tool === 'Write' || tool === 'NotebookEdit' || tool === 'MultiEdit')
45 return DOC_EXT.test(String(input.file_path ?? input.notebook_path ?? '')) ? 'docs' : 'code'
46 if (/docs|drive|notion|confluence|atlassian|artifact/i.test(tool)) return 'docs'
47 if (/figma|canva/i.test(tool)) return 'design'
48 if (tool === 'WebSearch' || tool === 'WebFetch' || /chrome|playwright|browser/i.test(tool)) return 'research'
49 if (tool === 'Bash' || tool === 'PowerShell') return 'commands'
50 if (tool === 'Agent' || tool === 'Workflow' || tool === 'SendMessage') return 'agents'
51 if (tool === 'Read' || tool === 'Grep' || tool === 'Glob' || tool === 'LSP') return 'reading'
52 return 'other'
53}
54
55/** The task's kind from its tool counts: the first present in PRIORITY, 'chat' with none. */
56export function mainKind(counts: Partial<Record<Kind, number>>): Kind {
57 return PRIORITY.find(k => (counts[k] ?? 0) > 0) ?? 'chat'
58}
59
60export function familyOf(model: string): string {
61 return /fable|opus|sonnet|haiku/i.exec(model)?.[0]?.toLowerCase() ?? model
62}
63
64const empty = (): Day => ({ tasks: 0, workMs: 0, usd: 0, tokensIn: 0, cachedIn: 0, tokensOut: 0, models: {}, kinds: {} })
65const num = (v: unknown) => (typeof v === 'number' && Number.isFinite(v) ? v : 0)
66const counts = (v: unknown): Record<string, number> =>
67 v && typeof v === 'object' ? Object.fromEntries(Object.entries(v as Record<string, unknown>).map(([k, n]) => [k, num(n)])) : {}
68
69/** A stored day read back whole, whatever an older version or a hand edit left. */
70export function dayOf(v: unknown): Day {
71 if (!v || typeof v !== 'object') return empty()
72 const d = v as Record<string, unknown>
73 return {
74 tasks: num(d.tasks),
75 workMs: num(d.workMs),
76 usd: num(d.usd),
77 tokensIn: num(d.tokensIn),
78 cachedIn: num(d.cachedIn),
79 tokensOut: num(d.tokensOut),
80 models: counts(d.models),
81 kinds: counts(d.kinds),
82 }
83}
84
85const pad = (n: number) => String(n).padStart(2, '0')
86/** YYYY-MM-DD of a clock time, local. */
87export const dayKey = (ms: number) => {
88 const d = new Date(ms)
89 return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
90}
91const shift = (key: string, days: number) => {
92 const [y, m, d] = key.split('-').map(Number)
93 return dayKey(new Date(y ?? 1970, (m ?? 1) - 1, (d ?? 1) + days, 12).getTime())
94}
95const add = (a: Record<string, number>, b: Record<string, number>) => {
96 const out = { ...a }
97 for (const [k, n] of Object.entries(b)) out[k] = (out[k] ?? 0) + n
98 return out
99}
100
101/** The period's totals over every stored day, with the streak (which looks past the period). */
102export function summarize(days: Record<string, Day>, period: Period, today: string): Report {
103 const from = PERIOD_DAYS[period] === Infinity ? '' : shift(today, 1 - PERIOD_DAYS[period])
104 const keys = Object.keys(days).sort()
105 const total = empty()
106 let activeDays = 0
107 for (const key of keys) {
108 if (key < from || key > today) continue
109 const d = days[key] ?? empty()
110 total.tasks += d.tasks
111 total.workMs += d.workMs
112 total.usd += d.usd
113 total.tokensIn += d.tokensIn
114 total.cachedIn += d.cachedIn
115 total.tokensOut += d.tokensOut
116 total.models = add(total.models, d.models)
117 total.kinds = add(total.kinds, d.kinds as Record<string, number>)
118 if (d.tasks > 0) activeDays++
119 }
120 const has = (key: string) => (days[key]?.tasks ?? 0) > 0
121 let streak = 0
122 for (let key = has(today) ? today : shift(today, -1); has(key); key = shift(key, -1)) streak++
123 let best = 0
124 let run = 0
125 let prev = ''
126 for (const key of keys.filter(has)) {
127 run = prev && shift(prev, 1) === key ? run + 1 : 1
128 best = Math.max(best, run)
129 prev = key
130 }
131 return { ...total, period, activeDays, streak, best, since: keys[0] ?? '' }
132}
133
134// 1,234: no Intl in the module's environment to lean on
135const grouped = (n: number) => String(Math.round(n)).replace(/\B(?=(\d{3})+(?!\d))/g, ',')
136const money = (n: number) => (n < 100 ? `$${n.toFixed(2)}` : `$${grouped(n)}`)
137const tokens = (n: number) => (n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${Math.round(n / 1e3)}k` : String(n))
138const duration = (ms: number) => {
139 const m = Math.round(ms / 60_000)
140 return m < 60 ? `${m}m` : `${Math.floor(m / 60)}h ${pad(m % 60)}m`
141}
142const hours = (h: number) => (h < 10 ? h.toFixed(1) : grouped(h))
143const plural = (n: number, one: string) => `${grouped(n)} ${one}${n === 1 ? '' : 's'}`
144
145async function usdNow($: EngineInterface): Promise<number> {
146 return $.session.usage().then(
147 u => num(u.cost?.usd),
148 () => 0,
149 )
150}
151
152// One read-modify-write of a day at a time in this session: a subagent and the main turn ending together
153// would otherwise both read the day before either wrote it, and one's counts would be lost. Two sessions
154// ending a turn in the same instant can still lose one task's tally: the store has no compare-and-set,
155// and per-session keys would grow it without bound; for a local estimate, that rare loss is accepted.
156let recording: Promise<void> = Promise.resolve()
157
158/** Adds to one day's stored totals, after any add already under way. */
159function record($: EngineInterface, at: number, change: (d: Day) => Day): Promise<void> {
160 const key = `day:${dayKey(at)}`
161 const done = recording.then(async () => $.store.set(key, change(dayOf(await $.store.get(key)))))
162 recording = done.catch(() => undefined)
163 return done
164}
165
166async function stored($: EngineInterface): Promise<Record<string, Day>> {
167 const keys = (await $.store.keys()).filter(k => k.startsWith('day:'))
168 const days: Record<string, Day> = {}
169 for (const key of keys) days[key.slice(4)] = dayOf(await $.store.get(key))
170 return days
171}
172
173/** Totals the period and hands the pane its report. */
174async function show($: EngineInterface, period: Period) {
175 const today = dayKey(await $.clock.now())
176 const r = summarize(await stored($), period, today)
177 await update($, report, () => r)
178}
179
180export const register: Register = (on, options) => {
181 const setting = (v: unknown, fallback: number) => (typeof v === 'number' && Number.isFinite(v) && v >= 0 ? v : fallback)
182 const minutesPerTask = setting(options?.minutesPerTask, DEFAULT_MINUTES)
183 const hourlyRate = setting(options?.hourlyRate, DEFAULT_RATE)
184 // the main turn now running: the cost when it started, and its tool calls by kind (its subagents' too)
185 let turn: { startUsd: number; tools: Partial<Record<Kind, number>> } | undefined
186
187 on('session.start', async ($, e, next) => {
188 await $.command.register({
189 name: 'report',
190 description: "Today's report: tasks Claude finished, time worked, kinds of work, models, cost, streak, est. time saved",
191 argumentHint: '[today | week | month | year | all]',
192 immediate: true,
193 })
194 return next(e)
195 })
196
197 on('turn.start', async ($, e, next) => {
198 turn = { startUsd: await usdNow($), tools: {} }
199 return next(e)
200 }).catch(($, e, next) => next(e))
201
202 on('tool.call', async ($, e, next) => {
203 if (turn) {
204 const kind = kindOf(String(e.tool), e as Record<string, unknown>)
205 turn.tools[kind] = (turn.tools[kind] ?? 0) + 1
206 }
207 return next(e)
208 }).catch(($, e, next) => next(e))
209
210 on('turn.complete', async ($, e, next) => {
211 const done = await next(e)
212 const u = e.usage
213 const fromUsage = (d: Day): Day =>
214 u
215 ? {
216 ...d,
217 tokensIn: d.tokensIn + u.input_tokens + u.cache_creation_input_tokens + u.cache_read_input_tokens,
218 cachedIn: d.cachedIn + u.cache_read_input_tokens,
219 tokensOut: d.tokensOut + u.output_tokens,
220 }
221 : d
222 const at = await $.clock.now()
223 // a subagent's run: its tokens count; its cost is in its main turn's, and it is not a task of its own
224 if (e.agentId !== undefined) {
225 if (u) await record($, at, fromUsage)
226 return done
227 }
228 const t = turn
229 turn = undefined
230 const usd = t ? Math.max(0, (await usdNow($)) - t.startUsd) : 0
231 const isTask = e.reason === 'answer'
232 const kind = mainKind(t?.tools ?? {})
233 await record($, at, d => {
234 const spent: Day = { ...fromUsage(d), usd: d.usd + usd }
235 if (!isTask) return spent
236 return {
237 ...spent,
238 tasks: d.tasks + 1,
239 workMs: d.workMs + e.durationMs,
240 models: u ? add(d.models, { [familyOf(u.model)]: 1 }) : d.models,
241 kinds: add(d.kinds as Record<string, number>, { [kind]: 1 }),
242 }
243 })
244 const shown = await read($, report)
245 if (shown) await show($, shown.period)
246 return done
247 }).catch(($, e, next) => next(e))
248
249 on('command.run', { command: 'report' }, async ($, e) => {
250 const word = String(e.args ?? '').trim().toLowerCase() || 'today'
251 const period = PERIODS.find(p => p === word)
252 if (!period) return { text: 'Usage: /report [today | week | month | year | all]' }
253 await show($, period)
254 await $.ui.open({ id: PANE, title: "Today's report", focus: true, closeOnEscape: true })
255 const r = await read($, report)
256 return { text: r ? `${PERIOD_LABEL[period]}: ${plural(r.tasks, 'task')}, Claude worked ${duration(r.workMs)}.` : 'Report opened.' }
257 })
258
259 on('ui.close', async ($, e, next) => {
260 if (e.id === PANE) await update($, report, () => null)
261 return next(e)
262 }).catch(($, e, next) => next(e))
263
264 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
265 const { Box, Button, Text } = $.ui.resolve(e)
266 const r = await read($, report)
267 if (!r) return <Text dimColor>/report opens it.</Text>
268 const saved = (r.tasks * minutesPerTask) / 60
269 const rows = (entries: [string, number][]) => {
270 const max = Math.max(1, ...entries.map(([, n]) => n))
271 const width = Math.max(...entries.map(([label]) => label.length))
272 return entries.map(([label, n]) => (
273 <Text key={`row-${label}`}>
274 {' '}
275 {label.padEnd(width)} <Text color="#7c9cf5">{'█'.repeat(Math.max(1, Math.round((n / max) * BAR)))}</Text> {n}
276 </Text>
277 ))
278 }
279 const kinds = Object.entries(r.kinds as Record<string, number>)
280 .filter(([, n]) => n > 0)
281 .sort((a, b) => b[1] - a[1])
282 .map(([k, n]): [string, number] => [KIND_LABEL[k as Kind] ?? k, n])
283 const models = Object.entries(r.models)
284 .filter(([, n]) => n > 0)
285 .sort((a, b) => b[1] - a[1])
286 const cachedShare = r.tokensIn > 0 ? Math.round((r.cachedIn / r.tokensIn) * 100) : 0
287 return (
288 <Box flexDirection="column" rowGap={1}>
289 <Box flexDirection="row" flexWrap="wrap" columnGap={1}>
290 {PERIODS.map(p => (
291 <Button key={`p-${p}`} label={p === 'today' ? 'Today' : p[0]!.toUpperCase() + p.slice(1)} variant={p === r.period ? 'primary' : 'secondary'} onPress={() => void show($, p)} />
292 ))}
293 </Box>
294 <Box flexDirection="column">
295 <Text bold>
296 {PERIOD_LABEL[r.period]}: {plural(r.tasks, 'task')} · Claude worked {duration(r.workMs)}
297 {r.period === 'today' ? '' : ` · on ${plural(r.activeDays, 'day')}`}
298 </Text>
299 <Text>
300 Streak: {plural(r.streak, 'day')} (best {r.best})
301 </Text>
302 <Text>
303 Time saved (estimate): {hours(saved)} h = {plural(r.tasks, 'task')} × {minutesPerTask} min · worth {money(saved * hourlyRate)} at {money(hourlyRate)}/h
304 </Text>
305 <Text dimColor>
306 API-equivalent cost: {money(r.usd)} · {tokens(r.tokensIn)} tokens in ({cachedShare}% from cache), {tokens(r.tokensOut)} out
307 </Text>
308 </Box>
309 {kinds.length > 0 ? (
310 <Box flexDirection="column">
311 <Text bold>Where it went</Text>
312 {rows(kinds)}
313 </Box>
314 ) : null}
315 {models.length > 0 ? (
316 <Box flexDirection="column">
317 <Text bold>Models</Text>
318 {rows(models)}
319 </Box>
320 ) : null}
321 <Text dimColor>
322 {r.since ? `Counting since ${r.since}. ` : 'Nothing counted yet: tasks add up as Claude finishes them. '}
323 Minutes per task and hourly rate are in /config.
324 </Text>
325 </Box>
326 )
327 })
328}
329types/index.d.ts 46 lines1/** What a task was, by the most telling kind of tool it used. */
2export type Kind = 'email' | 'calendar' | 'code' | 'docs' | 'design' | 'research' | 'commands' | 'agents' | 'reading' | 'other' | 'chat'
3
4/** One day's totals, kept in $.store under `day:YYYY-MM-DD` (local date). */
5export type Day = {
6 /** main-loop turns that ended with an answer */
7 tasks: number
8 /** wall-clock length of those turns, ms */
9 workMs: number
10 /** API-equivalent dollars: the status line's cost across each turn, subagents included */
11 usd: number
12 /** input tokens (uncached, written to cache and read from it), main loop and subagents */
13 tokensIn: number
14 /** the part of tokensIn the prompt cache served */
15 cachedIn: number
16 tokensOut: number
17 /** model family -> tasks it answered */
18 models: Record<string, number>
19 /** kind -> tasks */
20 kinds: Partial<Record<Kind, number>>
21}
22
23export type Period = 'today' | 'week' | 'month' | 'year' | 'all'
24
25/** A period's totals as the pane draws them. */
26export type Report = Omit<Day, 'workMs'> & {
27 period: Period
28 workMs: number
29 /** days in the period with at least one task */
30 activeDays: number
31 /** consecutive days with a task, ending today (or yesterday, before today's first) */
32 streak: number
33 best: number
34 /** the first day anything was recorded, YYYY-MM-DD; '' before any */
35 since: string
36}
37
38declare module 'claude-code' {
39 interface PluginState {
40 'todays-report': {
41 /** the pane's report; null while it is closed */
42 report: Report | null
43 }
44 }
45}
46