SLOPSHOPPER

payback

See what Claude did for you: tasks, code, commits, tests and tokens, and an estimate of the time and money it saved. Open it with /payback.

panespinnerguardcommandpromptmodel
★ 1v0.1.0GPL-3.0-or-laterupdated 2026-10-16michaelSelbertinger/payback
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · payback
│ ┃ Payback ✕ › fix the failing auth test and add an audit log call │ ┃ TODAY T │ ┃ ━━━━━─────────────────────────────────────── ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ Today Week ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ ╭─────────────────────────╮ ╭─────────────── ⏺ Bash(bun test) │ ┃ │ HOURS SAVED │ │ TASKS DONE ⎿ 3 pass, 1 fail │ ┃ │ ┏━┓ ╺━┓ │ │ ╺┓ │ ┃ │ ┃ ┃ ━┫ │ │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┗━┛ ╹ ╺━┛ │ │ ╺┻╸ │ ┃ │ estimate │ │ 1 active day ✻ Worked for 42s · done 4:20 PM │ ┃ ╰─────────────────────────╯ ╰─────────────── │ ┃ ╭─────────────────────────╮ › /payback │ ┃ │ DOLLARS' WORTH │ ⎿ payback: Opened Payback. Hours and money in it are estimates. │ ┃ │ ╺┓ ╺━┓ │ │ ┃ │ ┃ ━┫ │ │ ┃ │ ╺┻╸ ╺━┛ │ │ ┃ │ estimate at $50/h │ │ ┃ ╰─────────────────────────╯ │ ┃ │ ┃ TASKS BY DAY ─────────────────────────────── │ ┃ ██ │ ┃ ██ │ ┃ ▁▁▁▁▁▁ ▁▁▁▁▁▁ ▁▁▁▁▁▁ ▁▁▁▁▁▁ ▁▁▁▁▁▁ ▁▁▁▁▁▁ ██ │ ┃ Fr Sa Su Mo Tu We Th ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts Payback

Draws

Pane · Payback
TODAY Th 9 Oct 2025 ━━━━━─────────────────────────────────────────────────── Today Week ╭─────────────────────────╮ ╭─────────────────────────╮ │ HOURS SAVED │ │ TASKS DONE │ │ ┏━┓ ╺━┓ │ │ ╺┓ │ │ ┃ ┃ ━┫ │ │ ┃ │ │ ┗━┛ ╹ ╺━┛ │ │ ╺┻╸ │ │ estimate │ │ 1 active day │ ╰─────────────────────────╯ ╰─────────────────────────╯ ╭─────────────────────────╮ │ DOLLARS' WORTH │ │ ╺┓ ╺━┓ │ │ ┃ ━┫ │ │ ╺┻╸ ╺━┛ │ │ estimate at $50/h │ ╰─────────────────────────╯ TASKS BY DAY ─────────────────────────────────────────── ██████ ██████ ▁▁▁▁▁▁ ▁▁▁▁▁▁ ▁▁▁▁▁▁ ▁▁▁▁▁▁ ▁▁▁▁▁▁ ▁▁▁▁▁▁ ██████ Fr Sa Su Mo Tu We Th Streak: 1 day in a row ╭──────────────────────────────────────────────────────╮ │ WHERE THE TIME WENT │ │ Coding 1 task · 0.3 h │ ╰──────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────╮ │ CODE │ │ Lines +0 −0 · 3 files │ │ Git 1 push │ │ Tests 1 run · 0% pass · 0s │ │ Task types judge is off (/payback settings judge on) │ │ Sizes S 1 · M 0 · L 0 │ ╰──────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────╮ │ MODELS AND TOKENS │ │ Tasks In Out Cached │ │ Opus 5.5 1 97k 1.5k 93% │ ╰──────────────────────────────────────────────────────╯ Hours and money are estimates. /payback settings change…
Prompt hint
? for shortcuts Payback
README

Payback: see what Claude did for you, and what it was worth.

License: GPL-3.0 A Claude Code mod, needs Claude Code 2.1.287 or later Written in TypeScript Stars

Payback is a Claude Code mod that counts what Claude finished for you, sorts it, reads your git and test activity, and estimates the hours and money it saved.

Install · What you get · How it counts · Import history · Commands


Install

/plugin install payback --marketplace michaelSelbertinger/payback

Then type /payback. Want your past sessions in the report too? Run /payback import once. A mod installs like any plugin, through the normal marketplace flow; if you install from a shell while a session is open, run /reload-plugins there to load it.

From a shell, with Claude Code 2.1.292 or later:

claude plugin install payback --marketplace michaelSelbertinger/payback

On an older version, add the marketplace first and then install:

claude plugin marketplace add michaelSelbertinger/payback
claude plugin install payback@payback

In the desktop app, where /plugin does not run: click + next to the prompt box, then Plugins, Add marketplace with michaelSelbertinger/payback, then Add plugin.

[!NOTE] Payback is a mod: a plugin that changes how Claude Code looks and behaves. Mods need Claude Code 2.1.287 or later in the terminal (2.1.286 in the desktop app). Payback was built and tested on 2.1.299, and the mods API is young enough to change between releases.

Try it

Three things to type once it is installed:

  1. /payback opens the pane. Pick Today, Week, Month or Year, or switch between $ and €.
  2. /payback settings currency eur shows money in euro; /payback settings rate 75 sets your hourly rate.
  3. /payback import 30d previews the last 30 days of your past sessions, and imports them when you press Import.

See it

The Payback pane on the Month tab: tiles for hours saved, tasks and money, a bar chart of tasks for each of the last 30 days, time per kind of task, models and tokens, and code, git and test statistics (sample data).

The pane on the Month tab, in euro, with invented sample data. Above it: Today · Week · Month · Year, a $ / € switch and an All · You · SDK filter. The tiles, chart and cards are drawn by the mod itself.

What you get

📈 Tasks, counted honestlyOne request you typed and Claude finished is one task. Slash commands, helper agents and interrupted requests are not. Tabs for today, the week, the month and the year, with streaks and your best day.
🧑‍💻 Real developer statsLines added and removed, files touched, languages, commits, pushes, merges, rebases, branches, pull requests and issues, read straight from what Claude ran.
✅ Tests and buildsRuns, pass rate, total and average runtime, and passed and failed counts for pytest, jest, vitest, go, cargo, dotnet, npm and more.
🪙 Tokens per modelInput, output and the share served from cache, for every model that did the work.
⏱️ Time and moneyMinutes saved per kind of task, times your hourly rate, in dollars or euro. Always labelled as an estimate.
🕰️ Your past, importedReplays the session transcripts Claude Code already keeps. Preview first, import on one button, undo any time.
🤖 SDK-awareWork done by an assistant or script you run through the Agent SDK is kept apart, with an All · You · SDK filter.

How it counts

flowchart LR
    A["You type a request"] --> B["Claude works<br/>edits · shell · web · tools"]
    B --> C{"Finished with<br/>an answer?"}
    C -- "interrupted" --> N["Not counted"]
    C -- "yes" --> D["Sorted by the tools it used<br/>Coding · DevOps · Research · Docs …"]
    D --> F["Coding tasks sized<br/>by lines changed"]
    F -. "optional" .-> G["Haiku 5.5 judge:<br/>type and size"]
    F --> H[("Daily record in<br/>the mod's store")]
    G --> H
    H --> I["/payback pane"]

Each task gets one kind, from the tools it used:

KindWhenMinutes saved (default)
CodingIt edited code, ran git, gh, tests or a build, or used a code tool such as Unity or GitHubby size: 15 / 45 / 120
DevOps & shellIt ran shell commands without editing code, or used Docker, Kubernetes or cloud tools20
EmailsIt used a mail tool10
Images and videoIt used an image or video tool, or wrote an image or video file20
DocumentsIt used a document, slide or spreadsheet tool, or wrote a document file30
ResearchIt searched or read the web25
Questions & planningIt used no tools, or only read and searched files10
OtherAnything else10

The size of a coding task comes from the lines it changed (under 40 small, under 250 medium, larger is large) until the Haiku judge says otherwise.

  • Code: lines added and removed, files touched, top languages.
  • Git: commits, pushes, merges, rebases and new branches, read from Bash and PowerShell commands that succeeded. git -C, chained commands and PowerShell's if ($?) work; quoted messages and here-strings are ignored, as are dry runs and aborted merges.
  • GitHub: pull requests opened and merged, issues opened and closed (gh pr …, gh issue …).
  • Tests: runs of pytest, jest, vitest, go test, cargo test, dotnet test, npm/pnpm/yarn/bun test and others, with pass rate, runtime and passed and failed counts. A command only counts when the runner is the command itself, so pip install pytest is not a test run.
  • Builds: runs and failures.
  • Tokens: input, output and cache share per model, helper agents included.

A helper agent's lines, commits, test runs and tokens count, but it never makes a task of its own. Payback counts what Claude did through its own tools, not commits you make yourself in a terminal.

Started by you or by the SDK

Sessions started through the Claude Agent SDK (an assistant of your own, a script) are counted apart. The pane shows a Started by row with an All / You / SDK filter. SDK sessions run from a temp folder are treated as tests and left out, as are requests that only ask Claude to summarise an earlier session.

The Haiku judge

[!NOTE] Off by default. It is the only part of Payback that calls a model. Turn it on with /payback settings judge on, and off again with /payback settings judge off.

When the judge is on, each finished coding task gets one small call to Claude Haiku 5.5. It labels the task's type (feature, bugfix, refactor, tests, debug, docs, devops, review) and its size. The call is sent the first 1,500 characters of your request, the names and counts of the tools it used and the number of lines changed, and it uses your own Claude account or API key. Its tokens are shown in the pane under Haiku judge. With the judge off, which is how Payback starts, sizes come from the lines changed alone and the pane's Task types row says the judge is off.

Importing your history

/payback import reads the session transcripts Claude Code keeps in ~/.claude/projects/ and replays them by the same rules as live tracking.

[!TIP] Nothing is saved until you press Import. The pane first shows what it found: tasks, days, sessions, kinds and the estimate.

  • No double counting. It reads only what happened before Payback's first live record, and an import replaces its whole date range, so running it twice counts once. History that a fork or a resume copied into another file is counted once.
  • Undo with /payback import undo.
  • Imported coding tasks are sized by lines changed; the Haiku judge is not run on them.

[!IMPORTANT] Claude Code deletes transcripts after 30 days by default, so that is as far back as an import can go. Raise cleanupPeriodDays in your settings to keep more from now on.

Where it runs

WhereWhat you get
Desktop app, Code tabThe pane, the hint-line button and every command. This is where Payback was developed and used.
TerminalThe same pane in a text layout. Covered by automated tests, not yet by daily use.
VS Code extensionMods draw there from extension version 2.1.299. Payback has not been tried in it.
claude -p and the Agent SDKCounting runs, nothing is drawn.
WSL sessions in the desktop appMods do not run there.

Commands

CommandWhat it does
/paybackOpens the pane. There is also a Payback button in the hint line under the prompt.
/payback settingsLists the hourly rate, currency, minutes per kind of task and whether the Haiku judge is on.
/payback settings rate 75Sets the hourly rate.
/payback settings currency eurShows money in euro (usd for dollars). Nothing is converted: the rate is read in the chosen currency. The pane has a $ / € switch too.
/payback settings <kind> 15Minutes saved per task of a kind: devops, emails, images, documents, research, questions, other.
`/payback settings small\medium\large 45`Minutes saved per coding task of that size.
`/payback settings judge on\off`Turns the Haiku judge on or off.
/payback importReads your past sessions into the report. Also /payback import 30d or /payback import 2026-09-20 2026-10-10.
/payback import undoRemoves everything imported. Live data is untouched.

Trust and privacy

A mod runs inside Claude Code with your permissions, so here is exactly what Payback touches:

  • Your session transcripts in ~/.claude/projects, read only when you run /payback import, and only to count. Large ones are read with cmd /c type (Windows) or cat; on Windows, a path containing & | < > ^ % ! or a quote is skipped rather than handed to cmd.
  • A few environment variables: CLAUDE_CODE_ENTRYPOINT, CLAUDE_CONFIG_DIR, HOME, USERPROFILE and OS.
  • Claude Haiku 5.5, for the judge described above. It is the only model call, and it stays off unless you turn it on.
  • The mod's own store, where the counts live. Payback makes no network requests of its own.

What Payback sends, and where. One thing only, and only if you turn it on: the Haiku judge's request to Anthropic through your own Claude Code session (described above). The files and transcripts Payback reads are never sent anywhere.

Programs it runs. At most one, and only during /payback import: cmd /c type <file> on Windows or cat <file> elsewhere, to print a session transcript larger than 4 MB, because the file API reads at most 4 MiB at a time. It runs on files under ~/.claude/projects only, takes no other arguments, and prints to Payback and nowhere else.

What it changes. Nothing outside its own store. Its hooks watch tool calls, prompts and turns and pass them on unchanged: they never block, rewrite or approve anything, and Payback does not touch permissions, agents, settings or files. It answers its own /payback command, and it draws its pane and one button in the hint line under the prompt.

Run claude plugin validate on a clone of this repository to see for yourself: its hooks: and calls: lines list the events Payback handles and what it asks Claude Code to do.

[!NOTE] All counts stay on your machine. Out of the box nothing is sent anywhere. If you turn the Haiku judge on, its calls go to Anthropic through your own Claude Code session. Payback sees your prompts and tool calls in order to count them, and keeps none of their text. Read the privacy policy.

Support

Bug reports, questions and ideas: open an issue. For security concerns, write to michael.selbertinger@web.de. The full privacy policy is a short read.

Development

claude plugin validate .
claude plugin test .

The logic lives in hooks/stats.ts (counting, sorting, dates) and hooks/history.ts (the transcript replay), both free of Claude Code's runtime so the tests reach them directly. hooks/register.tsx holds the hooks, the commands and the pane.

To run your working copy in every session, add its folder to CLAUDE_CODE_PLUGIN_DIRS in the env block of ~/.claude/settings.json, and set CLAUDE_CODE_PLUGIN_DIR_WATCH to 1 there so desktop sessions reload it when you save.

The pictures in this README are generated, with no hosted services involved, from the mod's own drawing code (hooks/dashboard.ts) and invented sample data. The generator scripts are kept outside this repository, so that everything in it is the mod itself.

License

Copyright (C) 2026 Michael Selbertinger.

Payback is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. It is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See LICENSE for the full text.

