SLOPSHOPPER

work-report

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

newpanebandguardcommandtoast
v0.2.0MITupdated 2026-10-14afterever/claude-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 │ ┃ WORK REPORT │ ┃ ━━━━━━━━━━━───────────────────────────────── ⏺ Read(src/auth.ts) │ ┃ ───────────────────────────────── ⎿ 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. │ ┃ ───────────────────────────────── │ ┃ ──── ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ t: tab-today w: tab-week m: tab-month y: tab › /report │ ┃ ⎿ work-report: Opening the work report. Hours and dollars in it ar │ ┃ TODAY Thu 9 Oct 2025 │ ┃ ──────────────────────────────────────────── │ ┃ ──────────────────────────────────────────── │ ┃ ──────────────────────────────────────────── │ ┃ ──────────────────────────────────────────── │ ┃ ──────────────────────────────────────────── │ ┃ ──────────────────────────────────────────── │ ┃ ──────────────────────────────────────────── │ ┃ ──────────────────────────────────────────── │ ┃ ──────────────────────────────────────────── │ ┃ ──── work-report-open 0 tasks today ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
work-report-open 0 tasks today ⟨Claude Code's own drawing⟩
Pane · Work Report
WORK REPORT ━━━━━━━━━━━───────────────────────────────────────────────── ───────────────────────────────────────────────── ───────────────────────────────────────────────── ───────────────────────────────────────────────── ───────────────────────────────────────────────── ───────────────────────────────────────────────── ──────────────────────────────────────── t: tab-today w: tab-week m: tab-month y: tab-year TODAY Thu 9 Oct 2025 ──────────────────────────────────────────────────────────── ──────────────────────────────────────────────────────────── ──────────────────────────────────────────────────────────── ──────────────────────────────────────────────────────────── ──────────────────────────────────────────────────────────── ──────────────────────────────────────────────────────────── ──────────────────────────────────────── ╭──────────────────────────────────────────────────────────╮ │ Nothing finished yet today. Your first finished task │ │ shows up here. │ ╰──────────────────────────────────────────────────────────╯
README

work-report

A Claude Code mod that shows what Claude has done for you: how many tasks it finished, an estimate of the hours that saved you, and what that time is worth. /report opens a pane with four tabs: Today, Week, Month and Year.

Hours and dollars are always estimates: the minutes you say a task saves, times your hourly rate. The mod never claims more than that.

Use it

CommandWhat it does
/reportOpens the pane.
/report today, week, month, yearOpens the pane on that tab.
/report settingsLists the minutes saved per kind of task and the hourly rate.
/report settings rate 75Sets the hourly rate to 75.
/report settings emails 15Sets the minutes one email task saves to 15. Also code, documents, images, research and other.
/report helpOne line of usage.

A Work report chip above the prompt, with today's count beside it, opens the pane too. Claude Code has no pressable entry in the status line itself, so the button sits in the band above the prompt and keeps whatever the other mods draw there beneath it.

The pane

Four tabs across the top, the chosen one a gold chip with dark text. The stretch and its dates sit in a title bar under them.

TabCoversChart (three rows tall)Line under the chart
Todaytodaythe last seven days by weekdaythe streak
Weekthe last 7 daysthe last seven days by weekdaythe streak
Monththe last 30 daysthirty daily barsbest day, active days
YearJanuary 1 through todayJanuary to December, this month gold, months to come emptybest month, active days

Under the title bar:

  1. Three tiles with big numerals: estimated hours saved, tasks done, estimated worth in dollars. A number too wide for big numerals is shown as plain bold text in its tile.
  2. Where the time went: one row per kind that has a task in the stretch, biggest first and Other last, with a coloured bar, the count and the estimated hours. A coder sees Code and Research; someone who makes videos sees Images and video and Documents. There is nothing to set: the rows follow what you did.
  3. Who did the work: one row per model, with a bar and the count. More than five models: the rest are added up as "Others".
  4. The chart, then one summary line.

