A pane that shows what Claude has done for you: tasks finished, an estimate of the hours that saved you, and what that time is worth. Tabs for Today, Week…

A Claude Code mod that shows what Claude has done for you: how many tasks it finished, an estimate of the hours that saved you, and what that time is worth. /report opens a pane with four tabs: Today, Week, Month and Year.
Hours and dollars are always estimates: the minutes you say a task saves, times your hourly rate. The mod never claims more than that.
| Command | What it does |
|---|---|
/report | Opens the pane. |
/report today, week, month, year | Opens the pane on that tab. |
/report settings | Lists the minutes saved per kind of task and the hourly rate. |
/report settings rate 75 | Sets the hourly rate to 75. |
/report settings emails 15 | Sets the minutes one email task saves to 15. Also code, documents, images, research and other. |
/report help | One line of usage. |
A Work report chip above the prompt, with today's count beside it, opens the pane too. Claude Code has no pressable entry in the status line itself, so the button sits in the band above the prompt and keeps whatever the other mods draw there beneath it.
Four tabs across the top, the chosen one a gold chip with dark text. The stretch and its dates sit in a title bar under them.
| Tab | Covers | Chart (three rows tall) | Line under the chart |
|---|---|---|---|
| Today | today | the last seven days by weekday | the streak |
| Week | the last 7 days | the last seven days by weekday | the streak |
| Month | the last 30 days | thirty daily bars | best day, active days |
| Year | January 1 through today | January to December, this month gold, months to come empty | best month, active days |
Under the title bar:
When there is nothing in the stretch yet, the pane says so in one calm sentence: Nothing finished yet today. Your first finished task shows up here. On the other tabs today becomes in the last 7 days, in the last 30 days or this year.
Every row is sized to the pane's width, so nothing wraps. As the pane narrows:
One typed request that finishes with an answer is one task.
prompt-upgrade after you pressed Run).claude -p runs.By the tools it used. A task is one kind. Each kind has strong rules and some have weak ones, which count only when no strong rule matched.
Strong rules. When a task touched several kinds, the first of these wins:
| # | Kind | What puts a task there |
|---|---|---|
| 1 | Code | Writing a source or config file with Edit, Write, MultiEdit or NotebookEdit (.ts, .py, .go, .rs, .json, .yml, .html, .css, .sql and so on). |
| 2 | Emails | Any mail tool: Gmail, Outlook, IMAP, SMTP and the like. |
| 3 | Images and video | An image or video tool; writing an image or video file (.png, .jpg, .svg, .mp4, .mov and so on); or running ffmpeg, magick, yt-dlp and the like. |
| 4 | Documents | A document, slide, spreadsheet or PDF tool that writes (update, create, append, batch and so on); or writing a document file (.md, .docx, .pdf, .pptx, .xlsx, .csv and so on). |
| 5 | Research | Web search, fetching a page, reading a page in the browser, transcripts; or a document tool that only looks something up (read_doc, query-docs, search_apify_docs, get_values). |
Weak rules, tried in this order, only when no strong rule matched:
| # | Kind | What puts a task there |
|---|---|---|
| 6 | Code | Running a command with Bash or PowerShell (builds, tests, git), or writing a file nothing recognised (Makefile, Dockerfile, .env, .gitignore). |
| 7 | Images and video | Only looking at an image file with Read: a screenshot. |
| 8 | Research | Only reading or searching your own files (Read, Grep, Glob), or using the browser (navigating, clicking). |
| 9 | Other | Everything else, including a task that used no tools. |
What that means in practice:
ls is Research. A task that read a file and then ran its tests is Code.ffmpeg is video, git is not). The command text is held in memory until the turn ends and is never stored.The tools of helper agents working inside your request count toward its kind. The model that finished the turn is recorded by its short name ("Sonnet 5.5"), not its id.
Only counts are stored, not the tools, so tasks finished before these rules existed stay where they were counted (mostly Other and Research). New tasks are sorted by the rules above.
| Kind | Minutes saved per task |
|---|---|
| Emails | 10 |
| Code | 20 |
| Documents | 30 |
| Images and video | 20 |
| Research | 20 |
| Other | 10 |
Hourly rate: 50. Hours are minutes divided by 60; dollars are hours times the rate. Change any of them with /report settings; they are kept between sessions. A kind has one figure whichever rule put a task there, so Code and Research are averages: Code covers a small edit and a long session, Research a web investigation and a quick look through your files. Set them to what a typical task of that kind is worth to you (/report settings code 40).
Days in a row with at least one task, counted back from today. A day that has not had its first task yet does not break the streak: it counts from yesterday until today has one.
Only counts, in the mod's own store: one small record per calendar day (day:2026-10-13, the count of each kind and the count per model) and one settings record. No prompt text, no file names and no answers are kept. Nothing is written inside the mod's folder.
A day's record is read back from the store before each task is added, so two sessions open at once do not overwrite each other's counts.
None. The mod makes no model calls: it listens to events and counts. Recording a task shows nothing; it speaks up only when you ask for the pane and the window is too narrow to open it, or a button press fails.
From a terminal session:
claude plugin marketplace add afterever/claude-mods
claude plugin install work-report@afterever-mods
Restart Claude Code to apply. To try it from a checkout without installing:
claude --plugin-dir <path to claude-mods>\work-report
prompt.submit arms a typed prompt, turn.start claims it by its words, and turn.complete records it only if the turn ended with an answer in the main loop. A turn nobody typed is never claimed, so it is never counted.$.state (the days, the settings, the tab), so a hot reload keeps it; the days and settings are also kept in the mod's own $.store, across sessions. A draw only reads; every change comes from a button, a command or an event. The contract is types/index.d.ts.╺ ╸ ╻ ╹) are missing from many fonts. The decimal point is a plain . on the bottom row./report returns its sentence at once and opens the pane in the background; every form of it is wrapped so a failure comes back as a plain sentence..claude-plugin/plugin.json name, version, state contract
hooks/hooks.json points at register.tsx
hooks/register.tsx events, commands, the pane and the button
hooks/view.tsx the pane, drawn from state
hooks/layout.ts widths, big numerals, tiles, bars, the chart's text
hooks/svg.ts the tiles, bars and chart as drawings, for the desktop
hooks/report.ts totals, the estimate, the streak, chart bars, model names
hooks/dates.ts local-noon day stepping, ranges, the day of the year
hooks/kinds.ts sorting a task by its tools
hooks/record.ts which prompts count, and claiming a turn
hooks/*.test.ts kinds, typed prompts, dates, totals, layout, drawings and the full flow
types/index.d.ts the mod's $.state contract
claude plugin validate <path to claude-mods>
claude plugin test <path to claude-mods>\work-report
The tests make no model calls and cost nothing. They cover sorting a task by its tools, counting only typed prompts, the streak, adding days together, the day of the year and stepping across clock changes, each tab drawing its own title, dates and chart, the empty state, three then two then one tiles, that on the desktop the tiles, bars and chart are drawings whose rows share one start, one length and one right edge at every width, and that no row of the pane is wider than the pane at narrow and wide widths on both the terminal and the desktop.
Bump version in plugin.json when you change the mod, or installed copies will not pick the change up.
Built as a Claude Code mod for afterever/claude-mods.
hooks/register.tsx 275 lines1import { atom, read, update } from 'claude-code'
2import type { Elements, EngineInterface, Register } from 'claude-code'
3
4import type { DayRecord, Kind, Settings, Tab } from '../types'
5import { keyOf, noonOf } from './dates'
6import { classifyTask, KIND_LABEL } from './kinds'
7import type { ToolUse } from './kinds'
8import { COLORS, fit } from './layout'
9import { arm, claim, disarm, isTypedPrompt } from './record'
10import { addTask, DEFAULT_SETTINGS, modelName, normalizeDay, normalizeSettings, tasksOf } from './report'
11import { drawPane } from './view'
12
13const PANE = 'work-report'
14// Asked for by a command or a button, so it is placed at any width that holds it
15const PANE_ARGS = { id: PANE, title: 'Work Report', focus: true, closeOnEscape: true, rows: 38, columns: 72 } as const
16
17// What the drawings read lives in $.state, so a hot reload keeps it; the days
18// and the settings are also kept in $.store across sessions
19const daysAtom = atom({ plugin: 'work-report', key: 'days' } as const, {})
20const settingsAtom = atom({ plugin: 'work-report', key: 'settings' } as const, DEFAULT_SETTINGS)
21const tabAtom = atom({ plugin: 'work-report', key: 'tab' } as const, 'today')
22
23const DAY_PREFIX = 'day:'
24const TAB_WORDS: readonly Tab[] = ['today', 'week', 'month', 'year']
25
26// Typed prompts waiting for their turn to start, and the turn being counted.
27// A reload drops these: at worst the one task in flight is not counted.
28let armed: string[] = []
29let active: { turnId: string; uses: ToolUse[] } | null = null
30const MAX_USES = 500
31
32const plain = (err: unknown) => (err instanceof Error && err.message ? err.message : 'something went wrong')
33
34// ── Reading and writing the store ────────────────────────────────────────
35
36async function loadAll($: EngineInterface) {
37 const keys = (await $.store.keys()).filter(k => k.startsWith(DAY_PREFIX))
38 const entries = await Promise.all(keys.map(async k => [k.slice(DAY_PREFIX.length), normalizeDay(await $.store.get(k))] as const))
39 const days: Record<string, DayRecord> = {}
40 for (const [key, rec] of entries) days[key] = rec
41 await update($, daysAtom, () => days)
42 const settings = normalizeSettings(await $.store.get('settings'))
43 await update($, settingsAtom, () => settings)
44}
45
46/** One more finished task, added to today's record. Read back first, so a second session's counts are not lost. */
47async function recordTask($: EngineInterface, kind: Kind, model: string) {
48 const key = keyOf(noonOf(await $.clock.now()))
49 const now = addTask(normalizeDay(await $.store.get(`${DAY_PREFIX}${key}`)), kind, model)
50 await $.store.set(`${DAY_PREFIX}${key}`, now)
51 await update($, daysAtom, days => ({ ...days, [key]: now }))
52}
53
54// ── The pane ─────────────────────────────────────────────────────────────
55
56/** Opens the pane. A window too narrow for it is the one thing worth a line: the person asked and nothing came. */
57async function openPane($: EngineInterface): Promise<void> {
58 try {
59 const r = await $.ui.open(PANE_ARGS)
60 if (!r.isPlaced) $.ui.toast('Work Report needs a wider window to open. Widen the terminal and try /report again.')
61 } catch {
62 // no surface to ask: nothing to show
63 }
64}
65
66async function openOn($: EngineInterface, tab: Tab | null): Promise<void> {
67 if (tab) await update($, tabAtom, () => tab)
68 await openPane($)
69}
70
71// ── /report settings ─────────────────────────────────────────────────────
72
73const SETTING_ORDER: readonly Kind[] = ['emails', 'code', 'documents', 'images', 'research', 'other']
74
75const SETTING_NAMES: Record<string, Kind | 'rate'> = {
76 rate: 'rate',
77 emails: 'emails',
78 email: 'emails',
79 code: 'code',
80 coding: 'code',
81 commands: 'code',
82 documents: 'documents',
83 document: 'documents',
84 docs: 'documents',
85 images: 'images',
86 image: 'images',
87 video: 'images',
88 research: 'research',
89 other: 'other',
90}
91
92const num = (n: number) => String(Math.round(n * 100) / 100)
93
94function settingsText(s: Settings): string {
95 const minutes = SETTING_ORDER.map(k => `${KIND_LABEL[k]} ${num(s.minutes[k])}`).join(', ')
96 return (
97 `Minutes saved per task: ${minutes}. Hourly rate: $${num(s.rate)}. ` +
98 'Hours and dollars are estimates. Change one with /report settings rate 75 or /report settings emails 15.'
99 )
100}
101
102async function settingsCommand($: EngineInterface, args: string[]): Promise<string> {
103 const current = normalizeSettings(await read($, settingsAtom))
104 if (args.length === 0) return settingsText(current)
105
106 const name = SETTING_NAMES[(args[0] ?? '').toLowerCase()]
107 if (!name) {
108 return `I do not know the setting "${args[0]}". The ones you can change are rate, emails, code, documents, images, research and other. For example: /report settings rate 75.`
109 }
110 const raw = args[1]
111 const value = raw === undefined ? NaN : Number(raw.replace(/^\$/, ''))
112 if (!Number.isFinite(value) || value < 0) {
113 return name === 'rate' ? 'The rate must be a number, for example /report settings rate 75.' : `The minutes must be a number, for example /report settings ${name} 15.`
114 }
115 const limit = name === 'rate' ? 100_000 : 1440
116 if (value > limit) {
117 return name === 'rate' ? `That rate is too high. Use a number up to ${limit}.` : `That is more than a day. Use a number of minutes up to ${limit}.`
118 }
119 const next: Settings = name === 'rate' ? { ...current, rate: value } : { ...current, minutes: { ...current.minutes, [name]: value } }
120 await update($, settingsAtom, () => next)
121 try {
122 await $.store.set('settings', next)
123 } catch {
124 // changed for this session still
125 }
126 return name === 'rate'
127 ? `Hourly rate is now $${num(value)}. Hours and dollars stay estimates.`
128 : `${KIND_LABEL[name]} now save ${num(value)} minutes each. Hours and dollars stay estimates.`
129}
130
131const USAGE =
132 'Work Report: /report opens the pane, /report today, week, month or year opens it on that tab, /report settings lists the estimate settings, and /report settings rate 75 or /report settings emails 15 changes one.'
133
134// ── The module ───────────────────────────────────────────────────────────
135
136export const register: Register = on => {
137 on('session.start', async ($, e, next) => {
138 armed = []
139 active = null
140 try {
141 await $.command.register({
142 name: 'report',
143 description: 'Show what Claude has done for you: tasks finished, hours saved and what that is worth',
144 argumentHint: '[today | week | month | year | settings]',
145 immediate: true,
146 })
147 } catch {
148 // the recording still works without the command
149 }
150 try {
151 await loadAll($)
152 } catch {
153 // start empty: the next finished task is still recorded
154 }
155 return next(e)
156 })
157
158 // A typed prompt is armed here and claimed when its turn starts. Anything
159 // that fails in this hook lets the prompt through untouched.
160 on('prompt.submit', async ($, e, next) => {
161 const origin = e.origin as { kind: string; asUser?: boolean }
162 const typed = isTypedPrompt(e.text, { kind: origin.kind, asUser: origin.asUser })
163 if (typed) armed = arm(armed, e.text)
164 const r = await next(e)
165 if (typed) {
166 if ('drop' in r && r.drop !== undefined) armed = disarm(armed, e.text)
167 else if (typeof r.text === 'string' && r.text !== e.text) armed = arm(armed, r.text)
168 }
169 return r
170 }).catch(($, e, next) => next(e))
171
172 on('turn.start', async ($, e, next) => {
173 const hit = claim(armed, e.text)
174 armed = hit.queue
175 active = hit.found ? { turnId: e.turnId, uses: [] } : null
176 return next(e)
177 }).catch(($, e, next) => next(e))
178
179 // The tools the counted turn used, helper agents' too: they are part of the work
180 on('tool.call', async ($, e, next) => {
181 if (active && active.uses.length < MAX_USES) {
182 const input = e as unknown as Record<string, unknown>
183 const path = [input.file_path, input.path, input.notebook_path].find(v => typeof v === 'string')
184 // the start of a command is enough to tell ffmpeg from git; it is not kept past the turn
185 const command = typeof input.command === 'string' ? input.command.slice(0, 400) : undefined
186 active.uses.push({
187 tool: String(e.tool),
188 ...(typeof path === 'string' ? { path } : {}),
189 ...(command ? { command } : {}),
190 })
191 }
192 return next(e)
193 }).catch(($, e, next) => next(e))
194
195 // A finished task: the turn answered, was not cancelled, and is the main loop's
196 on('turn.complete', async ($, e, next) => {
197 const turn = active
198 if (turn && turn.turnId === e.turnId && !e.agentId) {
199 active = null
200 if (e.reason === 'answer' && !e.isAborted) {
201 try {
202 await recordTask($, classifyTask(turn.uses), modelName(e.usage?.model))
203 } catch {
204 // a task that cannot be saved is not worth interrupting the answer for
205 }
206 }
207 }
208 return next(e)
209 }).catch(($, e, next) => next(e))
210
211 // Answers at once; the pane opens in the background
212 on('command.run', { command: 'report' }, async ($, e) => {
213 try {
214 const raw = String(e.args ?? '').trim()
215 const parts = raw === '' ? [] : raw.split(/\s+/)
216 const word = (parts[0] ?? '').toLowerCase()
217 if (word === 'settings' || word === 'setting') return { text: await settingsCommand($, parts.slice(1)) }
218 if (word === 'help' || word === '?') return { text: USAGE }
219 const tab = TAB_WORDS.find(t => t === word) ?? null
220 if (raw === '' || tab) {
221 void openOn($, tab).catch(() => undefined)
222 return { text: tab ? `Opening the work report on ${tab}. Hours and dollars in it are estimates.` : 'Opening the work report. Hours and dollars in it are estimates.' }
223 }
224 return { text: `I do not know "${raw}". ${USAGE}` }
225 } catch (err) {
226 return { text: `Work Report could not do that: ${plain(err)}.` }
227 }
228 }).catch(() => ({ text: 'Work Report could not do that right now.' }))
229
230 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
231 const el = $.ui.resolve(e) as unknown as Elements['terminal']
232 if (e.surface === 'mobile') {
233 const { Text } = el
234 return <Text color={COLORS.muted}>Work Report needs the terminal or the desktop app.</Text>
235 }
236 const days = (await read($, daysAtom)) ?? {}
237 const settings = normalizeSettings(await read($, settingsAtom))
238 const tab = (await read($, tabAtom)) ?? 'today'
239 const now = await $.clock.now()
240 const width = Math.max(20, Math.min(110, e.props.bodyColumns || PANE_ARGS.columns))
241 const press =
242 (fn: () => Promise<unknown>) =>
243 () => {
244 fn().catch(err => $.ui.toast(`Work Report: ${plain(err)}`))
245 }
246 // the terminal's table answers Svg too but draws it empty: its figures are text
247 const Svg = e.surface === 'terminal' ? undefined : (el as unknown as Partial<Elements['desktop']>).Svg
248 return drawPane(el, { width, tab, days, settings, now, ...(Svg ? { Svg } : {}) }, { pickTab: t => press(() => update($, tabAtom, () => t))() })
249 })
250
251 // The one button: a chip above the prompt that opens the pane. The band is
252 // shared, so what the others draw is kept beneath it.
253 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
254 const below = await next(e)
255 if (e.props.hasSurvey) return below
256 const { Box, Button, Text } = $.ui.resolve(e)
257 const days = (await read($, daysAtom)) ?? {}
258 const today = tasksOf(days[keyOf(noonOf(await $.clock.now()))])
259 const label = fit(Math.max(0, (e.props.bodyColumns || 80) - 14), [`${today} ${today === 1 ? 'task' : 'tasks'} today`, `${today} today`, ''])
260 return (
261 <Box flexDirection="column">
262 <Box flexDirection="row" columnGap={1}>
263 <Button key="work-report-open" plain onPress={() => void openPane($)}>
264 <Text backgroundColor={COLORS.hair} color={COLORS.text}>
265 {' Work report '}
266 </Text>
267 </Button>
268 {label !== '' && <Text color={COLORS.muted}>{label}</Text>}
269 </Box>
270 {below}
271 </Box>
272 )
273 })
274}
275hooks/dates.ts 112 lines1// Calendar days in the person's own time zone. Every day is held as its local
2// noon: stepping from midnight by 24 hours lands on the wrong date across a
3// clock change (a day is 23 or 25 hours long then); noon never does.
4import type { Tab } from '../types'
5
6export const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'] as const
7export const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'] as const
8export const MONTHS_LONG = [
9 'January', 'February', 'March', 'April', 'May', 'June',
10 'July', 'August', 'September', 'October', 'November', 'December',
11] as const
12
13/** The local noon of the day a moment falls on. */
14export function noonOf(ms: number): Date {
15 const d = new Date(ms)
16 return new Date(d.getFullYear(), d.getMonth(), d.getDate(), 12)
17}
18
19/** The day `n` days from this one (negative: before), at its noon. */
20export function addDays(day: Date, n: number): Date {
21 return new Date(day.getFullYear(), day.getMonth(), day.getDate() + n, 12)
22}
23
24const pad = (n: number) => String(n).padStart(2, '0')
25
26/** "2026-10-13": the key a day's record is stored under. */
27export function keyOf(day: Date): string {
28 return `${day.getFullYear()}-${pad(day.getMonth() + 1)}-${pad(day.getDate())}`
29}
30
31/** 1 for January 1, 365 or 366 for December 31. */
32export function dayOfYear(day: Date): number {
33 const start = new Date(day.getFullYear(), 0, 1, 12)
34 const noon = new Date(day.getFullYear(), day.getMonth(), day.getDate(), 12)
35 // both are at noon, so the gap is a whole number of days give or take the clock change's hour
36 return Math.round((noon.getTime() - start.getTime()) / 86_400_000) + 1
37}
38
39/** The last `count` days, the oldest first, ending with the day `ms` falls on. */
40export function lastDays(ms: number, count: number): Date[] {
41 const today = noonOf(ms)
42 const out: Date[] = []
43 for (let i = count - 1; i >= 0; i--) out.push(addDays(today, -i))
44 return out
45}
46
47/** January 1 through the day `ms` falls on. */
48export function yearToDate(ms: number): Date[] {
49 return lastDays(ms, dayOfYear(noonOf(ms)))
50}
51
52/** The days a tab covers, oldest first: today; the last 7; the last 30; January 1 through today. */
53export function daysFor(tab: Tab, ms: number): Date[] {
54 switch (tab) {
55 case 'today':
56 return lastDays(ms, 1)
57 case 'week':
58 return lastDays(ms, 7)
59 case 'month':
60 return lastDays(ms, 30)
61 case 'year':
62 return yearToDate(ms)
63 }
64}
65
66const dayMonth = (d: Date) => `${d.getDate()} ${MONTHS[d.getMonth()]}`
67
68/** "Tue 13 Oct 2026" for one day, "7 Oct to 13 Oct 2026" for a stretch. */
69export function describeDays(days: readonly Date[]): string {
70 const first = days[0]
71 const last = days[days.length - 1]
72 if (!first || !last) return ''
73 if (days.length === 1) return `${WEEKDAYS[last.getDay()]} ${dayMonth(last)} ${last.getFullYear()}`
74 if (first.getFullYear() === last.getFullYear()) return `${dayMonth(first)} to ${dayMonth(last)} ${last.getFullYear()}`
75 return `${dayMonth(first)} ${first.getFullYear()} to ${dayMonth(last)} ${last.getFullYear()}`
76}
77
78export const TAB_NAME: Record<Tab, string> = {
79 today: 'Today',
80 week: 'Week',
81 month: 'Month',
82 year: 'Year',
83}
84
85/** What the stretch is called in the title bar. */
86export function stretchName(tab: Tab, ms: number): string {
87 switch (tab) {
88 case 'today':
89 return 'Today'
90 case 'week':
91 return 'Last 7 days'
92 case 'month':
93 return 'Last 30 days'
94 case 'year':
95 return `Year ${noonOf(ms).getFullYear()}`
96 }
97}
98
99/** "today", "in the last 7 days": the words the empty state uses. */
100export function sinceWords(tab: Tab): string {
101 switch (tab) {
102 case 'today':
103 return 'today'
104 case 'week':
105 return 'in the last 7 days'
106 case 'month':
107 return 'in the last 30 days'
108 case 'year':
109 return 'this year'
110 }
111}
112hooks/kinds.ts 166 lines1// Sorting a finished task into one kind, by the tools it used. Pure: the
2// register module collects the tool uses, this decides.
3import type { Kind } from '../types'
4
5// The order the rows are stored and shown in; a tie in the pane keeps it.
6export const KINDS: readonly Kind[] = ['emails', 'images', 'code', 'documents', 'research', 'other']
7
8// Which kind wins when a task touched several. Code is first on purpose: a
9// source file that was written says what the task was, and outweighs a
10// screenshot that was looked at or an email that was sent along the way.
11const PRIORITY: readonly Kind[] = ['code', 'emails', 'images', 'documents', 'research']
12
13export const KIND_LABEL: Record<Kind, string> = {
14 emails: 'Emails',
15 images: 'Images and video',
16 code: 'Code',
17 documents: 'Documents',
18 research: 'Research',
19 other: 'Other',
20}
21
22/** The same kinds in a narrow pane. */
23export const KIND_SHORT: Record<Kind, string> = {
24 emails: 'Emails',
25 images: 'Images',
26 code: 'Code',
27 documents: 'Docs',
28 research: 'Research',
29 other: 'Other',
30}
31
32/**
33 * One tool call of the turn: its name, for a file tool the path it worked on,
34 * and for a shell tool the start of its command. Kept in memory until the turn
35 * ends, to be sorted; none of it is stored.
36 */
37export type ToolUse = { tool: string; path?: string; command?: string }
38
39const IMAGE_EXT = new Set([
40 'png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'svg', 'heic', 'tif', 'tiff', 'ico', 'psd',
41 'mp4', 'mov', 'webm', 'avi', 'mkv', 'm4v', 'wmv', 'gifv',
42])
43const DOCUMENT_EXT = new Set([
44 'md', 'txt', 'rtf', 'doc', 'docx', 'odt', 'pdf', 'tex',
45 'ppt', 'pptx', 'odp', 'key',
46 'xls', 'xlsx', 'ods', 'csv', 'tsv',
47])
48// Source and the files that configure it. Writing one is code.
49const CODE_EXT = new Set([
50 'ts', 'tsx', 'js', 'jsx', 'mjs', 'cjs', 'vue', 'svelte', 'astro',
51 'py', 'ipynb', 'rb', 'php', 'go', 'rs', 'java', 'kt', 'kts', 'scala', 'swift', 'dart', 'lua', 'r',
52 'c', 'h', 'cc', 'cpp', 'hpp', 'cs', 'm', 'mm',
53 'sh', 'bash', 'zsh', 'ps1', 'bat', 'cmd',
54 'sql', 'graphql', 'proto',
55 'html', 'htm', 'css', 'scss', 'sass', 'less',
56 'json', 'jsonc', 'yaml', 'yml', 'toml', 'ini', 'xml', 'gradle',
57])
58
59// The built-in tools that read or write one file by path
60const READ_TOOLS = new Set(['read'])
61const WRITE_TOOLS = new Set(['write', 'edit', 'multiedit', 'notebookedit'])
62// The weak signals, looked at only when nothing stronger was found, in this
63// order (see classifyTask): writing a file nothing else recognised, or running
64// a command, is code; looking at an image is images; reading or searching
65// files, or using the browser, is research.
66const SHELL_TOOLS = new Set(['bash', 'powershell'])
67const EXPLORE_TOOLS = new Set(['read', 'grep', 'glob', 'notebookread'])
68// A command that works on pictures or video is images, whatever tool ran it
69const MEDIA_COMMAND = /\b(ffmpeg|ffprobe|magick|imagemagick|handbrakecli|yt-dlp|youtube-dl)\b/i
70// A tool of a browser server: navigating and reading pages is looking things up
71const isBrowserWord = (w: string) => w === 'chrome' || w === 'browser'
72
73// What a document tool does, from its name: `read_doc`, `query-docs` and
74// `search_apify_docs` look something up; `update_doc` and `create` write one.
75// A name with a writing verb is a document whatever else it says.
76const LOOKUP_VERBS = new Set([
77 'get', 'read', 'list', 'search', 'query', 'find', 'fetch', 'resolve', 'lookup', 'show', 'view', 'describe', 'download', 'inspect',
78])
79const WRITE_VERBS = new Set([
80 'create', 'update', 'write', 'edit', 'append', 'insert', 'delete', 'remove', 'set', 'batch', 'replace', 'upload', 'add', 'copy',
81 'duplicate', 'move', 'share', 'clear', 'trash', 'import', 'export', 'convert', 'send', 'publish', 'save', 'apply', 'patch',
82 'rename', 'format', 'merge', 'restore', 'generate',
83])
84
85const IMAGE_WORDS = new Set([
86 'image', 'images', 'img', 'video', 'videos', 'photo', 'photos', 'picture', 'pictures',
87 'imagen', 'dalle', 'midjourney', 'ffmpeg',
88])
89const DOCUMENT_WORDS = new Set([
90 'doc', 'docs', 'document', 'documents', 'docx', 'slide', 'slides', 'deck', 'pptx',
91 'sheet', 'sheets', 'spreadsheet', 'spreadsheets', 'xlsx', 'presentation', 'presentations', 'pdf',
92])
93const SCRAPE_WORDS = new Set(['transcript', 'transcripts', 'scrape', 'crawl', 'websearch', 'webfetch'])
94
95/** A tool name as lower-case words: `mcp__claude_ai_Gmail__send_message` is mcp, claude, ai, gmail, send, message. */
96function words(tool: string): string[] {
97 return tool.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean)
98}
99
100function extOf(path: string | undefined): string {
101 if (!path) return ''
102 const name = path.split(/[\\/]/).pop() ?? ''
103 const dot = name.lastIndexOf('.')
104 return dot > 0 ? name.slice(dot + 1).toLowerCase() : ''
105}
106
107const isMailWord = (w: string) => w.startsWith('mail') || w.endsWith('mail') || w === 'outlook' || w === 'imap' || w === 'smtp'
108
109/** The kind one tool call points to, or null when it says nothing. */
110export function kindOfUse(use: ToolUse): Kind | null {
111 const name = use.tool.toLowerCase()
112 const w = words(use.tool)
113 const ext = extOf(use.path)
114
115 if (w.some(isMailWord)) return 'emails'
116
117 if (w.some(x => IMAGE_WORDS.has(x))) return 'images'
118 // writing a picture or video is images; only looking at one is a weak signal (classifyTask)
119 if (WRITE_TOOLS.has(name) && IMAGE_EXT.has(ext)) return 'images'
120 if (SHELL_TOOLS.has(name) && MEDIA_COMMAND.test(use.command ?? '')) return 'images'
121
122 if (WRITE_TOOLS.has(name) && CODE_EXT.has(ext)) return 'code'
123
124 if (w.some(x => DOCUMENT_WORDS.has(x))) {
125 const isWriting = w.some(x => WRITE_VERBS.has(x))
126 const isLookup = w.some(x => LOOKUP_VERBS.has(x))
127 return isLookup && !isWriting ? 'research' : 'documents'
128 }
129 if (WRITE_TOOLS.has(name) && DOCUMENT_EXT.has(ext)) return 'documents'
130
131 if (w.some(x => SCRAPE_WORDS.has(x))) return 'research'
132 if (w.includes('web') && (w.includes('search') || w.includes('fetch'))) return 'research'
133 // reading a page in the browser: read_page, get_page_text
134 if (w.includes('page') && (w.includes('text') || w.includes('read') || w.includes('get'))) return 'research'
135
136 return null
137}
138
139/**
140 * One kind for the whole task. When a task touched several, PRIORITY decides:
141 * a task that searched the web and then wrote an email is an email, and one
142 * that also wrote a source file is code.
143 *
144 * Only when no kind was found are the weak signals looked at, in this order:
145 * 1. a file was written that nothing recognised (a Makefile, a .env), or a
146 * command was run: code
147 * 2. an image was looked at: images
148 * 3. files were read or searched, or the browser was used: research
149 * Anything else, a task that used no tools included, is other.
150 */
151export function classifyTask(uses: readonly ToolUse[]): Kind {
152 const seen = new Set<Kind>()
153 for (const use of uses) {
154 const kind = kindOfUse(use)
155 if (kind) seen.add(kind)
156 }
157 const found = PRIORITY.find(k => seen.has(k))
158 if (found) return found
159
160 const names = uses.map(u => u.tool.toLowerCase())
161 if (names.some(n => WRITE_TOOLS.has(n) || SHELL_TOOLS.has(n))) return 'code'
162 if (uses.some(u => READ_TOOLS.has(u.tool.toLowerCase()) && IMAGE_EXT.has(extOf(u.path)))) return 'images'
163 if (names.some(n => EXPLORE_TOOLS.has(n)) || uses.some(u => words(u.tool).some(isBrowserWord))) return 'research'
164 return 'other'
165}
166hooks/layout.ts 293 lines1// What the pane needs that is only arithmetic: cell widths, big numerals, how
2// many tiles fit, and the text of the bars and the chart. Every row built here
3// is measured in cells and is never wider than the width it is given.
4import type { Kind } from '../types'
5import type { Bar } from './report'
6
7export type Tone = 'text' | 'muted' | 'hair' | 'gold' | 'green' | 'amber' | 'dark' | 'blue' | 'violet' | 'teal' | 'orange' | 'pink'
8export type Seg = { t: string; tone: Tone; bold?: boolean }
9export type Line = Seg[]
10
11export const COLORS: Record<Tone, string> = {
12 text: '#E6EDF3',
13 muted: '#7D8590',
14 hair: '#262B33',
15 gold: '#F2C46D',
16 green: '#3DD68C',
17 amber: '#E3B341',
18 dark: '#0D1117',
19 blue: '#58A6FF',
20 violet: '#BC8CFF',
21 teal: '#39C5CF',
22 orange: '#F0883E',
23 pink: '#F778BA',
24}
25
26export const KIND_TONE: Record<Kind, Tone> = {
27 emails: 'blue',
28 images: 'violet',
29 code: 'pink',
30 documents: 'teal',
31 research: 'orange',
32 other: 'muted',
33}
34
35export const TITLE = 'WORK REPORT'
36
37// ── Widths ───────────────────────────────────────────────────────────────
38
39/** Terminal cells one character takes: 2 for wide characters, 0 for marks, else 1. */
40export function cellWidth(ch: string): number {
41 const c = ch.codePointAt(0) ?? 0
42 if (c < 32 || (c >= 0x7f && c < 0xa0)) return 0
43 if ((c >= 0x300 && c <= 0x36f) || c === 0x200d || (c >= 0xfe00 && c <= 0xfe0f)) return 0
44 const isWide =
45 (c >= 0x1100 && c <= 0x115f) ||
46 (c >= 0x2e80 && c <= 0xa4cf && c !== 0x303f) ||
47 (c >= 0xac00 && c <= 0xd7a3) ||
48 (c >= 0xf900 && c <= 0xfaff) ||
49 (c >= 0xfe30 && c <= 0xfe6f) ||
50 (c >= 0xff00 && c <= 0xff60) ||
51 (c >= 0xffe0 && c <= 0xffe6) ||
52 (c >= 0x1f300 && c <= 0x1f64f) ||
53 (c >= 0x1f900 && c <= 0x1f9ff) ||
54 (c >= 0x20000 && c <= 0x3fffd)
55 return isWide ? 2 : 1
56}
57
58export function strWidth(s: string): number {
59 let w = 0
60 for (const ch of s) w += cellWidth(ch)
61 return w
62}
63
64export function lineWidth(line: Line): number {
65 return line.reduce((n, s) => n + strWidth(s.t), 0)
66}
67
68/** The text cut to `width` cells, an ellipsis where it was cut. */
69export function clip(s: string, width: number): string {
70 if (width <= 0) return ''
71 if (strWidth(s) <= width) return s
72 let out = ''
73 let w = 0
74 for (const ch of s) {
75 const cw = cellWidth(ch)
76 if (w + cw > width - 1) break
77 out += ch
78 w += cw
79 }
80 return out + '…'
81}
82
83/** The first of the wordings that fits `width`; the last, cut, when none does. */
84export function fit(width: number, wordings: readonly string[]): string {
85 for (const w of wordings) if (strWidth(w) <= width) return w
86 return clip(wordings[wordings.length - 1] ?? '', width)
87}
88
89const padEnd = (s: string, width: number) => s + ' '.repeat(Math.max(0, width - strWidth(s)))
90const padStart = (s: string, width: number) => ' '.repeat(Math.max(0, width - strWidth(s))) + s
91
92// ── Big numerals ─────────────────────────────────────────────────────────
93
94const NB = ' '
95
96// Only the common heavy box-drawing characters: the half-line ones are
97// missing from many fonts and a substitute font drew the tiles out of line.
98// The gaps are non-breaking spaces so a surface cannot collapse them.
99const DIGITS: Record<string, [string, string, string]> = {
100 '0': ['┏━┓', `┃${NB}┃`, '┗━┛'],
101 '1': [`┏┓${NB}`, `${NB}┃${NB}`, '┗┻┛'],
102 '2': ['━━┓', '┏━┛', '┗━━'],
103 '3': ['━━┓', `${NB}━┫`, '━━┛'],
104 '4': [`┃${NB}┃`, '┗━┫', `${NB}${NB}┃`],
105 '5': ['┏━━', '┗━┓', '━━┛'],
106 '6': ['┏━━', '┣━┓', '┗━┛'],
107 '7': ['━━┓', `${NB}${NB}┃`, `${NB}${NB}┃`],
108 '8': ['┏━┓', '┣━┫', '┗━┛'],
109 '9': ['┏━┓', '┗━┫', '━━┛'],
110 '.': [NB, NB, '.'],
111}
112
113/**
114 * A number three rows tall in heavy box-drawing characters, one cell between
115 * glyphs; `null` for text that is not digits and points, which is shown plain.
116 */
117export function bigNumber(text: string): [string, string, string] | null {
118 if (text === '') return null
119 const rows: [string, string, string] = ['', '', '']
120 let first = true
121 for (const ch of text) {
122 const glyph = DIGITS[ch]
123 if (!glyph) return null
124 for (let r = 0; r < 3; r++) rows[r] += (first ? '' : NB) + glyph[r]
125 first = false
126 }
127 return rows
128}
129
130// ── Tiles ────────────────────────────────────────────────────────────────
131
132export type TilePlan = {
133 /** How many of the three tiles are drawn: three, then two, then one. */
134 count: 1 | 2 | 3
135 /** Cells inside one tile's frame. */
136 inner: number
137}
138
139export function tilePlan(width: number): TilePlan {
140 const count = width >= 60 ? 3 : width >= 40 ? 2 : 1
141 // the gaps between tiles, then the two cells of each frame
142 const inner = Math.max(4, Math.floor((width - (count - 1)) / count) - 2)
143 return { count, inner }
144}
145
146export type TileFace =
147 | { kind: 'big'; rows: [string, string, string] }
148 | { kind: 'plain'; text: string }
149
150/** Big numerals when they fit inside the frame, otherwise the number as plain bold text. */
151export function tileFace(numerals: string, plain: string, inner: number): TileFace {
152 const rows = bigNumber(numerals)
153 if (rows && strWidth(rows[0]) <= inner) return { kind: 'big', rows }
154 return { kind: 'plain', text: clip(plain, inner) }
155}
156
157// ── Bar rows ─────────────────────────────────────────────────────────────
158
159const FILL = '█'
160const TRACK = '▁'
161
162/** A bar `width` cells long: `count` of `max` filled, never nothing for a count above zero. */
163export function barSegs(count: number, max: number, width: number, tone: Tone): Line {
164 if (width <= 0) return []
165 const filled = count <= 0 || max <= 0 ? 0 : Math.min(width, Math.max(1, Math.round((count / max) * width)))
166 const line: Line = []
167 if (filled > 0) line.push({ t: FILL.repeat(filled), tone })
168 if (filled < width) line.push({ t: TRACK.repeat(width - filled), tone: 'hair' })
169 return line
170}
171
172export type BarRowInput = {
173 label: string
174 count: number
175 max: number
176 /** Text after the bar, already padded to a common width. */
177 tail: string
178 tone: Tone
179}
180
181/**
182 * One labelled bar: label, bar, tail. The label is cut and the bar takes what
183 * is left so the row is at most `width` cells; the bar is dropped under three.
184 */
185export function barRow(width: number, labelWidth: number, row: BarRowInput): Line {
186 const tailW = strWidth(row.tail)
187 const gaps = 2
188 const rest = width - tailW - gaps
189 const lw = Math.max(3, Math.min(labelWidth, rest - 3))
190 const bw = rest - lw
191 const line: Line = [{ t: padEnd(clip(row.label, lw), lw), tone: 'text' }]
192 if (bw >= 3) {
193 line.push({ t: ' ', tone: 'text' }, ...barSegs(row.count, row.max, bw, row.tone), { t: ' ', tone: 'text' })
194 } else {
195 line.push({ t: ' ', tone: 'text' })
196 }
197 line.push({ t: row.tail, tone: 'muted' })
198 return line
199}
200
201export { padEnd, padStart }
202
203// ── The chart ────────────────────────────────────────────────────────────
204
205const LEVELS = '▁▂▃▄▅▆▇█'
206const ROWS = 3
207/** Eighths of a row: a bar three rows tall has this many steps. */
208const STEPS = ROWS * 8
209
210export type ChartPlan = {
211 rows: Line[]
212 axis: string
213 /** Cells the chart takes. */
214 width: number
215 /** Bars drawn: fewer than given when the pane is narrower than the bars. */
216 bars: number
217}
218
219/** Adjacent bars added in pairs, until no more than `limit` are left. */
220function shrink(bars: readonly Bar[], limit: number): Bar[] {
221 let out = [...bars]
222 while (out.length > Math.max(1, limit)) {
223 const merged: Bar[] = []
224 for (let i = 0; i < out.length; i += 2) {
225 const a = out[i]!
226 const b = out[i + 1]
227 merged.push(
228 b
229 ? { label: b.label || a.label, value: a.value + b.value, isCurrent: a.isCurrent || b.isCurrent, isFuture: a.isFuture && b.isFuture }
230 : a,
231 )
232 }
233 out = merged
234 }
235 return out
236}
237
238/**
239 * The bars three rows tall, in eighths of a row, and the labels under them.
240 * Each bar takes a slot of the pane's width, one cell of it a gap once there
241 * is room; the chart is never wider than `width`.
242 */
243export function chartPlan(input: readonly Bar[], width: number): ChartPlan {
244 const bars = shrink(input, width)
245 const n = bars.length
246 const slot = Math.max(1, Math.min(8, Math.floor(width / Math.max(1, n))))
247 const fill = slot === 1 ? 1 : slot - 1
248 const max = Math.max(1, ...bars.map(b => b.value))
249
250 const rows: Line[] = []
251 for (let r = 0; r < ROWS; r++) {
252 const line: Line = []
253 const push = (t: string, tone: Tone) => {
254 const last = line[line.length - 1]
255 if (last && last.tone === tone) last.t += t
256 else line.push({ t, tone })
257 }
258 for (const bar of bars) {
259 const units = bar.value <= 0 ? 0 : Math.max(1, Math.round((bar.value / max) * STEPS))
260 const inRow = Math.max(0, Math.min(8, units - (ROWS - 1 - r) * 8))
261 const tone: Tone = bar.isCurrent ? 'gold' : 'blue'
262 let glyph = ' '
263 let t: Tone = tone
264 if (inRow > 0) glyph = LEVELS.charAt(inRow - 1)
265 else if (r === ROWS - 1 && !bar.isFuture) {
266 // a day with nothing still has its place on the baseline
267 glyph = TRACK
268 t = 'hair'
269 }
270 push(glyph.repeat(fill), t)
271 if (slot > fill) push(' '.repeat(slot - fill), t)
272 }
273 rows.push(line)
274 }
275
276 // labels: placed from the last bar back so the newest wins a clash, and
277 // never touching another label
278 const cells = new Array<string>(n * slot).fill(' ')
279 for (let i = n - 1; i >= 0; i--) {
280 const bar = bars[i]!
281 if (bar.label === '') continue
282 // a word shrinks to its initial when the slot is under three cells; a date keeps its digits
283 const text = slot >= 3 || !/^[A-Za-z]+$/.test(bar.label) ? bar.label : bar.label.charAt(0)
284 const start = Math.min(cells.length - text.length, i * slot + Math.max(0, Math.floor((slot - text.length) / 2)))
285 if (start < 0) continue
286 const from = Math.max(0, start - 1)
287 const to = Math.min(cells.length, start + text.length + 1)
288 if (cells.slice(from, to).some(c => c !== ' ')) continue
289 for (let c = 0; c < text.length; c++) cells[start + c] = text.charAt(c)
290 }
291 return { rows, axis: cells.join('').trimEnd(), width: n * slot, bars: n }
292}
293hooks/record.ts 59 lines1// Which prompts count as a task. One typed request is one task: a prompt the
2// person typed (or sent from the phone, or a mod sent as their own words)
3// that goes on to finish with an answer. Pure; register.tsx wires it to events.
4
5export type Origin = { kind: string; asUser?: boolean | undefined }
6
7/** `/compact`, `/report week`: a slash command, as against a path that happens to start with one. */
8export function isSlashCommand(text: string): boolean {
9 const first = text.trimStart().split(/\s+/)[0] ?? ''
10 return /^\/[A-Za-z][\w:.-]*$/.test(first)
11}
12
13/**
14 * Whether a submitted prompt is the person's own request. Their Enter at the
15 * prompt and a message from the phone count. A mod's prompt counts only when
16 * it sent the words as the person's own (an upgraded prompt they approved).
17 * Background notifications, schedules, other sessions, helper agents and
18 * `claude -p` runs are not requests anyone typed.
19 */
20export function isTypedPrompt(text: string, origin: Origin): boolean {
21 if (text.trim() === '') return false
22 if (isSlashCommand(text)) return false
23 if (origin.kind === 'composer' || origin.kind === 'bridge') return true
24 return origin.kind === 'plugin' && origin.asUser === true
25}
26
27// A typed prompt is "armed" when it is submitted and claimed when a turn
28// starts with those words, so a turn nobody typed is never counted.
29
30const LIMIT = 20
31
32const same = (a: string, b: string) => a.trim() === b.trim()
33
34export function arm(queue: readonly string[], text: string): string[] {
35 return [...queue, text].slice(-LIMIT)
36}
37
38export function disarm(queue: readonly string[], text: string): string[] {
39 const at = queue.findIndex(q => same(q, text))
40 return at < 0 ? [...queue] : [...queue.slice(0, at), ...queue.slice(at + 1)]
41}
42
43/**
44 * Takes the armed prompt a turn started with off the queue. The words match
45 * exactly, or, where something added to them on the way in (a settings hook's
46 * note), when one holds the other and is long enough not to be a coincidence.
47 */
48export function claim(queue: readonly string[], turnText: string): { found: boolean; queue: string[] } {
49 const exact = queue.findIndex(q => same(q, turnText))
50 const near = exact >= 0 ? exact : queue.findIndex(q => {
51 const a = q.trim()
52 const b = turnText.trim()
53 const [short, long] = a.length <= b.length ? [a, b] : [b, a]
54 return short.length >= 8 && long.includes(short)
55 })
56 if (near < 0) return { found: false, queue: [...queue] }
57 return { found: true, queue: [...queue.slice(0, near), ...queue.slice(near + 1)] }
58}
59hooks/report.ts 291 lines1// The numbers: day records, totals over a stretch, the estimate, the streak
2// and the chart's bars. Pure; register.tsx reads and writes the store.
3import type { DayRecord, Kind, Settings, Tab } from '../types'
4import { addDays, keyOf, lastDays, MONTHS, MONTHS_LONG, noonOf, WEEKDAYS } from './dates'
5import { KINDS } from './kinds'
6
7// ── Day records ──────────────────────────────────────────────────────────
8
9export const emptyDay = (): DayRecord => ({ kinds: {}, models: {} })
10
11const count = (v: unknown): number => (typeof v === 'number' && Number.isFinite(v) && v > 0 ? Math.floor(v) : 0)
12
13/** A record as the store gave it back, made safe: unknown keys and bad numbers are dropped. */
14export function normalizeDay(raw: unknown): DayRecord {
15 const out = emptyDay()
16 if (!raw || typeof raw !== 'object') return out
17 const r = raw as { kinds?: unknown; models?: unknown }
18 if (r.kinds && typeof r.kinds === 'object') {
19 for (const k of KINDS) {
20 const n = count((r.kinds as Record<string, unknown>)[k])
21 if (n > 0) out.kinds[k] = n
22 }
23 }
24 if (r.models && typeof r.models === 'object') {
25 for (const [name, v] of Object.entries(r.models as Record<string, unknown>)) {
26 const n = count(v)
27 if (n > 0) out.models[name] = n
28 }
29 }
30 return out
31}
32
33/** How many tasks a day holds. */
34export function tasksOf(rec: DayRecord | undefined): number {
35 if (!rec) return 0
36 return KINDS.reduce((n, k) => n + (rec.kinds[k] ?? 0), 0)
37}
38
39/** Two days added together: the counts of each kind and of each model summed. */
40export function mergeDays(a: DayRecord, b: DayRecord): DayRecord {
41 const out = emptyDay()
42 for (const k of KINDS) {
43 const n = (a.kinds[k] ?? 0) + (b.kinds[k] ?? 0)
44 if (n > 0) out.kinds[k] = n
45 }
46 for (const src of [a.models, b.models]) {
47 for (const [name, n] of Object.entries(src)) out.models[name] = (out.models[name] ?? 0) + n
48 }
49 return out
50}
51
52/** One more task on a day. */
53export function addTask(rec: DayRecord, kind: Kind, model: string): DayRecord {
54 return mergeDays(rec, { kinds: { [kind]: 1 }, models: { [model]: 1 } })
55}
56
57// ── Totals ───────────────────────────────────────────────────────────────
58
59export type Totals = {
60 tasks: number
61 kinds: Record<Kind, number>
62 models: Record<string, number>
63 /** Tasks on each of the given days, in order. */
64 perDay: number[]
65}
66
67/** The days of `keys` added together. */
68export function totalsFor(days: Readonly<Record<string, DayRecord>>, keys: readonly string[]): Totals {
69 let sum = emptyDay()
70 const perDay: number[] = []
71 for (const key of keys) {
72 const rec = days[key]
73 perDay.push(tasksOf(rec))
74 if (rec) sum = mergeDays(sum, rec)
75 }
76 const kinds = {} as Record<Kind, number>
77 for (const k of KINDS) kinds[k] = sum.kinds[k] ?? 0
78 return { tasks: KINDS.reduce((n, k) => n + kinds[k], 0), kinds, models: sum.models, perDay }
79}
80
81/**
82 * The kinds a stretch has tasks of, most first. `other` is the catch-all and
83 * stays last however many it holds; a tie keeps the order of KINDS.
84 */
85export function shownKinds(kinds: Readonly<Record<Kind, number>>): Kind[] {
86 const used = KINDS.filter(k => (kinds[k] ?? 0) > 0)
87 const named = used.filter(k => k !== 'other').sort((a, b) => (kinds[b] ?? 0) - (kinds[a] ?? 0))
88 return used.includes('other') ? [...named, 'other'] : named
89}
90
91// ── The estimate ─────────────────────────────────────────────────────────
92
93export const DEFAULT_SETTINGS: Settings = {
94 rate: 50,
95 minutes: { emails: 10, documents: 30, images: 20, code: 20, research: 20, other: 10 },
96}
97
98/** Settings as the store gave them back, each value checked, the rest the default. */
99export function normalizeSettings(raw: unknown): Settings {
100 const out: Settings = { rate: DEFAULT_SETTINGS.rate, minutes: { ...DEFAULT_SETTINGS.minutes } }
101 if (!raw || typeof raw !== 'object') return out
102 const r = raw as { rate?: unknown; minutes?: unknown }
103 if (typeof r.rate === 'number' && Number.isFinite(r.rate) && r.rate >= 0) out.rate = r.rate
104 if (r.minutes && typeof r.minutes === 'object') {
105 for (const k of KINDS) {
106 const v = (r.minutes as Record<string, unknown>)[k]
107 if (typeof v === 'number' && Number.isFinite(v) && v >= 0) out.minutes[k] = v
108 }
109 }
110 return out
111}
112
113export type Estimate = {
114 minutes: number
115 hours: number
116 dollars: number
117 /** Minutes and hours of each kind, for the rows. */
118 byKind: Record<Kind, { minutes: number; hours: number }>
119}
120
121export function estimate(kinds: Readonly<Record<Kind, number>>, settings: Settings): Estimate {
122 const byKind = {} as Estimate['byKind']
123 let minutes = 0
124 for (const k of KINDS) {
125 const m = (kinds[k] ?? 0) * settings.minutes[k]
126 byKind[k] = { minutes: m, hours: m / 60 }
127 minutes += m
128 }
129 const hours = minutes / 60
130 return { minutes, hours, dollars: hours * settings.rate, byKind }
131}
132
133// ── Streak, best day, best month ─────────────────────────────────────────
134
135/**
136 * Days in a row with at least one task, counted back from today. A day that
137 * has not had its first task yet does not break the streak: it counts from
138 * yesterday until today has one.
139 */
140export function streak(days: Readonly<Record<string, DayRecord>>, ms: number): number {
141 let cursor = noonOf(ms)
142 if (tasksOf(days[keyOf(cursor)]) === 0) cursor = addDays(cursor, -1)
143 let n = 0
144 while (n < 3700 && tasksOf(days[keyOf(cursor)]) > 0) {
145 n += 1
146 cursor = addDays(cursor, -1)
147 }
148 return n
149}
150
151// ── The chart ────────────────────────────────────────────────────────────
152
153export type Bar = {
154 /** What stands under the bar; empty when this bar carries none. */
155 label: string
156 value: number
157 /** Today, or this month: drawn in gold. */
158 isCurrent: boolean
159 /** A month that has not come yet: drawn empty. */
160 isFuture: boolean
161}
162
163/** Tasks in each month of `year`, January first. */
164export function monthTotals(days: Readonly<Record<string, DayRecord>>, year: number): number[] {
165 const out = new Array<number>(12).fill(0)
166 const prefix = `${year}-`
167 for (const [key, rec] of Object.entries(days)) {
168 if (!key.startsWith(prefix)) continue
169 const m = Number(key.slice(5, 7)) - 1
170 if (m >= 0 && m < 12) out[m] = (out[m] ?? 0) + tasksOf(rec)
171 }
172 return out
173}
174
175/**
176 * The bars a tab draws. Today and Week: the last seven days by weekday. Month:
177 * thirty daily bars. Year: January through December, this month current and
178 * the months to come empty.
179 */
180export function chartFor(tab: Tab, days: Readonly<Record<string, DayRecord>>, ms: number): Bar[] {
181 if (tab === 'today' || tab === 'week') {
182 return lastDays(ms, 7).map((d, i, all) => ({
183 label: WEEKDAYS[d.getDay()] ?? '',
184 value: tasksOf(days[keyOf(d)]),
185 isCurrent: i === all.length - 1,
186 isFuture: false,
187 }))
188 }
189 if (tab === 'month') {
190 return lastDays(ms, 30).map((d, i, all) => ({
191 // every fifth date, and today, carry their number
192 label: d.getDate() % 5 === 0 || i === all.length - 1 ? String(d.getDate()) : '',
193 value: tasksOf(days[keyOf(d)]),
194 isCurrent: i === all.length - 1,
195 isFuture: false,
196 }))
197 }
198 const today = noonOf(ms)
199 const totals = monthTotals(days, today.getFullYear())
200 return MONTHS.map((name, m) => ({
201 label: name,
202 value: totals[m] ?? 0,
203 isCurrent: m === today.getMonth(),
204 isFuture: m > today.getMonth(),
205 }))
206}
207
208export type Summary = {
209 text: string
210 /** The same in fewer words, for a narrow pane. */
211 short: string
212 isGood: boolean
213}
214
215/** The one line under the chart. */
216export function summaryFor(tab: Tab, days: Readonly<Record<string, DayRecord>>, ms: number): Summary {
217 if (tab === 'today' || tab === 'week') {
218 const n = streak(days, ms)
219 if (n === 0) return { text: 'No streak yet. Finish a task today to start one.', short: 'No streak yet', isGood: false }
220 return { text: `Streak: ${n} ${n === 1 ? 'day' : 'days'} in a row.`, short: `Streak: ${n} ${n === 1 ? 'day' : 'days'}`, isGood: true }
221 }
222 if (tab === 'month') {
223 const span = lastDays(ms, 30)
224 const per = span.map(d => tasksOf(days[keyOf(d)]))
225 const active = per.filter(n => n > 0).length
226 let best = -1
227 per.forEach((n, i) => {
228 if (n > 0 && (best < 0 || n > (per[best] ?? 0))) best = i
229 })
230 const day = best >= 0 ? span[best] : undefined
231 if (!day) return { text: `Best day: none yet. 0 active days of ${span.length}.`, short: '0 active days', isGood: false }
232 const dayText = `${WEEKDAYS[day.getDay()]} ${day.getDate()} ${MONTHS[day.getMonth()]}`
233 const n = per[best] ?? 0
234 return {
235 text: `Best day: ${dayText}, ${n} ${n === 1 ? 'task' : 'tasks'}. ${active} active ${active === 1 ? 'day' : 'days'} of ${span.length}.`,
236 short: `Best ${day.getDate()} ${MONTHS[day.getMonth()]} (${n}), ${active} active`,
237 isGood: true,
238 }
239 }
240 const today = noonOf(ms)
241 const totals = monthTotals(days, today.getFullYear())
242 let bestMonth = -1
243 totals.forEach((n, m) => {
244 if (n > 0 && (bestMonth < 0 || n > (totals[bestMonth] ?? 0))) bestMonth = m
245 })
246 let active = 0
247 const prefix = `${today.getFullYear()}-`
248 for (const [key, rec] of Object.entries(days)) if (key.startsWith(prefix) && tasksOf(rec) > 0) active += 1
249 if (bestMonth < 0) return { text: 'Best month: none yet. 0 active days.', short: '0 active days', isGood: false }
250 const n = totals[bestMonth] ?? 0
251 return {
252 text: `Best month: ${MONTHS_LONG[bestMonth]}, ${n} ${n === 1 ? 'task' : 'tasks'}. ${active} active ${active === 1 ? 'day' : 'days'}.`,
253 short: `Best ${MONTHS[bestMonth]} (${n}), ${active} active`,
254 isGood: true,
255 }
256}
257
258// ── Names ────────────────────────────────────────────────────────────────
259
260/** "claude-sonnet-5-5" is "Sonnet 5.5"; an id this does not know is shown as it came, less its date. */
261export function modelName(id: string | undefined): string {
262 if (!id) return 'Unknown model'
263 const clean = id.replace(/\[[^\]]*\]$/, '').trim()
264 // claude-sonnet-5-5, claude-opus-4-1-20250805, claude-sonnet-4-20250514
265 const family = /claude-(opus|sonnet|haiku|fable)-(\d+)(?:-(\d{1,2})(?!\d))?/.exec(clean)
266 // claude-3-5-sonnet-20241022
267 const old = /claude-(\d+)(?:-(\d{1,2})(?!\d))?-(opus|sonnet|haiku)/.exec(clean)
268 const make = (name: string, major: string, minor?: string) => `${name.charAt(0).toUpperCase()}${name.slice(1)} ${major}${minor ? `.${minor}` : ''}`
269 if (family) return make(family[1] ?? '', family[2] ?? '', family[3])
270 if (old) return make(old[3] ?? '', old[1] ?? '', old[2])
271 const bare = clean.replace(/^claude-/, '').replace(/-\d{8}$/, '')
272 return bare === '' ? 'Unknown model' : bare
273}
274
275// ── Numbers as words ─────────────────────────────────────────────────────
276
277/** "2.5" under ten hours, a whole number from there. */
278export function hoursText(hours: number): string {
279 if (!(hours > 0)) return '0'
280 if (hours < 10) {
281 const t = (Math.round(hours * 10) / 10).toFixed(1)
282 return t.endsWith('.0') ? t.slice(0, -2) : t
283 }
284 return String(Math.round(hours))
285}
286
287/** 1234567 is "1,234,567". */
288export function withCommas(n: number): string {
289 return String(Math.round(n)).replace(/\B(?=(\d{3})+(?!\d))/g, ',')
290}
291hooks/view.tsx 361 lines1// The pane, drawn from what the state holds. Nothing here writes: a press
2// calls one of the actions register.tsx hands in.
3//
4// Every row is sized to the width the pane reports, in cells, so nothing wraps:
5// text is chosen from wordings that fit, bars and the chart are built to the
6// width, and a row is clipped at the edge rather than wrapped should a surface
7// be narrower than it said.
8import type { Elements, UiPressArgument } from 'claude-code'
9
10import type { DayRecord, Kind, Settings, Tab } from '../types'
11import { daysFor, describeDays, keyOf, sinceWords, stretchName, TAB_NAME } from './dates'
12import { KIND_LABEL, KIND_SHORT, KINDS } from './kinds'
13import { barRow, chartPlan, clip, COLORS, fit, KIND_TONE, padStart, strWidth, tileFace, tilePlan, TITLE } from './layout'
14import type { Line, Tone } from './layout'
15import { chartFor, estimate, hoursText, shownKinds, summaryFor, totalsFor, withCommas } from './report'
16import { barsSvg, chartSvg, drawingWidth, tilesSvg } from './svg'
17
18type El = Elements['terminal']
19type SvgEl = Elements['desktop']['Svg']
20type Press = (e: UiPressArgument) => void
21
22export type Actions = {
23 pickTab: (tab: Tab) => void
24}
25
26export type Model = {
27 /** What the pane reports it is wide, in cells. */
28 width: number
29 tab: Tab
30 days: Readonly<Record<string, DayRecord>>
31 settings: Settings
32 /** Now, in milliseconds. */
33 now: number
34 /**
35 * The vector element, where the surface draws one (the desktop, the editor):
36 * the tiles, bars and chart are then drawings, which no font can put out of
37 * line. Absent on the terminal, which draws them as text.
38 */
39 Svg?: SvgEl
40}
41
42// Far more glyphs than any pane is wide: the box around it clips the rest
43const RUN = 400
44const TABS: readonly { tab: Tab; hotkey: string }[] = [
45 { tab: 'today', hotkey: 't' },
46 { tab: 'week', hotkey: 'w' },
47 { tab: 'month', hotkey: 'm' },
48 { tab: 'year', hotkey: 'y' },
49]
50
51// ── Pieces ───────────────────────────────────────────────────────────────
52
53/** One row of coloured segments, at most one line tall: clipped at the edge, never wrapped. */
54function lineRow(el: El, line: Line) {
55 const { Box, Text } = el
56 const segs = line.filter(s => s.t !== '')
57 if (segs.length === 0) return <Text> </Text>
58 return (
59 <Box flexDirection="row" width="100%" height={1} overflow="hidden">
60 {segs.map(s => (
61 <Text color={COLORS[s.tone]} {...(s.bold ? { bold: true } : {})}>
62 {s.t}
63 </Text>
64 ))}
65 </Box>
66 )
67}
68
69const blank = (el: El) => lineRow(el, [])
70
71/** A rule that fills what is left of its row: clipped at the edge, never wrapped. */
72function rule(el: El, color: string = COLORS.hair, glyph = '─') {
73 const { Box, Text } = el
74 return (
75 <Box flexGrow={1} flexShrink={1} minWidth={0} height={1} overflow="hidden">
76 <Text color={color}>{glyph.repeat(RUN)}</Text>
77 </Box>
78 )
79}
80
81/** SMALL MUTED CAPITALS with a note at the far end, and a hairline under them across the pane. */
82function heading(el: El, width: number, label: string, right?: string) {
83 const { Box, Text } = el
84 const note = right ? clip(right, Math.max(0, width - strWidth(label) - 2)) : ''
85 return (
86 <Box flexDirection="column" width="100%">
87 <Box flexDirection="row" justifyContent="space-between" width="100%" columnGap={2}>
88 <Box flexShrink={0}>
89 <Text color={COLORS.muted}>{label.toUpperCase()}</Text>
90 </Box>
91 {note !== '' ? (
92 <Box flexShrink={1}>
93 <Text color={COLORS.muted}>{note}</Text>
94 </Box>
95 ) : null}
96 </Box>
97 <Box flexDirection="row" width="100%">
98 {rule(el)}
99 </Box>
100 </Box>
101 )
102}
103
104function chip(el: El, o: { key: string; label: string; isMain: boolean; hotkey: string; onPress: Press }) {
105 const { Button, Text } = el
106 const look = o.isMain
107 ? { backgroundColor: COLORS.gold, color: COLORS.dark, bold: true }
108 : { backgroundColor: COLORS.hair, color: COLORS.text }
109 return (
110 <Button key={o.key} plain hotkey={o.hotkey} onPress={o.onPress}>
111 <Text {...look}>{` ${o.label} `}</Text>
112 </Button>
113 )
114}
115
116/** The title in bold capitals, a gold rule the width of the title that carries on as a hairline. */
117function top(el: El) {
118 const { Box, Text } = el
119 return [
120 <Box flexDirection="row" width="100%">
121 <Text bold color={COLORS.text}>
122 {TITLE}
123 </Text>
124 </Box>,
125 <Box flexDirection="row" width="100%">
126 <Box flexShrink={0}>
127 <Text color={COLORS.gold}>{'━'.repeat(TITLE.length)}</Text>
128 </Box>
129 {rule(el)}
130 </Box>,
131 ]
132}
133
134/** The stretch on the left, its dates on the right, a hairline under. */
135function titleBar(el: El, width: number, left: string, right: string) {
136 const { Box, Text } = el
137 const l = clip(left, width)
138 const r = fit(Math.max(0, width - strWidth(l) - 2), [right, right.replace(/ \d{4}$/, ''), ''])
139 return (
140 <Box flexDirection="column" width="100%">
141 <Box flexDirection="row" justifyContent="space-between" width="100%" columnGap={2}>
142 <Box flexShrink={0}>
143 <Text bold color={COLORS.text}>
144 {l.toUpperCase()}
145 </Text>
146 </Box>
147 {r !== '' ? (
148 <Box flexShrink={1}>
149 <Text color={COLORS.muted}>{r}</Text>
150 </Box>
151 ) : null}
152 </Box>
153 <Box flexDirection="row" width="100%">
154 {rule(el)}
155 </Box>
156 </Box>
157 )
158}
159
160// ── Sections ─────────────────────────────────────────────────────────────
161
162function tilesBlock(el: El, width: number, tasks: number, hours: number, dollars: number, Svg?: SvgEl) {
163 const { Box, Text } = el
164 const plan = tilePlan(width)
165 const hoursNum = hoursText(hours)
166 const dollarsNum = String(Math.round(dollars))
167 const tiles: { id: string; numerals: string; plain: string; tone: Tone; captions: string[] }[] = [
168 { id: 'hours', numerals: hoursNum, plain: `${hoursNum} h`, tone: 'gold', captions: ['est. hours saved', 'est. hours', 'hours'] },
169 { id: 'tasks', numerals: String(tasks), plain: withCommas(tasks), tone: 'text', captions: ['tasks done', 'tasks'] },
170 { id: 'worth', numerals: dollarsNum, plain: `$${withCommas(dollars)}`, tone: 'green', captions: ['est. worth in $', 'est. $', '$'] },
171 ]
172 const shown = tiles.slice(0, plan.count)
173 const hidden = tiles.slice(plan.count)
174 const share = plan.count === 3 ? '33%' : plan.count === 2 ? '50%' : '100%'
175 const hiddenLine =
176 hidden.length > 0 &&
177 lineRow(el, [
178 { t: fit(width, [hidden.map(t => `${t.captions[0]} ${t.plain}`).join(' · '), hidden.map(t => `${t.captions[t.captions.length - 1]} ${t.plain}`).join(' · ')]), tone: 'muted' },
179 ])
180 if (Svg) {
181 const art = tilesSvg(
182 drawingWidth(width + 1),
183 shown.map(t => ({ face: t.plain, captions: t.captions, tone: t.tone })),
184 shown.map(t => `${t.captions[0]} ${t.plain}`).join(', '),
185 )
186 return (
187 <Box flexDirection="column" width="100%">
188 <Svg source={art.source} alt={art.alt} />
189 {hiddenLine}
190 </Box>
191 )
192 }
193 return (
194 <Box flexDirection="column" width="100%">
195 <Box flexDirection="row" columnGap={1} width="100%">
196 {shown.map(t => {
197 const face = tileFace(t.numerals, t.plain, plan.inner)
198 return (
199 <Box key={`tile-${t.id}`} flexDirection="column" alignItems="center" width={share} flexShrink={1} borderStyle="round" borderColor={COLORS.hair}>
200 {face.kind === 'big' ? (
201 <Box flexDirection="column" alignItems="flex-start">
202 {face.rows.map(row => (
203 <Text bold color={COLORS[t.tone]}>
204 {row}
205 </Text>
206 ))}
207 </Box>
208 ) : (
209 <Text bold color={COLORS[t.tone]}>
210 {face.text}
211 </Text>
212 )}
213 <Text color={COLORS.muted}>{fit(plan.inner, t.captions)}</Text>
214 </Box>
215 )
216 })}
217 </Box>
218 {hiddenLine}
219 </Box>
220 )
221}
222
223function kindRows(el: El, width: number, kinds: Record<Kind, number>, hours: Record<Kind, number>, Svg?: SvgEl) {
224 const isWide = width >= 46
225 const names = isWide ? KIND_LABEL : KIND_SHORT
226 // only what this stretch holds: a coder is not shown an empty Emails row
227 const shown = shownKinds(kinds)
228 const max = Math.max(1, ...shown.map(k => kinds[k]))
229 const cw = String(max).length
230 const hourTexts = shown.map(k => `${hoursText(hours[k])}h`)
231 const hw = Math.max(1, ...hourTexts.map(t => t.length))
232 const showHours = width >= 30
233 if (Svg) {
234 const art = barsSvg(
235 drawingWidth(width + 1),
236 shown.map((k, i) => ({
237 label: names[k],
238 count: kinds[k],
239 max,
240 tails: showHours ? [String(kinds[k]), hourTexts[i] ?? ''] : [String(kinds[k])],
241 tone: KIND_TONE[k],
242 })),
243 shown.map(k => `${KIND_LABEL[k]} ${kinds[k]}`).join(', '),
244 )
245 return [<Svg source={art.source} alt={art.alt} />]
246 }
247 return shown.map((k, i) =>
248 lineRow(
249 el,
250 barRow(width, isWide ? 16 : 8, {
251 label: names[k],
252 count: kinds[k],
253 max,
254 tail: showHours ? `${padStart(String(kinds[k]), cw)} ${padStart(hourTexts[i] ?? '', hw)}` : padStart(String(kinds[k]), cw),
255 tone: KIND_TONE[k],
256 }),
257 ),
258 )
259}
260
261const MODEL_ROWS = 5
262
263function modelRows(el: El, width: number, models: Record<string, number>, Svg?: SvgEl) {
264 const sorted = Object.entries(models).sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
265 const top3 = sorted.slice(0, MODEL_ROWS)
266 const rest = sorted.slice(MODEL_ROWS).reduce((n, [, c]) => n + c, 0)
267 const rows: [string, number][] = rest > 0 ? [...top3, ['Others', rest]] : top3
268 const max = Math.max(1, ...rows.map(r => r[1]))
269 const cw = String(max).length
270 if (Svg) {
271 const art = barsSvg(
272 drawingWidth(width + 1),
273 rows.map(([name, count]) => ({ label: name, count, max, tails: [String(count)], tone: 'gold' as const })),
274 rows.map(([name, count]) => `${name} ${count}`).join(', '),
275 )
276 return [<Svg source={art.source} alt={art.alt} />]
277 }
278 const labelWidth = Math.min(16, Math.max(...rows.map(r => strWidth(r[0]))))
279 return rows.map(([name, count]) => lineRow(el, barRow(width, labelWidth, { label: name, count, max, tail: padStart(String(count), cw), tone: 'gold' })))
280}
281
282// ── The pane ─────────────────────────────────────────────────────────────
283
284/** The pane's whole body for the model. `el` is the terminal's table. */
285export function drawPane(el: El, m: Model, a: Actions) {
286 const { Box, Text } = el
287 // one cell is kept back: a row the surface lays out narrower than it reported is clipped, not wrapped
288 const width = Math.max(20, m.width - 1)
289
290 const days = daysFor(m.tab, m.now)
291 const totals = totalsFor(m.days, days.map(keyOf))
292 const header = (
293 <Box flexDirection="column" width="100%">
294 {top(el)}
295 {blank(el)}
296 <Box flexDirection="row" flexWrap="wrap" columnGap={1}>
297 {TABS.map(t =>
298 chip(el, { key: `tab-${t.tab}`, label: TAB_NAME[t.tab], isMain: t.tab === m.tab, hotkey: t.hotkey, onPress: () => a.pickTab(t.tab) }),
299 )}
300 </Box>
301 {blank(el)}
302 {titleBar(el, width, stretchName(m.tab, m.now), describeDays(days))}
303 {blank(el)}
304 </Box>
305 )
306
307 if (totals.tasks === 0) {
308 return (
309 <Box flexDirection="column" width="100%">
310 {header}
311 <Box flexDirection="column" width="100%" borderStyle="round" borderColor={COLORS.hair} paddingX={1}>
312 <Text color={COLORS.text} wrap="wrap">
313 {`Nothing finished yet ${sinceWords(m.tab)}. Your first finished task shows up here.`}
314 </Text>
315 </Box>
316 </Box>
317 )
318 }
319
320 const est = estimate(totals.kinds, m.settings)
321 const hours = {} as Record<Kind, number>
322 for (const k of KINDS) hours[k] = est.byKind[k].hours
323 const bars = chartFor(m.tab, m.days, m.now)
324 const summary = summaryFor(m.tab, m.days, m.now)
325 const chart = m.Svg
326 ? (() => {
327 const { Svg } = m
328 const art = chartSvg(drawingWidth(width + 1), bars, `${m.tab === 'year' ? 'Tasks per month' : 'Tasks per day'}: ${bars.map(b => b.value).join(', ')}`)
329 return [<Svg source={art.source} alt={art.alt} />]
330 })()
331 : (() => {
332 const plan = chartPlan(bars, width)
333 return [...plan.rows.map(line => lineRow(el, line)), lineRow(el, [{ t: plan.axis, tone: 'muted' as const }])]
334 })()
335 const note = fit(width, [
336 `Hours and dollars are estimates: minutes per task at $${m.settings.rate} an hour. /report settings changes them.`,
337 `Estimates: minutes per task at $${m.settings.rate} an hour. /report settings changes them.`,
338 `Estimates only. /report settings changes them.`,
339 'Estimates only.',
340 ])
341
342 return (
343 <Box flexDirection="column" width="100%">
344 {header}
345 {tilesBlock(el, width, totals.tasks, est.hours, est.dollars, m.Svg)}
346 {lineRow(el, [{ t: note, tone: 'muted' }])}
347 {blank(el)}
348 {heading(el, width, 'Where the time went', 'estimate')}
349 {kindRows(el, width, totals.kinds, hours, m.Svg)}
350 {blank(el)}
351 {heading(el, width, 'Who did the work')}
352 {modelRows(el, width, totals.models, m.Svg)}
353 {blank(el)}
354 {heading(el, width, 'Tasks', m.tab === 'year' ? 'per month' : 'per day')}
355 {chart}
356 {blank(el)}
357 {lineRow(el, [{ t: fit(width, [summary.text, summary.short]), tone: summary.isGood ? 'green' : 'muted' }])}
358 </Box>
359 )
360}
361hooks/svg.ts 163 lines1// The pane's figures as drawings, for the surfaces that draw vectors (the
2// desktop app, the editor). Text set in rows only lines up when the font gives
3// every character the same width, and the block and box-drawing characters of
4// the tiles, bars and chart come from whatever font the page falls back to; a
5// drawing places every mark by its own coordinates and depends on no font. The
6// terminal, which is always monospace, keeps its text rows (layout.ts).
7import type { Tone } from './layout'
8import { COLORS } from './layout'
9import type { Bar } from './report'
10
11const FONT = "ui-monospace, 'Cascadia Mono', SFMono-Regular, Menlo, Consolas, monospace"
12
13/** CSS pixels a reported column is worth; the drawing scales down to its slot. */
14export const CELL = 8
15// advance of one character of the 12 px monospace text below, a little over
16const CHAR = 7.4
17
18export type Drawing = { source: string; width: number; height: number; alt: string }
19
20const esc = (s: string) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
21
22const wrap = (width: number, height: number, body: string): string =>
23 `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">${body}</svg>`
24
25const r1 = (n: number) => Math.round(n * 10) / 10
26
27/** The drawing's width for a pane `columns` wide, within what reads well. */
28export function drawingWidth(columns: number): number {
29 return Math.max(240, Math.min(900, columns * CELL - CELL))
30}
31
32/** `s` cut to `chars` characters, an ellipsis where it was cut. */
33function cut(s: string, chars: number): string {
34 if (chars <= 0) return ''
35 return s.length <= chars ? s : s.slice(0, Math.max(1, chars - 1)) + '…'
36}
37
38// ── Tiles ────────────────────────────────────────────────────────────────
39
40export type TileInput = {
41 /** The figure, as it is read: `3.5 h`, `12`, `$1,234`. */
42 face: string
43 /** Wordings for the caption, longest first; the first that fits the tile is drawn. */
44 captions: string[]
45 tone: Tone
46}
47
48const TILE_H = 76
49const TILE_GAP = 8
50const FACE = 30
51const FACE_MIN = 12
52
53/** Layout of the tiles, for the tests: where each starts and how wide it is. */
54export function tileSlots(width: number, count: number): { x: number; w: number }[] {
55 const w = (width - (count - 1) * TILE_GAP) / count
56 return Array.from({ length: count }, (_, i) => ({ x: r1(i * (w + TILE_GAP)), w: r1(w) }))
57}
58
59/** Up to three framed tiles side by side, each a bold figure over a small caption. */
60export function tilesSvg(width: number, tiles: readonly TileInput[], alt: string): Drawing {
61 const slots = tileSlots(width, Math.max(1, tiles.length))
62 const body = tiles
63 .map((t, i) => {
64 const { x, w } = slots[i]!
65 const cx = r1(x + w / 2)
66 // a figure too wide for its tile is drawn smaller, never cut
67 const size = Math.max(FACE_MIN, Math.min(FACE, Math.floor((w - 16) / (Math.max(1, t.face.length) * 0.62))))
68 const caption = t.captions.find(c => c.length * 6.6 <= w - 12) ?? cut(t.captions[t.captions.length - 1] ?? '', Math.floor((w - 12) / 6.6))
69 return (
70 `<rect x="${x + 0.75}" y="0.75" width="${r1(w - 1.5)}" height="${TILE_H - 1.5}" rx="10" fill="none" stroke="${COLORS.hair}" stroke-width="1.5"/>` +
71 `<text x="${cx}" y="42" text-anchor="middle" font-family="${FONT}" font-size="${size}" font-weight="700" fill="${COLORS[t.tone]}">${esc(t.face)}</text>` +
72 `<text x="${cx}" y="${TILE_H - 14}" text-anchor="middle" font-family="${FONT}" font-size="11" fill="${COLORS.muted}">${esc(caption)}</text>`
73 )
74 })
75 .join('')
76 return { source: wrap(width, TILE_H, body), width, height: TILE_H, alt }
77}
78
79// ── Bar rows ─────────────────────────────────────────────────────────────
80
81export type BarInput = {
82 label: string
83 count: number
84 max: number
85 /** Right-aligned columns after the bar: the count, then the hours. */
86 tails: string[]
87 tone: Tone
88}
89
90const ROW_H = 22
91const BAR_H = 8
92const TAIL_GAP = 12
93
94/** Labelled bars: label, a track filled to its share, and right-aligned figures. */
95export function barsSvg(width: number, rows: readonly BarInput[], alt: string): Drawing {
96 const labelChars = Math.min(Math.max(1, ...rows.map(r => r.label.length)), Math.floor((width * 0.38) / CHAR))
97 const labelW = labelChars * CHAR
98 const columns = Math.max(0, ...rows.map(r => r.tails.length))
99 const colW = Array.from({ length: columns }, (_, c) => Math.max(...rows.map(r => (r.tails[c] ?? '').length)) * CHAR + TAIL_GAP)
100 const tailsW = colW.reduce((a, b) => a + b, 0)
101 const barX = r1(labelW + TAIL_GAP)
102 const barW = Math.max(24, r1(width - barX - tailsW))
103 const height = Math.max(1, rows.length) * ROW_H
104 const body = rows
105 .map((r, i) => {
106 const cy = i * ROW_H + ROW_H / 2
107 const fill = r.count <= 0 || r.max <= 0 ? 0 : Math.min(barW, Math.max(4, Math.round((r.count / r.max) * barW)))
108 let right = width
109 const tails: string[] = []
110 for (let c = columns - 1; c >= 0; c--) {
111 tails.unshift(`<text x="${r1(right)}" y="${cy + 4}" text-anchor="end" font-family="${FONT}" font-size="12" fill="${COLORS.muted}">${esc(r.tails[c] ?? '')}</text>`)
112 right -= colW[c]!
113 }
114 return (
115 `<text x="0" y="${cy + 4}" font-family="${FONT}" font-size="12" fill="${COLORS.text}">${esc(cut(r.label, labelChars))}</text>` +
116 `<rect x="${barX}" y="${cy - BAR_H / 2}" width="${barW}" height="${BAR_H}" rx="4" fill="${COLORS.hair}"/>` +
117 (fill > 0 ? `<rect x="${barX}" y="${cy - BAR_H / 2}" width="${fill}" height="${BAR_H}" rx="4" fill="${COLORS[r.tone]}"/>` : '') +
118 tails.join('')
119 )
120 })
121 .join('')
122 return { source: wrap(width, height, body), width, height, alt }
123}
124
125// ── The chart ────────────────────────────────────────────────────────────
126
127const AREA_H = 72
128const AXIS_H = 20
129
130/** Tasks per day or month as bars, a baseline mark for an empty one, labels under. */
131export function chartSvg(width: number, bars: readonly Bar[], alt: string): Drawing {
132 const n = Math.max(1, bars.length)
133 const slot = width / n
134 const bw = Math.max(2, Math.min(40, slot * 0.68))
135 const max = Math.max(1, ...bars.map(b => b.value))
136 const height = AREA_H + AXIS_H
137 const marks: string[] = []
138 bars.forEach((b, i) => {
139 const x = r1(i * slot + (slot - bw) / 2)
140 if (b.value > 0) {
141 const h = Math.max(3, Math.round((b.value / max) * AREA_H))
142 marks.push(`<rect x="${x}" y="${AREA_H - h}" width="${r1(bw)}" height="${h}" rx="2" fill="${b.isCurrent ? COLORS.gold : COLORS.blue}"/>`)
143 } else if (!b.isFuture) {
144 // a day with nothing still has its place on the baseline
145 marks.push(`<rect x="${x}" y="${AREA_H - 2}" width="${r1(bw)}" height="2" rx="1" fill="${COLORS.hair}"/>`)
146 }
147 })
148 // labels are placed from the newest back, and never touch another
149 const taken: [number, number][] = []
150 const labels: string[] = []
151 for (let i = bars.length - 1; i >= 0; i--) {
152 const text = bars[i]!.label
153 if (text === '') continue
154 const w = text.length * 6.6
155 const cx = i * slot + slot / 2
156 const x = Math.max(0, Math.min(width - w, cx - w / 2))
157 if (taken.some(([a, b]) => x < b + 4 && x + w > a - 4)) continue
158 taken.push([x, x + w])
159 labels.push(`<text x="${r1(x)}" y="${AREA_H + 14}" font-family="${FONT}" font-size="11" fill="${COLORS.muted}">${esc(text)}</text>`)
160 }
161 return { source: wrap(width, height, marks.join('') + labels.join('')), width, height, alt }
162}
163types/index.d.ts 32 lines1/** The six kinds a finished task is sorted into, by the tools it used. */
2export type Kind = 'emails' | 'images' | 'code' | 'documents' | 'research' | 'other'
3
4/** One calendar day: how many tasks of each kind, and how many each model did. */
5export type DayRecord = {
6 kinds: Partial<Record<Kind, number>>
7 /** By the model's short name ("Sonnet 5.5"), never by an id. */
8 models: Record<string, number>
9}
10
11/** What the estimate is worked out from. */
12export type Settings = {
13 /** What an hour is worth, in dollars. */
14 rate: number
15 /** Minutes one finished task of each kind saved. */
16 minutes: Record<Kind, number>
17}
18
19export type Tab = 'today' | 'week' | 'month' | 'year'
20
21declare module 'claude-code' {
22 interface PluginState {
23 'work-report': {
24 /** Day records by local date ("2026-10-13"); mirrors the mod's store. */
25 days: Record<string, DayRecord>
26 settings: Settings
27 /** The tab the pane is on. */
28 tab: Tab
29 }
30 }
31}
32