Your Claude Code usage on this machine as a receipt: total tokens, prompts, sessions, issues closed, daily activity, and tokens by project and model. Opens…

A Claude Code mod that shows your Claude Code usage on this machine. Type /usage-stats to open it. It never opens on its own. It opens as a side pane, brought to the front; where no pane can be shown, the same receipt is printed into the conversation.

Usage 7d 30d All ⟳
▀█ ▀▀█ █▀█ █▀▄
█ █ █▀█ █▀▄
▀▀▀ ▀ ▀ ▀▀▀ ▀▀
tokens in the last 30 days
816 prompts 104 sessions 71 issues closed
▄▄▄▄ ▃▃ ▅▅ ██▅▅
▄▄▃▃▃▃ ▃▃████▅▅██▆▆ ▇▇██▂▂▆▆▁▁ ▁▁██▄▄████▅▅ ▁▁▆▆████
Sep 9 Oct 8
Projects
webapp ······································ 10.8B 61%
shop-api ···································· 3.8B 21%
Models
████████████████████████████████████████████████████████████
■ Opus 5 45% ■ Opus 5.5 42% ■ Sonnet 5 7% ■ other 7%
Busiest day ········································ Oct 7
Busiest hour ···································· Thu 15:00
Top issue ··········································· ENG-4
It reads like a receipt:
Press 7d, 30d or All to change the range; the choice is kept. Press ⟳ to read the logs again.
~/.claude/projects, read by scanner/scan.py. Each API response is counted once, and subagents' logs are included.~/.claude/usage-stats/cache.json.claude.ai Linear by default). Set linearServer in /config to use another server, or leave it empty to hide the section.Nothing leaves your machine except the Linear request.
python3claude plugin marketplace add omeerdvrn/o-mods
claude plugin install usage-stats@o-mods
claude --plugin-dir /path/to/o-mods/mods/usage-stats
claude plugin validate .
claude plugin test .
python3 scanner/scan.py | head -c 400
| File | Contents |
|---|---|
hooks/register.tsx | The /usage-stats command, the scan and Linear calls, and the view |
hooks/stats.ts | Ranges, totals and rankings from the scan |
hooks/charts.ts | Block digits, dotted leaders, the sparkline and the proportion bar |
scanner/scan.py | Reads the session logs and prints aggregates as JSON |
types/index.d.ts | The mod's state contract |
tests/usage-stats.test.ts | Tests with the scanner and Linear faked |
hooks/register.tsx 344 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Closed, Range, Scan } from '../types'
5import { ACCENT, LEADER, SHADES, bigText, bucket, leaderLine, proportions, sparkRows, stretch } from './charts'
6import { receiptText } from './receipt'
7import { compact, dayKey, rangeStart, summarize, topWithOther } from './stats'
8
9const PANE = 'usage-stats'
10const TITLE = 'Usage stats'
11const SCAN_TIMEOUT_MS = 600_000
12const RECEIPT_WIDTH = 60
13const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
14const RANGE_KEY = 'range'
15const WEEKDAYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
16const LINEAR_PAGES = 6
17
18const scan = atom({ plugin: 'usage-stats', key: 'scan' } as const, null as Scan | null)
19const closed = atom({ plugin: 'usage-stats', key: 'closed' } as const, null as Closed[] | null)
20const isScanning = atom({ plugin: 'usage-stats', key: 'isScanning' } as const, false)
21const error = atom({ plugin: 'usage-stats', key: 'error' } as const, null as string | null)
22const linearError = atom({ plugin: 'usage-stats', key: 'linearError' } as const, null as string | null)
23const range = atom({ plugin: 'usage-stats', key: 'range' } as const, '30d' as Range)
24
25type Options = { linearServer: string }
26
27const RANGES: { range: Range; label: string }[] = [
28 { range: '7d', label: '7d' },
29 { range: '30d', label: '30d' },
30 { range: 'all', label: 'All' },
31]
32
33/** Runs the bundled scanner over ~/.claude/projects; it caches per log file, so a rescan is quick. */
34async function runScan($: EngineInterface) {
35 if (await read($, isScanning)) return
36 await update($, isScanning, () => true)
37 try {
38 const root = $.plugin.root.replace(/\/\.claude-plugin\/?$/, '')
39 const result = await $.process.run(['python3', '-I', `${root}/scanner/scan.py`], { timeoutMs: SCAN_TIMEOUT_MS })
40 if (result.exitCode !== 0) throw new Error(result.stderr.trim().split('\n').pop() || `scanner exited ${result.exitCode}`)
41 const data = JSON.parse(result.stdout) as Scan
42 await update($, scan, () => data)
43 await update($, error, () => null)
44 } catch (err) {
45 const message = err instanceof Error ? err.message : String(err)
46 await update($, error, () => `Couldn't read the session logs: ${message}`)
47 } finally {
48 await update($, isScanning, () => false)
49 }
50}
51
52/** The configured Linear server first, then the names it usually goes by (claude.ai connector, `claude mcp add`). */
53const SERVER_NAMES = ['claude.ai Linear', 'linear', 'Linear']
54let connected: string | null = null
55
56async function linearCall($: EngineInterface, o: Options, args: Record<string, unknown>) {
57 const tried = [...new Set([connected, o.linearServer, ...SERVER_NAMES].filter((name): name is string => Boolean(name)))]
58 for (const server of tried) {
59 try {
60 const result = await $.mcp.call(server, 'list_issues', args)
61 connected = server
62 return result
63 } catch (err) {
64 const message = err instanceof Error ? err.message : String(err)
65 if (!/no connected MCP/i.test(message)) throw err
66 if (connected === server) connected = null
67 }
68 }
69 throw new Error(`no connected MCP server has Linear (tried ${tried.join(', ')})`)
70}
71
72/** Issues assigned to you and completed in the last 120 days, from Linear. */
73async function loadClosed($: EngineInterface, o: Options) {
74 if (!o.linearServer) return
75 try {
76 const found: Closed[] = []
77 let cursor: string | undefined
78 for (let page = 0; page < LINEAR_PAGES; page++) {
79 const result = await linearCall($, o, {
80 assignee: 'me',
81 state: 'completed',
82 updatedAt: '-P120D',
83 limit: 50,
84 fields: ['id', 'completedAt', 'estimate'],
85 ...(cursor ? { cursor } : {}),
86 })
87 const text = result.content.map(block => block.text ?? '').join('')
88 if (result.isError) throw new Error(text)
89 const data = JSON.parse(text) as {
90 issues?: { id: string; completedAt?: string | null; estimate?: { value?: number } | null }[]
91 hasNextPage?: boolean
92 cursor?: string
93 }
94 for (const issue of data.issues ?? []) {
95 if (!issue.completedAt) continue
96 found.push({ id: issue.id, day: dayKey(Date.parse(issue.completedAt)), points: issue.estimate?.value ?? 0 })
97 }
98 if (!data.hasNextPage || !data.cursor) break
99 cursor = data.cursor
100 }
101 await update($, closed, () => found)
102 await update($, linearError, () => null)
103 } catch (err) {
104 const message = err instanceof Error ? err.message : String(err)
105 await update($, linearError, () => linearProblem(message))
106 }
107}
108
109/** Shows another range and remembers it for next time. */
110async function pickRange($: EngineInterface, next: Range) {
111 await update($, range, () => next)
112 await $.store.set(RANGE_KEY, next)
113}
114
115/** A Linear failure in words: not connected, unavailable for now, or its own message. */
116function linearProblem(message: string): string {
117 if (/no connected MCP/i.test(message)) return 'Linear is not connected. Connect it in /mcp to see issues closed.'
118 try {
119 const body = JSON.parse(message) as { message?: string; status?: number }
120 if (body.status !== undefined && body.status >= 500) return 'Linear is unavailable right now. Press Refresh to try again.'
121 if (body.message) return body.message
122 } catch {
123 // Not JSON: the server's own words.
124 }
125 return message.length > 160 ? `${message.slice(0, 159)}…` : message
126}
127
128async function refresh($: EngineInterface, o: Options) {
129 await Promise.all([runScan($), loadClosed($, o)])
130}
131
132export const register: Register = (on, rawOptions) => {
133 const options = rawOptions as unknown as Options
134
135 on('session.start', async ($, e, next) => {
136 // Not awaited: registering makes Claude Code re-describe every command (~0.7 s), which would hold startup.
137 // Typed as /usage-stats in the terminal; the command file is what the desktop app lists in its menu.
138 void $.command.register({
139 name: 'usage-stats',
140 description: "Show your Claude Code usage on this machine: tokens, prompts, sessions, issues closed",
141 }).catch(() => undefined)
142 const stored = await $.store.get(RANGE_KEY)
143 if (stored === '7d' || stored === '30d' || stored === 'all') await update($, range, () => stored)
144
145 return next(e)
146 })
147
148 // The view opens only when asked for.
149 on('command.run', { command: ['usage-stats', 'usage-stats:usage-stats'] }, async $ => {
150 // Focused, so it comes to the front where a surface shows one pane at a time (the desktop app).
151 const opened = await $.ui.open({ id: PANE, title: TITLE, focus: true })
152 if (opened.isPlaced) {
153 void refresh($, options)
154 return { text: 'Usage stats opened.' }
155 }
156 // No pane here (a desktop app that places none): print the receipt into the conversation instead.
157 await refresh($, options)
158 const data = await read($, scan)
159 if (data === null) return { text: `Couldn't open the usage view: ${opened.reason}. ${(await read($, error)) ?? ''}`.trim() }
160 const shown = await read($, range)
161 const sum = summarize(data, (await read($, closed)) ?? [], shown, Date.now())
162 const period = shown === 'all' ? 'all time' : shown === '7d' ? 'the last 7 days' : 'the last 30 days'
163 const linearFailed = await read($, linearError)
164 const footer = `${data.files.toLocaleString('en-US')} session logs · shown here because ${opened.reason}`
165
166 return { text: receiptText(sum, period, linearFailed === null ? String(sum.closed.length) : '–', footer) }
167 })
168
169 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
170 const { Box, Text, Button } = $.ui.resolve(e)
171 const data = await read($, scan)
172 const issues = (await read($, closed)) ?? []
173 const scanning = await read($, isScanning)
174 const failed = await read($, error)
175 const linearFailed = await read($, linearError)
176 const shown = await read($, range)
177 // A receipt is narrow: cap the width so lines stay easy to follow on a wide pane.
178 const width = Math.max(36, Math.min(RECEIPT_WIDTH, e.props.bodyColumns - 2))
179 const now = Date.now()
180
181 const title = (
182 <Box flexDirection="row" justifyContent="space-between" width={width}>
183 <Text bold>Usage</Text>
184 <Box flexDirection="row" columnGap={2}>
185 {RANGES.map(item => (
186 <Button
187 key={`range:${item.range}`}
188 label={item.label}
189 plain
190 variant={shown === item.range ? 'primary' : undefined}
191 dimColor={shown !== item.range}
192 onPress={() => pickRange($, item.range)}
193 />
194 ))}
195 <Button key="refresh" label={scanning ? '…' : '⟳'} plain dimColor onPress={() => refresh($, options)} />
196 </Box>
197 </Box>
198 )
199
200 if (data === null) {
201 return (
202 <Box flexDirection="column" rowGap={1}>
203 {title}
204 {failed !== null ? (
205 <Text color="error" wrap="wrap">
206 {failed}
207 </Text>
208 ) : (
209 <Text dimColor>Reading your session logs…</Text>
210 )}
211 </Box>
212 )
213 }
214
215 const sum = summarize(data, issues, shown, now)
216 const line = (name: string, columns: string[], isStrong = false) => {
217 const parts = leaderLine(name, columns, width)
218 return (
219 <Box key={`line:${name}`} flexDirection="row">
220 <Text>{parts.name}</Text>
221 <Text color={LEADER}>{parts.leader}</Text>
222 <Text bold={isStrong}>{parts.tail}</Text>
223 </Box>
224 )
225 }
226 const share = (value: number) => `${Math.round((value / Math.max(1, sum.tokens)) * 100)}%`
227 const dateLabel = (day: string) => {
228 const [, month, date] = day.split('-').map(Number)
229 return `${MONTHS[month! - 1]} ${date}`
230 }
231
232 // The total, in block digits: the one loud thing on the page.
233 const hero = bigText(compact(sum.tokens))
234 const period = shown === 'all' ? 'all time' : shown === '7d' ? 'the last 7 days' : 'the last 30 days'
235
236 // The range at a glance: tokens per day, squeezed to the receipt's width.
237 const spark = sparkRows(stretch(bucket(sum.daily.map(day => day.tokens), width), width), 2)
238 const firstDay = sum.daily[0]?.day ?? ''
239 const lastDay = sum.daily[sum.daily.length - 1]?.day ?? ''
240
241 // Projects: the top four, the rest as one line.
242 const projects = topWithOther(sum.byProject, 5)
243
244 // Models: one proportion bar in shades of the accent; the labels carry the names.
245 const models = topWithOther(sum.byModel, 4)
246 const cells = proportions(
247 models.map(slice => slice.value),
248 width,
249 )
250
251 // The busiest moments.
252 const busiestDay = sum.daily.reduce((best, day) => (day.tokens > best.tokens ? day : best), { day: '', tokens: 0, prompts: 0 })
253 let hotDay = 0
254 let hotHour = 0
255 sum.weekHours.forEach((hours, day) =>
256 hours.forEach((count, hour) => {
257 if (count > (sum.weekHours[hotDay]![hotHour] ?? 0)) {
258 hotDay = day
259 hotHour = hour
260 }
261 }),
262 )
263 const hasHours = (sum.weekHours[hotDay]![hotHour] ?? 0) > 0
264 const topIssue = sum.byIssue[0]
265
266 return (
267 <Box flexDirection="column">
268 {title}
269
270 <Box flexDirection="column" marginTop={1}>
271 {hero.map(row => (
272 <Text color={ACCENT} bold>
273 {row}
274 </Text>
275 ))}
276 <Text dimColor>{`tokens in ${period}`}</Text>
277 </Box>
278
279 <Box flexDirection="row" columnGap={3} marginTop={1}>
280 <Text>
281 <Text bold>{compact(sum.prompts)}</Text>
282 <Text dimColor> prompts</Text>
283 </Text>
284 <Text>
285 <Text bold>{compact(sum.sessions)}</Text>
286 <Text dimColor> sessions</Text>
287 </Text>
288 <Text>
289 <Text bold>{linearFailed === null ? String(sum.closed.length) : '–'}</Text>
290 <Text dimColor> issues closed</Text>
291 </Text>
292 </Box>
293
294 <Box flexDirection="column" marginTop={1}>
295 {spark.map(row => (
296 <Text color={ACCENT}>{row}</Text>
297 ))}
298 <Box flexDirection="row" justifyContent="space-between" width={Math.max(spark[0]?.length ?? 0, 20)}>
299 <Text dimColor>{firstDay === '' ? '' : dateLabel(firstDay)}</Text>
300 <Text dimColor>{lastDay === '' ? '' : dateLabel(lastDay)}</Text>
301 </Box>
302 </Box>
303
304 <Box flexDirection="column" marginTop={1}>
305 <Text bold>Projects</Text>
306 {projects.map(slice => line(slice.label, [compact(slice.value), share(slice.value)]))}
307 </Box>
308
309 <Box flexDirection="column" marginTop={1}>
310 <Text bold>Models</Text>
311 <Box flexDirection="row">
312 {models.map((slice, index) => (
313 <Text color={SHADES[slice.label === 'other' ? 3 : Math.min(index, 2)]}>{'█'.repeat(cells[index] ?? 0)}</Text>
314 ))}
315 </Box>
316 <Box flexDirection="row" flexWrap="wrap" columnGap={3}>
317 {models.map((slice, index) => (
318 <Text key={`model:${slice.label}`}>
319 <Text color={SHADES[slice.label === 'other' ? 3 : Math.min(index, 2)]}>■ </Text>
320 <Text>{slice.label}</Text>
321 <Text dimColor>{` ${share(slice.value)}`}</Text>
322 </Text>
323 ))}
324 </Box>
325 </Box>
326
327 <Box flexDirection="column" marginTop={1}>
328 {busiestDay.tokens > 0 && line('Busiest day', [dateLabel(busiestDay.day)])}
329 {hasHours && line('Busiest hour', [`${WEEKDAYS[hotDay]} ${String(hotHour).padStart(2, '0')}:00`])}
330 {topIssue !== undefined && line('Top issue', [topIssue.label])}
331 {linearFailed === null && sum.closedPoints > 0 && line('Points closed', [String(sum.closedPoints)])}
332 </Box>
333
334 <Box marginTop={1} width={width}>
335 <Text dimColor wrap="wrap">
336 {`${data.files.toLocaleString('en-US')} session logs since ${dateLabel(rangeStart('all', now, data))}` +
337 (linearFailed === null ? '' : ` · ${linearFailed}`)}
338 </Text>
339 </Box>
340 </Box>
341 )
342 })
343}
344hooks/charts.ts 88 lines1/**
2 * The receipt's type: block digits for the total, dotted leaders for line items, a two-row sparkline,
3 * and a proportion bar. Everything is text, so it draws the same on every surface.
4 */
5
6/** The one accent, and its darker shades for proportions. */
7export const ACCENT = '#8AB4F8'
8export const SHADES = ['#8AB4F8', '#5B7FC4', '#3A5590', '#4A4A46']
9/** Leaders and rules: present, never loud. */
10export const LEADER = '#4A4A46'
11
12/** A 3-row block font for the total: each glyph is 3 cells wide. */
13const FONT: Record<string, [string, string, string]> = {
14 '0': ['█▀█', '█ █', '▀▀▀'],
15 '1': ['▀█ ', ' █ ', '▀▀▀'],
16 '2': ['▀▀█', '█▀▀', '▀▀▀'],
17 '3': ['▀▀█', ' ▀█', '▀▀▀'],
18 '4': ['█ █', '▀▀█', ' ▀'],
19 '5': ['█▀▀', '▀▀█', '▀▀▀'],
20 '6': ['█▀▀', '█▀█', '▀▀▀'],
21 '7': ['▀▀█', ' █', ' ▀'],
22 '8': ['█▀█', '█▀█', '▀▀▀'],
23 '9': ['█▀█', '▀▀█', '▀▀▀'],
24 '.': [' ', ' ', '▀'],
25 ',': [' ', ' ', '▀'],
26 B: ['█▀▄', '█▀▄', '▀▀ '],
27 M: ['█▄█', '█ █', '▀ ▀'],
28 k: ['█ ', '█▄▀', '▀ ▀'],
29 ' ': [' ', ' ', ' '],
30}
31
32/** `text` in block digits, three rows, one column between glyphs. */
33export function bigText(text: string): [string, string, string] {
34 const glyphs = [...text].map(char => FONT[char] ?? FONT[' ']!)
35 return [0, 1, 2].map(row => glyphs.map(glyph => glyph[row]).join(' ')) as [string, string, string]
36}
37
38/** A receipt line: name, dotted leader, then right-aligned columns, `width` cells in all. */
39export function leaderLine(name: string, columns: string[], width: number): { name: string; leader: string; tail: string } {
40 const tail = columns.map(column => column.padStart(6)).join(' ')
41 const room = Math.max(6, width - tail.length - 1)
42 const shown = name.length > room - 3 ? `${name.slice(0, room - 4)}…` : name
43 const dots = Math.max(1, room - shown.length - 2)
44 return { name: `${shown} `, leader: '·'.repeat(dots), tail: ` ${tail}` }
45}
46
47const RISE = ' ▁▂▃▄▅▆▇█'
48
49/** A sparkline `rows` tall, one column per value, eighth-block tops. Top row first. */
50export function sparkRows(values: number[], rows: number): string[] {
51 const max = Math.max(0, ...values)
52 const heights = values.map(value => (max > 0 && value > 0 ? Math.max(1, Math.round((value / max) * rows * 8)) : 0))
53 const out: string[] = []
54 for (let row = rows - 1; row >= 0; row--) {
55 out.push(heights.map(height => RISE[Math.max(0, Math.min(8, height - row * 8))]!).join(''))
56 }
57 return out
58}
59
60/** Repeats each value so the series spans `width` cells: a short range still fills the receipt. */
61export function stretch(values: number[], width: number): number[] {
62 if (values.length === 0 || values.length >= width) return values
63 const each = Math.floor(width / values.length)
64 return values.flatMap(value => Array.from({ length: each }, () => value))
65}
66
67/** Squeezes `values` into at most `width` buckets by summing neighbours, so a long range still fits. */
68export function bucket(values: number[], width: number): number[] {
69 if (values.length <= width) return values
70 const size = Math.ceil(values.length / width)
71 const out: number[] = []
72 for (let index = 0; index < values.length; index += size) {
73 out.push(values.slice(index, index + size).reduce((sum, value) => sum + value, 0))
74 }
75 return out
76}
77
78/** Cell counts for a proportion bar of `width` cells: every non-zero share gets at least one. */
79export function proportions(values: number[], width: number): number[] {
80 const total = values.reduce((sum, value) => sum + value, 0)
81 if (total <= 0) return values.map(() => 0)
82 const cells = values.map(value => (value > 0 ? Math.max(1, Math.round((value / total) * width)) : 0))
83 // Give or take from the largest share so the bar is exactly `width`.
84 const largest = cells.indexOf(Math.max(...cells))
85 cells[largest]! += width - cells.reduce((sum, count) => sum + count, 0)
86 return cells
87}
88hooks/receipt.ts 48 lines1import { bigText, bucket, leaderLine, sparkRows, stretch } from './charts'
2import type { Summary } from './stats'
3import { compact, topWithOther } from './stats'
4
5const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
6
7function dateLabel(day: string): string {
8 const [, month, date] = day.split('-').map(Number)
9 return `${MONTHS[month! - 1]} ${date}`
10}
11
12/**
13 * The receipt as plain text, for where no pane is drawn (a desktop app that places none): the same
14 * lines as the pane, in a code block so they keep their alignment.
15 */
16export function receiptText(sum: Summary, period: string, closedIssues: string, footer: string, width = 56): string {
17 const share = (value: number) => `${Math.round((value / Math.max(1, sum.tokens)) * 100)}%`
18 const line = (name: string, columns: string[]) => {
19 const parts = leaderLine(name, columns, width)
20 return `${parts.name}${parts.leader}${parts.tail}`
21 }
22 const spark = sparkRows(stretch(bucket(sum.daily.map(day => day.tokens), width), width), 2)
23 const first = sum.daily[0]?.day
24 const last = sum.daily[sum.daily.length - 1]?.day
25 const dates = first && last ? dateLabel(first).padEnd(Math.max(0, (spark[0]?.length ?? 0) - dateLabel(last).length)) + dateLabel(last) : ''
26 const models = topWithOther(sum.byModel, 4)
27
28 const lines = [
29 ...bigText(compact(sum.tokens)),
30 `tokens in ${period}`,
31 '',
32 `${compact(sum.prompts)} prompts ${compact(sum.sessions)} sessions ${closedIssues} issues closed`,
33 '',
34 ...spark,
35 dates,
36 '',
37 'Projects',
38 ...topWithOther(sum.byProject, 5).map(slice => line(slice.label, [compact(slice.value), share(slice.value)])),
39 '',
40 'Models',
41 ...models.map(slice => line(slice.label, [compact(slice.value), share(slice.value)])),
42 '',
43 footer,
44 ]
45
46 return ['```', ...lines, '```'].join('\n')
47}
48hooks/stats.ts 187 lines1import type { Closed, Range, Scan } from '../types'
2
3export type Slice = { label: string; value: number }
4
5export type Summary = {
6 tokens: number
7 input: number
8 output: number
9 cacheWrite: number
10 cacheRead: number
11 replies: number
12 prompts: number
13 sessions: number
14 byProject: Slice[]
15 byModel: Slice[]
16 byIssue: Slice[]
17 /** One entry per day of the range, oldest first, empty days included. */
18 daily: { day: string; tokens: number; prompts: number }[]
19 /** Tokens per hour of the day, 0 to 23. */
20 hourly: number[]
21 /** Prompts by weekday (0 = Monday) and hour: when you work. */
22 weekHours: number[][]
23 closed: Closed[]
24 closedPoints: number
25 /** Issues closed per ISO week, oldest first. */
26 weekly: Slice[]
27}
28
29const DAY_MS = 86_400_000
30
31export function dayKey(time: number): string {
32 const date = new Date(time)
33 return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
34}
35
36/** The first day a range covers, as YYYY-MM-DD; for `all`, the earliest day in the scan. */
37export function rangeStart(range: Range, now: number, scan: Scan): string {
38 if (range === '7d') return dayKey(now - 6 * DAY_MS)
39 if (range === '30d') return dayKey(now - 29 * DAY_MS)
40 const days = scan.tokens.map(row => row[0]).concat(scan.prompts.map(row => row[0]))
41 return days.length > 0 ? days.reduce((low, day) => (day < low ? day : low)) : dayKey(now)
42}
43
44function add(map: Map<string, number>, key: string, value: number) {
45 map.set(key, (map.get(key) ?? 0) + value)
46}
47
48function ranked(map: Map<string, number>): Slice[] {
49 return [...map.entries()]
50 .filter(([, value]) => value > 0)
51 .map(([label, value]) => ({ label, value }))
52 .sort((a, b) => b.value - a.value)
53}
54
55/** "claude-opus-5-5" -> "Opus 5.5", "claude-haiku-4-5-20251001" -> "Haiku 4.5". */
56export function modelName(model: string): string {
57 const match = /^claude-([a-z]+)-(\d+)(?:-(\d{1,2}))?(?:-\d{8})?/i.exec(model)
58 if (!match) return model
59 const [, family, major, minor] = match
60 return `${family![0]!.toUpperCase()}${family!.slice(1)} ${major}${minor ? `.${minor}` : ''}`
61}
62
63/** Monday-first weekday of a day: 0 = Monday ... 6 = Sunday. */
64export function weekdayOf(day: string): number {
65 const [year, month, date] = day.split('-').map(Number)
66 return (new Date(year!, month! - 1, date!).getDay() + 6) % 7
67}
68
69/** The same-length period just before the range, for comparisons; none for `all`. */
70export function previousNow(range: Range, now: number): number | null {
71 if (range === '7d') return now - 7 * DAY_MS
72 if (range === '30d') return now - 30 * DAY_MS
73 return null
74}
75
76/** Change from `before` to `now` as a whole percentage; null when there is nothing to compare with. */
77export function change(now: number, before: number): number | null {
78 if (before <= 0) return null
79 return Math.round(((now - before) / before) * 100)
80}
81
82/** Every model ranked by its all-time tokens, so its color stays the same whatever range is shown. */
83export function modelOrder(scan: Scan): string[] {
84 const totals = new Map<string, number>()
85 for (const [, , model, , input, output, cacheWrite, cacheRead] of scan.tokens) {
86 add(totals, modelName(model), input + output + cacheWrite + cacheRead)
87 }
88 return ranked(totals).map(slice => slice.label)
89}
90
91/** ISO week label of a day: "W41". */
92export function weekOf(day: string): string {
93 const [year, month, date] = day.split('-').map(Number)
94 const time = Date.UTC(year!, month! - 1, date!)
95 const target = new Date(time)
96 const weekday = (target.getUTCDay() + 6) % 7
97 target.setUTCDate(target.getUTCDate() - weekday + 3)
98 const firstThursday = new Date(Date.UTC(target.getUTCFullYear(), 0, 4))
99 const week = 1 + Math.round(((target.getTime() - firstThursday.getTime()) / DAY_MS - 3 + ((firstThursday.getUTCDay() + 6) % 7)) / 7)
100 return `W${week}`
101}
102
103export function summarize(scan: Scan, closed: Closed[], range: Range, now: number): Summary {
104 const from = rangeStart(range, now, scan)
105 const to = dayKey(now)
106 const inRange = (day: string) => day >= from && day <= to
107 const byProject = new Map<string, number>()
108 const byModel = new Map<string, number>()
109 const byIssue = new Map<string, number>()
110 const perDay = new Map<string, { tokens: number; prompts: number }>()
111 const totals = { input: 0, output: 0, cacheWrite: 0, cacheRead: 0, replies: 0 }
112
113 for (const [day, project, model, issue, input, output, cacheWrite, cacheRead, replies] of scan.tokens) {
114 if (!inRange(day)) continue
115 const tokens = input + output + cacheWrite + cacheRead
116 totals.input += input
117 totals.output += output
118 totals.cacheWrite += cacheWrite
119 totals.cacheRead += cacheRead
120 totals.replies += replies
121 add(byProject, project, tokens)
122 add(byModel, modelName(model), tokens)
123 if (issue !== '') add(byIssue, issue, tokens)
124 const entry = perDay.get(day) ?? { tokens: 0, prompts: 0 }
125 entry.tokens += tokens
126 perDay.set(day, entry)
127 }
128 let prompts = 0
129 for (const [day, , , count] of scan.prompts) {
130 if (!inRange(day)) continue
131 prompts += count
132 const entry = perDay.get(day) ?? { tokens: 0, prompts: 0 }
133 entry.prompts += count
134 perDay.set(day, entry)
135 }
136 const hourly = Array.from({ length: 24 }, () => 0)
137 const weekHours = Array.from({ length: 7 }, () => Array.from({ length: 24 }, () => 0))
138 for (const [day, hour, tokens, count] of scan.hours) {
139 if (!inRange(day)) continue
140 hourly[hour] = (hourly[hour] ?? 0) + tokens
141 weekHours[weekdayOf(day)]![hour]! += count
142 }
143
144 const daily: Summary['daily'] = []
145 const [year, month, date] = from.split('-').map(Number)
146 for (let time = new Date(year!, month! - 1, date!).getTime(); dayKey(time) <= to; time += DAY_MS) {
147 const day = dayKey(time)
148 daily.push({ day, ...(perDay.get(day) ?? { tokens: 0, prompts: 0 }) })
149 }
150
151 const done = closed.filter(issue => inRange(issue.day))
152 const weekly = new Map<string, number>()
153 for (const day of daily.map(entry => entry.day)) if (!weekly.has(weekOf(day))) weekly.set(weekOf(day), 0)
154 for (const issue of done) add(weekly, weekOf(issue.day), 1)
155
156 return {
157 tokens: totals.input + totals.output + totals.cacheWrite + totals.cacheRead,
158 ...totals,
159 prompts,
160 sessions: scan.sessions.filter(([day]) => inRange(day)).length,
161 byProject: ranked(byProject),
162 byModel: ranked(byModel),
163 byIssue: ranked(byIssue),
164 daily,
165 hourly,
166 weekHours,
167 closed: done,
168 closedPoints: done.reduce((sum, issue) => sum + issue.points, 0),
169 weekly: [...weekly.entries()].map(([label, value]) => ({ label, value })),
170 }
171}
172
173/** 26_607_881_459 -> "26.6B", 22_499_414 -> "22.5M", 1_174 -> "1,174". */
174export function compact(value: number): string {
175 if (value >= 1e9) return `${(value / 1e9).toFixed(1)}B`
176 if (value >= 1e6) return `${(value / 1e6).toFixed(1)}M`
177 if (value >= 1e4) return `${(value / 1e3).toFixed(1)}k`
178 return value.toLocaleString('en-US')
179}
180
181/** The top `count` slices, the rest folded into one "other" slice. */
182export function topWithOther(slices: Slice[], count: number): Slice[] {
183 if (slices.length <= count) return slices
184 const rest = slices.slice(count - 1).reduce((sum, slice) => sum + slice.value, 0)
185 return [...slices.slice(0, count - 1), { label: 'other', value: rest }]
186}
187types/index.d.ts 34 lines1/** What scanner/scan.py prints: aggregates of every session log on this machine. */
2export type Scan = {
3 generatedAt: number
4 files: number
5 parsed: number
6 seconds: number
7 /** [day, project, model, issue, input, output, cacheWrite, cacheRead, replies] */
8 tokens: [string, string, string, string, number, number, number, number, number][]
9 /** [day, project, issue, prompts] */
10 prompts: [string, string, string, number][]
11 /** [day, hour, tokens, prompts] */
12 hours: [string, number, number, number][]
13 /** [day, project], one per session */
14 sessions: [string, string][]
15}
16
17/** Linear issues you completed: id, completion day, estimate in points. */
18export type Closed = { id: string; day: string; points: number }
19
20export type Range = '7d' | '30d' | 'all'
21
22declare module 'claude-code' {
23 interface PluginState {
24 'usage-stats': {
25 scan: Scan | null
26 closed: Closed[] | null
27 isScanning: boolean
28 error: string | null
29 linearError: string | null
30 range: Range
31 }
32 }
33}
34