When there is nothing in the stretch yet, the pane says so in one calm sentence: Nothing finished yet today. Your first finished task shows up here. On the other tabs today becomes in the last 7 days, in the last 30 days or this year.

Narrow panes

Every row is sized to the pane's width, so nothing wraps. As the pane narrows:

  • the three tiles become two, then one; the figures that lost their tile are said in one plain line under the tiles
  • long labels shorten (Images and video becomes Images, Documents becomes Docs) and the hours column drops
  • sentences swap for a shorter wording, and are cut with an ellipsis only as a last resort
  • Month's thirty bars merge in pairs under 30 columns

What counts as a task

One typed request that finishes with an answer is one task.

  • Counted: a prompt you typed at the prompt, a message sent from the phone, and an upgraded prompt another mod sent as your own words (for example prompt-upgrade after you pressed Run).
  • Not counted: helper agents and their steps, tool calls, cancelled or failed turns, slash commands, background notifications, scheduled prompts, other sessions' messages and claude -p runs.

How a task is sorted

By the tools it used. A task is one kind. Each kind has strong rules and some have weak ones, which count only when no strong rule matched.

Strong rules. When a task touched several kinds, the first of these wins:

#KindWhat puts a task there
1CodeWriting a source or config file with Edit, Write, MultiEdit or NotebookEdit (.ts, .py, .go, .rs, .json, .yml, .html, .css, .sql and so on).
2EmailsAny mail tool: Gmail, Outlook, IMAP, SMTP and the like.
3Images and videoAn image or video tool; writing an image or video file (.png, .jpg, .svg, .mp4, .mov and so on); or running ffmpeg, magick, yt-dlp and the like.
4DocumentsA document, slide, spreadsheet or PDF tool that writes (update, create, append, batch and so on); or writing a document file (.md, .docx, .pdf, .pptx, .xlsx, .csv and so on).
5ResearchWeb search, fetching a page, reading a page in the browser, transcripts; or a document tool that only looks something up (read_doc, query-docs, search_apify_docs, get_values).

Weak rules, tried in this order, only when no strong rule matched:

#KindWhat puts a task there
6CodeRunning a command with Bash or PowerShell (builds, tests, git), or writing a file nothing recognised (Makefile, Dockerfile, .env, .gitignore).
7Images and videoOnly looking at an image file with Read: a screenshot.
8ResearchOnly reading or searching your own files (Read, Grep, Glob), or using the browser (navigating, clicking).
9OtherEverything else, including a task that used no tools.

What that means in practice:

  • A source file that was written wins. A change to code and its README is Code; code plus an email sent along the way is Code; code plus a logo written into the project is Code.
  • Commands are weak. A web search that ran ls is Research. A task that read a file and then ran its tests is Code.
  • A screenshot is only a weak signal. Reading a screenshot to debug a UI and then changing the code is Code. Reading it and nothing else is Images and video.
  • A documents tool is judged by what it does, from its name: a lookup is Research, a write is Documents, and a name that says neither is Documents.
  • A command's own words are read only to tell media work from the rest (ffmpeg is video, git is not). The command text is held in memory until the turn ends and is never stored.

The tools of helper agents working inside your request count toward its kind. The model that finished the turn is recorded by its short name ("Sonnet 5.5"), not its id.

Only counts are stored, not the tools, so tasks finished before these rules existed stay where they were counted (mostly Other and Research). New tasks are sorted by the rules above.

The estimate

KindMinutes saved per task
Emails10
Code20
Documents30
Images and video20
Research20
Other10

Hourly rate: 50. Hours are minutes divided by 60; dollars are hours times the rate. Change any of them with /report settings; they are kept between sessions. A kind has one figure whichever rule put a task there, so Code and Research are averages: Code covers a small edit and a long session, Research a web investigation and a quick look through your files. Set them to what a typical task of that kind is worth to you (/report settings code 40).

