SLOPSHOPPER

work-report

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

newpanespinnerguardcommandtoast
v0.4.0no licenseupdated 2026-10-15iansutherland75/claude-mods/mods/work-report
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · work-report
│ ┃ Work Report ✕ › fix the failing auth test and add an audit log call │ ┃ tab-today tab-week tab-month tab-year │ ┃ ⏺ Read(src/auth.ts) │ ┃ TODAY ⎿ Read 6 lines │ ┃ ━━━━━─────────────────────────────────────── ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ ╭─────────────────────────╮ ╭─────────────── ⏺ Bash(bun test) │ ┃ │ ┏━┓ ┏━╸ │ │ ╺┓ ⎿ 3 pass, 1 fail │ ┃ │ ┃ ┃ ┣━┓ │ │ ┃ │ ┃ │ ┗━┛ ▪ ┗━┛ │ │ ╺┻╸ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ EST. HOURS SAVED │ │ TASKS DONE │ ┃ ╰─────────────────────────╯ ╰─────────────── ✻ Worked for 42s · done 4:20 PM │ ┃ ╭─────────────────────────╮ │ ┃ │ ┏╋╸ ╺━┓ ╺━┓ │ › /report │ ┃ │ ┗╋┓ ━┫ ┏━┛ │ ⎿ work-report: Work Report is open. Hours and dollars are estimate │ ┃ │ ╺╋┛ ╺━┛ ┗━╸ │ │ ┃ │ EST. VALUE │ │ ┃ ╰─────────────────────────╯ │ ┃ Estimates: minutes saved per task, building │ ┃ 25 lines of code an hour, at $50 an hour. │ ┃ │ ┃ WHERE THE TIME WENT · EST. HOURS ─────────── │ ┃ Emails ─────────────────────────── │ ┃ Documents ─────────────────────────── │ ┃ Building softwa… ━━━━━━━━━━━━━━━━━━━━━━━━━━━ │ ┃ Images and video ─────────────────────────── ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⟨Claude Code's own drawing⟩ open-work-report

Draws

Pane · Work Report
tab-today tab-week tab-month tab-year TODAY Thu, Oct 9 ━━━━━─────────────────────────────────────────────────── ╭─────────────────────────╮ ╭─────────────────────────╮ │ ┏━┓ ┏━╸ │ │ ╺┓ │ │ ┃ ┃ ┣━┓ │ │ ┃ │ │ ┗━┛ ▪ ┗━┛ │ │ ╺┻╸ │ │ EST. HOURS SAVED │ │ TASKS DONE │ ╰─────────────────────────╯ ╰─────────────────────────╯ ╭─────────────────────────╮ │ ┏╋╸ ╺━┓ ╺━┓ │ │ ┗╋┓ ━┫ ┏━┛ │ │ ╺╋┛ ╺━┛ ┗━╸ │ │ EST. VALUE │ ╰─────────────────────────╯ Estimates: minutes saved per task, building software at 25 lines of code an hour, at $50 an hour. WHERE THE TIME WENT · EST. HOURS ─────────────────────── Emails ─────────────────────────────── 0 0h Documents ─────────────────────────────── 0 0h Building softwa… ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 1 0.6h Images and video ─────────────────────────────── 0 0h Research ─────────────────────────────── 0 0h Other ─────────────────────────────── 0 0h BY PROJECT · EST. HOURS ──────────────────────────────── src ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 1 0.6h WHO DID THE WORK ─────────────────────────────────────── Opus 5.5 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 1 THE LAST 7 DAYS ──────────────────────────────────────── ███████ ███████ ▁▁▁▁▁▁▁ ▁▁▁▁▁▁▁ ▁▁▁▁▁▁▁ ▁▁▁▁▁▁▁ ▁▁▁▁▁▁▁ ▁▁▁▁▁▁▁ ███████ Fr Sa Su Mo Tu We Th Streak: 1 day. close
README

claude-mods

Ian's Claude Code mods. The repo is a plugin marketplace called ian-mods, and each mod lives in its own folder under mods/.

ModWhat 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-kitClean 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.

Set up a machine

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.

Keep machines in step

The marketplace is a local folder, so sessions read each mod straight from ~/claude-mods. Nothing needs reinstalling after a change.

  1. git pull in ~/claude-mods.
  2. Run /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.

Add a mod

  1. Put it in mods/<mod-name>/ with its .claude-plugin/plugin.json, hooks/ and tests.
  2. Add an entry to .claude-plugin/marketplace.json with "source": "./mods/<mod-name>".
  3. Check it: claude plugin validate mods/<mod-name> and claude plugin test mods/<mod-name>.
  4. Commit, push, then install it on each machine.

Not covered

Cloud sessions (claude.ai/code, cloud Cowork) do not see mods installed on a machine.

Source 4 files
hooks/register.tsx 812 lines
1import 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}
812
hooks/logic.ts 735 lines
1import 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}
735
hooks/desktop.ts 182 lines
1// 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
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}
182
types/index.d.ts 46 lines
1export 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