Counts the tasks Claude finishes for you and estimates the hours saved and what that time is worth, by day, week, month and year.

Ian's Claude Code mods. The repo is a plugin marketplace called ian-mods, and each mod lives in its own folder under mods/.
| Mod | What it does |
|---|---|
prompt-upgrade | /better <prompt> asks up to three quick questions, rewrites the prompt and shows the original and the rewrite side by side. Nothing runs until you press Run. /better on sends what you type to it first. |
work-report | /report opens a pane with the tasks Claude finished for you and an estimate of the hours saved and what that time is worth, for today, the last 7 days, the last 30 days and the year. /report settings shows and changes the minutes per task and the hourly rate. /report backfill fills in past days from this machine's session history (needs /usr/bin/python3). Building software is estimated from the lines of code written (/report settings buildrate 25), and a By project section shows hours per repo. /report add "QBR builder" 160 2026-05-01 adds hours for older work (/report list, /report remove 1). Counts are kept per machine. |
ai-employee-kit | Clean View: while Claude works, tool calls, diffs and command output are hidden and a plain-English checklist of the job sits above the prompt (header, steps, progress meters, Needs you / Stuck / Stopped / All done). /simple on / /simple off, or the "Clean View" button in the footer. In the terminal Claude must lay out its steps first; in the desktop app its own task list is the plan. |
The clone lives at ~/claude-mods on every machine. Install each mod once per machine at user scope. After that it loads in every local session: the desktop app's Code tab and the terminal.
git clone https://github.com/iansutherland75/claude-mods.git ~/claude-mods
claude plugin marketplace add ~/claude-mods
claude plugin install prompt-upgrade@ian-mods --scope user
claude plugin install work-report@ian-mods --scope user
claude plugin install ai-employee-kit@ian-mods --scope user
Run these with Claude Code 2.1.295 or newer. If claude --version shows an older build, run brew upgrade claude-code first.
The marketplace is a local folder, so sessions read each mod straight from ~/claude-mods. Nothing needs reinstalling after a change.
git pull in ~/claude-mods./reload-plugins in any open session. New sessions pick it up on their own.A new mod needs one extra step on each machine: claude plugin install <mod>@ian-mods --scope user.
mods/<mod-name>/ with its .claude-plugin/plugin.json, hooks/ and tests..claude-plugin/marketplace.json with "source": "./mods/<mod-name>".claude plugin validate mods/<mod-name> and claude plugin test mods/<mod-name>.Cloud sessions (claude.ai/code, cloud Cowork) do not see mods installed on a machine.
hooks/register.tsx 812 lines1import type { Elements, EngineInterface, Register } from 'claude-code'
2
3import type { DayRecord, Kind, ManualEntry, Settings, Tab } from '../types'
4import {
5 DEFAULT_SETTINGS,
6 KINDS,
7 KIND_LABEL,
8 addDays,
9 addTask,
10 applySetting,
11 barCells,
12 barsOf,
13 bigText,
14 clip,
15 dayKey,
16 daysOf,
17 emptyText,
18 hoursText,
19 isTypedPrompt,
20 kindMinutes,
21 kindOf,
22 manualListText,
23 minutesSaved,
24 parseAdd,
25 projectOfTask,
26 projectRows,
27 readManual,
28 taskLines,
29 writtenLines,
30 modelName,
31 moneyText,
32 pathsOf,
33 historyDays,
34 readDay,
35 readHistory,
36 readSettings,
37 shortDate,
38 streak,
39 sumDays,
40 summaryOf,
41 tasksOn,
42 tilesPerRow,
43 titleOf,
44 wrap,
45} from './logic'
46import type { Bar, ToolUse, Totals } from './logic'
47import { barRowsSvg, chartSvg, headerSvg, kindRows, pixelsFor, projectBarRows, tilesSvg } from './desktop'
48import type { Drawing } from './desktop'
49
50type D = EngineInterface
51
52const PANE = 'work-report'
53const TITLE = 'Work Report'
54const TABS: readonly Tab[] = ['today', 'week', 'month', 'year']
55const TAB_LABEL: Record<Tab, string> = { today: 'Today', week: 'Week', month: 'Month', year: 'Year' }
56
57const GOLD = '#F2C46D'
58const LINE = '#262B33'
59const MUTED = '#7D8590'
60const TEXT = '#E6EDF3'
61const GREEN = '#3DD68C'
62const INK = '#0D1117'
63
64const KIND_COLOR: Record<Kind, string> = {
65 emails: '#79C0FF',
66 documents: GOLD,
67 building: '#F0876A',
68 media: '#D2A8FF',
69 research: '#56D4DD',
70 other: MUTED,
71}
72const MODEL_COLORS = [GOLD, '#79C0FF', '#D2A8FF', '#56D4DD', GREEN, MUTED]
73
74const FAILED = 'Work Report hit a problem and could not do that. Nothing was changed.'
75const NARROW = 'Work Report needs a wider window. Widen it and run /report again.'
76
77// ---------------------------------------------------------------- state
78
79const DAYS = { plugin: 'work-report', key: 'days' } as const
80const SETTINGS = { plugin: 'work-report', key: 'settings' } as const
81const TAB = { plugin: 'work-report', key: 'tab' } as const
82const MANUAL = { plugin: 'work-report', key: 'manual' } as const
83
84// Reads while drawing subscribe the drawing; writes come only from events.
85const getDays = async ($: D): Promise<Record<string, DayRecord>> => (await $.state.get(DAYS)).value ?? {}
86const getSettings = async ($: D): Promise<Settings> => readSettings((await $.state.get(SETTINGS)).value)
87const getTab = async ($: D): Promise<Tab> => (await $.state.get(TAB)).value ?? 'today'
88const getManual = async ($: D): Promise<ManualEntry[]> => readManual((await $.state.get(MANUAL)).value)
89
90// Hours added by hand: kept in the store, mirrored into state for drawing.
91const loadManual = async ($: D) => {
92 await $.state.set(MANUAL, readManual(await $.store.get('manual')))
93}
94
95const saveManual = async ($: D, list: ManualEntry[]) => {
96 await $.store.set('manual', list)
97 await $.state.set(MANUAL, list)
98}
99
100const todayOf = async ($: D) => dayKey(new Date(await $.clock.now()))
101
102// Read, apply, write with ifVersion; again on a miss.
103const changeDays = async ($: D, change: (days: Record<string, DayRecord>) => Record<string, DayRecord>) => {
104 for (let tries = 0; tries < 20; tries++) {
105 const now = await $.state.get(DAYS)
106 const result = await $.state.set(DAYS, change(now.value ?? {}), { ifVersion: now.version })
107 if (result.isSet) return
108 }
109}
110
111// Loads the kept days the tabs can show (this calendar year and the last 31
112// days) so every session on this machine sees the same counts.
113const loadDays = async ($: D) => {
114 const today = await todayOf($)
115 const oldest = [`${today.slice(0, 4)}-01-01`, addDays(today, -31)].sort()[0]!
116 const loaded: Record<string, DayRecord> = {}
117 for (const name of await $.store.keys()) {
118 if (!name.startsWith('day:')) continue
119 const key = name.slice(4)
120 if (key < oldest) continue
121 const day = readDay(await $.store.get(name))
122 if (day !== undefined) loaded[key] = day
123 }
124 await changeDays($, () => loaded)
125}
126
127const loadSettings = async ($: D) => {
128 await $.state.set(SETTINGS, readSettings(await $.store.get('settings')))
129}
130
131const record = async ($: D, kind: Kind, model: string, project: string, lines: number) => {
132 const today = await todayOf($)
133 const day = addTask(readDay(await $.store.get(`day:${today}`)), kind, model, project, lines)
134 await $.store.set(`day:${today}`, day)
135 await changeDays($, days => ({ ...days, [today]: day }))
136}
137
138const openReport = async ($: D) => {
139 await loadDays($)
140 const opened = await $.ui.open({ id: PANE, title: TITLE })
141 if (!opened.isPlaced) $.ui.toast(NARROW)
142}
143
144const BACKFILL_FAILED = 'Work Report could not read your session history. Nothing was changed.'
145
146// Fills in past days from this machine's session transcripts. Read-only on the
147// transcripts; each day it finds replaces that day's record, so running it
148// again never counts a request twice.
149const backfill = async ($: D) => {
150 const home = await $.env.get('HOME')
151 const config = (await $.env.get('CLAUDE_CONFIG_DIR')) ?? (home === undefined ? undefined : `${home}/.claude`)
152 if (config === undefined) {
153 $.ui.toast(BACKFILL_FAILED)
154
155 return
156 }
157 const run = await $.process.run(['/usr/bin/python3', '-I', `${$.plugin.root}/scripts/history.py`, `${config}/projects`], {
158 timeoutMs: 600_000,
159 })
160 if (run.exitCode !== 0 || run.isStdoutTruncated) {
161 $.ui.toast(BACKFILL_FAILED)
162
163 return
164 }
165 const tasks = readHistory(run.stdout)
166 const days = historyDays(tasks, home ?? '')
167 for (const [key, day] of Object.entries(days)) await $.store.set(`day:${key}`, day)
168 await loadDays($)
169 const keys = Object.keys(days).sort()
170 const first = keys[0]
171 const last = keys[keys.length - 1]
172 $.ui.toast(
173 first === undefined || last === undefined
174 ? 'Work Report found no finished requests in your session history.'
175 : `Work Report filled in ${tasks.length} tasks from your session history, ${shortDate(first)} to ${shortDate(last)}.`,
176 )
177}
178
179const act = (work: () => Promise<unknown>) => () => {
180 void work().catch(() => undefined)
181}
182
183// ---------------------------------------------------------------- drawing
184
185type Kit = {
186 Box: Elements['terminal']['Box']
187 Text: Elements['terminal']['Text']
188 Button: Elements['terminal']['Button']
189}
190
191type Chip = { key: string; label: string; tone: 'gold' | 'slate' | 'quiet'; onPress: () => void }
192
193const chipWidth = (chip: Chip) => (chip.tone === 'quiet' ? chip.label.length : chip.label.length + 2)
194
195const drawChip = (kit: Kit, chip: Chip) => {
196 const { Button, Text } = kit
197 if (chip.tone === 'quiet') {
198 return (
199 <Button key={chip.key} plain onPress={chip.onPress}>
200 <Text color={MUTED}>{chip.label}</Text>
201 </Button>
202 )
203 }
204
205 return (
206 <Button key={chip.key} plain onPress={chip.onPress}>
207 <Text backgroundColor={chip.tone === 'gold' ? GOLD : LINE} color={chip.tone === 'gold' ? INK : TEXT} bold={chip.tone === 'gold'}>
208 {` ${chip.label} `}
209 </Text>
210 </Button>
211 )
212}
213
214// Chips laid out in rows that each fit the width.
215const drawChips = (kit: Kit, chips: Chip[], width: number) => {
216 const { Box } = kit
217 const rows: Chip[][] = []
218 let row: Chip[] = []
219 let used = 0
220 for (const chip of chips.map(one => ({ ...one, label: clip(one.label, Math.max(1, width - 2)) }))) {
221 const w = chipWidth(chip)
222 if (row.length > 0 && used + 1 + w > width) {
223 rows.push(row)
224 row = []
225 used = 0
226 }
227 used += (row.length > 0 ? 1 : 0) + w
228 row.push(chip)
229 }
230 if (row.length > 0) rows.push(row)
231
232 return (
233 <Box flexDirection="column" width={width}>
234 {rows.map(line => (
235 <Box flexDirection="row" gap={1}>
236 {line.map(chip => drawChip(kit, chip))}
237 </Box>
238 ))}
239 </Box>
240 )
241}
242
243const drawTabs = ($: D, kit: Kit, width: number, tab: Tab) =>
244 drawChips(
245 kit,
246 TABS.map(one => ({
247 key: `tab-${one}`,
248 label: TAB_LABEL[one],
249 tone: one === tab ? 'gold' : 'slate',
250 onPress: act(async () => {
251 await loadDays($)
252 await $.state.set(TAB, one)
253 }),
254 })),
255 width,
256 )
257
258const drawTitle = (kit: Kit, width: number, name: string, dates: string) => {
259 const { Box, Text } = kit
260 const title = clip(name, width)
261 const hasDates = title.length + 2 + dates.length <= width
262 const gold = Math.min(title.length, width)
263
264 return (
265 <Box flexDirection="column" width={width} marginTop={1}>
266 <Box flexDirection="row" justifyContent="space-between" width={width}>
267 <Text bold color={TEXT}>
268 {title}
269 </Text>
270 {hasDates && <Text color={MUTED}>{dates}</Text>}
271 </Box>
272 <Box flexDirection="row" width={width}>
273 <Text color={GOLD}>{'━'.repeat(gold)}</Text>
274 <Text color={LINE}>{'─'.repeat(Math.max(0, width - gold))}</Text>
275 </Box>
276 </Box>
277 )
278}
279
280const drawSection = (kit: Kit, width: number, label: string) => {
281 const { Box, Text } = kit
282 const shown = clip(label.toUpperCase(), width)
283
284 return (
285 <Box flexDirection="row" width={width} marginTop={1}>
286 <Text color={MUTED}>{shown}</Text>
287 <Text color={LINE}>{shown.length + 1 < width ? ` ${'─'.repeat(width - shown.length - 1)}` : ''}</Text>
288 </Box>
289 )
290}
291
292const TILE_MIN = 20
293
294const drawTiles = (kit: Kit, width: number, totals: Totals, settings: Settings) => {
295 const { Box, Text } = kit
296 const minutes = minutesSaved(totals, settings)
297 const tiles = [
298 { key: 'hours', value: hoursText(minutes), plain: `${hoursText(minutes)} h`, label: 'EST. HOURS SAVED', color: GOLD },
299 { key: 'tasks', value: String(totals.tasks), plain: totals.tasks.toLocaleString('en-US'), label: 'TASKS DONE', color: TEXT },
300 {
301 key: 'value',
302 value: moneyText(minutes, settings.rate),
303 plain: moneyText(minutes, settings.rate, true),
304 label: 'EST. VALUE',
305 color: GREEN,
306 },
307 ]
308 const perRow = tilesPerRow(width, TILE_MIN, tiles.length)
309 const tileWidth = Math.floor((width - (perRow - 1)) / perRow)
310 const inner = Math.max(1, tileWidth - 4)
311 const rows: (typeof tiles)[] = []
312 for (let i = 0; i < tiles.length; i += perRow) rows.push(tiles.slice(i, i + perRow))
313
314 return (
315 <Box flexDirection="column" width={width} marginTop={1}>
316 {rows.map(row => (
317 <Box flexDirection="row" gap={1} width={width}>
318 {row.map(tile => {
319 const glyph = bigText(tile.value)
320 const isBig = glyph[0].length <= inner
321
322 return (
323 <Box key={`tile-${tile.key}`} borderStyle="round" borderColor={LINE} paddingX={1} flexDirection="column" width={tileWidth}>
324 {isBig
325 ? glyph.map(line => (
326 <Text color={tile.color} bold>
327 {line}
328 </Text>
329 ))
330 : [' ', clip(tile.plain, inner), ' '].map(line => (
331 <Text color={tile.color} bold>
332 {line}
333 </Text>
334 ))}
335 <Text color={MUTED}>{clip(tile.label, inner)}</Text>
336 </Box>
337 )
338 })}
339 </Box>
340 ))}
341 </Box>
342 )
343}
344
345// A labelled bar row: the label, a colored bar on a dim track, then the figures.
346const drawBarRow = (kit: Kit, width: number, row: { key: string; label: string; value: number; max: number; color: string; figures: string }) => {
347 const { Box, Text } = kit
348 const labelWidth = Math.min(16, Math.max(6, Math.floor(width * 0.35)))
349 const figures = row.figures
350 const track = width - labelWidth - 1 - 1 - figures.length
351 const isZero = row.value === 0
352
353 if (track < 3) {
354 const label = clip(row.label, Math.max(1, width - figures.length - 1))
355
356 return (
357 <Box key={row.key} flexDirection="row" width={width}>
358 <Text color={isZero ? MUTED : TEXT}>{label.padEnd(width - figures.length)}</Text>
359 <Text color={isZero ? MUTED : TEXT}>{figures}</Text>
360 </Box>
361 )
362 }
363 const filled = row.max <= 0 || isZero ? 0 : Math.max(1, Math.round((row.value / row.max) * track))
364
365 return (
366 <Box key={row.key} flexDirection="row" width={width}>
367 <Text color={isZero ? MUTED : TEXT}>{`${clip(row.label, labelWidth).padEnd(labelWidth)} `}</Text>
368 <Text color={row.color}>{'━'.repeat(filled)}</Text>
369 <Text color={LINE}>{`${'─'.repeat(track - filled)} `}</Text>
370 <Text color={isZero ? MUTED : TEXT}>{figures}</Text>
371 </Box>
372 )
373}
374
375const drawKinds = (kit: Kit, width: number, totals: Totals, settings: Settings) => {
376 const { Box } = kit
377 const max = Math.max(...KINDS.map(kind => kindMinutes(totals, kind, settings)))
378 const hourTexts = KINDS.map(kind => `${hoursText(kindMinutes(totals, kind, settings))}h`)
379 const hourWidth = Math.max(...hourTexts.map(text => text.length))
380 const countWidth = Math.max(...KINDS.map(kind => String(totals.kinds[kind]).length))
381
382 return (
383 <Box flexDirection="column" width={width}>
384 {drawSection(kit, width, 'Where the time went · est. hours')}
385 {KINDS.map((kind, i) =>
386 drawBarRow(kit, width, {
387 key: `kind-${kind}`,
388 label: KIND_LABEL[kind],
389 value: kindMinutes(totals, kind, settings),
390 max,
391 color: KIND_COLOR[kind],
392 figures: `${String(totals.kinds[kind]).padStart(countWidth)} ${hourTexts[i]!.padStart(hourWidth)}`,
393 }),
394 )}
395 </Box>
396 )
397}
398
399const drawModels = (kit: Kit, width: number, totals: Totals) => {
400 const { Box } = kit
401 const list = Object.entries(totals.models)
402 .sort((a, b) => b[1] - a[1])
403 .slice(0, 6)
404 const max = list[0]?.[1] ?? 0
405 const countWidth = Math.max(1, ...list.map(([, n]) => String(n).length))
406
407 return (
408 <Box flexDirection="column" width={width}>
409 {drawSection(kit, width, 'Who did the work')}
410 {list.map(([model, n], i) =>
411 drawBarRow(kit, width, {
412 key: `model-${i}`,
413 label: model,
414 value: n,
415 max,
416 color: MODEL_COLORS[i % MODEL_COLORS.length]!,
417 figures: String(n).padStart(countWidth),
418 }),
419 )}
420 </Box>
421 )
422}
423
424const drawProjects = (kit: Kit, width: number, totals: Totals, settings: Settings) => {
425 const { Box } = kit
426 const rows = projectRows(totals, settings)
427 if (rows.length === 0) return null
428 const max = Math.max(...rows.map(row => row.minutes))
429 const hourTexts = rows.map(row => `${hoursText(row.minutes)}h`)
430 const hourWidth = Math.max(...hourTexts.map(text => text.length))
431 const countWidth = Math.max(...rows.map(row => String(row.tasks).length))
432
433 return (
434 <Box flexDirection="column" width={width}>
435 {drawSection(kit, width, 'By project · est. hours')}
436 {rows.map((row, i) =>
437 drawBarRow(kit, width, {
438 key: `project-${i}`,
439 label: row.name,
440 value: row.minutes,
441 max,
442 color: MODEL_COLORS[i % MODEL_COLORS.length]!,
443 figures: `${String(row.tasks).padStart(countWidth)} ${hourTexts[i]!.padStart(hourWidth)}`,
444 }),
445 )}
446 </Box>
447 )
448}
449
450const CHART_HEADING: Record<Tab, string> = {
451 today: 'The last 7 days',
452 week: 'The last 7 days',
453 month: 'The last 30 days',
454 year: 'Month by month',
455}
456
457const barColor = (bar: Bar) => (bar.isCurrent ? GOLD : bar.isFuture ? LINE : MUTED)
458
459const drawChart = (kit: Kit, width: number, tab: Tab, all: Bar[], today: string) => {
460 const { Box, Text } = kit
461 // Too narrow for thirty: the most recent days that fit.
462 const bars = all.length > width ? all.slice(all.length - width) : all
463 const slot = Math.max(1, Math.floor(width / bars.length))
464 const barWidth = slot >= 3 ? slot - 1 : 1
465 const gap = slot - barWidth
466 const max = Math.max(0, ...bars.map(bar => bar.value))
467
468 const rows = [0, 1, 2].map(row => (
469 <Box flexDirection="row" width={width}>
470 {bars.map(bar => {
471 const cells = barCells(bar.value, max)
472 const isBase = row === 2 && bar.value === 0 && !bar.isFuture
473
474 return (
475 <Text color={isBase ? LINE : barColor(bar)}>{`${(isBase ? '▁' : cells[row]!).repeat(barWidth)}${' '.repeat(gap)}`}</Text>
476 )
477 })}
478 </Box>
479 ))
480
481 let labels
482 if (tab === 'month') {
483 const first = shortDate(addDays(today, -(bars.length - 1)))
484 const last = 'Today'
485 const room = bars.length * slot
486 labels =
487 room >= first.length + last.length + 1 ? (
488 <Box flexDirection="row" width={width}>
489 <Text color={MUTED}>{first.padEnd(room - last.length)}</Text>
490 <Text color={GOLD}>{last}</Text>
491 </Box>
492 ) : (
493 <Text color={MUTED}>{clip(first, width)}</Text>
494 )
495 } else {
496 labels = (
497 <Box flexDirection="row" width={width}>
498 {bars.map(bar => (
499 <Text color={bar.isCurrent ? GOLD : MUTED}>{(slot >= 2 ? bar.label : bar.label.charAt(0)).slice(0, slot).padEnd(slot)}</Text>
500 ))}
501 </Box>
502 )
503 }
504
505 return (
506 <Box flexDirection="column" width={width}>
507 {drawSection(kit, width, CHART_HEADING[tab])}
508 {rows}
509 {labels}
510 </Box>
511 )
512}
513
514// ---------------------------------------------------------------- hooks
515
516export const register: Register = on => {
517 // Typed prompts waiting for their turn, and the turn being counted.
518 let pending: { text: string; turnId?: string }[] = []
519 let current: { turnId: string; uses: ToolUse[]; cwd: string } | null = null
520 let lastCommandAt = -Infinity
521 let home = ''
522
523 on('session.start', async ($, e, next) => {
524 try {
525 await $.command.register({
526 name: 'report',
527 description: 'Open the Work Report: tasks Claude finished and the time it saved. Also: week, month, year, settings, backfill, add, list, remove',
528 argumentHint: '[today | week | month | year | settings | backfill | add | list | remove]',
529 })
530 } catch {
531 // registration refused; counting still works
532 }
533 try {
534 home = (await $.env.get('HOME').catch(() => undefined)) ?? ''
535 await loadSettings($)
536 await loadManual($)
537 await loadDays($)
538 } catch {
539 // nothing kept yet
540 }
541
542 return next(e)
543 })
544
545 on('command.run', { command: 'report' }, async ($, e) => {
546 try {
547 const words = e.args.trim().split(/\s+/).filter(Boolean)
548 const first = (words[0] ?? '').toLowerCase()
549
550 if (first === 'settings') {
551 const result = applySetting(words.slice(1).join(' '), await getSettings($))
552 if (result.settings !== undefined) {
553 await $.state.set(SETTINGS, result.settings)
554 await $.store.set('settings', result.settings)
555 }
556
557 return { text: result.text }
558 }
559
560 if (first === 'add') {
561 const parsed = parseAdd(e.args.trim().slice(3), await todayOf($))
562 if (parsed.entry === undefined) return { text: parsed.text ?? FAILED }
563 const list = await getManual($)
564 const entry: ManualEntry = { ...parsed.entry, id: `${await $.clock.now()}-${list.length}` }
565 await saveManual($, [...list, entry])
566
567 return { text: `Added ${entry.hours} estimated hours for ${entry.project} on ${entry.date}. /report list shows everything you have added.` }
568 }
569
570 if (first === 'list') return { text: manualListText(await getManual($)) }
571
572 if (first === 'remove') {
573 const list = await getManual($)
574 const n = Number(words[1])
575 if (!Number.isInteger(n) || n < 1 || n > list.length) {
576 return { text: list.length === 0 ? manualListText(list) : `Give the number from /report list, from 1 to ${list.length}, like /report remove 1.` }
577 }
578 const gone = list[n - 1]!
579 await saveManual($, list.filter((_, i) => i !== n - 1))
580
581 return { text: `Removed ${gone.hours} hours for ${gone.project} on ${gone.date}.` }
582 }
583
584 if (first === 'backfill') {
585 $.clock.after(0, () => {
586 void backfill($).catch(() => $.ui.toast(BACKFILL_FAILED))
587 })
588
589 return {
590 text: 'Reading your session history on this machine. The report fills in when it is done. Nothing leaves this computer.',
591 }
592 }
593
594 const tab = TABS.find(one => one === first)
595 if (first !== '' && tab === undefined) {
596 return { text: 'Try /report, /report week, /report month, /report year, /report settings, /report add, /report list or /report backfill.' }
597 }
598 if (tab !== undefined) await $.state.set(TAB, tab)
599 void openReport($).catch(() => undefined)
600
601 return { text: 'Work Report is open. Hours and dollars are estimates.' }
602 } catch {
603 return { text: FAILED }
604 }
605 }).catch(() => ({ text: FAILED }))
606
607 // A slash command that expands into a prompt is not a typed request.
608 on('command.run', async ($, e, next) => {
609 lastCommandAt = await $.clock.now()
610
611 return next(e)
612 }).catch(($, e, next) => next(e))
613
614 on('prompt.submit', async ($, e, next) => {
615 const isCommand = (await $.clock.now()) - lastCommandAt < 1500
616 const origin = e.origin
617 const isTyped =
618 !isCommand &&
619 isTypedPrompt({
620 originKind: origin.kind,
621 asUser: origin.kind === 'plugin' ? origin.asUser : undefined,
622 text: e.text,
623 hasAttachments: (e.attachments?.length ?? 0) > 0,
624 })
625 const entry = { text: e.text, turnId: e.turnId }
626 if (isTyped) pending = [...pending, entry].slice(-20)
627
628 const result = await next(e)
629 if (result.drop !== undefined) pending = pending.filter(one => one !== entry)
630
631 return result
632 }).catch(($, e, next) => next(e))
633
634 on('turn.start', async ($, e, next) => {
635 // The prompt the turn starts with, else the oldest typed while idle.
636 const match = pending.find(one => one.text === e.text) ?? pending.find(one => one.turnId === undefined)
637 if (match !== undefined) {
638 pending = pending.filter(one => one !== match)
639 // A lost folder costs only the project, never the count.
640 current = { turnId: e.turnId, uses: [], cwd: await $.session.cwd().catch(() => '') }
641 } else {
642 current = null
643 }
644
645 return next(e)
646 }).catch(($, e, next) => next(e))
647
648 // Helper agents' tool calls count toward the request that started them.
649 on('tool.call', ($, e, next) => {
650 if (current !== null && current.uses.length < 300) {
651 const input = e as unknown as Record<string, unknown>
652 current.uses.push({ tool: String(e.tool), paths: pathsOf(input), lines: writtenLines(input) })
653 }
654
655 return next(e)
656 }).catch(($, e, next) => next(e))
657
658 on('turn.complete', async ($, e, next) => {
659 if (e.agentId === undefined && current !== null && current.turnId === e.turnId) {
660 const task = current
661 current = null
662 if (e.reason === 'answer') {
663 try {
664 const model = modelName(e.usage?.model ?? (await $.session.model()))
665 const kind = kindOf(task.uses)
666 await record($, kind, model, projectOfTask(task.cwd, task.uses, home), kind === 'building' ? taskLines(task.uses) : 0)
667 } catch {
668 // a lost count is better than a stuck turn
669 }
670 }
671 }
672
673 return next(e)
674 }).catch(($, e, next) => next(e))
675
676 // The status bar button, drawn after whatever else the footer holds (the
677 // engine's mode labels, other mods' buttons), so they sit side by side.
678 on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
679 const below = await next(e)
680 const { Box, Button, Text } = $.ui.resolve(e)
681 const days = await getDays($)
682 const count = tasksOn(days[await todayOf($)])
683 const label = count === 0 ? 'Work report' : `Work report · ${count} today`
684
685 return (
686 <Box flexDirection="row" gap={2}>
687 {below}
688 <Button key="open-work-report" plain onPress={act(() => openReport($))}>
689 <Text dimColor>{label}</Text>
690 </Button>
691 </Box>
692 )
693 })
694
695 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
696 const table = $.ui.resolve(e)
697 const kit: Kit = { Box: table.Box, Text: table.Text, Button: table.Button }
698 const { Box, Text, Button } = kit
699 const width = Math.max(10, e.props.bodyColumns)
700
701 const tab = await getTab($)
702 const days = await getDays($)
703 const settings = await getSettings($)
704 const manual = await getManual($)
705 const today = await todayOf($)
706
707 const { name, dates } = titleOf(tab, today)
708 const totals = sumDays(days, daysOf(tab, today), manual)
709 const isEmpty = totals.tasks === 0 && totals.manualMinutes === 0
710 const footnote = `Estimates: minutes saved per task, building software at ${settings.buildRate} lines of code an hour, at $${settings.rate} an hour.`
711 const bars = barsOf(tab, days, today)
712 const hasChart = bars.some(bar => bar.value > 0)
713
714 // The desktop and other app surfaces set text in a proportional font: they get the SVG drawing.
715 if (e.surface !== 'terminal' && 'Svg' in table) {
716 const { Svg } = table
717 const px = pixelsFor(e.props.bodyColumns)
718 const picture = (key: string, drawing: Drawing) => (
719 <Box key={key} marginTop={1}>
720 <Svg source={drawing.source} alt={drawing.alt} />
721 </Box>
722 )
723 const models = Object.entries(totals.models)
724 .sort((a, b) => b[1] - a[1])
725 .slice(0, 6)
726 .map(([model, n], i) => ({ label: model, value: n, color: MODEL_COLORS[i % MODEL_COLORS.length]!, figures: [String(n)] }))
727 const isStreak = (tab === 'today' || tab === 'week') && streak(days, today) > 0
728 const first = shortDate(addDays(today, -29))
729
730 return (
731 <Box flexDirection="column">
732 <Box flexDirection="row" gap={1}>
733 {TABS.map(one => (
734 <Button
735 key={`tab-${one}`}
736 variant={one === tab ? 'primary' : 'secondary'}
737 onPress={act(async () => {
738 await loadDays($)
739 await $.state.set(TAB, one)
740 })}
741 >
742 {TAB_LABEL[one]}
743 </Button>
744 ))}
745 </Box>
746 {picture('header', headerSvg(px, name, dates))}
747 {isEmpty ? (
748 <Box marginTop={1}>
749 <Text color={MUTED}>{emptyText(tab)}</Text>
750 </Box>
751 ) : (
752 <Box flexDirection="column">
753 {picture('tiles', tilesSvg(px, totals, settings))}
754 <Text color={MUTED}>{footnote}</Text>
755 {picture('kinds', barRowsSvg(px, 'Where the time went · est. hours', kindRows(totals, settings, KIND_COLOR)))}
756 {projectRows(totals, settings).length > 0 &&
757 picture('projects', barRowsSvg(px, 'By project · est. hours', projectBarRows(totals, settings, MODEL_COLORS)))}
758 {models.length > 0 && picture('models', barRowsSvg(px, 'Who did the work', models))}
759 </Box>
760 )}
761 {hasChart && picture('chart', chartSvg(px, tab, CHART_HEADING[tab], bars, first))}
762 {hasChart && (
763 <Box marginTop={1}>
764 <Text color={isStreak ? GREEN : TEXT}>{summaryOf(tab, days, today)}</Text>
765 </Box>
766 )}
767 <Box marginTop={1}>
768 <Button key="close" dimColor onPress={act(() => $.ui.close({ id: PANE }))}>
769 Close
770 </Button>
771 </Box>
772 </Box>
773 )
774 }
775
776 return (
777 <Box flexDirection="column" width={width}>
778 {drawTabs($, kit, width, tab)}
779 {drawTitle(kit, width, name, dates)}
780 {isEmpty ? (
781 <Box flexDirection="column" marginTop={1} width={width}>
782 {wrap(emptyText(tab), width).map(line => (
783 <Text color={MUTED}>{line}</Text>
784 ))}
785 </Box>
786 ) : (
787 <Box flexDirection="column" width={width}>
788 {drawTiles(kit, width, totals, settings)}
789 {wrap(footnote, width).map(line => (
790 <Text color={MUTED}>{line}</Text>
791 ))}
792 {drawKinds(kit, width, totals, settings)}
793 {drawProjects(kit, width, totals, settings)}
794 {Object.keys(totals.models).length > 0 && drawModels(kit, width, totals)}
795 </Box>
796 )}
797 {hasChart && drawChart(kit, width, tab, bars, today)}
798 {hasChart && (
799 <Box flexDirection="column" marginTop={1} width={width}>
800 {wrap(summaryOf(tab, days, today), width).map(line => (
801 <Text color={(tab === 'today' || tab === 'week') && streak(days, today) > 0 ? GREEN : TEXT}>{line}</Text>
802 ))}
803 </Box>
804 )}
805 <Box marginTop={1} width={width}>
806 {drawChips(kit, [{ key: 'close', label: 'Close', tone: 'quiet', onPress: act(() => $.ui.close({ id: PANE })) }], width)}
807 </Box>
808 </Box>
809 )
810 })
811}
812hooks/logic.ts 735 lines1import type { DayRecord, Kind, ManualEntry, ProjectDay, Settings, Tab } from '../types'
2
3// ---------------------------------------------------------------- kinds
4
5export const KINDS: readonly Kind[] = ['emails', 'documents', 'building', 'media', 'research', 'other']
6
7export const KIND_LABEL: Record<Kind, string> = {
8 emails: 'Emails',
9 documents: 'Documents',
10 building: 'Building software',
11 media: 'Images and video',
12 research: 'Research',
13 other: 'Other',
14}
15
16// Building is sized by the code it wrote: lines divided by buildRate (lines an
17// hour), and never less than minutes.building per task.
18export const DEFAULT_SETTINGS: Settings = {
19 rate: 50,
20 minutes: { emails: 10, documents: 30, building: 30, media: 20, research: 25, other: 10 },
21 buildRate: 25,
22}
23
24// Reads whatever was kept, filling anything missing or broken with the default.
25export const readSettings = (kept: unknown): Settings => {
26 const out: Settings = { rate: DEFAULT_SETTINGS.rate, minutes: { ...DEFAULT_SETTINGS.minutes }, buildRate: DEFAULT_SETTINGS.buildRate }
27 if (kept === null || typeof kept !== 'object') return out
28 const raw = kept as { rate?: unknown; minutes?: Record<string, unknown>; buildRate?: unknown }
29 if (typeof raw.rate === 'number' && Number.isFinite(raw.rate) && raw.rate >= 0) out.rate = raw.rate
30 if (typeof raw.buildRate === 'number' && Number.isFinite(raw.buildRate) && raw.buildRate > 0) out.buildRate = raw.buildRate
31 for (const kind of KINDS) {
32 const value = raw.minutes?.[kind]
33 if (typeof value === 'number' && Number.isFinite(value) && value >= 0) out.minutes[kind] = value
34 }
35
36 return out
37}
38
39// ---------------------------------------------------------------- what counts
40
41// The prompts the person typed: at the terminal, in the desktop app (an SDK
42// host), from the phone, or a mod sending their own words. An image sent with
43// no words counts too. Slash commands, notifications, scheduled runs and other
44// sessions' messages do not.
45export const isTypedPrompt = (prompt: { originKind: string; asUser?: boolean; text: string; hasAttachments?: boolean }): boolean => {
46 const text = prompt.text.trim()
47 if (text.startsWith('/') || (text === '' && prompt.hasAttachments !== true)) return false
48 if (prompt.originKind === 'plugin') return prompt.asUser === true
49
50 return prompt.originKind === 'composer' || prompt.originKind === 'bridge' || prompt.originKind === 'sdk'
51}
52
53// A tool call as the sorting reads it: its name, the paths (or shell command)
54// it names, and how many lines of text it wrote, for a tool that writes.
55export type ToolUse = { tool: string; paths: string[]; lines?: number }
56
57const MEDIA_FILE = /\.(png|jpe?g|gif|webp|heic|heif|tiff?|bmp|svg|psd|dng|cr[23]|nef|arw|raw|mp4|mov|m4v|webm|avi|mkv|prproj|aep)$/i
58const DOC_FILE = /\.(docx?|pptx?|xlsx?|csv|pdf|md|markdown|txt|rtf|odt|ods|odp|pages|key|numbers)$/i
59const WRITES = /^(Write|Edit|MultiEdit|NotebookEdit)$|create|write|save|export|upload/i
60const FILE_WRITES = /^(Write|Edit|MultiEdit|NotebookEdit)$/
61export const CODE_FILE = /\.(tsx?|jsx?|mjs|cjs|mts|cts|py|rb|go|rs|java|kt|swift|cs|php|sh|zsh|bash|ps1|psm1|sql|html?|css|scss|less|vue|svelte|json|jsonc|ya?ml|toml|ini|ipynb)$/i
62const BUILD_COMMAND =
63 /\b(npm|npx|pnpm|yarn|bun|node|deno|wrangler|pytest|vitest|jest|tsc|docker|az|terraform|pip3?|uv|cargo|go (build|test|run)|git (commit|push)|gh (pr|repo|release)|claude plugin|python3? [\w./-]+\.py)\b/
64// Remotion projects are video production, whatever the file type.
65const REMOTION_PATH = /remotion/i
66
67const EMAIL_TOOL = /mail|inbox|outlook_.*draft/i
68const MEDIA_TOOL =
69 /image|video|photo|thumbnail|remotion|frame_?io|youtube|vidiq|firefly|premiere|blender|instagram|reel|quick_cut|animate_design|remove.background/i
70const MEDIA_COMMAND = /\b(ffmpeg|ffprobe|magick|sips|remotion)\b/
71const DOC_TOOL = /document|docx|pptx|xlsx|slide|presentation|spreadsheet|sheet|excel|powerpoint|word|pdf|pandadoc/i
72const RESEARCH_TOOL =
73 /^(WebSearch|WebFetch)$|web_?search|web_?fetch|get_page_text|read_page|transcript|fetch_url|scrape|crawl|apify|deep_research/i
74
75// The words "password" and "keyword" are not about Word documents.
76const docName = (tool: string) => tool.replace(/password|keyword/gi, '')
77
78// One kind per task, by the tools it used. When a task used several kinds of
79// tool, the first that matches in this order wins: emails, images and video,
80// building software, documents, research. A task that matched none is Other.
81export const kindOf = (uses: readonly ToolUse[]): Kind => {
82 // Image or video tools, saving an image or video file, or running a media
83 // command. Reading, listing or merely naming an image file is not image work.
84 const media = (use: ToolUse) =>
85 MEDIA_TOOL.test(use.tool) ||
86 (WRITES.test(use.tool) && use.paths.some(path => MEDIA_FILE.test(path))) ||
87 (use.tool === 'Bash' && use.paths.some(path => MEDIA_COMMAND.test(path))) ||
88 (FILE_WRITES.test(use.tool) && use.paths.some(path => REMOTION_PATH.test(path)))
89 // Writing code, or running a build, test or deploy command.
90 const building = (use: ToolUse) =>
91 (FILE_WRITES.test(use.tool) && use.paths.some(path => CODE_FILE.test(path))) ||
92 (use.tool === 'Bash' && use.paths.some(path => BUILD_COMMAND.test(path)))
93 const documents = (use: ToolUse) =>
94 DOC_TOOL.test(docName(use.tool)) || (WRITES.test(use.tool) && use.paths.some(path => DOC_FILE.test(path)))
95
96 if (uses.some(use => EMAIL_TOOL.test(use.tool))) return 'emails'
97 if (uses.some(media)) return 'media'
98 if (uses.some(building)) return 'building'
99 if (uses.some(documents)) return 'documents'
100 if (uses.some(use => RESEARCH_TOOL.test(use.tool))) return 'research'
101
102 return 'other'
103}
104
105// The file paths a tool call names, and a shell command's text.
106export const pathsOf = (input: Record<string, unknown>): string[] => {
107 const found: string[] = []
108 for (const [key, value] of Object.entries(input)) {
109 if (typeof value !== 'string' || value === '') continue
110 if (/path|file/i.test(key) || key === 'command') found.push(value.trim())
111 }
112
113 return found.slice(0, 20)
114}
115
116// How many lines a writing tool's input writes: a new file's content, an
117// edit's replacement text, each edit of a multi-edit, a notebook cell.
118export const writtenLines = (input: Record<string, unknown>): number => {
119 const count = (text: unknown) => (typeof text === 'string' && text !== '' ? text.split('\n').length : 0)
120 const edits = Array.isArray(input.edits) ? (input.edits as { new_string?: unknown }[]) : []
121
122 return count(input.content) + count(input.new_string) + count(input.new_source) + edits.reduce((n, edit) => n + count(edit?.new_string), 0)
123}
124
125// The lines of code a task wrote: a file written whole counts its last
126// version once; edits add their new lines. Files that are not code, and
127// Remotion video projects, count nothing.
128export const taskLines = (uses: readonly ToolUse[]): number => {
129 const whole = new Map<string, number>()
130 const edited = new Map<string, number>()
131 for (const use of uses) {
132 if (!FILE_WRITES.test(use.tool)) continue
133 const file = use.paths.find(path => CODE_FILE.test(path) && !REMOTION_PATH.test(path))
134 if (file === undefined) continue
135 const lines = Math.max(0, Math.round(use.lines ?? 0))
136 if (use.tool === 'Write') {
137 whole.set(file, lines)
138 edited.delete(file)
139 } else edited.set(file, (edited.get(file) ?? 0) + lines)
140 }
141
142 return [...whole.values(), ...edited.values()].reduce((n, lines) => n + lines, 0)
143}
144
145// The project a session works in, named by its folder: the repo under a dev
146// root (/Volumes/<drive>/dev/<repo>), a top folder on another drive, or the
147// first folder under home.
148export const projectOf = (cwd: string, home = ''): string => {
149 const path = cwd.replace(/\/+$/, '')
150 const dev = /^\/Volumes\/[^/]+\/dev(?:\/([^/]+))?/.exec(path)
151 if (dev) return dev[1] ?? 'dev workspace'
152 const drive = /^\/Volumes\/[^/]+\/([^/]+)/.exec(path)
153 if (drive) return drive[1]!
154 const base = home.replace(/\/+$/, '')
155 if (base !== '' && path === base) return 'home folder'
156 if (base !== '' && path.startsWith(`${base}/`)) return path.slice(base.length + 1).split('/')[0]!
157
158 return path.split('/').pop() || 'unknown'
159}
160
161// A task's project: where most of the files it wrote live, else the folder
162// it ran in. A session started at the dev root that builds inside one repo
163// counts toward that repo. Mods under construction count toward claude-mods.
164export const projectOfTask = (cwd: string, uses: readonly ToolUse[], home = ''): string => {
165 const tally = new Map<string, number>()
166 for (const use of uses) {
167 if (!FILE_WRITES.test(use.tool)) continue
168 for (const path of use.paths) {
169 if (!path.startsWith('/')) continue
170 // Mods under construction belong to claude-mods; scratch files, temp
171 // folders and Claude's own notes and settings belong to no project.
172 if (/\/\.claude\/dev-mods\//.test(path)) {
173 tally.set('claude-mods', (tally.get('claude-mods') ?? 0) + 1)
174 continue
175 }
176 if (/^\/(private|tmp|var)\/|\/Library\/|\/\.claude\//.test(path)) continue
177 const name = projectOf(path.replace(/\/[^/]*$/, ''), home)
178 tally.set(name, (tally.get(name) ?? 0) + 1)
179 }
180 }
181 const best = [...tally.entries()].sort((a, b) => b[1] - a[1])[0]
182
183 return best !== undefined ? best[0] : projectOf(cwd, home)
184}
185
186// "claude-opus-5-5" reads "Opus 5.5"; an id it cannot read is kept as given.
187export const modelName = (id: string): string => {
188 const bare = id.replace(/\[[^\]]*\]/g, '').trim()
189 const older = /claude-(\d)(?:-(\d))?-(opus|sonnet|haiku)/i.exec(bare)
190 if (older) {
191 const family = older[3]!.charAt(0).toUpperCase() + older[3]!.slice(1).toLowerCase()
192
193 return `${family} ${older[1]}${older[2] ? `.${older[2]}` : ''}`
194 }
195 const named = /(opus|sonnet|haiku|fable)-(\d{1,2})(?!\d)(?:-(\d{1,2})(?!\d))?/i.exec(bare)
196 if (named) {
197 const family = named[1]!.charAt(0).toUpperCase() + named[1]!.slice(1).toLowerCase()
198
199 return `${family} ${named[2]}${named[3] ? `.${named[3]}` : ''}`
200 }
201
202 return bare === '' ? 'Unknown model' : bare
203}
204
205// ---------------------------------------------------------------- days
206
207const two = (n: number) => String(n).padStart(2, '0')
208
209export const dayKey = (date: Date): string => `${date.getFullYear()}-${two(date.getMonth() + 1)}-${two(date.getDate())}`
210
211// Local noon of a day. Days are stepped from noon, so a clock change (an hour
212// either way) never lands on the wrong date.
213export const noonOf = (key: string): Date => {
214 const [y, m, d] = key.split('-').map(Number)
215
216 return new Date(y ?? 1970, (m ?? 1) - 1, d ?? 1, 12, 0, 0, 0)
217}
218
219const DAY_MS = 86_400_000
220
221export const addDays = (key: string, n: number): string => dayKey(new Date(noonOf(key).getTime() + n * DAY_MS))
222
223// 1 for January 1.
224export const dayOfYear = (key: string): number =>
225 Math.round((noonOf(key).getTime() - noonOf(`${key.slice(0, 4)}-01-01`).getTime()) / DAY_MS) + 1
226
227// The days a tab covers, oldest first, ending today.
228export const daysOf = (tab: Tab, today: string): string[] => {
229 const count = tab === 'today' ? 1 : tab === 'week' ? 7 : tab === 'month' ? 30 : dayOfYear(today)
230
231 return Array.from({ length: count }, (_, i) => addDays(today, i - (count - 1)))
232}
233
234export const tasksOn = (day: DayRecord | undefined): number =>
235 day === undefined ? 0 : KINDS.reduce((n, kind) => n + (day.kinds[kind] ?? 0), 0)
236
237// What an estimate is worked out from: tasks of each kind, the lines each
238// Building task wrote, and hours added by hand.
239export type Bucket = { kinds: Record<Kind, number>; buildLines: number[]; manualMinutes: number }
240
241export type Totals = Bucket & {
242 tasks: number
243 models: Record<string, number>
244 projects: Record<string, Bucket & { tasks: number }>
245}
246
247const emptyKinds = (): Record<Kind, number> => ({ emails: 0, documents: 0, building: 0, media: 0, research: 0, other: 0 })
248
249const addInto = (bucket: Bucket, kinds: Partial<Record<Kind, number>>, buildLines: readonly number[] | undefined) => {
250 for (const kind of KINDS) bucket.kinds[kind] += kinds[kind] ?? 0
251 if (buildLines) bucket.buildLines.push(...buildLines)
252}
253
254// Adds the records of the given days together, with any hours added by hand
255// on those days.
256export const sumDays = (
257 days: Readonly<Record<string, DayRecord>>,
258 keys: readonly string[],
259 manual: readonly ManualEntry[] = [],
260): Totals => {
261 const totals: Totals = { kinds: emptyKinds(), buildLines: [], manualMinutes: 0, tasks: 0, models: {}, projects: {} }
262 const project = (name: string) =>
263 (totals.projects[name] ??= { kinds: emptyKinds(), buildLines: [], manualMinutes: 0, tasks: 0 })
264 const inRange = new Set(keys)
265 for (const key of keys) {
266 const day = days[key]
267 if (day === undefined) continue
268 addInto(totals, day.kinds, day.buildLines)
269 for (const [model, n] of Object.entries(day.models)) totals.models[model] = (totals.models[model] ?? 0) + n
270 for (const [name, share] of Object.entries(day.projects ?? {})) {
271 const one = project(name)
272 addInto(one, share.kinds, share.buildLines)
273 one.tasks += KINDS.reduce((n, kind) => n + (share.kinds[kind] ?? 0), 0)
274 }
275 }
276 for (const entry of manual) {
277 if (!inRange.has(entry.date)) continue
278 totals.manualMinutes += entry.hours * 60
279 project(entry.project).manualMinutes += entry.hours * 60
280 }
281 totals.tasks = KINDS.reduce((n, kind) => n + totals.kinds[kind], 0)
282
283 return totals
284}
285
286// One finished task added to a day's record, and to its project's share.
287export const addTask = (day: DayRecord | undefined, kind: Kind, model: string, project?: string, lines = 0): DayRecord => {
288 const next: DayRecord = {
289 kinds: { ...(day?.kinds ?? {}), [kind]: (day?.kinds[kind] ?? 0) + 1 },
290 models: { ...(day?.models ?? {}), [model]: (day?.models[model] ?? 0) + 1 },
291 }
292 const buildLines = kind === 'building' ? [...(day?.buildLines ?? []), Math.max(0, Math.round(lines))] : day?.buildLines
293 if (buildLines !== undefined && buildLines.length > 0) next.buildLines = buildLines
294 const projects = { ...(day?.projects ?? {}) }
295 if (project !== undefined && project !== '') {
296 const share: ProjectDay = projects[project] ?? { kinds: {} }
297 projects[project] = {
298 kinds: { ...share.kinds, [kind]: (share.kinds[kind] ?? 0) + 1 },
299 ...(kind === 'building'
300 ? { buildLines: [...(share.buildLines ?? []), Math.max(0, Math.round(lines))] }
301 : share.buildLines
302 ? { buildLines: share.buildLines }
303 : {}),
304 }
305 }
306 if (Object.keys(projects).length > 0) next.projects = projects
307
308 return next
309}
310
311const readCounts = (raw: Record<string, unknown> | undefined): Partial<Record<Kind, number>> => {
312 const kinds: Partial<Record<Kind, number>> = {}
313 for (const kind of KINDS) {
314 const n = raw?.[kind]
315 if (typeof n === 'number' && n > 0) kinds[kind] = Math.round(n)
316 }
317
318 return kinds
319}
320
321const readLines = (raw: unknown): number[] | undefined =>
322 Array.isArray(raw) ? raw.filter((n): n is number => typeof n === 'number' && n >= 0).map(Math.round) : undefined
323
324// Reads a kept day, dropping anything that is not a count.
325export const readDay = (kept: unknown): DayRecord | undefined => {
326 if (kept === null || typeof kept !== 'object') return undefined
327 const raw = kept as { kinds?: Record<string, unknown>; models?: Record<string, unknown>; buildLines?: unknown; projects?: Record<string, unknown> }
328 const day: DayRecord = { kinds: readCounts(raw.kinds), models: {} }
329 for (const [model, n] of Object.entries(raw.models ?? {})) {
330 if (typeof n === 'number' && n > 0) day.models[model] = Math.round(n)
331 }
332 const buildLines = readLines(raw.buildLines)
333 if (buildLines && buildLines.length > 0) day.buildLines = buildLines
334 const projects: Record<string, ProjectDay> = {}
335 for (const [name, share] of Object.entries(raw.projects ?? {})) {
336 if (share === null || typeof share !== 'object') continue
337 const one = share as { kinds?: Record<string, unknown>; buildLines?: unknown }
338 const lines = readLines(one.buildLines)
339 projects[name] = { kinds: readCounts(one.kinds), ...(lines && lines.length > 0 ? { buildLines: lines } : {}) }
340 }
341 if (Object.keys(projects).length > 0) day.projects = projects
342
343 return day
344}
345
346// Days in a row with at least one task, ending today. A day with nothing yet
347// does not break the streak until it is over, so the count runs to yesterday.
348export const streak = (days: Readonly<Record<string, DayRecord>>, today: string): number => {
349 let key = tasksOn(days[today]) > 0 ? today : addDays(today, -1)
350 let n = 0
351 while (tasksOn(days[key]) > 0 && n < 3700) {
352 n += 1
353 key = addDays(key, -1)
354 }
355
356 return n
357}
358
359// ---------------------------------------------------------------- the estimate
360
361// One kind's estimated minutes. Building is sized by the lines each task
362// wrote, never under the floor; the other kinds are flat minutes per task.
363export const kindMinutes = (bucket: Bucket, kind: Kind, settings: Settings): number => {
364 if (kind !== 'building') return bucket.kinds[kind] * settings.minutes[kind]
365 const floor = settings.minutes.building
366 const sized = bucket.buildLines.reduce((n, lines) => n + Math.max(floor, (lines / settings.buildRate) * 60), 0)
367 // Tasks counted before lines were recorded get the floor.
368 const unsized = Math.max(0, bucket.kinds.building - bucket.buildLines.length)
369
370 return sized + unsized * floor
371}
372
373// Everything in a bucket, hours added by hand included.
374export const minutesSaved = (bucket: Bucket, settings: Settings): number =>
375 KINDS.reduce((n, kind) => n + kindMinutes(bucket, kind, settings), 0) + bucket.manualMinutes
376
377// 0.4, 2.5, 12, 140: one decimal below ten, whole hours after.
378export const hoursText = (minutes: number): string => {
379 const hours = minutes / 60
380 if (hours === 0) return '0'
381 if (hours < 10) return String(Math.round(hours * 10) / 10)
382
383 return String(Math.round(hours))
384}
385
386export const moneyText = (minutes: number, rate: number, commas = false): string => {
387 const dollars = Math.round((minutes / 60) * rate)
388
389 return `$${commas ? dollars.toLocaleString('en-US') : String(dollars)}`
390}
391
392// ---------------------------------------------------------------- titles
393
394const MONTHS = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December']
395const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
396
397export const shortDate = (key: string): string => {
398 const date = noonOf(key)
399
400 return `${MONTHS[date.getMonth()]!.slice(0, 3)} ${date.getDate()}`
401}
402
403export const titleOf = (tab: Tab, today: string): { name: string; dates: string } => {
404 const first = daysOf(tab, today)[0] ?? today
405 if (tab === 'today') return { name: 'TODAY', dates: `${WEEKDAYS[noonOf(today).getDay()]}, ${shortDate(today)}` }
406 if (tab === 'week') return { name: 'LAST 7 DAYS', dates: `${shortDate(first)} to ${shortDate(today)}` }
407 if (tab === 'month') return { name: 'LAST 30 DAYS', dates: `${shortDate(first)} to ${shortDate(today)}` }
408
409 return { name: `${today.slice(0, 4)} SO FAR`, dates: `Jan 1 to ${shortDate(today)}` }
410}
411
412export const emptyText = (tab: Tab): string =>
413 tab === 'today'
414 ? 'Nothing finished yet today. Your first finished task shows up here.'
415 : tab === 'week'
416 ? 'Nothing finished in the last 7 days. Your first finished task shows up here.'
417 : tab === 'month'
418 ? 'Nothing finished in the last 30 days. Your first finished task shows up here.'
419 : 'Nothing finished this year. Your first finished task shows up here.'
420
421// ---------------------------------------------------------------- the chart
422
423export type Bar = { label: string; value: number; isCurrent: boolean; isFuture: boolean }
424
425// Today and Week: the last seven days by weekday. Month: thirty daily bars.
426// Year: January through December, the months still to come left empty.
427export const barsOf = (tab: Tab, days: Readonly<Record<string, DayRecord>>, today: string): Bar[] => {
428 if (tab === 'today' || tab === 'week' || tab === 'month') {
429 return daysOf(tab === 'month' ? 'month' : 'week', today).map(key => ({
430 label: WEEKDAYS[noonOf(key).getDay()]!.slice(0, 2),
431 value: tasksOn(days[key]),
432 isCurrent: key === today,
433 isFuture: false,
434 }))
435 }
436 const year = today.slice(0, 4)
437 const month = Number(today.slice(5, 7))
438
439 return MONTHS.map((name, i) => ({
440 label: name.charAt(0),
441 value: Object.entries(days)
442 .filter(([key]) => key.startsWith(`${year}-${two(i + 1)}-`))
443 .reduce((n, [, day]) => n + tasksOn(day), 0),
444 isCurrent: i + 1 === month,
445 isFuture: i + 1 > month,
446 }))
447}
448
449const EIGHTHS = [' ', '▁', '▂', '▃', '▄', '▅', '▆', '▇', '█']
450
451// One character per bar per row, top row first: three rows, 24 steps.
452export const barCells = (value: number, max: number): [string, string, string] => {
453 const steps = max <= 0 || value <= 0 ? 0 : Math.max(1, Math.round((value / max) * 24))
454 const cell = (row: number) => EIGHTHS[Math.max(0, Math.min(8, steps - row * 8))]!
455
456 return [cell(2), cell(1), cell(0)]
457}
458
459// The line under the chart.
460export const summaryOf = (tab: Tab, days: Readonly<Record<string, DayRecord>>, today: string): string => {
461 if (tab === 'today' || tab === 'week') {
462 const n = streak(days, today)
463 if (n === 0) return 'No streak yet. Finish a task today to start one.'
464
465 return n === 1 ? 'Streak: 1 day.' : `Streak: ${n} days in a row.`
466 }
467 const keys = daysOf(tab, today)
468 const active = keys.filter(key => tasksOn(days[key]) > 0).length
469 const activeText = active === 1 ? '1 active day' : `${active} active days`
470 if (active === 0) return 'No active days yet.'
471
472 if (tab === 'month') {
473 const best = keys.reduce((a, b) => (tasksOn(days[b]) >= tasksOn(days[a]) ? b : a))
474 const n = tasksOn(days[best])
475
476 return `Best day ${shortDate(best)}, ${n} ${n === 1 ? 'task' : 'tasks'}. ${activeText}.`
477 }
478 const bars = barsOf('year', days, today)
479 const best = bars.reduce((a, b) => (b.value >= a.value ? b : a))
480 const index = bars.indexOf(best)
481
482 return `Best month ${MONTHS[index]}, ${best.value} ${best.value === 1 ? 'task' : 'tasks'}. ${activeText}.`
483}
484
485// ---------------------------------------------------------------- settings
486
487const SETTING_NAMES: Record<string, 'rate' | 'buildRate' | Kind> = {
488 rate: 'rate',
489 hourly: 'rate',
490 email: 'emails',
491 emails: 'emails',
492 document: 'documents',
493 documents: 'documents',
494 docs: 'documents',
495 building: 'building',
496 build: 'building',
497 code: 'building',
498 buildrate: 'buildRate',
499 lines: 'buildRate',
500 image: 'media',
501 images: 'media',
502 video: 'media',
503 media: 'media',
504 research: 'research',
505 other: 'other',
506}
507
508const NAMES_TEXT = 'rate, buildrate, emails, documents, building, images, research, other'
509
510export const settingsText = (settings: Settings): string =>
511 [
512 'Work Report settings. Hours and dollars are estimates.',
513 ` Hourly rate: $${settings.rate}`,
514 ` Minutes saved per task: ${KINDS.filter(kind => kind !== 'building').map(kind => `${KIND_LABEL[kind]} ${settings.minutes[kind]}`).join(', ')}`,
515 ` Building software: lines of code written ÷ ${settings.buildRate} an hour, at least ${settings.minutes.building} minutes a task`,
516 'Change one with /report settings rate 75, /report settings emails 15 or /report settings buildrate 40.',
517 `Names: ${NAMES_TEXT}.`,
518 ].join('\n')
519
520// `/report settings ...`: lists them, or changes one and says what changed.
521export const applySetting = (args: string, settings: Settings): { settings?: Settings; text: string } => {
522 const words = args.trim().split(/\s+/).filter(Boolean)
523 if (words.length === 0) return { text: settingsText(settings) }
524
525 const name = SETTING_NAMES[words[0]!.toLowerCase()]
526 if (name === undefined) {
527 return { text: `There is no setting called "${words[0]}". Use ${NAMES_TEXT}.` }
528 }
529 const raw = (words[1] ?? '').replace(/^\$/, '')
530 const value = Number(raw)
531 const [low, cap] = name === 'rate' ? [0, 10000] : name === 'buildRate' ? [1, 1000] : [0, 480]
532 if (raw === '' || !Number.isFinite(value) || value < low || value > cap) {
533 return {
534 text:
535 name === 'rate'
536 ? 'Give the hourly rate as a number from 0 to 10000, like /report settings rate 75.'
537 : name === 'buildRate'
538 ? 'Give the lines of code an hour as a number from 1 to 1000, like /report settings buildrate 40.'
539 : `Give the minutes as a number from 0 to 480, like /report settings ${words[0]!.toLowerCase()} 15.`,
540 }
541 }
542 const rounded = Math.round(value * 100) / 100
543
544 if (name === 'rate') {
545 return { settings: { ...settings, rate: rounded }, text: `Hourly rate is now $${rounded}. The report uses it from now on, for every day.` }
546 }
547 if (name === 'buildRate') {
548 return {
549 settings: { ...settings, buildRate: rounded },
550 text: `Building software is now estimated at ${rounded} lines of code an hour, for every day.`,
551 }
552 }
553 if (name === 'building') {
554 return {
555 settings: { ...settings, minutes: { ...settings.minutes, building: rounded } },
556 text: `Every Building software task now counts as at least ${rounded} minutes, for every day.`,
557 }
558 }
559
560 return {
561 settings: { ...settings, minutes: { ...settings.minutes, [name]: rounded } },
562 text: `${KIND_LABEL[name]} now count as ${rounded} minutes saved per task, for every day.`,
563 }
564}
565
566// ---------------------------------------------------------------- hours added by hand
567
568const DATE = /^\d{4}-\d{2}-\d{2}$/
569
570// `/report add "QBR builder" 160 2026-05-01`: a project (quoted, or the words
571// before the hours), the hours, and an optional date (today when left out).
572export const parseAdd = (args: string, today: string): { entry?: Omit<ManualEntry, 'id'>; text?: string } => {
573 const usage = 'Try /report add "QBR builder" 160 2026-05-01: the project, the hours, and a date if it was not today.'
574 const quoted = /^\s*["“]([^"”]+)["”]\s*(.*)$/.exec(args)
575 let project: string
576 let rest: string[]
577 if (quoted) {
578 project = quoted[1]!.trim()
579 rest = quoted[2]!.trim().split(/\s+/).filter(Boolean)
580 } else {
581 const words = args.trim().split(/\s+/).filter(Boolean)
582 const at = words.findIndex(word => /^\d+(\.\d+)?h?$/i.test(word))
583 if (at <= 0) return { text: usage }
584 project = words.slice(0, at).join(' ')
585 rest = words.slice(at)
586 }
587 const hours = Number((rest[0] ?? '').replace(/h$/i, ''))
588 if (project === '' || !Number.isFinite(hours) || hours <= 0 || hours > 5000) return { text: usage }
589 const date = rest[1] ?? today
590 if (!DATE.test(date) || dayKey(noonOf(date)) !== date) return { text: 'Give the date as year-month-day, like 2026-05-01.' }
591 if (date > today) return { text: 'That date is in the future. Add hours for work already done.' }
592
593 return { entry: { project: project.slice(0, 60), hours: Math.round(hours * 10) / 10, date } }
594}
595
596export const readManual = (kept: unknown): ManualEntry[] =>
597 Array.isArray(kept)
598 ? kept.filter(
599 (one): one is ManualEntry =>
600 typeof one === 'object' &&
601 one !== null &&
602 typeof one.id === 'string' &&
603 typeof one.project === 'string' &&
604 typeof one.hours === 'number' &&
605 typeof one.date === 'string' &&
606 DATE.test(one.date),
607 )
608 : []
609
610export const manualListText = (list: readonly ManualEntry[]): string =>
611 list.length === 0
612 ? 'No hours added by hand yet. Add one with /report add "QBR builder" 160 2026-05-01.'
613 : [
614 'Hours you added by hand (estimates):',
615 ...list.map((one, i) => ` ${i + 1}. ${one.project}: ${one.hours} h on ${one.date}`),
616 'Remove one with /report remove 2.',
617 ].join('\n')
618
619// ---------------------------------------------------------------- by project
620
621export type ProjectRow = { name: string; minutes: number; tasks: number }
622
623// Projects by estimated time, the top ones by name and the rest together.
624export const projectRows = (totals: Totals, settings: Settings, top = 8): ProjectRow[] => {
625 const rows = Object.entries(totals.projects)
626 .map(([name, bucket]) => ({ name, minutes: minutesSaved(bucket, settings), tasks: bucket.tasks }))
627 .filter(row => row.minutes > 0 || row.tasks > 0)
628 .sort((a, b) => b.minutes - a.minutes || b.tasks - a.tasks)
629 if (rows.length <= top) return rows
630 const rest = rows.slice(top - 1)
631
632 return [
633 ...rows.slice(0, top - 1),
634 { name: `${rest.length} other projects`, minutes: rest.reduce((n, row) => n + row.minutes, 0), tasks: rest.reduce((n, row) => n + row.tasks, 0) },
635 ]
636}
637
638// ---------------------------------------------------------------- drawing helpers
639
640export const clip = (text: string, width: number): string =>
641 width <= 0 ? '' : text.length <= width ? text : `${text.slice(0, Math.max(0, width - 1))}…`
642
643// Breaks text into lines no wider than `width`, so nothing is left to wrap.
644export const wrap = (text: string, width: number): string[] => {
645 const room = Math.max(1, width)
646 const lines: string[] = []
647 let line = ''
648 for (const word of text.split(/\s+/).filter(Boolean)) {
649 let rest = word
650 while (rest.length > room) {
651 if (line !== '') lines.push(line)
652 line = ''
653 lines.push(rest.slice(0, room))
654 rest = rest.slice(room)
655 }
656 if (line === '') line = rest
657 else if (line.length + 1 + rest.length <= room) line = `${line} ${rest}`
658 else {
659 lines.push(line)
660 line = rest
661 }
662 }
663 if (line !== '') lines.push(line)
664
665 return lines
666}
667
668// Characters three rows tall in heavy box-drawing strokes.
669const GLYPHS: Record<string, [string, string, string]> = {
670 '0': ['┏━┓', '┃ ┃', '┗━┛'],
671 '1': ['╺┓ ', ' ┃ ', '╺┻╸'],
672 '2': ['╺━┓', '┏━┛', '┗━╸'],
673 '3': ['╺━┓', ' ━┫', '╺━┛'],
674 '4': ['╻ ╻', '┗━┫', ' ╹'],
675 '5': ['┏━╸', '┗━┓', '╺━┛'],
676 '6': ['┏━╸', '┣━┓', '┗━┛'],
677 '7': ['╺━┓', ' ┃', ' ╹'],
678 '8': ['┏━┓', '┣━┫', '┗━┛'],
679 '9': ['┏━┓', '┗━┫', '╺━┛'],
680 '.': [' ', ' ', '▪'],
681 $: ['┏╋╸', '┗╋┓', '╺╋┛'],
682}
683
684export const bigText = (text: string): [string, string, string] => {
685 const chars = text.split('').filter(char => GLYPHS[char] !== undefined)
686 const rows: [string, string, string] = ['', '', '']
687 for (const row of [0, 1, 2] as const) rows[row] = chars.map(char => GLYPHS[char]![row]).join(' ')
688
689 return rows
690}
691
692// How many tiles sit side by side: three, then two, then one.
693export const tilesPerRow = (width: number, widest: number, count = 3): number => {
694 for (let n = count; n > 1; n--) {
695 if (n * widest + (n - 1) <= width) return n
696 }
697
698 return 1
699}
700
701// ---------------------------------------------------------------- history
702
703// One finished request as scripts/history.py prints it: when, the model, its
704// tool calls (name, paths, lines written) and the folder it ran in.
705export type PastTask = { t: number; m: string; u: [string, string[], number?][]; c?: string }
706
707export const readHistory = (text: string): PastTask[] => {
708 const raw: unknown = JSON.parse(text)
709 if (!Array.isArray(raw)) return []
710
711 return raw.filter(
712 (task): task is PastTask =>
713 typeof task === 'object' && task !== null && typeof task.t === 'number' && typeof task.m === 'string' && Array.isArray(task.u),
714 )
715}
716
717// The past requests counted into day records, sorted, sized and named by
718// project as live ones are.
719export const historyDays = (tasks: readonly PastTask[], home = ''): Record<string, DayRecord> => {
720 const days: Record<string, DayRecord> = {}
721 for (const task of tasks) {
722 const key = dayKey(new Date(task.t))
723 const uses: ToolUse[] = task.u.map(([tool, paths, lines]) => ({
724 tool: String(tool),
725 paths: Array.isArray(paths) ? paths.map(String) : [],
726 lines: typeof lines === 'number' ? lines : 0,
727 }))
728 const kind = kindOf(uses)
729 const project = typeof task.c === 'string' && task.c !== '' ? projectOfTask(task.c, uses, home) : undefined
730 days[key] = addTask(days[key], kind, modelName(task.m), project, kind === 'building' ? taskLines(uses) : 0)
731 }
732
733 return days
734}
735hooks/desktop.ts 182 lines1// The desktop app's drawing. It sets text in a proportional font, so rows
2// sized in character cells drift; the header, the tiles, the breakdowns and
3// the chart are drawn here as SVG instead, sized in pixels.
4
5import type { Kind, Settings, Tab } from '../types'
6import { KINDS, KIND_LABEL, hoursText, kindMinutes, minutesSaved, moneyText, projectRows } from './logic'
7import type { Bar, Totals } from './logic'
8
9const GOLD = '#F2C46D'
10const LINE = '#262B33'
11const MUTED = '#7D8590'
12const TEXT = '#E6EDF3'
13const GREEN = '#3DD68C'
14
15const SANS = `-apple-system, BlinkMacSystemFont, 'SF Pro Text', Inter, 'Segoe UI', sans-serif`
16
17export type Drawing = { source: string; alt: string }
18
19const esc = (text: string) => text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
20
21// The pixel width to draw at. The desktop reports fewer columns than it
22// shows; a column is about 13 pixels there. An SVG drawn wider than the pane
23// is scaled down to fit, never cut.
24export const pixelsFor = (columns: number): number => Math.max(260, Math.round(columns * 13))
25
26// About how wide a run of text sets, for laying things beside it.
27const textWidth = (text: string, size: number, isBold = false) => text.length * size * (isBold ? 0.64 : 0.56)
28
29const svg = (width: number, height: number, body: string) =>
30 `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" font-family="${esc(SANS)}">${body}</svg>`
31
32const label = (x: number, y: number, text: string, size: number, fill: string, extra = '') =>
33 `<text x="${x}" y="${y}" font-size="${size}" fill="${fill}"${extra}>${esc(text)}</text>`
34
35// A small muted heading in capitals, then a hairline to the edge.
36const heading = (width: number, y: number, text: string) => {
37 const caps = text.toUpperCase()
38 const end = Math.min(width, caps.length * 11 * 0.74 + 12)
39
40 return (
41 label(0, y, caps, 11, MUTED, ` letter-spacing="0.08em" font-weight="600"`) +
42 (end < width ? `<line x1="${end.toFixed(1)}" y1="${y - 4}" x2="${width}" y2="${y - 4}" stroke="${LINE}" stroke-width="1"/>` : '')
43 )
44}
45
46// The title in bold capitals, its dates on the right, and under it a gold
47// rule as wide as the title that runs on as a dim hairline to the edge.
48export const headerSvg = (width: number, name: string, dates: string): Drawing => {
49 const titleWidth = Math.min(width, textWidth(name, 16, true) * 1.08)
50 const hasDates = titleWidth + 16 + textWidth(dates, 13) <= width
51 const body =
52 `<text x="0" y="20" font-size="16" font-weight="700" letter-spacing="0.06em" fill="${TEXT}" textLength="${titleWidth.toFixed(1)}" lengthAdjust="spacing">${esc(name)}</text>` +
53 (hasDates ? label(width, 20, dates, 13, MUTED, ` text-anchor="end"`) : '') +
54 `<line x1="0" y1="33" x2="${titleWidth.toFixed(1)}" y2="33" stroke="${GOLD}" stroke-width="2"/>` +
55 `<line x1="${titleWidth.toFixed(1)}" y1="33" x2="${width}" y2="33" stroke="${LINE}" stroke-width="1"/>`
56
57 return { source: svg(width, 38, body), alt: `${name}, ${dates}` }
58}
59
60const TILE_MIN = 168
61const TILE_GAP = 12
62
63export const tilesAcross = (width: number): number => {
64 for (let n = 3; n > 1; n--) if (n * TILE_MIN + (n - 1) * TILE_GAP <= width) return n
65
66 return 1
67}
68
69// Three rounded tiles with big numerals; fewer side by side as it narrows.
70// A number too wide for the big size is set smaller, still bold.
71export const tilesSvg = (width: number, totals: Totals, settings: Settings): Drawing => {
72 const minutes = minutesSaved(totals, settings)
73 const tiles = [
74 { big: hoursText(minutes), plain: `${hoursText(minutes)} h`, label: 'EST. HOURS SAVED', color: GOLD },
75 { big: totals.tasks.toLocaleString('en-US'), plain: totals.tasks.toLocaleString('en-US'), label: 'TASKS DONE', color: TEXT },
76 { big: moneyText(minutes, settings.rate, true), plain: moneyText(minutes, settings.rate, true), label: 'EST. VALUE', color: GREEN },
77 ]
78 const across = tilesAcross(width)
79 const tileWidth = Math.floor((width - (across - 1) * TILE_GAP) / across)
80 const tileHeight = 96
81 const room = tileWidth - 32
82 let body = ''
83 tiles.forEach((tile, i) => {
84 const x = (i % across) * (tileWidth + TILE_GAP)
85 const y = Math.floor(i / across) * (tileHeight + TILE_GAP)
86 const isBig = textWidth(tile.big, 38, true) <= room
87 const size = isBig ? 38 : Math.max(14, Math.min(24, Math.floor(room / (tile.plain.length * 0.64))))
88 body +=
89 `<rect x="${x + 0.5}" y="${y + 0.5}" width="${tileWidth - 1}" height="${tileHeight - 1}" rx="12" fill="none" stroke="${LINE}"/>` +
90 `<text x="${x + 16}" y="${y + 54}" font-size="${size}" font-weight="700" fill="${tile.color}" style="font-variant-numeric: tabular-nums">${esc(isBig ? tile.big : tile.plain)}</text>` +
91 label(x + 16, y + 78, tile.label, 11, MUTED, ` letter-spacing="0.08em" font-weight="600"`)
92 })
93 const rows = Math.ceil(tiles.length / across)
94 const alt = tiles.map(tile => `${tile.label.toLowerCase()} ${tile.plain}`).join(', ')
95
96 return { source: svg(width, rows * tileHeight + (rows - 1) * TILE_GAP, body), alt }
97}
98
99export type BarRow = { label: string; value: number; color: string; figures: string[] }
100
101// A heading, then one row per item: its name, a colored bar on a dim track,
102// and its figures right-aligned.
103export const barRowsSvg = (width: number, title: string, rows: BarRow[]): Drawing => {
104 const labelWidth = Math.min(170, Math.round(width * 0.32))
105 const figureWidth = rows[0] !== undefined && rows[0].figures.length > 1 ? 112 : 44
106 const trackStart = labelWidth
107 const trackEnd = width - figureWidth - 12
108 const max = Math.max(0, ...rows.map(row => row.value))
109 let body = heading(width, 12, title)
110 rows.forEach((row, i) => {
111 const y = 36 + i * 26
112 const isZero = row.value === 0
113 const track = Math.max(0, trackEnd - trackStart)
114 const filled = max <= 0 || isZero ? 0 : Math.max(4, (row.value / max) * track)
115 body += label(0, y, row.label, 13, isZero ? MUTED : TEXT)
116 if (track >= 24) {
117 body += `<rect x="${trackStart}" y="${y - 7}" width="${track.toFixed(1)}" height="5" rx="2.5" fill="${LINE}"/>`
118 if (filled > 0) body += `<rect x="${trackStart}" y="${y - 7}" width="${filled.toFixed(1)}" height="5" rx="2.5" fill="${row.color}"/>`
119 }
120 // The last figure sits at the edge; any before it, 64 pixels in from there.
121 row.figures.forEach((figure, j) => {
122 const x = width - (row.figures.length - 1 - j) * 64
123 body += label(x, y, figure, 13, isZero ? MUTED : TEXT, ` text-anchor="end" style="font-variant-numeric: tabular-nums"`)
124 })
125 })
126 const alt = `${title}: ${rows.map(row => `${row.label} ${row.figures.join(' ')}`).join(', ')}`
127
128 return { source: svg(width, 36 + rows.length * 26 - 12, body), alt }
129}
130
131export const kindRows = (totals: Totals, settings: Settings, colors: Record<Kind, string>): BarRow[] =>
132 KINDS.map(kind => ({
133 label: KIND_LABEL[kind],
134 value: kindMinutes(totals, kind, settings),
135 color: colors[kind],
136 figures: [String(totals.kinds[kind]), `${hoursText(kindMinutes(totals, kind, settings))} h`],
137 }))
138
139// One row per project: its tasks and estimated hours, hours added by hand
140// included.
141export const projectBarRows = (totals: Totals, settings: Settings, colors: readonly string[]): BarRow[] =>
142 projectRows(totals, settings).map((row, i) => ({
143 label: row.name,
144 value: row.minutes,
145 color: colors[i % colors.length]!,
146 figures: [String(row.tasks), `${hoursText(row.minutes)} h`],
147 }))
148
149// The chart: a heading, bars on a plot 64 pixels tall, labels under them.
150// The current day or month is gold; months still to come are left empty.
151export const chartSvg = (width: number, tab: Tab, title: string, bars: Bar[], first: string): Drawing => {
152 const slot = width / bars.length
153 const barWidth = Math.max(2, Math.min(28, slot * 0.62))
154 const max = Math.max(0, ...bars.map(bar => bar.value))
155 const base = 92
156 const plot = 64
157 let body = heading(width, 12, title)
158 bars.forEach((bar, i) => {
159 const x = i * slot + (slot - barWidth) / 2
160 if (bar.isFuture) return
161 if (bar.value === 0) {
162 body += `<rect x="${x.toFixed(1)}" y="${base - 2}" width="${barWidth.toFixed(1)}" height="2" rx="1" fill="${LINE}"/>`
163
164 return
165 }
166 const tall = Math.max(4, (bar.value / max) * plot)
167 const fill = bar.isCurrent ? GOLD : MUTED
168 body += `<rect x="${x.toFixed(1)}" y="${(base - tall).toFixed(1)}" width="${barWidth.toFixed(1)}" height="${tall.toFixed(1)}" rx="${Math.min(3, barWidth / 2).toFixed(1)}" fill="${fill}"${bar.isCurrent ? '' : ' fill-opacity="0.6"'}/>`
169 })
170 if (tab === 'month') {
171 body += label(0, base + 18, first, 11, MUTED) + label(width, base + 18, 'Today', 11, GOLD, ` text-anchor="end"`)
172 } else {
173 bars.forEach((bar, i) => {
174 const x = i * slot + slot / 2
175 body += label(Number(x.toFixed(1)), base + 18, bar.label, 11, bar.isCurrent ? GOLD : MUTED, ` text-anchor="middle"`)
176 })
177 }
178 const alt = `${title}: ${bars.filter(bar => !bar.isFuture).map(bar => `${bar.label} ${bar.value}`).join(', ')}`
179
180 return { source: svg(width, base + 24, body), alt }
181}
182types/index.d.ts 46 lines1export type Kind = 'emails' | 'media' | 'building' | 'documents' | 'research' | 'other'
2
3// One project's share of a day: tasks of each kind, and the lines of code
4// each Building task wrote (one number per task, so the size estimate can be
5// worked out again when the settings change).
6export type ProjectDay = {
7 kinds: Partial<Record<Kind, number>>
8 buildLines?: number[]
9}
10
11// One calendar day: how many tasks of each kind, how many each model did,
12// the lines each Building task wrote, and the same split by project.
13export type DayRecord = {
14 kinds: Partial<Record<Kind, number>>
15 models: Record<string, number>
16 buildLines?: number[]
17 projects?: Record<string, ProjectDay>
18}
19
20// Hours the person added by hand for work older than the session history.
21export type ManualEntry = {
22 id: string
23 project: string
24 hours: number
25 date: string
26}
27
28export type Settings = {
29 rate: number
30 minutes: Record<Kind, number>
31 buildRate: number
32}
33
34export type Tab = 'today' | 'week' | 'month' | 'year'
35
36declare module 'claude-code' {
37 interface PluginState {
38 'work-report': {
39 days: Record<string, DayRecord>
40 settings: Settings
41 tab: Tab
42 manual: ManualEntry[]
43 }
44 }
45}
46