The streak

Days in a row with at least one task, counted back from today. A day that has not had its first task yet does not break the streak: it counts from yesterday until today has one.

What is stored

Only counts, in the mod's own store: one small record per calendar day (day:2026-10-13, the count of each kind and the count per model) and one settings record. No prompt text, no file names and no answers are kept. Nothing is written inside the mod's folder.

A day's record is read back from the store before each task is added, so two sessions open at once do not overwrite each other's counts.

Cost

None. The mod makes no model calls: it listens to events and counts. Recording a task shows nothing; it speaks up only when you ask for the pane and the window is too narrow to open it, or a button press fails.

Install

From a terminal session:

claude plugin marketplace add afterever/claude-mods
claude plugin install work-report@afterever-mods

Restart Claude Code to apply. To try it from a checkout without installing:

claude --plugin-dir <path to claude-mods>\work-report

How it is built

  • A task is claimed, not guessed. prompt.submit arms a typed prompt, turn.start claims it by its words, and turn.complete records it only if the turn ended with an answer in the main loop. A turn nobody typed is never claimed, so it is never counted.
  • State lives in the host's $.state (the days, the settings, the tab), so a hot reload keeps it; the days and settings are also kept in the mod's own $.store, across sessions. A draw only reads; every change comes from a button, a command or an event. The contract is types/index.d.ts.
  • Days are stepped at local noon. Adding 24 hours to midnight lands on the wrong date across a clock change; noon never does. Today, Week, Month and Year are lists of noons, and the day of the year is counted between two of them.
  • Rows are built to the width. In a terminal the bars and the chart need whole cells, so each row is made to the width the pane reports less one cell, in a one-line box that clips rather than wraps, and the width tests below hold every row to it.
  • On the desktop the figures are drawings. The tiles, the bars and the chart are each one SVG, because block and box-drawing characters come from whatever font the page falls back to and then do not line up. A drawing places every mark by its own coordinates: the bars start and end in the same place on every row, and the figures are right-aligned at fixed edges. The headings, rules, note and summary stay text. The terminal, which is always monospace, keeps its text rows.
  • Only the heavy box-drawing set is used for the big numerals in a terminal; the half-line glyphs (╺ ╸ ╻ ╹) are missing from many fonts. The decimal point is a plain . on the bottom row.
  • The command answers first. /report returns its sentence at once and opens the pane in the background; every form of it is wrapped so a failure comes back as a plain sentence.

Layout

.claude-plugin/plugin.json    name, version, state contract
hooks/hooks.json              points at register.tsx
hooks/register.tsx            events, commands, the pane and the button
hooks/view.tsx                the pane, drawn from state
hooks/layout.ts               widths, big numerals, tiles, bars, the chart's text
hooks/svg.ts                  the tiles, bars and chart as drawings, for the desktop
hooks/report.ts               totals, the estimate, the streak, chart bars, model names
hooks/dates.ts                local-noon day stepping, ranges, the day of the year
hooks/kinds.ts                sorting a task by its tools
hooks/record.ts               which prompts count, and claiming a turn
hooks/*.test.ts               kinds, typed prompts, dates, totals, layout, drawings and the full flow
types/index.d.ts              the mod's $.state contract

Develop

claude plugin validate <path to claude-mods>
claude plugin test <path to claude-mods>\work-report

The tests make no model calls and cost nothing. They cover sorting a task by its tools, counting only typed prompts, the streak, adding days together, the day of the year and stepping across clock changes, each tab drawing its own title, dates and chart, the empty state, three then two then one tiles, that on the desktop the tiles, bars and chart are drawings whose rows share one start, one length and one right edge at every width, and that no row of the pane is wider than the pane at narrow and wide widths on both the terminal and the desktop.

Bump version in plugin.json when you change the mod, or installed copies will not pick the change up.

Credits

Built as a Claude Code mod for afterever/claude-mods.

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