Source 6 files
hooks/register.tsx 940 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderChildren } from 'claude-code'
3
4import type { Counter, Currency, Day, DevKind, ImportView, Kind, Settings, Size, Source, Tab } from '../types'
5import {
6  DEV_KINDS, JUDGE_MODEL, JUDGE_SYSTEM, KINDS, KIND_LABEL, SIZES, TITLES,
7  addDays, barCell, bigText, bySource, dayKey, isSummaryJob, isTempDir, daysBack, finishTask, fit, fmtCompact, fmtDuration, fmtMoney,
8  fmtHours, fmtInt, isEmptyDay, judgePrompt, minutesSaved, modelName, newTask, parseJudge,
9  shortDay, taskCount, tilesPerRow, toolDelta, view, withDefaults,
10} from './stats'
11import type { Task, View } from './stats'
12import { createImporter } from './history'
13import { dashboardSvg } from './dashboard'
14import { C, KIND_COLOR } from './theme'
15import type { ImportRange, ImportResult } from './history'
16
17const PANE = 'payback'
18const tab = atom({ plugin: 'payback', key: 'tab' } as const, 'today' as Tab)
19const rev = atom({ plugin: 'payback', key: 'rev' } as const, 0)
20const IDLE: ImportView = { phase: 'idle', fromDay: '', untilDay: '', done: 0, total: 0 }
21const importView = atom({ plugin: 'payback', key: 'import' } as const, IDLE)
22const sourceView = atom({ plugin: 'payback', key: 'source' } as const, 'all' as Source)
23
24// Who started this session, read at its start: the SDK's work is stored apart, a harness test not at all.
25let isFromSdk = false
26let isTestSession = false
27
28const TYPED_ORIGINS = new Set(['composer', 'sdk', 'bridge'])
29
30type Host = EngineInterface
31
32// ---------------------------------------------------------------- store
33
34const yearKey = (day: string) => `days:${day.slice(0, 4)}`
35
36async function loadSettings($: Host): Promise<Settings> {
37  return withDefaults(await $.store.get('settings'))
38}
39
40/** This year's days and last year's, so a week or a streak may reach back over January 1: live and imported added up. */
41async function loadDays($: Host, nowMs: number): Promise<Record<string, Day>> {
42  const y = new Date(nowMs).getFullYear()
43  const layers = await Promise.all([`days:${y - 1}`, `days:${y}`, `imported:${y - 1}`, `imported:${y}`].map(k => $.store.get(k)))
44  const days: Record<string, Day> = {}
45  for (const layer of layers) for (const [k, d] of Object.entries((layer ?? {}) as Record<string, Day>)) days[k] = addDays(days[k] ?? {}, d)
46  return days
47}
48
49let chain: Promise<unknown> = Promise.resolve()
50let hasStarted = false
51
52/** Adds `delta` to the stored day, read fresh so another session's writes are kept. */
53function record($: Host, day: string, delta: Day): Promise<unknown> {
54  if (isEmptyDay(delta) || isTestSession) return Promise.resolve()
55  const stored: Day = isFromSdk ? { sdk: delta } : delta
56  // ponytail: one key per year, read-modify-write; two sessions writing in the same millisecond can drop one delta.
57  const run = chain.then(async () => {
58    const year = ((await $.store.get(yearKey(day))) ?? {}) as Record<string, Day>
59    year[day] = addDays(year[day] ?? {}, stored)
60    await $.store.set(yearKey(day), year)
61    if (!hasStarted) {
62      hasStarted = true
63      if ((await $.store.get('tracked-since-ms')) === undefined) await $.store.set('tracked-since-ms', await $.clock.now())
64    }
65    await update($, rev, n => n + 1)
66  })
67  chain = run.catch(() => {})
68  return run
69}
70
71// ---------------------------------------------------------------- the task in flight
72
73type LiveTask = Task & { turnId: string; text: string }
74
75let typed: string | null = null
76let cur: LiveTask | null = null
77type Job = { day: string; size: Size; prompt: string }
78// ponytail: the judge queue lives in the module; a reload drops jobs not yet sent, and those keep the lines-based size.
79const queue: Job[] = []
80let isJudging = false
81
82async function drain($: Host): Promise<void> {
83  if (isJudging) return
84  isJudging = true
85  try {
86    for (let job = queue.shift(); job; job = queue.shift()) {
87      const n: Partial<Record<Counter, number>> = { judgeCalls: 1 }
88      const delta: Day = { n }
89      try {
90        const r = await $.model.complete({ model: JUDGE_MODEL, system: JUDGE_SYSTEM, prompt: job.prompt, maxTokens: 16, timeoutMs: 30_000 })
91        n.judgeIn = r.usage.input_tokens
92        n.judgeOut = r.usage.output_tokens
93        n.judgeCacheRead = r.usage.cache_read_input_tokens
94        n.judgeCacheWrite = r.usage.cache_creation_input_tokens
95        const verdict = r.isAnswered ? parseJudge(r.text) : null
96        if (verdict) {
97          delta.dev = { [verdict.kind]: 1 }
98          if (verdict.size !== job.size) delta.sizes = { [job.size]: -1, [verdict.size]: 1 }
99        } else n.judgeFails = 1
100      } catch {
101        n.judgeFails = 1
102      }
103      await record($, job.day, delta)
104    }
105  } finally {
106    isJudging = false
107  }
108}
109
110// ---------------------------------------------------------------- history import
111//
112// Reads Claude Code's own transcripts into a layer of its own (`imported:YYYY`), which the pane adds to the
113// live days. It reads only days before live tracking began, and an import replaces its whole range, so running
114// it again never counts twice; undo deletes the layer. Nothing is saved until the person presses Import.
115
116const ALL_HISTORY = '0000-01-01'
117const STREAM_ABOVE = 4_000_000 // $.fs.read stops at 4 MiB; bigger transcripts are streamed through the shell
118/** `cmd` reads these as commands (& | < > ^ % ! and quotes), not as part of a file name: a Windows path with one is skipped, never handed to it. */
119const CMD_SAFE_PATH = /^[\p{L}\p{N} _.:\\/()[\]@+,'~-]+$/u
120
121type Transcript = { path: string; size: number; isSubagent: boolean }
122
123let scan: { isCancelled: boolean } | null = null
124let scanned: { range: ImportRange; result: ImportResult } | null = null
125
126const setImport = ($: Host, next: ImportView) => update($, importView, () => next)
127
128/** The moment the live tracker first saved anything: the import reads only what came before it. */
129async function trackedSince($: Host, nowMs: number): Promise<number> {
130  const saved = await $.store.get('tracked-since-ms')
131  return typeof saved === 'number' ? saved : nowMs
132}
133
134/** Every transcript, main sessions and their subagents', changed on or after `sinceMs`. */
135async function listTranscripts($: Host, sinceMs: number): Promise<{ files: Transcript[]; isWindows: boolean }> {
136  const isWindows = (await $.env.get('OS')) === 'Windows_NT'
137  const config = await $.env.get('CLAUDE_CONFIG_DIR')
138  const home = (await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME'))
139  const base = config ?? (home ? `${home}/.claude` : undefined)
140  if (!base) throw new Error('Could not find your home folder, so the transcripts could not be found.')
141  const root = `${base}/projects`
142  if (!(await $.fs.exists(root))) throw new Error(`There are no transcripts in ${root}.`)
143  const files: Transcript[] = []
144  const take = (path: string, e: { name: string; kind: string; size: number; mtimeMs: number }, isSubagent: boolean) => {
145    if (e.kind === 'file' && e.name.endsWith('.jsonl') && e.mtimeMs >= sinceMs) files.push({ path, size: e.size, isSubagent })
146  }
147  for (const project of await $.fs.list(root)) {
148    if (project.kind !== 'dir') continue
149    const dir = `${root}/${project.name}`
150    for (const e of await $.fs.list(dir).catch(() => [])) {
151      if (e.kind !== 'dir') take(`${dir}/${e.name}`, e, false)
152      else {
153        const sub = `${dir}/${e.name}/subagents`
154        for (const s of await $.fs.list(sub).catch(() => [])) take(`${sub}/${s.name}`, s, true)
155      }
156    }
157  }
158  return { files, isWindows }
159}
160
161/** The lines of one transcript: read whole when small, streamed through `type`/`cat` when large. */
162async function* transcriptLines($: Host, file: Transcript, isWindows: boolean): AsyncGenerator<string> {
163  if (file.size <= STREAM_ABOVE) {
164    yield* (await $.fs.read(file.path)).split('\n')
165    return
166  }
167  if (isWindows && !CMD_SAFE_PATH.test(file.path)) throw new Error('its path has a character cmd would read as a command')
168  const argv = isWindows ? ['cmd', '/c', 'type', file.path.replaceAll('/', '\\')] : ['cat', file.path]
169  let buffer = ''
170  for await (const chunk of $.process.spawn({ argv })) {
171    if (chunk.stream !== 'stdout') continue
172    buffer += chunk.text
173    for (let i = buffer.indexOf('\n'); i >= 0; i = buffer.indexOf('\n')) {
174      yield buffer.slice(0, i)
175      buffer = buffer.slice(i + 1)
176    }
177  }
178  if (buffer) yield buffer
179}
180
181async function runScan($: Host, range: ImportRange): Promise<void> {
182  const mine = { isCancelled: false }
183  scan = mine
184  scanned = null
185  const base: ImportView = { ...IDLE, phase: 'scanning', fromDay: range.fromDay, untilDay: range.untilDay }
186  try {
187    await setImport($, base)
188    const since = range.fromDay === ALL_HISTORY ? 0 : daysBack(dayMs(range.fromDay) ?? 0, 1)
189    const { files, isWindows } = await listTranscripts($, since)
190    const importer = createImporter(range)
191    let skipped = 0
192    let shownAt = 0
193    for (const [i, file] of files.entries()) {
194      if (mine.isCancelled) return
195      const now = await $.clock.now()
196      if (now - shownAt > 400) {
197        shownAt = now
198        await setImport($, { ...base, done: i, total: files.length })
199      }
200      const reader = importer.file(file.isSubagent)
201      try {
202        let n = 0
203        for await (const text of transcriptLines($, file, isWindows)) {
204          reader.line(text)
205          if (++n % 5000 === 0 && mine.isCancelled) return
206        }
207        reader.end()
208      } catch {
209        skipped++ // unreadable or gone mid-read: left out and counted, the rest goes on
210      }
211    }
212    const result = importer.result()
213    const split = Object.values(result.days).reduce((a, d) => addDays(a, d), {} as Day)
214    const total = bySource(split, 'all')
215    const settings = await loadSettings($)
216    scanned = { range, result }
217    await setImport($, {
218      ...base,
219      phase: 'preview',
220      done: files.length,
221      total: files.length,
222      preview: {
223        tasks: taskCount(total),
224        kinds: total.tasks ?? {},
225        minutes: minutesSaved(total, settings),
226        sessions: result.sessions,
227        sdkSessions: result.sdkSessions,
228        sdkTasks: taskCount(split.sdk ?? {}),
229        testSessions: result.testSessions,
230        skipped: skipped + result.badLines,
231        activeDays: Object.values(result.days).filter(d => taskCount(bySource(d, 'all')) > 0).length,
232      },
233    })
234  } catch (err) {
235    if (!mine.isCancelled) await setImport($, { ...base, phase: 'error', message: err instanceof Error ? err.message : 'The history could not be read.' })
236  } finally {
237    if (scan === mine) scan = null
238  }
239}
240
241/** Saves what the scan found, replacing the imported days of its range, so an import run twice counts once. */
242async function applyImport($: Host): Promise<void> {
243  const shown = await read($, importView)
244  if (!scanned) {
245    await setImport($, { ...shown, phase: 'error', message: 'The scan was lost when the mod reloaded. Run /payback import again.' })
246    return
247  }
248  const { range, result } = scanned
249  const inRange = (day: string) => day >= range.fromDay && day < range.untilDay
250  const years = new Set(Object.keys(result.days).map(d => d.slice(0, 4)))
251  for (const key of await $.store.keys()) if (key.startsWith('imported:')) years.add(key.slice(9))
252  for (const year of years) {
253    const key = `imported:${year}`
254    const kept = Object.entries(((await $.store.get(key)) ?? {}) as Record<string, Day>).filter(([d]) => !inRange(d))
255    const merged = Object.fromEntries([...kept, ...Object.entries(result.days).filter(([d]) => d.startsWith(year))])
256    if (Object.keys(merged).length > 0) await $.store.set(key, merged)
257    else await $.store.delete(key)
258  }
259  scanned = null
260  await setImport($, { ...shown, phase: 'done' })
261  await update($, rev, r => r + 1)
262}
263
264async function undoImport($: Host): Promise<string> {
265  const keys = (await $.store.keys()).filter(k => k.startsWith('imported:'))
266  for (const key of keys) await $.store.delete(key)
267  await setImport($, IDLE)
268  await update($, rev, r => r + 1)
269  return keys.length > 0 ? 'Removed the imported history. Live tracking is untouched.' : 'There was no imported history to remove.'
270}
271
272/** `YYYY-MM-DD` at local noon, or null when it is no real day. */
273function dayMs(key: string): number | null {
274  const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(key)
275  if (!m) return null
276  const ms = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]), 12).getTime()
277  return dayKey(ms) === key ? ms : null
278}
279
280const rangeText = (r: { fromDay: string; untilDay: string }) =>
281  `${r.fromDay === ALL_HISTORY ? 'all kept history' : shortDay(r.fromDay)} to ${shortDay(dayKey(daysBack(dayMs(r.untilDay) ?? 0, 1)))}`
282
283async function importCommand($: Host, args: string[]): Promise<string> {
284  const sub = args[0]?.toLowerCase()
285  if (sub === 'undo') return undoImport($)
286  if (sub === 'cancel') return cancelImport($)
287  if (scan) return 'The history is already being read; Payback shows how far it got.'
288  const now = await $.clock.now()
289  const since = await trackedSince($, now)
290  let fromDay = ALL_HISTORY
291  let untilDay = dayKey(daysBack(since, -1))
292  if (sub && /^\d+d$/.test(sub)) fromDay = dayKey(daysBack(now, Number(sub.slice(0, -1))))
293  else if (sub) {
294    const from = dayMs(sub)
295    const to = args[1] === undefined ? null : dayMs(args[1])
296    if (from === null || (args[1] !== undefined && to === null))
297      return 'Give the days as YYYY-MM-DD, for example /payback import 2026-09-20 2026-10-10, or a span such as /payback import 30d.'
298    fromDay = sub
299    if (to !== null) untilDay = [dayKey(daysBack(to, -1)), untilDay].sort()[0]!
300  }
301  if (fromDay >= untilDay) return `Nothing to import: Payback has counted everything since ${shortDay(dayKey(since))} itself.`
302  const range = { fromDay, untilDay, untilMs: since }
303  $.clock.after(0, () => void runScan($, range))
304  void openPane($)
305  return `Reading your Claude Code history, ${rangeText(range)}. Payback shows a preview when it is done; nothing is saved until you press Import.`
306}
307
308async function cancelImport($: Host): Promise<string> {
309  if (scan) scan.isCancelled = true
310  scanned = null
311  await setImport($, IDLE)
312  return 'Import cancelled. Nothing was saved.'
313}
314
315// ---------------------------------------------------------------- commands
316
317const SETTING_NAMES: Record<string, (s: Settings, v: number) => void> = {
318  rate: (s, v) => (s.rate = v),
319  emails: (s, v) => (s.minutes.emails = v),
320  documents: (s, v) => (s.minutes.documents = v),
321  images: (s, v) => (s.minutes.images = v),
322  research: (s, v) => (s.minutes.research = v),
323  devops: (s, v) => (s.minutes.devops = v),
324  questions: (s, v) => (s.minutes.questions = v),
325  other: (s, v) => (s.minutes.other = v),
326  small: (s, v) => (s.sizes.s = v),
327  medium: (s, v) => (s.sizes.m = v),
328  large: (s, v) => (s.sizes.l = v),
329}
330
331function listSettings(s: Settings): string {
332  return [
333    'Payback settings (hours and money are estimates):',
334    `  currency   ${s.currency === 'eur' ? 'euro' : 'dollar'}`,
335    `  rate       ${fmtMoney(s.rate, s.currency)} per hour`,
336    `  emails     ${s.minutes.emails} min per task`,
337    `  documents  ${s.minutes.documents} min per task`,
338    `  images     ${s.minutes.images} min per task`,
339    `  research   ${s.minutes.research} min per task`,
340    `  devops     ${s.minutes.devops} min per task`,
341    `  questions  ${s.minutes.questions} min per task`,
342    `  other      ${s.minutes.other} min per task`,
343    `  small      ${s.sizes.s} min per small coding task`,
344    `  medium     ${s.sizes.m} min per medium coding task`,
345    `  large      ${s.sizes.l} min per large coding task`,
346    `  judge      ${s.judge ? 'on' : 'off'} (off by default; when on, Haiku 5.5 labels each coding task's type and size)`,
347    'Change one with /payback settings <name> <value>, for example /payback settings rate 75.',
348  ].join('\n')
349}
350
351async function settingsCommand($: Host, args: string[]): Promise<string> {
352  const s = await loadSettings($)
353  if (args.length === 0) return listSettings(s)
354  const [name, value] = [args[0]!.toLowerCase(), args[1]]
355  if (name === 'judge') {
356    if (value !== 'on' && value !== 'off') return 'Use /payback settings judge on or /payback settings judge off.'
357    s.judge = value === 'on'
358    await $.store.set('settings', s)
359    await update($, rev, r => r + 1)
360    return `The Haiku judge is ${value}.`
361  }
362  if (name === 'currency') {
363    const currency = CURRENCY_NAMES[(value ?? '').toLowerCase()]
364    if (!currency) return 'Use /payback settings currency eur or /payback settings currency usd.'
365    await setCurrency($, currency)
366    return `Money is now shown in ${currency === 'eur' ? 'euro' : 'dollars'}. The hourly rate stays ${fmtMoney(s.rate, currency)}; nothing is converted.`
367  }
368  const set = SETTING_NAMES[name]
369  if (!set) return `There is no setting called "${name}". The settings are: ${[...Object.keys(SETTING_NAMES), 'currency', 'judge'].join(', ')}.`
370  const n = Number(value)
371  if (!Number.isFinite(n) || n <= 0 || n > 100_000) return `Give ${name} a number above 0, for example /payback settings ${name} ${name === 'rate' ? 75 : 15}.`
372  set(s, n)
373  await $.store.set('settings', s)
374  await update($, rev, r => r + 1)
375  return name === 'rate' ? `Hourly rate is now ${fmtMoney(n, s.currency)}.` : `${name} is now ${n} minutes per task.`
376}
377
378const CURRENCY_NAMES: Record<string, Currency> = { eur: 'eur', euro: 'eur', euros: 'eur', '€': 'eur', usd: 'usd', dollar: 'usd', dollars: 'usd', '$': 'usd' }
379
380/** Saves the currency read fresh, so a change made elsewhere meanwhile is kept, and redraws the pane. */
381async function setCurrency($: Host, currency: Currency) {
382  const s = await loadSettings($)
383  await $.store.set('settings', { ...s, currency })
384  await update($, rev, r => r + 1)
385}
386
387const openPane = ($: Host) => $.ui.open({ id: PANE, title: 'Payback' }).catch(() => undefined)
388
389// ---------------------------------------------------------------- drawing
390//
391// Every Text is `mono`: the desktop then measures in the same cells as `bodyColumns`, as the terminal always does.
392// Rows hold fixed-width boxes that never shrink, so a long value is cut by us, never squeezed by the layout.
393
394type Els = ReturnType<EngineInterface['ui']['resolve']>
395type Seg = [string, string?, boolean?]
396// `mono` and the type scale are refused on the terminal (always monospaced there), so they ride along only elsewhere.
397type Ctx = { ui: Els; isTerminal: boolean; mono: { mono?: true } }
398
399const len = (text: string) => [...text].length
400
401/** The segments cut so together they fill at most `width` cells. */
402function cut(segs: Seg[], width: number): Seg[] {
403  let room = width
404  return segs
405    .map(([text, color, bold]): Seg => {
406      const t = len(text) <= room ? text : fit(text, Math.max(0, room))
407      room -= len(t)
408      return [t, color, bold]
409    })
410    .filter(([text]) => text !== '')
411}
412
413/** One Text holding coloured parts, so the parts are laid out as one run of text. */
414function spans(c: Ctx, key: string, segs: Seg[]) {
415  const { Text } = c.ui
416  return (
417    <Text key={key} {...c.mono} wrap="truncate-end">
418      {segs.map(([text, color, bold], i) => (
419        <Text key={`${key}-${i}`} {...c.mono} color={color ?? C.text} bold={bold}>{text}</Text>
420      ))}
421    </Text>
422  )
423}
424
425/** A row exactly `width` cells wide. */
426function line(c: Ctx, key: string, width: number, segs: Seg[]) {
427  const { Box } = c.ui
428  return (
429    <Box key={key} width={width} flexDirection="row" overflow="hidden">
430      {spans(c, `${key}-t`, cut(segs, width))}
431    </Box>
432  )
433}
434
435/** A fixed cell of `width` that never shrinks, its text cut to fit, left or right aligned. */
436function cell(c: Ctx, key: string, width: number, segs: Seg[], isRight = false) {
437  const { Box } = c.ui
438  return (
439    <Box key={key} width={width} flexShrink={0} flexDirection="row" justifyContent={isRight ? 'flex-end' : 'flex-start'} overflow="hidden">
440      {spans(c, `${key}-t`, cut(segs, width))}
441    </Box>
442  )
443}
444
445/** A section heading in small muted capitals, then a hairline to the edge. */
446function heading(c: Ctx, key: string, width: number, title: string) {
447  const { Box } = c.ui
448  const label = fit(title.toUpperCase(), width)
449  return (
450    <Box key={key} width={width} flexDirection="row" overflow="hidden" marginTop={1}>
451      {cell(c, `${key}-l`, len(label), [[label, C.muted, true]])}
452      {width - len(label) > 1 && cell(c, `${key}-r`, width - len(label), [[' ' + '─'.repeat(width - len(label) - 1), C.hair]])}
453    </Box>
454  )
455}
456
457/** A label, a bar of coloured cells for `value` against `max`, and the numbers on the right. */
458function barRow(c: Ctx, key: string, width: number, label: string, color: string, value: number, max: number, right: string, hasBar: boolean) {
459  const { Box } = c.ui
460  const labelW = width >= 56 ? 21 : Math.min(16, Math.max(8, Math.floor(width * 0.32)))
461  const rightW = Math.min(len(right), Math.max(0, width - labelW))
462  const barW = width - labelW - rightW - 2
463  const fill = value > 0 ? Math.max(1, Math.round((value / Math.max(1, max)) * barW)) : 0
464  return (
465    <Box key={key} width={width} flexDirection="row" overflow="hidden">
466      {cell(c, `${key}-l`, labelW, [[label, C.text]])}
467      {hasBar && barW >= 4
468        ? cell(c, `${key}-b`, barW + 2, [[' '], ['█'.repeat(fill), color], ['█'.repeat(barW - fill), C.hair], [' ']])
469        : cell(c, `${key}-b`, Math.max(0, width - labelW - rightW), [['']])}
470      {cell(c, `${key}-r`, rightW, [[right, C.text]], true)}
471    </Box>
472  )
473}
474
475/** A muted label column, then the value. */
476function kv(c: Ctx, key: string, width: number, label: string, value: Seg[]) {
477  const { Box } = c.ui
478  const labelW = width >= 36 ? 11 : 9
479  return (
480    <Box key={key} width={width} flexDirection="row" overflow="hidden">
481      {cell(c, `${key}-l`, labelW, [[label, C.muted]])}
482      {cell(c, `${key}-v`, width - labelW, value)}
483    </Box>
484  )
485}
486
487/** A rounded card with a muted title; `inner` is the width its rows draw in. */
488function card(c: Ctx, key: string, width: number, title: string, rows: (inner: number) => RenderChildren[]) {
489  const { Box } = c.ui
490  const inner = Math.max(8, width - 4)
491  return (
492    <Box key={key} width={width} flexDirection="column" borderStyle="round" borderColor={C.hair} paddingX={1}>
493      {line(c, `${key}-title`, inner, [[title.toUpperCase(), C.muted, true]])}
494      {rows(inner)}
495    </Box>
496  )
497}
498
499function tile(c: Ctx, key: string, width: number, label: string, digits: string, shown: string, foot: string) {
500  const { Box, Text } = c.ui
501  const inner = Math.max(1, width - 4)
502  const big = c.isTerminal ? bigText(digits, inner) : null
503  return (
504    <Box key={key} width={width} flexDirection="column" borderStyle="round" borderColor={C.hair} paddingX={1}>
505      {line(c, `${key}-label`, inner, [[label, C.muted]])}
506      {big
507        ? big.map((row, i) => line(c, `${key}-big-${i}`, inner, [[row, C.text]]))
508        : <Text key={`${key}-value`} {...c.mono} {...(c.isTerminal ? {} : { size: 'title' as const, weight: 'bold' as const })} bold color={C.text} wrap="truncate-end">{fit(shown, inner)}</Text>}
509      {line(c, `${key}-foot`, inner, [[foot, C.muted]])}
510    </Box>
511  )
512}
513
514/** The chart: on the terminal three rows of eighth blocks, elsewhere one SVG (text rows there leave gaps). */
515function chart(c: Ctx, width: number, v: View) {
516  const { Box, Text } = c.ui
517  const all = v.chart
518  const els = c.ui
519  if (!c.isTerminal && 'Svg' in els) {
520    const { Svg } = els
521    return [<Svg key="chart" source={chartSvg(all, width)} alt={`Tasks: ${all.map(b => `${b.label} ${b.isFuture ? '-' : b.value}`).join(', ')}`} height={132} />]
522  }
523  const gap = all.length < 30 || width >= all.length * 2 - 1 ? 1 : 0
524  const cap = all.length === 7 ? 6 : all.length === 12 ? 4 : 1
525  const bw = Math.max(1, Math.min(cap, Math.floor((width - gap * (all.length - 1)) / all.length)))
526  const shown = all.slice(-Math.max(1, Math.floor((width + gap) / (bw + gap))))
527  const max = Math.max(...shown.map(b => b.value))
528  const sep = (i: number) => (i < shown.length - 1 ? ' '.repeat(gap) : '')
529  const rows = [0, 1, 2].map(row =>
530    line(c, `chart-${row}`, width, shown.map((b, i): Seg => [(b.isFuture ? ' ' : barCell(b.value, max, row)).repeat(bw) + sep(i), b.isFuture || b.value === 0 ? C.hair : b.isCurrent ? C.gold : C.text])),
531  )
532  const labels =
533    shown.length === 30 || shown.length < all.length
534      ? (
535          <Box key="chart-labels" width={width} flexDirection="row" justifyContent="space-between">
536            {cell(c, 'cl-a', len(`${shown.length} days ago`), [[`${shown.length} days ago`, C.muted]])}
537            {cell(c, 'cl-b', 5, [['today', C.gold]])}
538          </Box>
539        )
540      : line(c, 'chart-labels', width, shown.map((b, i): Seg => [fit(b.label, bw).padEnd(bw) + sep(i), b.isCurrent ? C.gold : C.muted]))
541  return [...rows, labels]
542}
543
544function chartSvg(buckets: View['chart'], width: number): string {
545  const vw = Math.max(240, width * 8)
546  const vh = 132
547  const base = vh - 22
548  const slot = vw / buckets.length
549  const barW = Math.min(slot * 0.62, 40)
550  const max = Math.max(1, ...buckets.map(b => b.value))
551  const every = buckets.length > 12 ? 5 : 1
552  const parts = buckets.map((b, i) => {
553    const x = i * slot + (slot - barW) / 2
554    const cx = i * slot + slot / 2
555    const fill = b.isCurrent ? C.gold : C.text
556    const barH = b.isFuture ? 0 : b.value > 0 ? Math.max(3, (b.value / max) * (base - 18)) : 2
557    const bar = b.isFuture ? '' : `<rect x="${x.toFixed(1)}" y="${(base - barH).toFixed(1)}" width="${barW.toFixed(1)}" height="${barH.toFixed(1)}" rx="3" fill="${b.value > 0 ? fill : C.hair}"/>`
558    const count = b.value > 0 && slot >= 16 ? `<text x="${cx.toFixed(1)}" y="${(base - barH - 5).toFixed(1)}" text-anchor="middle" font-size="11" fill="${C.muted}">${b.value}</text>` : ''
559    const showLabel = (buckets.length - 1 - i) % every === 0
560    const label = showLabel ? `<text x="${cx.toFixed(1)}" y="${vh - 5}" text-anchor="middle" font-size="11" fill="${b.isCurrent ? C.gold : C.muted}">${b.label}</text>` : ''
561    return bar + count + label
562  })
563  return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${vw} ${vh}" width="${vw}" height="${vh}" font-family="ui-monospace, Menlo, Consolas, monospace"><line x1="0" y1="${base + 0.5}" x2="${vw}" y2="${base + 0.5}" stroke="${C.hair}"/>${parts.join('')}</svg>`
564}
565
566const EMPTY: Record<Tab, string> = {
567  today: 'Nothing finished yet today.',
568  week: 'Nothing finished in the last 7 days.',
569  month: 'Nothing finished in the last 30 days.',
570  year: 'Nothing finished yet this year.',
571}
572
573const plural = (x: number, one: string, many = `${one}s`) => `${fmtInt(x)} ${x === 1 ? one : many}`
574
575/** Columns from which the desktop draws the body as a picture, sized to the pane; below it the text layout is kinder. */
576const WIDE = 62
577
578type ImportActions = { importNow: () => void; cancel: () => void; close: () => void }
579type Who = { source: Source; you: number; sdk: number; hasSdk: boolean; set: (s: Source) => void }
580
581function importCard(c: Ctx, width: number, imp: ImportView, s: Settings, act: ImportActions) {
582  const { Box, Button } = c.ui
583  const buttons = (main: { label: string; onPress: () => void } | null, other: { key: string; label: string; onPress: () => void }) => (
584    <Box key="imp-buttons" flexDirection="row" columnGap={2} marginTop={1}>
585      {main && <Button key="imp-go" label={main.label} variant="primary" onPress={main.onPress} />}
586      <Button key={other.key} label={other.label} plain dimColor onPress={other.onPress} />
587    </Box>
588  )
589  return card(c, 'card-import', width, 'Import history', inner => {
590    const rows: RenderChildren[] = []
591    const range = rangeText(imp)
592    if (imp.phase === 'scanning') {
593      const fill = imp.total > 0 ? Math.round((imp.done / imp.total) * inner) : 0
594      rows.push(line(c, 'imp-status', inner, [[imp.total > 0 ? `Reading transcripts: ${fmtInt(imp.done)} of ${fmtInt(imp.total)}` : 'Finding transcripts…'], [`  ·  ${range}`, C.muted]]))
595      rows.push(line(c, 'imp-bar', inner, [['█'.repeat(fill), C.gold], ['█'.repeat(inner - fill), C.hair]]))
596      rows.push(buttons(null, { key: 'imp-cancel', label: 'Cancel', onPress: act.cancel }))
597    } else if (imp.phase === 'preview' && imp.preview) {
598      const p = imp.preview
599      rows.push(line(c, 'imp-found', inner, [[`Found ${plural(p.tasks, 'task')} on ${plural(p.activeDays, 'day')} in ${plural(p.sessions + p.sdkSessions, 'session')}`, C.text, true]]))
600      if (p.sdkTasks > 0) rows.push(line(c, 'imp-split', inner, [[`Started by you ${fmtInt(p.tasks - p.sdkTasks)} · SDK ${fmtInt(p.sdkTasks)}`]]))
601      rows.push(line(c, 'imp-range', inner, [[range, C.muted]]))
602      const kinds = KINDS.filter(k => (p.kinds[k] ?? 0) > 0).map(k => `${KIND_LABEL[k]} ${fmtInt(p.kinds[k] ?? 0)}`).join(' · ')
603      if (kinds) rows.push(line(c, 'imp-kinds', inner, [[kinds]]))
604      if (p.tasks > 0) rows.push(line(c, 'imp-worth', inner, [[`≈ ${fmtHours(p.minutes)} h · ${fmtMoney((p.minutes / 60) * s.rate, s.currency)}`, C.green], [' estimate', C.muted]]))
605      rows.push(line(c, 'imp-note', inner, [['Coding tasks are sized by lines changed, not judged by Haiku.', C.muted]]))
606      if (p.testSessions > 0) rows.push(line(c, 'imp-tests', inner, [[`Left out: ${plural(p.testSessions, 'SDK test session')} run from a temp folder.`, C.muted]]))
607      if (p.skipped > 0) rows.push(line(c, 'imp-skipped', inner, [[`${plural(p.skipped, 'unreadable file or line', 'unreadable files or lines')} skipped.`, C.amber]]))
608      rows.push(
609        p.tasks > 0
610          ? buttons({ label: 'Import', onPress: act.importNow }, { key: 'imp-cancel', label: 'Cancel', onPress: act.cancel })
611          : buttons(null, { key: 'imp-close', label: 'Close', onPress: act.close }),
612      )
613    } else if (imp.phase === 'done') {
614      rows.push(line(c, 'imp-done', inner, [[`Imported ${plural(imp.preview?.tasks ?? 0, 'task')}, ${range}.`, C.green]]))
615      rows.push(line(c, 'imp-undo', inner, [['Undo it any time with /payback import undo.', C.muted]]))
616      rows.push(buttons(null, { key: 'imp-close', label: 'Close', onPress: act.close }))
617    } else {
618      rows.push(line(c, 'imp-error', inner, [[imp.message ?? 'The history could not be read.', C.amber]]))
619      rows.push(buttons(null, { key: 'imp-close', label: 'Close', onPress: act.close }))
620    }
621    return rows
622  })
623}
624
625function drawPane(ui: Els, isTerminal: boolean, v: View, s: Settings, width: number, setTab: (t: Tab) => void, setMoney: (c: Currency) => void, imp: ImportView, act: ImportActions, who: Who) {
626  const { Box, Text, Button } = ui
627  const c: Ctx = { ui, isTerminal, mono: isTerminal ? {} : { mono: true } }
628  const W = Math.max(16, width)
629  const d = v.total
630  const n = (k: Counter) => d.n?.[k] ?? 0
631  const out: RenderChildren[] = []
632
633  // Title bar: the stretch on the left, its dates on the right, a gold rule under the title.
634  const dates = fit(v.dates, Math.max(0, W - v.title.length - 1))
635  out.push(
636    <Box key="title" width={W} flexDirection="row" justifyContent="space-between">
637      {cell(c, 'title-l', v.title.length, [[v.title, C.text, true]])}
638      {cell(c, 'title-r', len(dates), [[dates, C.muted]])}
639    </Box>,
640  )
641  out.push(
642    <Box key="rule" width={W} flexDirection="row">
643      {cell(c, 'rule-gold', v.title.length, [['━'.repeat(v.title.length), C.gold]])}
644      {cell(c, 'rule-hair', W - v.title.length, [['─'.repeat(W - v.title.length), C.hair]])}
645    </Box>,
646  )
647
648  // Tabs on the left, the currency on the right: the chosen chip gold with dark text, the others slate.
649  const chip = (key: string, label: string, isOn: boolean, onPress: () => void) =>
650    isOn ? (
651      <Text key={`${key}-on`} {...c.mono} backgroundColor={C.gold} color={C.dark} bold>{` ${label} `}</Text>
652    ) : (
653      <Box key={`${key}-chip`} backgroundColor={C.hair} paddingX={1}>
654        <Button key={key} label={label} plain onPress={onPress} />
655      </Box>
656    )
657  const tabs: Tab[] = ['today', 'week', 'month', 'year']
658  const isTight = W < 40
659  out.push(
660    <Box key="tabs" width={W} flexDirection="row" justifyContent="space-between" marginTop={1}>
661      <Box key="tabs-left" flexDirection="row" columnGap={1}>
662        {tabs.map(t => chip(`tab-${t}`, isTight ? TITLES[t][0]! : TITLES[t][0] + TITLES[t].slice(1).toLowerCase(), t === v.tab, () => setTab(t)))}
663      </Box>
664      <Box key="tabs-money" flexDirection="row" columnGap={1}>
665        {chip('cur-usd', '$', s.currency === 'usd', () => setMoney('usd'))}
666        {chip('cur-eur', '€', s.currency === 'eur', () => setMoney('eur'))}
667      </Box>
668    </Box>,
669  )
670
671  // Who started the work, and a filter on it: shown once the SDK has done any work in this stretch.
672  if (who.hasSdk || who.source !== 'all') {
673    const lead = W >= 60 ? 'Started by' : ''
674    const gaps = lead ? 4 : 3
675    const counts = `You ${fmtInt(who.you)} · SDK ${fmtInt(who.sdk)}`
676    out.push(
677      <Box key="sources" width={W} flexDirection="row" columnGap={1} marginTop={1}>
678        {lead && cell(c, 'src-lead', len(lead), [[lead, C.muted]])}
679        {chip('src-all', 'All', who.source === 'all', () => who.set('all'))}
680        {chip('src-you', 'You', who.source === 'you', () => who.set('you'))}
681        {chip('src-sdk', 'SDK', who.source === 'sdk', () => who.set('sdk'))}
682        {cell(c, 'src-counts', Math.max(0, W - len(lead) - 15 - gaps), [[counts, C.muted]])}
683      </Box>,
684    )
685  }
686
687  if (imp.phase !== 'idle') {
688    out.push(<Box key="gap-import" height={1} />)
689    out.push(importCard(c, W, imp, s, act))
690  }
691
692  if (taskCount(d) === 0 && Object.keys(d.n ?? {}).length === 0) {
693    out.push(<Box key="gap-empty" height={1} />)
694    out.push(line(c, 'empty', W, [[EMPTY[v.tab], C.text, true]]))
695    out.push(line(c, 'empty-hint', W, [['Your first finished task shows up here.', C.muted]]))
696    return <Box key="report" flexDirection="column" width={W}>{out}</Box>
697  }
698
699  // A wide desktop pane draws everything below as one picture; the terminal and narrow panes keep the text layout below.
700  if (!isTerminal && W >= WIDE && 'Svg' in ui) {
701    const { source, alt } = dashboardSvg(v, s, W)
702    const { Svg } = ui
703    out.push(<Box key="gap-dashboard" height={1} />, <Svg key="dashboard" source={source} alt={alt} />)
704    return <Box key="report" flexDirection="column" width={W}>{out}</Box>
705  }
706
707  // Tiles: three, then two, then one per row as the pane narrows.
708  const minutes = minutesSaved(d, s)
709  const hours = fmtHours(minutes)
710  const amount = (minutes / 60) * s.rate
711  const worth = s.currency === 'eur' ? Math.round(amount).toLocaleString('de-DE') : fmtInt(amount)
712  const tiles = [
713    ['tile-hours', 'HOURS SAVED', hours, `${hours} h`, 'estimate'],
714    ['tile-tasks', 'TASKS DONE', fmtInt(taskCount(d)), fmtInt(taskCount(d)), `${plural(v.activeDays, 'active day')}`],
715    ['tile-worth', s.currency === 'eur' ? 'WORTH IN EUROS' : "DOLLARS' WORTH", worth, fmtMoney(amount, s.currency), `estimate at ${fmtMoney(s.rate, s.currency)}/h`],
716  ] as const
717  const per = tilesPerRow(W)
718  const tileW = Math.floor((W - (per - 1)) / per)
719  for (let r = 0; r * per < tiles.length; r++)
720    out.push(
721      <Box key={`tilerow-${r}`} width={W} flexDirection="row" columnGap={1} marginTop={r === 0 ? 1 : 0}>
722        {tiles.slice(r * per, r * per + per).map(([key, label, digits, shown, foot]) => tile(c, key, tileW, label, digits, shown, foot))}
723      </Box>,
724    )
725
726  // Activity: the chart and its one summary line.
727  out.push(heading(c, 'h-chart', W, v.tab === 'year' ? 'Tasks by month' : 'Tasks by day'))
728  out.push(...chart(c, W, v))
729  out.push(line(c, 'summary', W, [[v.summary, C.green]]))
730
731  // Cards: where the time went and models on the left, code on the right; stacked when narrow.
732  const isTwo = W >= 92
733  const leftW = isTwo ? Math.floor((W - 1) / 2) : W
734  const rightW = isTwo ? W - 1 - leftW : W
735
736  const kindMinutes = (k: Kind) =>
737    k === 'coding' ? SIZES.reduce((a, z) => a + (d.sizes?.[z] ?? 0) * s.sizes[z], 0) : (d.tasks?.[k] ?? 0) * s.minutes[k]
738  const kinds = KINDS.filter(k => (d.tasks?.[k] ?? 0) > 0)
739  const maxKind = Math.max(0, ...kinds.map(k => d.tasks?.[k] ?? 0))
740  const timeCard = card(c, 'card-time', leftW, 'Where the time went', inner =>
741    kinds.length === 0
742      ? [line(c, 'kinds-none', inner, [['No finished tasks in this stretch.', C.muted]])]
743      : kinds.map(k => {
744          const count = d.tasks?.[k] ?? 0
745          return barRow(c, `kind-${k}`, inner, KIND_LABEL[k], KIND_COLOR[k], count, maxKind, `${plural(count, 'task')} · ${fmtHours(kindMinutes(k))} h`, kinds.length > 1)
746        }),
747  )
748
749  const models = [...new Set([...Object.keys(d.models ?? {}), ...Object.keys(d.tokens ?? {})])].sort(
750    (a, b) => (d.models?.[b] ?? 0) - (d.models?.[a] ?? 0) || (d.tokens?.[b]?.out ?? 0) - (d.tokens?.[a]?.out ?? 0),
751  )
752  const modelCard = card(c, 'card-models', leftW, 'Models and tokens', inner => {
753    const wide = inner >= 44
754    const colW = inner >= 56 ? 9 : 7
755    const cols = wide ? ['Tasks', 'In', 'Out', 'Cached'] : ['Tasks', 'Out']
756    const nameW = inner - cols.length * colW
757    const row = (key: string, name: string, vals: string[], color = C.text) => (
758      <Box key={key} width={inner} flexDirection="row" overflow="hidden">
759        {cell(c, `${key}-n`, nameW, [[name, color]])}
760        {vals.map((val, i) => cell(c, `${key}-${i}`, colW, [[val, color]], true))}
761      </Box>
762    )
763    const rows: RenderChildren[] = [row('models-head', '', cols, C.muted)]
764    for (const id of models) {
765      const t = d.tokens?.[id] ?? {}
766      const input = (t.in ?? 0) + (t.cacheRead ?? 0) + (t.cacheWrite ?? 0)
767      const cached = input > 0 ? `${Math.round(((t.cacheRead ?? 0) / input) * 100)}%` : '-'
768      const tasks = String(d.models?.[id] ?? 0)
769      rows.push(row(`model-${id}`, modelName(id), wide ? [tasks, fmtCompact(input), fmtCompact(t.out ?? 0), cached] : [tasks, fmtCompact(t.out ?? 0)]))
770    }
771    if (n('judgeCalls') > 0) {
772      const input = n('judgeIn') + n('judgeCacheRead') + n('judgeCacheWrite')
773      const calls = plural(n('judgeCalls'), 'call')
774      rows.push(row('tok-judge', 'Haiku judge', wide ? [calls.split(' ')[0]!, fmtCompact(input), fmtCompact(n('judgeOut')), '-'] : [calls.split(' ')[0]!, fmtCompact(n('judgeOut'))], C.muted))
775      if (n('judgeFails') > 0) rows.push(line(c, 'tok-judge-fails', inner, [[`${plural(n('judgeFails'), 'judge call')} failed`, C.amber]]))
776    }
777    if (models.length === 0 && n('judgeCalls') === 0) return [line(c, 'models-none', inner, [['No model use in this stretch.', C.muted]])]
778    return rows
779  })
780
781  const hasCode = (d.tasks?.coding ?? 0) > 0 || Object.keys(d.n ?? {}).some(k => !k.startsWith('judge'))
782  const codeCard = card(c, 'card-code', rightW, 'Code', inner => {
783    const rows: RenderChildren[] = []
784    if (n('linesAdded') + n('linesRemoved') > 0 || n('files') > 0)
785      rows.push(kv(c, 'code-lines', inner, 'Lines', [[`+${fmtInt(n('linesAdded'))}`, C.green], ['  '], [`−${fmtInt(n('linesRemoved'))}`, C.muted], [`  ·  ${plural(n('files'), 'file')}`, C.muted]]))
786    const langs = Object.entries(d.langs ?? {}).sort((a, b) => b[1] - a[1])
787    const langTotal = langs.reduce((a, [, x]) => a + x, 0)
788    if (langTotal > 0) rows.push(kv(c, 'code-langs', inner, 'Languages', [[langs.slice(0, 3).map(([l, x]) => `${l} ${Math.round((x / langTotal) * 100)}%`).join(' · ')]]))
789    const items = (pairs: [number, string, string?][]) => pairs.filter(([x]) => x > 0).map(([x, one, many]) => plural(x, one, many))
790    const wrapped = (key: string, label: string, parts: string[]) => {
791      const room = Math.max(10, inner - (inner >= 36 ? 11 : 9))
792      const lines: string[] = []
793      for (const part of parts) {
794        const last = lines[lines.length - 1]
795        if (last !== undefined && last.length + 3 + part.length <= room) lines[lines.length - 1] = `${last} · ${part}`
796        else lines.push(part)
797      }
798      lines.forEach((text, i) => rows.push(kv(c, `${key}-${i}`, inner, i === 0 ? label : '', [[text]])))
799    }
800    wrapped('code-git', 'Git', items([[n('commits'), 'commit'], [n('pushes'), 'push', 'pushes'], [n('merges'), 'merge'], [n('rebases'), 'rebase'], [n('branches'), 'branch', 'branches']]))
801    wrapped('code-github', 'GitHub', items([[n('prsOpened'), 'PR opened', 'PRs opened'], [n('prsMerged'), 'PR merged', 'PRs merged'], [n('issuesOpened'), 'issue opened', 'issues opened'], [n('issuesClosed'), 'issue closed', 'issues closed']]))
802    const runs = n('testRuns')
803    if (runs > 0) {
804      const pass = Math.round(((runs - n('testFails')) / runs) * 100)
805      rows.push(kv(c, 'code-tests', inner, 'Tests', [[`${plural(runs, 'run')} · `], [`${pass}% pass`, pass >= 90 ? C.green : C.amber], [` · ${fmtDuration(n('testMs'))}`, C.muted]]))
806      if (n('testsPassed') + n('testsFailed') > 0)
807        rows.push(kv(c, 'code-results', inner, 'Results', [[`${fmtInt(n('testsPassed'))} passed`, C.green], ['  '], [`${fmtInt(n('testsFailed'))} failed`, n('testsFailed') > 0 ? C.amber : C.muted]]))
808    }
809    if (n('builds') > 0) rows.push(kv(c, 'code-builds', inner, 'Builds', [[`${plural(n('builds'), 'run')} · `], [`${fmtInt(n('buildFails'))} failed`, n('buildFails') > 0 ? C.amber : C.muted]]))
810    if ((d.tasks?.coding ?? 0) > 0) {
811      const types = DEV_KINDS.filter(k => (d.dev?.[k] ?? 0) > 0).sort((a, b) => (d.dev?.[b] ?? 0) - (d.dev?.[a] ?? 0))
812      const typeText = types.map(k => `${k[0]!.toUpperCase()}${k.slice(1)} ${d.dev?.[k]}`).join(' · ')
813      rows.push(kv(c, 'code-types', inner, 'Task types', [[typeText || (s.judge ? 'not judged yet' : 'judge is off (/payback settings judge on)'), typeText ? C.text : C.muted]]))
814      rows.push(kv(c, 'code-sizes', inner, 'Sizes', [[SIZES.map(z => `${z.toUpperCase()} ${d.sizes?.[z] ?? 0}`).join(' · '), C.muted]]))
815    }
816    return rows.length > 0 ? rows : [line(c, 'code-none', inner, [['No code work in this stretch.', C.muted]])]
817  })
818
819  out.push(<Box key="gap-cards" height={1} />)
820  if (isTwo)
821    out.push(
822      <Box key="grid" width={W} flexDirection="row" columnGap={1}>
823        <Box key="col-left" width={leftW} flexDirection="column" rowGap={1}>{[timeCard, modelCard]}</Box>
824        <Box key="col-right" width={rightW} flexDirection="column">{hasCode ? [codeCard] : []}</Box>
825      </Box>,
826    )
827  else out.push(<Box key="stack" width={W} flexDirection="column" rowGap={1}>{[timeCard, ...(hasCode ? [codeCard] : []), modelCard]}</Box>)
828
829  out.push(line(c, 'foot', W, [['Hours and money are estimates. /payback settings changes them.', C.muted]]))
830  return <Box key="report" flexDirection="column" width={W}>{out}</Box>
831}
832
833// ---------------------------------------------------------------- hooks
834
835export const register: Register = on => {
836  on('session.start', async ($, e, next) => {
837    try {
838      isFromSdk = ((await $.env.get('CLAUDE_CODE_ENTRYPOINT')) ?? '').startsWith('sdk')
839      isTestSession = isFromSdk && isTempDir(e.cwd)
840      await $.command.register({
841        name: 'payback',
842        description: 'Payback: what Claude did for you (tasks, code, git, tests, tokens) and the time and money it saved',
843        argumentHint: '[settings [name value] | import [30d | from to | undo | cancel]]',
844      })
845    } catch {}
846    return next(e)
847  })
848
849  on('command.run', { command: 'payback' }, async ($, e) => {
850    const args = e.args.trim().split(/\s+/).filter(Boolean)
851    if (args[0]?.toLowerCase() === 'settings') return { text: await settingsCommand($, args.slice(1)) }
852    if (args[0]?.toLowerCase() === 'import') return { text: await importCommand($, args.slice(1)) }
853    void openPane($)
854    return { text: 'Opened Payback. Hours and money in it are estimates.' }
855  }).catch(() => ({ text: 'Payback could not run that command. Try /payback again.' }))
856
857  on('prompt.submit', ($, e, next) => {
858    if (!e.command && !e.isMeta && !e.turnId && TYPED_ORIGINS.has(e.origin.kind) && !isSummaryJob(e.text)) typed = e.text
859    return next(e)
860  }).catch(($, e, next) => next(e))
861
862  on('turn.start', ($, e, next) => {
863    if (!e.agentId && typed !== null) {
864      cur = { ...newTask(), turnId: e.turnId, text: typed }
865      typed = null
866    }
867    return next(e)
868  }).catch(($, e, next) => next(e))
869
870  on('tool.call', async ($, e, next) => {
871    const startedAt = await $.clock.now()
872    const ran = await next(e)
873    try {
874      if (ran.deny !== undefined) return ran
875      const ms = (await $.clock.now()) - startedAt
876      const run = { tool: String(e.tool), input: e as unknown as Record<string, unknown>, result: ran.result, isOk: ran.isError !== true, output: ran.text ?? '', ms }
877      const delta = toolDelta(run, cur)
878      await record($, dayKey(await $.clock.now()), delta)
879    } catch {}
880    return ran
881  }).catch(($, e, next) => next(e))
882
883  on('turn.complete', async ($, e, next) => {
884    try {
885      const day = dayKey(await $.clock.now())
886      const delta: Day = {}
887      if (e.usage)
888        delta.tokens = {
889          [e.usage.model]: { in: e.usage.input_tokens, out: e.usage.output_tokens, cacheRead: e.usage.cache_read_input_tokens, cacheWrite: e.usage.cache_creation_input_tokens },
890        }
891      const task = cur
892      if (!e.agentId && task && task.turnId === e.turnId) {
893        cur = null
894        if (e.reason === 'answer') {
895          const done = finishTask(task, e.usage?.model ?? 'unknown')
896          Object.assign(delta, done.delta)
897          const size = done.size
898          if (size) {
899            if ((await loadSettings($)).judge) {
900              queue.push({ day, size, prompt: judgePrompt(task.text, task, task.files.size) })
901              $.clock.after(50, () => void drain($))
902            }
903          }
904        }
905      }
906      await record($, day, delta)
907    } catch {}
908    return next(e)
909  })
910
911  // The status bar button: shown in the hint line under the prompt while it is idle.
912  on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
913    if (e.props.isDraft || e.props.isWorking) return next(e)
914    const { Box, Text, Button } = $.ui.resolve(e)
915    return (
916      <Box key="hint" flexDirection="row">
917        <Text dimColor wrap="truncate-end">{e.props.hint ? `${e.props.hint}  ` : ''}</Text>
918        <Button key="open-payback" label="Payback" plain onPress={() => void openPane($)} />
919      </Box>
920    )
921  })
922
923  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
924    const t = await read($, tab)
925    const imp = await read($, importView)
926    const source = await read($, sourceView)
927    await read($, rev)
928    const now = await $.clock.now()
929    const [settings, days] = await Promise.all([loadSettings($), loadDays($, now)])
930    const both = view(t, now, days).total
931    const shown = Object.fromEntries(Object.entries(days).map(([k, d]) => [k, bySource(d, source)]))
932    const who: Who = { source, you: taskCount(both), sdk: taskCount(both.sdk ?? {}), hasSdk: !isEmptyDay(both.sdk ?? {}), set: s => void update($, sourceView, () => s) }
933    return drawPane($.ui.resolve(e), e.surface === 'terminal', view(t, now, shown), settings, e.props.bodyColumns, next => void update($, tab, () => next), money => void setCurrency($, money), imp, {
934      importNow: () => void applyImport($),
935      cancel: () => void cancelImport($),
936      close: () => void setImport($, IDLE),
937    }, who)
938  })
939}
940
hooks/stats.ts 529 lines
1// Pure logic of Payback: no `$`, so the tests reach it directly.
2import type { Counter, Currency, Day, DevKind, Kind, Settings, Size, Source, Tab } from '../types'
3
4export const KINDS: Kind[] = ['coding', 'devops', 'emails', 'images', 'documents', 'research', 'questions', 'other']
5export const KIND_LABEL: Record<Kind, string> = {
6  coding: 'Coding',
7  devops: 'DevOps & shell',
8  emails: 'Emails',
9  images: 'Images and video',
10  documents: 'Documents',
11  research: 'Research',
12  questions: 'Questions & planning',
13  other: 'Other',
14}
15export const DEV_KINDS: DevKind[] = ['feature', 'bugfix', 'refactor', 'tests', 'debug', 'docs', 'devops', 'review']
16export const SIZES: Size[] = ['s', 'm', 'l']
17
18export const DEFAULTS: Settings = {
19  rate: 50,
20  currency: 'usd',
21  minutes: { devops: 20, emails: 10, documents: 30, images: 20, research: 25, questions: 10, other: 10 },
22  sizes: { s: 15, m: 45, l: 120 },
23  judge: false, // off until the person turns it on: it sends part of each coding request to a model
24}
25
26export function withDefaults(stored: unknown): Settings {
27  const s = (stored ?? {}) as Partial<Settings>
28  return {
29    rate: s.rate ?? DEFAULTS.rate,
30    currency: s.currency === 'eur' ? 'eur' : 'usd',
31    minutes: { ...DEFAULTS.minutes, ...s.minutes },
32    sizes: { ...DEFAULTS.sizes, ...s.sizes },
33    judge: s.judge ?? DEFAULTS.judge,
34  }
35}
36
37// ---------------------------------------------------------------- days
38
39const DAY_MS = 86_400_000
40
41/** Local noon of the day `ms` falls on: stepping whole days from noon never crosses a date when clocks change. */
42export function noon(ms: number): Date {
43  const d = new Date(ms)
44  return new Date(d.getFullYear(), d.getMonth(), d.getDate(), 12)
45}
46
47export function dayKey(ms: number): string {
48  const d = new Date(ms)
49  const pad = (n: number) => String(n).padStart(2, '0')
50  return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
51}
52
53/** The day `n` days before the day `ms` falls on, at local noon. */
54export function daysBack(ms: number, n: number): number {
55  return noon(ms).getTime() - n * DAY_MS
56}
57
58export function dayOfYear(ms: number): number {
59  const d = noon(ms)
60  return Math.round((d.getTime() - new Date(d.getFullYear(), 0, 1, 12).getTime()) / DAY_MS) + 1
61}
62
63type Tree = { [k: string]: number | Tree | undefined }
64
65function addTree(a: Tree, b: Tree): Tree {
66  const out: Tree = { ...a }
67  for (const [k, v] of Object.entries(b)) {
68    if (v === undefined) continue
69    const cur = out[k]
70    out[k] = typeof v === 'number' ? (typeof cur === 'number' ? cur : 0) + v : addTree((cur as Tree) ?? {}, v)
71  }
72  return out
73}
74
75/** Adds two days field by field; nested counts add, missing ones count as zero. */
76export function addDays(a: Day, b: Day): Day {
77  return addTree(a as Tree, b as Tree) as Day
78}
79
80/** A day as one source sees it: `you` the person's own sessions, `sdk` the ones the SDK started, `all` both. */
81export function bySource(day: Day, source: Source): Day {
82  const { sdk, ...own } = day
83  return source === 'you' ? own : source === 'sdk' ? (sdk ?? {}) : addDays(own, sdk ?? {})
84}
85
86/** A request that only asks Claude to summarise an earlier session: housekeeping, no task. */
87export const isSummaryJob = (text: string) => /^\s*Below is a conversation log from a Claude Code/.test(text)
88
89/** A session run from a temp folder: where scripts test the harness, not real work. */
90export const isTempDir = (cwd: string) =>
91  /[\\/]AppData[\\/]Local[\\/]Temp([\\/]|$)|^\/tmp(\/|$)|^(\/private)?\/var\/folders\//i.test(cwd)
92
93export function isEmptyDay(d: Day): boolean {
94  const walk = (t: Tree): boolean => Object.values(t).every(v => (typeof v === 'number' ? v === 0 : v === undefined || walk(v)))
95  return walk(d as Tree)
96}
97
98const sum = (r: Record<string, number | undefined> | undefined) => Object.values(r ?? {}).reduce<number>((a, v) => a + (v ?? 0), 0)
99
100export const taskCount = (d: Day) => sum(d.tasks)
101
102/** Days in a row with a finished task, counted back from today (or from yesterday while today has none yet). */
103export function streak(days: Record<string, Day>, nowMs: number): number {
104  let n = 0
105  let i = taskCount(days[dayKey(nowMs)] ?? {}) > 0 ? 0 : 1
106  while (taskCount(days[dayKey(daysBack(nowMs, i))] ?? {}) > 0) {
107    n++
108    i++
109  }
110  return n
111}
112
113export function minutesSaved(d: Day, s: Settings): number {
114  let m = 0
115  for (const k of KINDS) if (k !== 'coding') m += (d.tasks?.[k] ?? 0) * s.minutes[k]
116  for (const z of SIZES) m += (d.sizes?.[z] ?? 0) * s.sizes[z]
117  return m
118}
119
120// ---------------------------------------------------------------- tasks
121
122export type TaskSignals = {
123  tools: string[]
124  codeEdits: number
125  docWrites: number
126  mediaWrites: number
127  devShell: number
128  added: number
129  removed: number
130}
131
132const MAIL = /mail|gmail|outlook|inbox|imap/i
133const MEDIA_TOOL = /image|video|sprite|animat|portrait|screencast|gif_creator|3d_model|asset_preview/i
134const DOC_TOOL = /(^|_)(docs?|document|slides?|sheets?|spreadsheet|pptx|xlsx|docx|pdf|word|excel|powerpoint)(_|$)/i
135const RESEARCH_TOOL = /search|fetch|transcript|get_page_text|read_page|navigate/i
136/** Connected tools that are building software: a game engine, a code host, an error tracker, a database. */
137const CODE_TOOL = /unity|godot|github|gitlab|sentry|supabase|postgres|sqlite|mysql|prisma/i
138/** Connected tools that run infrastructure. */
139const OPS_TOOL = /docker|kube|k8s|aws|gcloud|azure|vercel|netlify|cloudflare|terraform|ssh/i
140/** Tools that only look, ask or keep notes: a task of these alone is a question answered or a plan made. */
141const LOOKING = new Set(['Read', 'Grep', 'Glob', 'LSP', 'ToolSearch', 'AskUserQuestion', 'TodoWrite', 'TaskCreate', 'TaskUpdate', 'TaskList', 'TaskGet', 'Skill', 'EnterPlanMode', 'ExitPlanMode'])
142const SHELL = new Set(['Bash', 'PowerShell', 'Monitor'])
143
144/** One kind per task, by the tools it used; coding first, then the order of the original report. */
145export function classify(t: TaskSignals): Kind {
146  // Names are read only off connected (MCP) tools: the engine's own ToolSearch or FetchInboxMessage say nothing of the task.
147  const mcp = t.tools.filter(n => n.startsWith('mcp__'))
148  if (t.codeEdits > 0 || t.devShell > 0 || mcp.some(n => CODE_TOOL.test(n))) return 'coding'
149  if (mcp.some(n => MAIL.test(n))) return 'emails'
150  if (t.mediaWrites > 0 || mcp.some(n => MEDIA_TOOL.test(n))) return 'images'
151  if (t.docWrites > 0 || mcp.some(n => DOC_TOOL.test(n))) return 'documents'
152  if (t.tools.some(n => n === 'WebSearch' || n === 'WebFetch') || mcp.some(n => RESEARCH_TOOL.test(n))) return 'research'
153  if (t.tools.some(n => SHELL.has(n)) || mcp.some(n => OPS_TOOL.test(n))) return 'devops'
154  if (t.tools.every(n => LOOKING.has(n))) return 'questions'
155  return 'other'
156}
157
158/** Size of a coding task from the lines it changed, until the judge says otherwise. */
159export function sizeByLines(lines: number): Size {
160  return lines < 40 ? 's' : lines < 250 ? 'm' : 'l'
161}
162
163/** A typed request in flight: what its tools did so far. */
164export type Task = TaskSignals & { files: Set<string> }
165
166export const newTask = (): Task => ({
167  files: new Set(), tools: [], codeEdits: 0, docWrites: 0, mediaWrites: 0, devShell: 0, added: 0, removed: 0,
168})
169
170/** One finished tool call, as the live hook sees it and as a transcript replays it. */
171export type ToolRun = {
172  tool: string
173  input: Record<string, unknown>
174  /** The tool's own record (an Edit's `structuredPatch`), when it has one. */
175  result: unknown
176  isOk: boolean
177  /** What the tool printed, for a test runner's pass and fail counts. */
178  output: string
179  ms: number
180}
181
182/** What one finished tool call adds to its day, also noting it on the task in flight. */
183export function toolDelta(r: ToolRun, task: Task | null): Day {
184  const delta: Day = {}
185  const counts: Partial<Record<Counter, number>> = {}
186  const add = (k: Counter, x: number) => x !== 0 && (counts[k] = (counts[k] ?? 0) + x)
187  task?.tools.push(r.tool)
188
189  const path = r.tool === 'NotebookEdit' ? r.input.notebook_path : r.input.file_path
190  if (r.isOk && (r.tool === 'Edit' || r.tool === 'Write' || r.tool === 'NotebookEdit') && typeof path === 'string') {
191    const lines =
192      r.tool === 'NotebookEdit'
193        ? { added: String(r.input.new_source ?? '').split('\n').length, removed: 0 }
194        : countPatch(r.result as PatchResult | undefined)
195    const fk = fileKind(path)
196    add('linesAdded', lines.added)
197    add('linesRemoved', lines.removed)
198    if (!task || !task.files.has(path)) add('files', 1)
199    if (fk.lang && lines.added + lines.removed > 0) delta.langs = { [fk.lang]: lines.added + lines.removed }
200    if (task) {
201      task.files.add(path)
202      task.added += lines.added
203      task.removed += lines.removed
204      if (fk.kind === 'code') task.codeEdits++
205      if (fk.kind === 'doc') task.docWrites++
206      if (fk.kind === 'media') task.mediaWrites++
207    }
208  }
209
210  if ((r.tool === 'Bash' || r.tool === 'PowerShell') && typeof r.input.command === 'string') {
211    const facts = parseShell(r.input.command)
212    const isForeground = r.input.run_in_background !== true
213    if (r.isOk) for (const [k, x] of Object.entries(facts.n)) add(k as Counter, x ?? 0)
214    if (isForeground && facts.isTest) {
215      add('testRuns', 1)
216      add('testFails', r.isOk ? 0 : 1)
217      add('testMs', r.ms)
218      const t = parseTestCounts(r.output)
219      add('testsPassed', t.passed)
220      // A run that exited cleanly failed nothing: its output's "failed" is a test's own name or log line.
221      add('testsFailed', r.isOk ? 0 : t.failed)
222    }
223    if (isForeground && facts.isBuild) {
224      add('builds', 1)
225      add('buildFails', r.isOk ? 0 : 1)
226    }
227    if (task && facts.isDev) task.devShell++
228  }
229
230  if (Object.keys(counts).length > 0) delta.n = counts
231  return delta
232}
233
234/** What a task that finished with an answer adds: one task of its kind, its model, and a size when it is coding. */
235export function finishTask(task: Task, model: string): { delta: Day; kind: Kind; size?: Size } {
236  const kind = classify(task)
237  const delta: Day = { tasks: { [kind]: 1 }, models: { [model]: 1 } }
238  if (kind !== 'coding') return { delta, kind }
239  const size = sizeByLines(task.added + task.removed)
240  delta.sizes = { [size]: 1 }
241  return { delta, kind, size }
242}
243
244// ---------------------------------------------------------------- files
245
246const LANG: Record<string, string> = {
247  ts: 'TypeScript', tsx: 'TypeScript', mts: 'TypeScript', cts: 'TypeScript',
248  js: 'JavaScript', jsx: 'JavaScript', mjs: 'JavaScript', cjs: 'JavaScript',
249  py: 'Python', ipynb: 'Python', cs: 'C#', java: 'Java', kt: 'Kotlin', go: 'Go', rs: 'Rust',
250  rb: 'Ruby', php: 'PHP', c: 'C', h: 'C', cpp: 'C++', cc: 'C++', hpp: 'C++', swift: 'Swift',
251  dart: 'Dart', scala: 'Scala', lua: 'Lua', r: 'R', ex: 'Elixir', exs: 'Elixir',
252  html: 'HTML', htm: 'HTML', css: 'CSS', scss: 'CSS', sass: 'CSS', less: 'CSS',
253  vue: 'Vue', svelte: 'Svelte', sql: 'SQL', sh: 'Shell', bash: 'Shell', zsh: 'Shell', ps1: 'PowerShell',
254  json: 'Config', yaml: 'Config', yml: 'Config', toml: 'Config', xml: 'Config', ini: 'Config',
255  shader: 'Shader', hlsl: 'Shader', glsl: 'Shader', gradle: 'Config', dockerfile: 'Config',
256}
257const DOC_EXT = new Set(['md', 'mdx', 'txt', 'rst', 'docx', 'doc', 'pptx', 'xlsx', 'csv', 'pdf', 'odt'])
258const MEDIA_EXT = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'mp4', 'mov', 'webm', 'mp3', 'wav'])
259
260export type FileKind = { lang: string | null; kind: 'code' | 'doc' | 'media' | 'other' }
261
262export function fileKind(path: string): FileKind {
263  const name = path.split(/[\\/]/).pop()!.toLowerCase()
264  const ext = name === 'dockerfile' || name === 'makefile' ? name : (name.includes('.') ? name.split('.').pop()! : '')
265  if (LANG[ext]) return { lang: LANG[ext], kind: 'code' }
266  if (ext === 'makefile') return { lang: 'Config', kind: 'code' }
267  if (DOC_EXT.has(ext)) return { lang: ext === 'md' || ext === 'mdx' ? 'Markdown' : null, kind: 'doc' }
268  if (MEDIA_EXT.has(ext)) return { lang: null, kind: 'media' }
269  return { lang: null, kind: 'other' }
270}
271
272type Hunk = { lines: string[] }
273type PatchResult = {
274  type?: string
275  content?: string
276  structuredPatch?: readonly Hunk[]
277  gitDiff?: { additions: number; deletions: number }
278}
279
280/** Lines an Edit or Write changed, from its result. */
281export function countPatch(r: PatchResult | undefined): { added: number; removed: number } {
282  if (!r) return { added: 0, removed: 0 }
283  if (r.gitDiff) return { added: r.gitDiff.additions, removed: r.gitDiff.deletions }
284  let added = 0
285  let removed = 0
286  for (const hunk of r.structuredPatch ?? [])
287    for (const line of hunk.lines) {
288      if (line.startsWith('+')) added++
289      else if (line.startsWith('-')) removed++
290    }
291  if (added + removed === 0 && r.type === 'create' && r.content) added = r.content.split('\n').length
292  return { added, removed }
293}
294
295// ---------------------------------------------------------------- shell
296
297export type ShellFacts = { n: Partial<Record<Counter, number>>; isTest: boolean; isBuild: boolean; isDev: boolean }
298
299// Runners match at the head of a command, so `pip install pytest` or `cat jest.config.js` is no test run.
300const TEST = /^(pytest|py\.test|jest|vitest|mocha|rspec|phpunit|ctest|go\s+test|cargo\s+(nextest|test)|dotnet\s+test|mvnw?\s+(\S+\s+)*test|(\.[\\/])?gradlew?(\.bat)?\s+(\S+\s+)*test|(python3?|py)\s+-m\s+(pytest|unittest)|(npm|pnpm|yarn|bun)\s+(run\s+)?test|claude\s+plugin\s+test|flutter\s+test|deno\s+test)\b/i
301const BUILD = /^(tsc|cargo\s+build|go\s+build|dotnet\s+build|mvnw?\s+(package|install|compile)|(\.[\\/])?gradlew?(\.bat)?\s+(assemble|build)|make|cmake\s+--build|vite\s+build|next\s+build|webpack|ng\s+build|(npm|pnpm|yarn|bun)\s+(run\s+)?build|msbuild)\b/i
302// Env assignments, PowerShell's `&` and runner wrappers in front of the command itself.
303const WRAPPER = /^(?:&\s*)?(?:[A-Z_][A-Z0-9_]*=\S*\s+)*(?:(?:npx|bunx|pnpm\s+(?:exec|dlx)|uv\s+run|poetry\s+run|pipenv\s+run)\s+)?/i
304const GIT = /^(?:&\s*)?git(?:\.exe)?((?:\s+-[Cc]\s+(?:"[^"]*"|'[^']*'|\S+))*)\s+([a-z-]+)(.*)$/i
305const GH = /^(?:&\s*)?gh(?:\.exe)?\s+(pr|issue)\s+(create|merge|close)\b/i
306
307/** Quoted strings and here-docs removed, so a commit message holding `; git push` counts nothing. */
308function stripQuoted(cmd: string): string {
309  return cmd
310    .replace(/<<-?\s*['"]?(\w+)['"]?[\s\S]*?\n\s*\1\b/g, '')
311    .replace(/@'[\s\S]*?'@|@"[\s\S]*?"@/g, '""')
312    .replace(/"(?:\\.|[^"\\])*"|'[^']*'/g, '""')
313}
314
315/** What one Bash or PowerShell command did for the developer: git, gh, test and build runs. */
316export function parseShell(cmd: string): ShellFacts {
317  const n: Partial<Record<Counter, number>> = {}
318  const inc = (k: Counter) => (n[k] = (n[k] ?? 0) + 1)
319  let isTest = false
320  let isBuild = false
321  let isDev = false
322  const clean = stripQuoted(cmd)
323  for (const raw of clean.split(/&&|\|\||;|\||\r?\n|\bif\s*\(\s*\$\?\s*\)\s*\{|[{}]/)) {
324    const seg = raw.trim()
325    const head = seg.replace(WRAPPER, '')
326    if (TEST.test(head)) isTest = isDev = true
327    else if (BUILD.test(head)) isBuild = isDev = true
328    if (/^(?:&\s*)?(git|gh)(\.exe)?\s/i.test(seg)) isDev = true
329    const git = GIT.exec(seg)
330    if (git) {
331      const sub = git[2]!.toLowerCase()
332      const rest = git[3] ?? ""
333      if (/--dry-run|(^|\s)-n(\s|$)/.test(rest) && (sub === 'commit' || sub === 'push')) continue
334      if (sub === 'commit') inc('commits')
335      else if (sub === 'push') inc('pushes')
336      else if (sub === 'merge' && !/--abort/.test(rest)) inc('merges')
337      else if (sub === 'rebase' && !/--abort|--continue|--skip|--quit/.test(rest)) inc('rebases')
338      else if ((sub === 'checkout' && /(^|\s)-[bB]\s/.test(rest)) || (sub === 'switch' && /(^|\s)(-[cC]|--create)\s/.test(rest))) inc('branches')
339      else if (sub === 'branch' && /^\s+[^-\s]\S*\s*\S*\s*$/.test(rest)) inc('branches')
340      continue
341    }
342    const gh = GH.exec(seg)
343    if (gh) {
344      const what = gh[1]!.toLowerCase()
345      const act = gh[2]!.toLowerCase()
346      if (what === 'pr' && act === 'create') inc('prsOpened')
347      if (what === 'pr' && act === 'merge') inc('prsMerged')
348      if (what === 'issue' && act === 'create') inc('issuesOpened')
349      if (what === 'issue' && act === 'close') inc('issuesClosed')
350    }
351  }
352  return { n, isTest, isBuild: isBuild && !isTest, isDev }
353}
354
355const lastNum = (text: string, re: RegExp) => {
356  let found: number | null = null
357  for (const m of text.matchAll(re)) found = Number(m[1])
358  return found
359}
360
361/** Passed and failed test counts from a runner's summary (pytest, jest, vitest, cargo, dotnet, go's own words). */
362export function parseTestCounts(output: string): { passed: number; failed: number } {
363  // ponytail: last summary line wins; a runner printing per-suite totals after the grand total reads low.
364  const passed = lastNum(output, /(\d+)\s+passed/gi) ?? lastNum(output, /Passed:\s*(\d+)/g) ?? 0
365  const failed = lastNum(output, /(\d+)\s+failed/gi) ?? lastNum(output, /Failed:\s*(\d+)/g) ?? 0
366  return { passed, failed }
367}
368
369// ---------------------------------------------------------------- judge
370
371export const JUDGE_MODEL = 'claude-haiku-5-5'
372
373export const JUDGE_SYSTEM =
374  'You label one finished software task. Reply with two words and nothing else: TYPE SIZE. ' +
375  'TYPE is one of feature, bugfix, refactor, tests, debug, docs, devops, review. ' +
376  'SIZE is S, M or L: the time a senior developer would need by hand, S under 30 minutes, M 30 to 90 minutes, L over 90 minutes.'
377
378export function judgePrompt(text: string, t: TaskSignals, files: number): string {
379  const tools = Object.entries(t.tools.reduce<Record<string, number>>((a, n) => ((a[n] = (a[n] ?? 0) + 1), a), {}))
380    .map(([n, c]) => `${n} x${c}`)
381    .join(', ')
382  return `Request: ${text.slice(0, 1500)}\nTools: ${tools || 'none'}\nLines: +${t.added} -${t.removed} across ${files} files`
383}
384
385export function parseJudge(reply: string): { kind: DevKind; size: Size } | null {
386  const m = /\b(feature|bugfix|refactor|tests|debug|docs|devops|review)\b\W+\b([sml])\b/i.exec(reply)
387  return m ? { kind: m[1]!.toLowerCase() as DevKind, size: m[2]!.toLowerCase() as Size } : null
388}
389
390// ---------------------------------------------------------------- views
391
392export type Bucket = { label: string; value: number; isCurrent: boolean; isFuture: boolean }
393export type View = {
394  tab: Tab
395  title: string
396  dates: string
397  total: Day
398  chart: Bucket[]
399  summary: string
400  activeDays: number
401}
402
403const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
404const WEEKDAYS = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa']
405const short = (ms: number) => {
406  const d = new Date(ms)
407  return `${d.getDate()} ${MONTHS[d.getMonth()]}`
408}
409/** `2026-09-19` reads `19 Sep`. */
410export const shortDay = (key: string) => {
411  const [y, m, d] = key.split('-').map(Number)
412  return short(new Date(y ?? 0, (m ?? 1) - 1, d ?? 1, 12).getTime())
413}
414const long = (ms: number) => `${WEEKDAYS[new Date(ms).getDay()]} ${short(ms)} ${new Date(ms).getFullYear()}`
415
416export const TITLES: Record<Tab, string> = { today: 'TODAY', week: 'WEEK', month: 'MONTH', year: 'YEAR' }
417
418/** Everything a tab draws, from the stored days: totals, the chart's buckets and the summary line. */
419export function view(tab: Tab, nowMs: number, days: Record<string, Day>): View {
420  const span = tab === 'today' ? 1 : tab === 'week' ? 7 : tab === 'month' ? 30 : dayOfYear(nowMs)
421  const keys = Array.from({ length: span }, (_, i) => daysBack(nowMs, span - 1 - i))
422  const dayOf = (ms: number) => days[dayKey(ms)] ?? {}
423  const total = keys.reduce((a, ms) => addDays(a, dayOf(ms)), {} as Day)
424  const activeDays = keys.filter(ms => taskCount(dayOf(ms)) > 0).length
425
426  let chart: Bucket[]
427  if (tab === 'year') {
428    const now = new Date(nowMs)
429    chart = MONTHS.map((label, m) => ({
430      label: label[0]!,
431      value: keys.filter(ms => new Date(ms).getMonth() === m).reduce((a, ms) => a + taskCount(dayOf(ms)), 0),
432      isCurrent: m === now.getMonth(),
433      isFuture: m > now.getMonth(),
434    }))
435  } else {
436    const n = tab === 'month' ? 30 : 7
437    chart = Array.from({ length: n }, (_, i) => {
438      const ms = daysBack(nowMs, n - 1 - i)
439      return { label: n === 7 ? WEEKDAYS[new Date(ms).getDay()]! : String(new Date(ms).getDate()), value: taskCount(dayOf(ms)), isCurrent: i === n - 1, isFuture: false }
440    })
441  }
442
443  let summary: string
444  if (tab === 'today' || tab === 'week') {
445    const s = streak(days, nowMs)
446    summary = `Streak: ${s} ${s === 1 ? 'day' : 'days'} in a row`
447  } else {
448    const best = tab === 'year' ? chart.reduce((a, b) => (b.value > a.value ? b : a), chart[0]!) : null
449    const bestDay = keys.reduce((a, ms) => (taskCount(dayOf(ms)) > taskCount(dayOf(a)) ? ms : a), keys[0]!)
450    const bestText =
451      tab === 'year'
452        ? `Best month: ${MONTHS[chart.indexOf(best!)]} (${best!.value} tasks)`
453        : `Best day: ${short(bestDay)} (${taskCount(dayOf(bestDay))} tasks)`
454    summary = `${bestText} · ${activeDays} active ${activeDays === 1 ? 'day' : 'days'} of ${span}`
455  }
456
457  const first = keys[0]!
458  const dates = tab === 'today' ? long(nowMs) : `${short(first)} – ${short(nowMs)} ${new Date(nowMs).getFullYear()}`
459  return { tab, title: TITLES[tab], dates, total, chart, summary, activeDays }
460}
461
462// ---------------------------------------------------------------- drawing helpers
463
464/** Big numerals, three rows tall, in heavy box-drawing characters. */
465const GLYPHS: Record<string, [string, string, string]> = {
466  '0': ['┏━┓', '┃ ┃', '┗━┛'],
467  '1': ['╺┓ ', ' ┃ ', '╺┻╸'],
468  '2': ['┏━┓', '┏━┛', '┗━╸'],
469  '3': ['╺━┓', ' ━┫', '╺━┛'],
470  '4': ['╻ ╻', '┗━┫', '  ╹'],
471  '5': ['┏━╸', '┗━┓', '╺━┛'],
472  '6': ['┏━╸', '┣━┓', '┗━┛'],
473  '7': ['╺━┓', '  ┃', '  ╹'],
474  '8': ['┏━┓', '┣━┫', '┗━┛'],
475  '9': ['┏━┓', '┗━┫', '╺━┛'],
476  '.': [' ', ' ', '╹'],
477  ',': [' ', ' ', '┛'],
478}
479
480/** The three rows of `text` in big numerals, or null when it holds a character the font lacks or is wider than `width`. */
481export function bigText(text: string, width: number): [string, string, string] | null {
482  const rows: [string, string, string] = ['', '', '']
483  for (const ch of text) {
484    const g = GLYPHS[ch]
485    if (!g) return null
486    for (let r = 0; r < 3; r++) rows[r] += (rows[r] ? ' ' : '') + g[r]
487  }
488  return [...rows[0]].length <= width ? rows : null
489}
490
491export const fmtInt = (n: number) => Math.round(n).toLocaleString('en-US')
492/** `$1,234` or `1.234 €`: each currency written the way its readers write it. */
493export const fmtMoney = (amount: number, currency: Currency) =>
494  currency === 'eur' ? `${Math.round(amount).toLocaleString('de-DE')} €` : `$${fmtInt(amount)}`
495
496export const fmtHours = (minutes: number) => {
497  const h = minutes / 60
498  return h < 10 ? (Math.round(h * 10) / 10).toFixed(1) : fmtInt(h)
499}
500export const fmtCompact = (n: number) =>
501  n >= 1e9 ? `${(n / 1e9).toFixed(1)}B` : n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1e4 ? `${Math.round(n / 1e3)}k` : n >= 1e3 ? `${(n / 1e3).toFixed(1)}k` : String(Math.round(n))
502export const fmtDuration = (ms: number) => {
503  const s = Math.round(ms / 1000)
504  return s < 60 ? `${s}s` : s < 3600 ? `${Math.floor(s / 60)}m ${s % 60}s` : `${Math.floor(s / 3600)}h ${Math.floor((s % 3600) / 60)}m`
505}
506
507/** `claude-opus-5-5` reads `Opus 5.5`; an id it cannot read stays as it is. */
508export function modelName(id: string): string {
509  const m = /claude-([a-z]+)-(\d+)(?:-(\d+))?/i.exec(id)
510  return m ? `${m[1]![0]!.toUpperCase()}${m[1]!.slice(1)} ${m[2]}${m[3] && m[3].length < 3 ? `.${m[3]}` : ''}` : id
511}
512
513/** Tiles per row for a body `width` cells wide. */
514export const tilesPerRow = (width: number) => (width >= 66 ? 3 : width >= 44 ? 2 : 1)
515
516const EIGHTHS = ' ▁▂▃▄▅▆▇█'
517
518/** Row `row` (0 top, 2 bottom) of a three-row bar for `value` against `max`. */
519export function barCell(value: number, max: number, row: number): string {
520  if (max <= 0 || value <= 0) return row === 2 ? '▁' : ' '
521  const eighths = Math.max(1, Math.round((value / max) * 24))
522  return EIGHTHS[Math.min(8, Math.max(0, eighths - (2 - row) * 8))] ?? " "
523}
524
525export function fit(text: string, width: number): string {
526  const chars = [...text]
527  return chars.length <= width ? text : width <= 1 ? chars.slice(0, width).join('') : chars.slice(0, width - 1).join('') + '…'
528}
529
hooks/history.ts 176 lines
1// Replays Claude Code's own transcripts (~/.claude/projects/**/*.jsonl) into days, by the live tracker's rules.
2// Pure: it takes lines; reading the files is the caller's.
3import type { Day } from '../types'
4import { addDays, dayKey, finishTask, isEmptyDay, isTempDir, newTask, toolDelta } from './stats'
5import type { Task } from './stats'
6
7/** Tools whose results the replay reads; any other result line (a Read's whole file) is skipped unparsed. */
8const TRACKED = new Set(['Edit', 'Write', 'NotebookEdit', 'Bash', 'PowerShell'])
9
10/** Text that is no typed request: slash commands, their output, and what the engine injects. */
11const NOT_TYPED = /^\s*(<command-name>|<command-message>|<command-args>|<local-command|<system-reminder>|<bash-input>|<bash-stdout>|<bash-stderr>|<task-notification>|<user-prompt-submit-hook>|Caveat:|This session is being continued from a previous conversation|Below is a conversation log from a Claude Code)/
12
13export type ImportRange = {
14  /** First day counted, `YYYY-MM-DD`. */
15  fromDay: string
16  /** First day not counted: the day after live tracking began, or after the end of the range asked for. */
17  untilDay: string
18  /** The moment live tracking began: nothing from then on is read, so a day it shares with the import counts once. */
19  untilMs: number
20}
21
22export type ImportResult = {
23  days: Record<string, Day>
24  /** Interactive sessions read. */
25  sessions: number
26  /** Sessions the SDK started (an assistant, a script): counted apart, under `sdk`. */
27  sdkSessions: number
28  /** SDK sessions run from a temp folder: harness tests, left out. */
29  testSessions: number
30  /** Lines that were not JSON. */
31  badLines: number
32}
33
34type Use = { tool: string; input: Record<string, unknown>; ms: number }
35type Open = Task & { ms: number; model: string; isAnswered: boolean }
36type Rec = {
37  type?: string
38  uuid?: string
39  timestamp?: string
40  entrypoint?: string
41  cwd?: string
42  isSidechain?: boolean
43  isMeta?: boolean
44  isCompactSummary?: boolean
45  toolUseResult?: unknown
46  message?: { id?: string; model?: string; content?: unknown; usage?: Record<string, number> }
47}
48type Block = { type?: string; text?: string; id?: string; name?: string; input?: Record<string, unknown>; tool_use_id?: string; is_error?: boolean; content?: unknown }
49
50const textOf = (content: unknown): string =>
51  typeof content === 'string'
52    ? content
53    : Array.isArray(content)
54      ? (content as Block[]).filter(b => b?.type === 'text').map(b => b.text ?? '').join('\n')
55      : ''
56
57/**
58 * One import run. Ids are remembered across every file it reads, so history a fork or a resume
59 * copied into another file, and a response split over several lines, count once.
60 */
61export function createImporter(range: ImportRange) {
62  const days: Record<string, Day> = {}
63  const seenPrompts = new Set<string>()
64  const seenMessages = new Set<string>()
65  const seenResults = new Set<string>()
66  let sessions = 0
67  let sdkSessions = 0
68  let testSessions = 0
69  let badLines = 0
70
71  const add = (ms: number, delta: Day) => {
72    const day = dayKey(ms)
73    if (day < range.fromDay || day >= range.untilDay || ms >= range.untilMs || isEmptyDay(delta)) return
74    days[day] = addDays(days[day] ?? {}, delta)
75  }
76
77  /** Reads one transcript; `isSubagent` for a file under `subagents/`, whose work adds counts but no tasks. */
78  function file(isSubagent: boolean) {
79    const uses = new Map<string, Use>()
80    let task: Open | null = null
81    let entry: string | null = null
82    let isSdk = false
83    let isTest = false
84    const put = (ms: number, delta: Day) => add(ms, isSdk ? { sdk: delta } : delta)
85
86    const close = () => {
87      if (task?.isAnswered) put(task.ms, finishTask(task, task.model).delta)
88      task = null
89    }
90
91    function line(raw: string) {
92      if (isTest) return
93      const isUser = raw.includes('"type":"user"')
94      if (!isUser && !raw.includes('"type":"assistant"')) return
95      // A result of a tool the replay does not count (a Read holding a whole file) is skipped before parsing.
96      const resultId = isUser ? /"tool_use_id":"([^"]+)"/.exec(raw.slice(0, 2000))?.[1] : undefined
97      if (resultId !== undefined && !TRACKED.has(uses.get(resultId)?.tool ?? '')) return
98
99      let o: Rec
100      try {
101        o = JSON.parse(raw) as Rec
102      } catch {
103        badLines++
104        return
105      }
106      if (entry === null && typeof o.entrypoint === 'string') {
107        entry = o.entrypoint
108        isSdk = entry.startsWith('sdk')
109        isTest = isSdk && isTempDir(o.cwd ?? '')
110        if (!isSubagent) isTest ? testSessions++ : isSdk ? sdkSessions++ : sessions++
111        if (isTest) return
112      }
113      const ms = Date.parse(o.timestamp ?? '')
114      if (Number.isNaN(ms)) return
115      const isSide = isSubagent || o.isSidechain === true
116      const content = o.message?.content
117
118      if (o.type === 'assistant') {
119        const model = o.message?.model ?? 'unknown'
120        if (model === '<synthetic>') return
121        const id = o.message?.id
122        const u = o.message?.usage
123        if (u && id && !seenMessages.has(id)) {
124          seenMessages.add(id)
125          put(ms, { tokens: { [model]: { in: u.input_tokens ?? 0, out: u.output_tokens ?? 0, cacheRead: u.cache_read_input_tokens ?? 0, cacheWrite: u.cache_creation_input_tokens ?? 0 } } })
126        }
127        for (const b of Array.isArray(content) ? (content as Block[]) : []) {
128          if (b?.type === 'tool_use' && b.id && b.name) {
129            uses.set(b.id, { tool: b.name, input: b.input ?? {}, ms })
130            // Every tool sorts the task (WebSearch makes it research); the tracked ones are noted when their result comes.
131            if (task && !isSubagent && !TRACKED.has(b.name)) task.tools.push(b.name)
132          }
133          if (b?.type === 'text' && (b.text ?? '').trim() !== '' && !isSide && task) {
134            task.isAnswered = true
135            task.model = model
136          }
137        }
138        return
139      }
140
141      if (o.type !== 'user') return
142      const results = Array.isArray(content) ? (content as Block[]).filter(b => b?.type === 'tool_result') : []
143      if (results.length > 0) {
144        for (const b of results) {
145          const id = b.tool_use_id
146          const use = id ? uses.get(id) : undefined
147          if (!id || !use || seenResults.has(id)) continue
148          seenResults.add(id)
149          const record = o.toolUseResult as { stdout?: string } | undefined
150          put(ms, toolDelta({ tool: use.tool, input: use.input, result: o.toolUseResult, isOk: b.is_error !== true, output: record?.stdout ?? textOf(b.content), ms: ms - use.ms }, isSubagent ? null : task))
151        }
152        return
153      }
154      if (isSide || o.isMeta || o.isCompactSummary) return
155      const text = textOf(content)
156      if (text.startsWith('[Request interrupted')) {
157        task = null // an interrupted request is no finished task
158        return
159      }
160      if (NOT_TYPED.test(text)) {
161        close()
162        return
163      }
164      close()
165      if (o.uuid && seenPrompts.has(o.uuid)) return // copied history: counted where it was first read
166      if (o.uuid) seenPrompts.add(o.uuid)
167      task = { ...newTask(), ms, model: 'unknown', isAnswered: false }
168    }
169
170    return { line, end: close }
171  }
172
173  const result = (): ImportResult => ({ days, sessions, sdkSessions, testSessions, badLines })
174  return { file, result }
175}
176
hooks/dashboard.ts 253 lines
1// The pane's body (tiles, chart, cards) drawn as one SVG, for wide desktop panes.
2// Text rows in the desktop's type leave uneven gaps and cannot round a corner or print a big numeral cleanly;
3// a picture lays out exactly as written. It carries its own dark background, so it reads in a light app theme too.
4import type { Counter, Kind, Settings } from '../types'
5import {
6  DEV_KINDS, KINDS, KIND_LABEL, SIZES, fit, fmtCompact, fmtDuration, fmtHours, fmtInt, fmtMoney, minutesSaved, modelName, taskCount,
7} from './stats'
8import type { View } from './stats'
9import { C, KIND_COLOR } from './theme'
10
11const MONO = "ui-monospace, SFMono-Regular, 'Cascadia Code', Menlo, Consolas, 'Liberation Mono', monospace"
12const SANS = "-apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif"
13/** Pixels one character takes at 13px, with room for whichever monospace font the machine falls back to. */
14const CHAR = 8
15
16/** Widest the picture is drawn; a wider pane leaves the rest empty rather than stretching the type. */
17export const DASHBOARD_MAX = 1200
18/** Pixels one pane column takes, as the desktop lays out its monospace type. */
19const COLUMN_PX = 7.8
20/** Narrowest picture that still lays out cleanly (tiles, the model table), about 62 pane columns. */
21export const MIN_WIDTH = 484
22const PAD = 28
23const GAP = 14
24const ROW = 24
25
26const esc = (s: string) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
27
28type Style = { fill?: string; size?: number; weight?: number; anchor?: 'start' | 'middle' | 'end'; font?: string }
29const text = (x: number, y: number, s: string, { fill = C.text, size = 13, weight = 400, anchor = 'start', font = MONO }: Style = {}) =>
30  `<text x="${x.toFixed(1)}" y="${y.toFixed(1)}" fill="${fill}" font-size="${size}" font-weight="${weight}" text-anchor="${anchor}" font-family="${font}">${esc(s)}</text>`
31const box = (x: number, y: number, w: number, h: number, fill: string, rx = 0, stroke?: string) =>
32  `<rect x="${x.toFixed(1)}" y="${y.toFixed(1)}" width="${w.toFixed(1)}" height="${h.toFixed(1)}" rx="${rx}" fill="${fill}"${stroke ? ` stroke="${stroke}"` : ''}/>`
33
34type Span = [text: string, color?: string]
35/** Coloured parts of one line, laid out as one run of text. */
36const spans = (x: number, y: number, parts: Span[], size = 13) =>
37  `<text x="${x.toFixed(1)}" y="${y.toFixed(1)}" font-size="${size}" font-family="${MONO}" xml:space="preserve">${parts.map(([t, c]) => `<tspan fill="${c ?? C.text}">${esc(t)}</tspan>`).join('')}</text>`
38
39/** Items joined with " · ", broken into lines no wider than `chars`. */
40function wrapItems(items: string[], chars: number): string[] {
41  const lines: string[] = []
42  let line = ''
43  for (const item of items) {
44    const next = line ? `${line} · ${item}` : item
45    if (line && next.length > chars) {
46      lines.push(line)
47      line = item
48    } else line = next
49  }
50  if (line) lines.push(line)
51  return lines
52}
53
54const plural = (x: number, one: string, many = `${one}s`) => `${fmtInt(x)} ${x === 1 ? one : many}`
55
56type Card = { h: number; svg: (x: number, y: number, w: number) => string }
57
58/** A rounded card with a muted title and `lines` rows below it; `draw` paints them given the card's left edge, first baseline and width. */
59function card(title: string, lines: number, draw: (x: number, y: number, w: number) => string): Card {
60  const h = 52 + lines * ROW
61  return {
62    h,
63    svg: (x, y, w) => box(x + 0.5, y + 0.5, w - 1, h - 1, C.dark, 12, C.hair) + text(x + 18, y + 31, title.toUpperCase(), { fill: C.muted, size: 12, weight: 700 }) + draw(x, y + 58, w),
64  }
65}
66
67/** The body of the pane as an SVG document, and a description of it for a reader that cannot see it. */
68export function dashboardSvg(v: View, s: Settings, columns = 160): { source: string; alt: string; width: number; height: number; body: string } {
69  // Drawn at about the pane's own pixel width, so type keeps its size; two columns of cards once there is room for them.
70  const W = Math.min(DASHBOARD_MAX, Math.max(MIN_WIDTH, Math.round(columns * COLUMN_PX)))
71  const isTwo = W >= 1000
72  const CW = W - 2 * PAD
73  const d = v.total
74  const n = (k: Counter) => d.n?.[k] ?? 0
75  const out: string[] = []
76  let y = PAD
77
78  // Tiles
79  const minutes = minutesSaved(d, s)
80  const hours = fmtHours(minutes)
81  const amount = (minutes / 60) * s.rate
82  const tw = (CW - 2 * GAP) / 3
83  const tiles: [string, string, string][] = [
84    ['HOURS SAVED', `${hours} h`, 'estimate'],
85    ['TASKS DONE', fmtInt(taskCount(d)), plural(v.activeDays, 'active day')],
86    [s.currency === 'eur' ? 'WORTH IN EUROS' : "DOLLARS' WORTH", fmtMoney(amount, s.currency), `estimate at ${fmtMoney(s.rate, s.currency)}/h`],
87  ]
88  const rate = `at ${fmtMoney(s.rate, s.currency)}/h` // the shorter caption, when the full one will not fit a narrow tile
89  const pad = tw > 260 ? 22 : 14
90  tiles.forEach(([label, value, foot], i) => {
91    const x = PAD + i * (tw + GAP)
92    const room = tw - 2 * pad
93    const size = Math.max(16, Math.min(44, Math.floor(room / (value.length * 0.6)))) // a long number shrinks rather than overflows
94    out.push(box(x + 0.5, y + 0.5, tw - 1, 115, C.dark, 12, C.hair), text(x + pad, y + 32, fit(label, Math.floor(room / 7.3)), { fill: C.muted, size: 12, weight: 600 }))
95    out.push(text(x + pad, y + 46 + size * 0.76, value, { size, weight: 800, font: SANS }), text(x + pad, y + 102, foot.length > Math.floor(room / 7.4) && i === 2 ? rate : fit(foot, Math.floor(room / 7.4)), { fill: C.muted, size: 12 }))
96  })
97  y += 116 + 30
98
99  // Chart: a bar per day (or month), its count above it, its label below
100  out.push(text(PAD, y, v.tab === 'year' ? 'TASKS BY MONTH' : 'TASKS BY DAY', { fill: C.muted, size: 12, weight: 700 }))
101  const headW = (v.tab === 'year' ? 14 : 12) * 8 + 14
102  out.push(box(PAD + headW, y - 4, CW - headW, 1, C.hair))
103  const plotH = 112
104  const base = y + 36 + plotH
105  const buckets = v.chart
106  const slot = CW / buckets.length
107  const barW = Math.min(46, slot * 0.62)
108  const top = Math.max(1, ...buckets.map(b => b.value))
109  const every = buckets.length > 12 ? 5 : 1
110  buckets.forEach((b, i) => {
111    const cx = PAD + slot * i + slot / 2
112    const h = b.isFuture ? 0 : b.value > 0 ? Math.max(4, (b.value / top) * plotH) : 2
113    if (!b.isFuture) out.push(box(cx - barW / 2, base - h, barW, h, b.isCurrent ? C.gold : b.value > 0 ? C.text : C.hair, 4))
114    if (!b.isFuture && b.value > 0 && slot >= 24) out.push(text(cx, base - h - 7, String(b.value), { fill: b.isCurrent ? C.gold : C.muted, size: 12, anchor: 'middle' }))
115    if ((buckets.length - 1 - i) % every === 0) out.push(text(cx, base + 22, b.label, { fill: b.isCurrent ? C.gold : C.muted, size: 12, weight: b.isCurrent ? 700 : 400, anchor: 'middle' }))
116  })
117  y = base + 22 + 30
118  out.push(text(PAD, y, v.summary, { fill: C.green }))
119  y += 38
120
121  // Cards
122  const kindMinutes = (k: Kind) =>
123    k === 'coding' ? SIZES.reduce((a, z) => a + (d.sizes?.[z] ?? 0) * s.sizes[z], 0) : (d.tasks?.[k] ?? 0) * s.minutes[k]
124  const kinds = KINDS.filter(k => (d.tasks?.[k] ?? 0) > 0)
125  const maxKind = Math.max(1, ...kinds.map(k => d.tasks?.[k] ?? 0))
126  const timeCard = card('Where the time went', Math.max(1, kinds.length), (x, ry, w) => {
127    if (kinds.length === 0) return text(x + 18, ry, 'No finished tasks in this stretch.', { fill: C.muted })
128    const labelW = 188
129    const numbers = 178
130    const barLen = w - 36 - labelW - numbers
131    return kinds
132      .map((k, i) => {
133        const count = d.tasks?.[k] ?? 0
134        const at = ry + i * ROW + 2
135        const bar = kinds.length > 1 && barLen >= 40 ? box(x + 18 + labelW, at - 10, barLen, 10, C.hair, 3) + box(x + 18 + labelW, at - 10, Math.max(4, (count / maxKind) * barLen), 10, KIND_COLOR[k], 3) : ''
136        return text(x + 18, at, KIND_LABEL[k]) + bar + text(x + w - 18, at, `${plural(count, 'task')} · ${fmtHours(kindMinutes(k))} h`, { anchor: 'end' })
137      })
138      .join('')
139  })
140
141  const models = [...new Set([...Object.keys(d.models ?? {}), ...Object.keys(d.tokens ?? {})])].sort(
142    (a, b) => (d.models?.[b] ?? 0) - (d.models?.[a] ?? 0) || (d.tokens?.[b]?.out ?? 0) - (d.tokens?.[a]?.out ?? 0),
143  )
144  const judge = n('judgeCalls') > 0
145  const modelRows = models.length + (judge ? 1 : 0) + (judge && n('judgeFails') > 0 ? 1 : 0)
146  const modelCard = card('Models and tokens', Math.max(1, modelRows + 1), (x, ry, w) => {
147    if (modelRows === 0) return text(x + 18, ry, 'No model use in this stretch.', { fill: C.muted })
148    const col = 80
149    const cachedX = x + w - 18
150    const outX = cachedX - col
151    const inX = outX - col
152    const tasksX = inX - col
153    const nameChars = Math.floor((tasksX - 40 - (x + 18)) / CHAR)
154    const head = [['Tasks', tasksX], ['In', inX], ['Out', outX], ['Cached', cachedX]] as const
155    const parts: string[] = head.map(([label, px]) => text(px, ry, label, { fill: C.muted, size: 12, anchor: 'end' }))
156    const rowAt = (i: number) => ry + 26 + i * ROW
157    let i = 0
158    for (const id of models) {
159      const t = d.tokens?.[id] ?? {}
160      const input = (t.in ?? 0) + (t.cacheRead ?? 0) + (t.cacheWrite ?? 0)
161      const cached = input > 0 ? `${Math.round(((t.cacheRead ?? 0) / input) * 100)}%` : '-'
162      parts.push(text(x + 18, rowAt(i), fit(modelName(id), nameChars)))
163      for (const [val, px] of [[String(d.models?.[id] ?? 0), tasksX], [fmtCompact(input), inX], [fmtCompact(t.out ?? 0), outX], [cached, cachedX]] as const) parts.push(text(px, rowAt(i), val, { anchor: 'end' }))
164      i++
165    }
166    if (judge) {
167      const input = n('judgeIn') + n('judgeCacheRead') + n('judgeCacheWrite')
168      parts.push(text(x + 18, rowAt(i), 'Haiku judge', { fill: C.muted }))
169      for (const [val, px] of [[fmtInt(n('judgeCalls')), tasksX], [fmtCompact(input), inX], [fmtCompact(n('judgeOut')), outX], ['-', cachedX]] as const) parts.push(text(px, rowAt(i), val, { fill: C.muted, anchor: 'end' }))
170      i++
171      if (n('judgeFails') > 0) parts.push(text(x + 18, rowAt(i), `${plural(n('judgeFails'), 'judge call')} failed`, { fill: C.amber }))
172    }
173    return parts.join('')
174  })
175
176  // Code rows: a label, then the value (several lines when it is long)
177  type Row = { label: string; lines?: Span[][]; items?: string[] }
178  const rows: Row[] = []
179  const list = (pairs: [number, string, string?][]) => pairs.filter(([x]) => x > 0).map(([x, one, many]) => plural(x, one, many))
180  if (n('linesAdded') + n('linesRemoved') > 0 || n('files') > 0)
181    rows.push({ label: 'Lines', lines: [[[`+${fmtInt(n('linesAdded'))}`, C.green], ['   '], [`−${fmtInt(n('linesRemoved'))}`, C.muted], [`   ·   ${plural(n('files'), 'file')}`, C.muted]]] })
182  const langs = Object.entries(d.langs ?? {}).sort((a, b) => b[1] - a[1])
183  const langTotal = langs.reduce((a, [, x]) => a + x, 0)
184  const textRow = (label: string, items: string[]) => {
185    if (items.length > 0) rows.push({ label, items })
186  }
187  textRow('Languages', langs.slice(0, 4).map(([l, x]) => `${l} ${Math.round((x / Math.max(1, langTotal)) * 100)}%`))
188  textRow('Git', list([[n('commits'), 'commit'], [n('pushes'), 'push', 'pushes'], [n('merges'), 'merge'], [n('rebases'), 'rebase'], [n('branches'), 'branch', 'branches']]))
189  textRow('GitHub', list([[n('prsOpened'), 'PR opened', 'PRs opened'], [n('prsMerged'), 'PR merged', 'PRs merged'], [n('issuesOpened'), 'issue opened', 'issues opened'], [n('issuesClosed'), 'issue closed', 'issues closed']]))
190  if (n('testRuns') > 0) {
191    const pass = Math.round(((n('testRuns') - n('testFails')) / n('testRuns')) * 100)
192    rows.push({ label: 'Tests', lines: [[[`${plural(n('testRuns'), 'run')} · `], [`${pass}% pass`, pass >= 90 ? C.green : C.amber], [` · ${fmtDuration(n('testMs'))}`, C.muted]]] })
193    if (n('testsPassed') + n('testsFailed') > 0)
194      rows.push({ label: 'Results', lines: [[[`${fmtInt(n('testsPassed'))} passed`, C.green], ['   '], [`${fmtInt(n('testsFailed'))} failed`, n('testsFailed') > 0 ? C.amber : C.muted]]] })
195  }
196  if (n('builds') > 0) rows.push({ label: 'Builds', lines: [[[`${plural(n('builds'), 'run')} · `], [`${fmtInt(n('buildFails'))} failed`, n('buildFails') > 0 ? C.amber : C.muted]]] })
197  if ((d.tasks?.coding ?? 0) > 0) {
198    const types = DEV_KINDS.filter(k => (d.dev?.[k] ?? 0) > 0).sort((a, b) => (d.dev?.[b] ?? 0) - (d.dev?.[a] ?? 0))
199    if (types.length > 0) textRow('Task types', types.map(k => `${k[0]!.toUpperCase()}${k.slice(1)} ${d.dev?.[k]}`))
200    else rows.push({ label: 'Task types', lines: [[[s.judge ? 'not judged yet' : 'judge is off (/payback settings judge on)', C.muted]]] })
201    rows.push({ label: 'Sizes', lines: [[[SIZES.map(z => `${z.toUpperCase()} ${d.sizes?.[z] ?? 0}`).join(' · '), C.muted]]] })
202  }
203  const hasCode = rows.length > 0 || (d.tasks?.coding ?? 0) > 0
204
205  const LEFT = isTwo ? Math.round(CW * 0.472) : CW
206  const RIGHT = isTwo ? CW - GAP - LEFT : CW
207  const codeChars = Math.floor((RIGHT - 36 - 112) / CHAR)
208  const laid = rows.map(r => ({ label: r.label, lines: r.items ? wrapItems(r.items, codeChars).map((t): Span[] => [[t]]) : (r.lines ?? []) }))
209  const codeLines = laid.reduce((a, r) => a + r.lines.length, 0)
210  const codeCard = card('Code', Math.max(1, codeLines), (x, ry, w) => {
211    if (laid.length === 0) return text(x + 18, ry, 'No code work in this stretch.', { fill: C.muted })
212    let line = 0
213    return laid
214      .map(r =>
215        r.lines
216          .map((parts, i) => {
217            const at = ry + line++ * ROW
218            return (i === 0 ? text(x + 18, at, r.label, { fill: C.muted }) : '') + spans(x + 130, at, parts)
219          })
220          .join(''),
221      )
222      .join('')
223  })
224
225  const colLeft = !isTwo ? [timeCard, ...(hasCode ? [codeCard] : []), modelCard] : hasCode ? [timeCard, modelCard] : [timeCard]
226  const colRight = !isTwo ? [] : hasCode ? [codeCard] : [modelCard]
227  const stack = (cards: Card[], x: number, w: number) => {
228    let cy = y
229    for (const c of cards) {
230      out.push(c.svg(x, cy, w))
231      cy += c.h + GAP
232    }
233    return cy - GAP
234  }
235  const bottom = Math.max(stack(colLeft, PAD, LEFT), colRight.length > 0 ? stack(colRight, PAD + LEFT + GAP, RIGHT) : 0)
236  y = bottom + 30
237  out.push(text(PAD, y, W < 600 ? 'All estimates. /payback settings changes them.' : 'Hours and money are estimates. /payback settings changes them.', { fill: C.muted, size: 12 }))
238  const H = y + PAD - 6
239
240  const alt =
241    `Payback, ${v.title.toLowerCase()}: ${fmtInt(taskCount(d))} tasks, ${hours} hours saved, ${fmtMoney(amount, s.currency)} (estimates). ` +
242    (kinds.length > 0 ? kinds.map(k => `${KIND_LABEL[k]} ${d.tasks?.[k]}`).join(', ') + '. ' : '') +
243    v.summary
244  const body = out.join('')
245  const source =
246    `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${H}" width="${W}" height="${H}">` +
247    box(0.5, 0.5, W - 1, H - 1, C.dark, 16, C.hair) +
248    body +
249    '</svg>'
250  // `body` is the drawing without its frame, in the picture's own coordinates: docs/make-pane.cjs sets it under a header for the README.
251  return { source, alt, width: W, height: H, body }
252}
253
hooks/theme.ts 12 lines
1import type { Kind } from '../types'
2
3export const C = {
4  gold: '#F2C46D', hair: '#262B33', muted: '#7D8590', text: '#E6EDF3',
5  green: '#3DD68C', amber: '#E3B341', dark: '#0D1117', panel: '#161B22',
6}
7
8export const KIND_COLOR: Record<Kind, string> = {
9  coding: '#58A6FF', devops: '#9DA5FF', emails: '#BC8CFF', images: '#F778BA', documents: '#56D4DD', research: '#FFA657',
10  questions: '#C9D1D9', other: '#7D8590',
11}
12
types/index.d.ts 73 lines
1export type Tab = 'today' | 'week' | 'month' | 'year'
2
3export type Kind = 'coding' | 'devops' | 'emails' | 'images' | 'documents' | 'research' | 'questions' | 'other'
4
5export type DevKind = 'feature' | 'bugfix' | 'refactor' | 'tests' | 'debug' | 'docs' | 'devops' | 'review'
6
7export type Size = 's' | 'm' | 'l'
8
9export type Counter =
10  | 'linesAdded' | 'linesRemoved' | 'files'
11  | 'commits' | 'pushes' | 'merges' | 'rebases' | 'branches'
12  | 'prsOpened' | 'prsMerged' | 'issuesOpened' | 'issuesClosed'
13  | 'testRuns' | 'testFails' | 'testMs' | 'testsPassed' | 'testsFailed'
14  | 'builds' | 'buildFails'
15  | 'judgeCalls' | 'judgeFails' | 'judgeIn' | 'judgeOut' | 'judgeCacheRead' | 'judgeCacheWrite'
16
17export type Tokens = { in?: number; out?: number; cacheRead?: number; cacheWrite?: number }
18
19/** One calendar day, every field optional so a stored day holds only what happened. */
20export type Day = {
21  tasks?: Partial<Record<Kind, number>>
22  models?: Record<string, number>
23  sizes?: Partial<Record<Size, number>>
24  dev?: Partial<Record<DevKind, number>>
25  langs?: Record<string, number>
26  n?: Partial<Record<Counter, number>>
27  tokens?: Record<string, Tokens>
28  /** Work in sessions the SDK started (an assistant, a script): the same fields, kept apart from the person's own. */
29  sdk?: Day
30}
31
32/** Whose work the pane shows: the person's own sessions, the SDK's, or both. */
33export type Source = 'all' | 'you' | 'sdk'
34
35export type Currency = 'usd' | 'eur'
36
37export type Settings = {
38  rate: number
39  currency: Currency
40  minutes: Record<Exclude<Kind, 'coding'>, number>
41  sizes: Record<Size, number>
42  judge: boolean
43}
44
45/** What the history import shows in the pane. */
46export type ImportView = {
47  phase: 'idle' | 'scanning' | 'preview' | 'done' | 'error'
48  /** First day read, `YYYY-MM-DD`; `0000-01-01` for all history kept. */
49  fromDay: string
50  /** First day not read: where live tracking began, or the end asked for. */
51  untilDay: string
52  done: number
53  total: number
54  message?: string
55  preview?: {
56    tasks: number
57    kinds: Partial<Record<Kind, number>>
58    minutes: number
59    sessions: number
60    sdkSessions: number
61    sdkTasks: number
62    testSessions: number
63    skipped: number
64    activeDays: number
65  }
66}
67
68declare module 'claude-code' {
69  interface PluginState {
70    payback: { tab: Tab; rev: number; import: ImportView; source: Source }
71  }
72}
73