A pane that counts the tasks Claude finished for you and estimates the hours and dollars they saved.

A small collection of Claude Code mods (hooks-module plugins), shared as a plugin marketplace. Use any of them, change them, pass them on: they're MIT-licensed.
claude plugin marketplace add hearsawho25/roberts-mods
claude plugin install <mod>@roberts-mods
Install as many as you like, then run /reload-plugins or start a new session.
| Mod | Command | What it does | |
|---|---|---|---|
roberts-toolbox | /toolbox | One button at the end of the prompt hint line that opens a menu of your mods and the commands that change Claude Code. | |
clean-view | `/simple on\ | off` | Hides tool calls, diffs and command output, and shows one plain-English checklist above the prompt instead. |
prompt-upgrade | /better | Turns a rough prompt into a better one in a pane: up to three quick questions, a rewrite, and a side-by-side review before anything is sent. | |
daily-challenge | /challenge | One small Claude Code exercise a day, with a streak and a year of challenges that never repeat. | |
work-report | /report | Counts the tasks Claude finished for you and estimates the hours and dollars saved, by day, week, month and year. | |
copy-blocks | /copyblock [n] | A Copy button under every code block in Claude's replies, so you get clean text without the frame. | |
relay | /relay, /handoff, /keepwarm | A context and prompt-cache meter above the prompt, one-click handoffs to a fresh session, and cache warming with a cold-cache guard. See relay/README.md. | |
git-ship | /ship | Repo status above the prompt, and a pane whose buttons fill ready-to-run git and gh commands (commit messages and PRs drafted for you). It never runs a write: you press Enter. See git-ship/README.md. | |
mod-doctor | /mod-doctor | Checks that every enabled mod actually loaded. Stays quiet until one didn't, then shows a red row and the reason from claude plugin validate. See mod-doctor/README.md. |
Several of these started as other people's ideas, and they deserve the credit:
The mods themselves were built with Claude Code.
MIT.
hooks/register.tsx 408 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { ReportTab } from '../types'
5import {
6 KINDS,
7 KIND_LABEL,
8 KIND_SHORT,
9 TAB_TITLE,
10 addDays,
11 addTask,
12 applySettingsArgs,
13 bestDay,
14 bigRows,
15 bigWidth,
16 chartBars,
17 chartRows,
18 classify,
19 dayKey,
20 emptyText,
21 estimate,
22 formatHours,
23 formatMoney,
24 isTypedPrompt,
25 minutesFor,
26 monthName,
27 monthTotals,
28 padEnd,
29 padStart,
30 prettyModel,
31 rangeDates,
32 rangeDays,
33 readDays,
34 readSettings,
35 shortDay,
36 streak,
37 takePending,
38 tileCount,
39 wrapWords,
40} from './report'
41import type { Cell, Kind, ToolUse } from './report'
42
43export const PANE = 'work-report'
44
45const GOLD = '#F2C46D'
46const INK = '#0D1117'
47const LINE = '#262B33'
48const MUTED = '#7D8590'
49const TEXT = '#E6EDF3'
50const GOOD = '#3DD68C'
51const WARN = '#E3B341'
52const BAR = '#8B9BB4'
53
54const KIND_COLOR: Record<Kind, string> = {
55 emails: '#6CB6FF',
56 documents: '#56D4DD',
57 media: '#D2A8FF',
58 research: '#F0883E',
59 other: '#7D8590',
60}
61
62const TABS: readonly { id: ReportTab; label: string; hotkey: string }[] = [
63 { id: 'today', label: 'Today', hotkey: 't' },
64 { id: 'week', label: 'Week', hotkey: 'w' },
65 { id: 'month', label: 'Month', hotkey: 'm' },
66 { id: 'year', label: 'Year', hotkey: 'y' },
67]
68
69const tab = atom({ plugin: 'work-report', key: 'tab' } as const, 'today')
70const rev = atom({ plugin: 'work-report', key: 'rev' } as const, 0)
71
72const FAILED = 'Work report could not finish that. Nothing was changed; try again in a moment.'
73
74function footnote(rate: number, cols: number): string {
75 const long = `Hours and dollars are estimates at $${rate}/h. /report settings`
76 if (long.length <= cols) return long
77 const mid = `Estimates at $${rate}/h. /report settings`
78 return mid.length <= cols ? mid : padEnd(`Estimates at $${rate}/h`, cols)
79}
80
81function openPane($: EngineInterface): void {
82 void $.ui.open({ id: PANE, title: 'Work report' }).catch(() => undefined)
83}
84
85export const register: Register = on => {
86 // A typed prompt waits here until the turn it starts begins; that turn's
87 // tool calls are gathered until it ends.
88 let pending: string[] = []
89 let current: { turnId: string; tools: ToolUse[] } | undefined
90
91 on('session.start', async ($, e, next) => {
92 try {
93 await $.command.register({
94 name: 'report',
95 description: 'Open the work report: tasks finished and the time they saved (estimate)',
96 argumentHint: '[settings [name number]]',
97 immediate: true,
98 })
99 } catch {
100 // The command is missing this session; the pane cannot be opened until it registers.
101 }
102 return next(e)
103 })
104
105 on('prompt.submit', ($, e, next) => {
106 if (isTypedPrompt(e.text, e.origin.kind)) pending = [...pending, e.text].slice(-5)
107 return next(e)
108 }).catch(($, e, next) => next(e))
109
110 on('turn.start', ($, e, next) => {
111 const taken = takePending(pending, e.text)
112 pending = taken.rest
113 current = taken.isTyped ? { turnId: e.turnId, tools: [] } : undefined
114 return next(e)
115 }).catch(($, e, next) => next(e))
116
117 on('tool.call', ($, e, next) => {
118 if (current !== undefined && current.tools.length < 500) {
119 const args = e as unknown as Record<string, unknown>
120 const path = [args.file_path, args.notebook_path, args.path].find(v => typeof v === 'string') as string | undefined
121 current.tools.push({ name: String(e.tool), path })
122 }
123 return next(e)
124 }).catch(($, e, next) => next(e))
125
126 on('turn.complete', async ($, e, next) => {
127 const result = await next(e)
128 if (e.agentId !== undefined || current === undefined || current.turnId !== e.turnId) return result
129 const task = current
130 current = undefined
131 if (e.reason !== 'answer' || e.isAborted) return result
132 try {
133 const now = await $.clock.now()
134 const days = readDays(await $.store.get('days'))
135 const model = prettyModel(e.usage?.model ?? 'unknown')
136 await $.store.set('days', addTask(days, now, classify(task.tools), model))
137 await update($, rev, n => n + 1)
138 } catch {
139 // A day that could not be saved is skipped; the turn itself is untouched.
140 }
141 return result
142 }).catch(($, e, next) => next(e))
143
144 on('command.run', { command: 'report' }, async ($, e) => {
145 try {
146 const words = e.args.trim().split(/\s+/).filter(word => word !== '')
147 if (words[0]?.toLowerCase() === 'settings') {
148 const settings = readSettings(await $.store.get('settings'))
149 const answer = applySettingsArgs(words.slice(1), settings)
150 if (answer.settings !== undefined) {
151 await $.store.set('settings', answer.settings)
152 await update($, rev, n => n + 1)
153 }
154 return { text: answer.text }
155 }
156 if (words.length > 0) {
157 return { text: 'Use /report to open the pane, or /report settings to see and change the estimate.' }
158 }
159 const now = await $.clock.now()
160 const days = readDays(await $.store.get('days'))
161 const settings = readSettings(await $.store.get('settings'))
162 const totals = addDays(days, [dayKey(now)])
163 const { hours } = estimate(totals, settings)
164 openPane($)
165 const tasks = `${totals.tasks} task${totals.tasks === 1 ? '' : 's'}`
166 return { text: `Work report: ${tasks} finished today, about ${formatHours(hours)} hours saved (estimate).` }
167 } catch {
168 return { text: FAILED }
169 }
170 }).catch(() => ({ text: FAILED }))
171
172 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
173 const { Box, Text, Button } = $.ui.resolve(e)
174 const cols = Math.max(20, e.props.bodyColumns)
175 const chosen = await read($, tab)
176 await read($, rev)
177 const now = await $.clock.now()
178 const days = readDays(await $.store.get('days'))
179 const settings = readSettings(await $.store.get('settings'))
180
181 const range = rangeDays(chosen, now)
182 const totals = addDays(days, range.map(dayKey))
183 const { hours, dollars } = estimate(totals, settings)
184 const perKind = minutesFor(totals, settings)
185
186 const heading = (label: string) => (
187 <Text wrap="truncate">
188 <Text color={MUTED}>{label} </Text>
189 <Text color={LINE}>{'─'.repeat(Math.max(0, cols - label.length - 1))}</Text>
190 </Text>
191 )
192
193 const chips = (
194 <Box flexDirection="row" gap={1}>
195 {TABS.map(one => (
196 <Button plain key={`tab-${one.id}`} hotkey={one.hotkey} onPress={() => update($, tab, () => one.id)}>
197 {one.id === chosen ? (
198 <Text backgroundColor={GOLD} color={INK} bold>
199 {` ${one.label} `}
200 </Text>
201 ) : (
202 <Text backgroundColor={LINE} color={TEXT}>
203 {` ${one.label} `}
204 </Text>
205 )}
206 </Button>
207 ))}
208 </Box>
209 )
210
211 const title = TAB_TITLE[chosen]
212 const dates = rangeDates(chosen, now)
213 const showDates = title.length + 2 + dates.length <= cols
214 const titleBar = (
215 <Box flexDirection="column" marginTop={1}>
216 <Box key="title" flexDirection="row" justifyContent="space-between" width={cols}>
217 <Text bold color={TEXT}>
218 {title}
219 </Text>
220 {showDates && <Text color={MUTED}>{dates}</Text>}
221 </Box>
222 <Text wrap="truncate">
223 <Text color={GOLD}>{'━'.repeat(title.length)}</Text>
224 <Text color={LINE}>{'─'.repeat(Math.max(0, cols - title.length))}</Text>
225 </Text>
226 </Box>
227 )
228
229 if (totals.tasks === 0) {
230 return (
231 <Box flexDirection="column" width={cols}>
232 {chips}
233 {titleBar}
234 <Box key="empty" flexDirection="column" marginTop={1}>
235 {wrapWords(emptyText(chosen), cols).map(row => (
236 <Text color={MUTED} wrap="truncate">
237 {row}
238 </Text>
239 ))}
240 </Box>
241 </Box>
242 )
243 }
244
245 // Tiles: three across, then two, then one; what does not fit goes on one line under them.
246 const tileFacts = [
247 { key: 'hours', label: 'HOURS SAVED EST.', value: formatHours(hours), color: GOOD },
248 { key: 'tasks', label: 'TASKS DONE', value: String(totals.tasks), color: TEXT },
249 { key: 'worth', label: 'WORTH EST.', value: formatMoney(dollars), color: TEXT },
250 ]
251 const across = tileCount(cols)
252 const tileWidth = Math.floor((cols - (across - 1)) / across)
253 const inner = tileWidth - 4
254 const shown = tileFacts.slice(0, across)
255 const hidden = tileFacts.slice(across)
256 const tiles = (
257 <Box flexDirection="row" gap={1} marginTop={1}>
258 {shown.map(fact => {
259 const rows = bigWidth(fact.value) <= inner ? bigRows(fact.value) : undefined
260 return (
261 <Box key={`tile-${fact.key}`} borderStyle="round" borderColor={LINE} flexDirection="column" width={tileWidth} paddingX={1}>
262 <Text color={MUTED} wrap="truncate">
263 {padEnd(fact.label, inner)}
264 </Text>
265 {rows !== undefined ? (
266 rows.map(row => (
267 <Text color={fact.color} wrap="truncate">
268 {row}
269 </Text>
270 ))
271 ) : (
272 <Box flexDirection="column">
273 <Text> </Text>
274 <Text bold color={fact.color} wrap="truncate">
275 {fact.value}
276 </Text>
277 <Text> </Text>
278 </Box>
279 )}
280 </Box>
281 )
282 })}
283 </Box>
284 )
285 const extraLine = hidden.map(fact => `${fact.label.replace(' EST.', ' (est.)').toLowerCase()}: ${fact.value}`)
286 const extraRows = extraLine.join(' · ').length <= cols ? [extraLine.join(' · ')] : extraLine.map(one => padEnd(one, cols))
287 const extra =
288 hidden.length > 0 ? (
289 <Box flexDirection="column">
290 {extraRows.map(row => (
291 <Text color={MUTED} wrap="truncate">
292 {row}
293 </Text>
294 ))}
295 </Box>
296 ) : undefined
297
298 // Where the time went.
299 const isNarrow = cols < 48
300 const labelWidth = isNarrow ? 9 : 17
301 const countWidth = 5
302 const hoursWidth = 8
303 const kindBar = cols - labelWidth - countWidth - hoursWidth - 1
304 const peakKind = Math.max(1, ...KINDS.map(kind => totals.kinds[kind]))
305 const kindRows = KINDS.map(kind => {
306 const count = totals.kinds[kind]
307 const fill = count > 0 ? Math.max(1, Math.round((count / peakKind) * Math.max(0, kindBar - 1))) : 0
308 return (
309 <Text wrap="truncate">
310 <Text color={count > 0 ? TEXT : MUTED}>{padEnd(isNarrow ? KIND_SHORT[kind] : KIND_LABEL[kind], labelWidth)}</Text>
311 {kindBar >= 4 && <Text color={KIND_COLOR[kind]}>{'━'.repeat(fill)}</Text>}
312 {kindBar >= 4 && <Text color={LINE}>{'─'.repeat(Math.max(0, kindBar - 1 - fill))} </Text>}
313 <Text color={count > 0 ? TEXT : MUTED}>{padStart(String(count), countWidth)}</Text>
314 <Text color={MUTED}>{padStart(`${formatHours(perKind[kind] / 60)} h`, hoursWidth)}</Text>
315 </Text>
316 )
317 })
318
319 // Who did the work.
320 const models = Object.entries(totals.models)
321 .filter(([, n]) => n > 0)
322 .sort((a, b) => b[1] - a[1])
323 .slice(0, 6)
324 const modelLabel = Math.min(isNarrow ? 10 : 18, Math.max(6, ...models.map(([name]) => name.length + 1)))
325 const modelBar = cols - modelLabel - countWidth - 1
326 const peakModel = Math.max(1, ...models.map(([, n]) => n))
327 const modelRows = models.map(([name, count]) => {
328 const fill = Math.max(1, Math.round((count / peakModel) * Math.max(0, modelBar - 1)))
329 return (
330 <Text wrap="truncate">
331 <Text color={TEXT}>{padEnd(name, modelLabel)}</Text>
332 {modelBar >= 4 && <Text color={GOLD}>{'━'.repeat(fill)}</Text>}
333 {modelBar >= 4 && <Text color={LINE}>{'─'.repeat(Math.max(0, modelBar - 1 - fill))} </Text>}
334 <Text color={TEXT}>{padStart(String(count), countWidth)}</Text>
335 </Text>
336 )
337 })
338
339 // The chart and the line under it.
340 const chart = chartRows(chartBars(chosen, days, now), cols)
341 const toneColor = (cell: Cell) => (cell.tone === 'current' ? GOLD : cell.tone === 'bar' ? BAR : LINE)
342 const chartLines = chart.rows.map((row, r) => (
343 <Text wrap="truncate">
344 {row.map(cell => (
345 <Text color={toneColor(cell)}>{cell.text}</Text>
346 ))}
347 </Text>
348 ))
349 let summary: { text: string; color: string }
350 if (chosen === 'today' || chosen === 'week') {
351 const run = streak(days, now)
352 summary =
353 run > 0
354 ? { text: `Streak: ${run} day${run === 1 ? '' : 's'} in a row`, color: GOOD }
355 : { text: 'No streak yet. Finish a task today to start one.', color: WARN }
356 } else if (chosen === 'month') {
357 const best = bestDay(days, range)
358 summary = {
359 text: `Best day: ${best ? `${shortDay(best.ms)}, ${best.count} task${best.count === 1 ? '' : 's'}` : 'none'} · ${totals.active} active day${totals.active === 1 ? '' : 's'}`,
360 color: TEXT,
361 }
362 } else {
363 const months = monthTotals(days, now)
364 const top = months.reduce((best, n, i) => (n > (months[best] ?? 0) ? i : best), 0)
365 const topCount = months[top] ?? 0
366 summary = {
367 text: `Best month: ${monthName(top)}, ${topCount} task${topCount === 1 ? '' : 's'} · ${totals.active} active day${totals.active === 1 ? '' : 's'}`,
368 color: TEXT,
369 }
370 }
371
372 return (
373 <Box flexDirection="column" width={cols}>
374 {chips}
375 {titleBar}
376 {tiles}
377 {extra}
378 <Box flexDirection="column" marginTop={1}>
379 {heading('WHERE THE TIME WENT')}
380 {kindRows}
381 </Box>
382 <Box flexDirection="column" marginTop={1}>
383 {heading('WHO DID THE WORK')}
384 {modelRows}
385 </Box>
386 <Box key="chart" flexDirection="column" marginTop={1}>
387 {heading(chosen === 'year' ? 'BY MONTH' : 'BY DAY')}
388 {chartLines}
389 <Text color={MUTED} wrap="truncate">
390 {chart.labels}
391 </Text>
392 </Box>
393 <Box key="summary" marginTop={1}>
394 <Text color={summary.color} wrap="truncate">
395 {summary.text}
396 </Text>
397 </Box>
398 <Text color={MUTED} wrap="truncate">
399 {footnote(settings.rate, cols)}
400 </Text>
401 </Box>
402 )
403 }).catch(($, e) => {
404 const { Text } = $.ui.resolve(e)
405 return <Text color={MUTED}>Work report could not draw right now.</Text>
406 })
407}
408hooks/report.ts 491 lines1// The work report's pure logic: sorting tasks, the calendar, the sums, the
2// estimate, and the strings the pane draws. Nothing here touches `$`.
3
4import type { ReportTab } from '../types'
5
6export type Kind = 'emails' | 'documents' | 'media' | 'research' | 'other'
7
8export const KINDS: readonly Kind[] = ['emails', 'documents', 'media', 'research', 'other']
9
10export const KIND_LABEL: Record<Kind, string> = {
11 emails: 'Emails',
12 documents: 'Documents',
13 media: 'Images and video',
14 research: 'Research',
15 other: 'Other',
16}
17
18export const KIND_SHORT: Record<Kind, string> = {
19 emails: 'Email',
20 documents: 'Docs',
21 media: 'Media',
22 research: 'Research',
23 other: 'Other',
24}
25
26export type DayRecord = { kinds: Partial<Record<Kind, number>>; models: Record<string, number> }
27export type Days = Record<string, DayRecord>
28export type Settings = { rate: number; minutes: Record<Kind, number> }
29export type ToolUse = { name: string; path?: string }
30
31export const DEFAULT_SETTINGS: Settings = {
32 rate: 50,
33 minutes: { emails: 10, documents: 30, media: 20, research: 25, other: 10 },
34}
35
36export const DAY_MS = 86_400_000
37export const KEEP_DAYS = 400
38
39// ---------------------------------------------------------------- sorting
40
41const MAIL_TOOL = /(gmail|outlook|e-?mail|mail|inbox|himalaya)/i
42const MEDIA_TOOL = /(image|video|photo|picture|canva|figma|gif|dall-?e|midjourney|imagen|sora|thumbnail)/i
43const MEDIA_FILE = /\.(png|jpe?g|gif|webp|svg|bmp|tiff?|heic|avif|ico|mp4|mov|m4v|webm|avi|mkv|wmv)$/i
44const RESEARCH_TOOL =
45 /(^webfetch$|^websearch$|web_?search|web_?fetch|search_web|fetch_url|context7|query-docs|transcript|youtube|read_page|get_page_text|navigate|browse|crawl|scrape|tavily|perplexity|deep-?research)/i
46const DOC_TOOL = /(docs?|document|slide|deck|spreadsheet|sheets?|excel|powerpoint|pptx|docx|xlsx|pdf|notion|drive)/i
47const DOC_FILE = /\.(md|markdown|txt|rtf|docx?|odt|pdf|pptx?|key|odp|xlsx?|csv|ods|tex)$/i
48const WRITE_TOOL = /^(write|edit|multiedit|notebookedit)$/i
49
50/** The kind one tool call points at, or undefined when it says nothing. */
51export function kindOfTool(use: ToolUse): Kind | undefined {
52 const path = use.path ?? ''
53 if (MAIL_TOOL.test(use.name)) return 'emails'
54 if (MEDIA_TOOL.test(use.name) || MEDIA_FILE.test(path)) return 'media'
55 if (RESEARCH_TOOL.test(use.name)) return 'research'
56 if (DOC_TOOL.test(use.name) || (WRITE_TOOL.test(use.name) && DOC_FILE.test(path))) return 'documents'
57 return undefined
58}
59
60/** One kind per task: Emails, then Images and video, Documents, Research, else Other. */
61export function classify(tools: readonly ToolUse[]): Kind {
62 const seen = new Set<Kind>()
63 for (const use of tools) {
64 const kind = kindOfTool(use)
65 if (kind !== undefined) seen.add(kind)
66 }
67 for (const kind of ['emails', 'media', 'documents', 'research'] as const) {
68 if (seen.has(kind)) return kind
69 }
70 return 'other'
71}
72
73// ------------------------------------------------------- counting prompts
74
75/** A prompt the person typed: their own Enter (or the remote bridge), not a slash command. */
76export function isTypedPrompt(text: string, originKind: string): boolean {
77 if (originKind !== 'composer' && originKind !== 'bridge') return false
78 const trimmed = text.trim()
79 return trimmed !== '' && !trimmed.startsWith('/')
80}
81
82/** Takes the pending typed prompt a turn starts with, if any; the rest stay pending. */
83export function takePending(pending: readonly string[], turnText: string): { isTyped: boolean; rest: string[] } {
84 const text = turnText.trim()
85 if (text === '') return { isTyped: false, rest: [...pending] }
86 const at = pending.findIndex(one => {
87 const typed = one.trim()
88 return typed === text || text.startsWith(typed) || typed.startsWith(text)
89 })
90 if (at < 0) return { isTyped: false, rest: [...pending] }
91 return { isTyped: true, rest: pending.filter((_, i) => i !== at) }
92}
93
94// --------------------------------------------------------------- calendar
95
96const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
97const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
98
99function pad2(n: number): string {
100 return n < 10 ? `0${n}` : String(n)
101}
102
103/** Local noon of the day `ms` falls on. */
104export function localNoon(ms: number): number {
105 const d = new Date(ms)
106 return new Date(d.getFullYear(), d.getMonth(), d.getDate(), 12).getTime()
107}
108
109/** Steps whole days from a day, landing on local noon, so a clock change never skips or repeats a date. */
110export function stepDays(ms: number, n: number): number {
111 return localNoon(localNoon(ms) + n * DAY_MS)
112}
113
114export function dayKey(ms: number): string {
115 const d = new Date(ms)
116 return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`
117}
118
119/** The last `n` days ending today, oldest first, as local noons. */
120export function lastDays(nowMs: number, n: number): number[] {
121 const out: number[] = []
122 for (let i = n - 1; i >= 0; i--) out.push(stepDays(nowMs, -i))
123 return out
124}
125
126/** 1 for January 1. */
127export function dayOfYear(ms: number): number {
128 const d = new Date(ms)
129 return Math.round((Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()) - Date.UTC(d.getFullYear(), 0, 1)) / DAY_MS) + 1
130}
131
132export function rangeDays(tab: ReportTab, nowMs: number): number[] {
133 if (tab === 'today') return lastDays(nowMs, 1)
134 if (tab === 'week') return lastDays(nowMs, 7)
135 if (tab === 'month') return lastDays(nowMs, 30)
136 return lastDays(nowMs, dayOfYear(nowMs))
137}
138
139export const TAB_TITLE: Record<ReportTab, string> = {
140 today: 'TODAY',
141 week: 'THIS WEEK',
142 month: 'THIS MONTH',
143 year: 'THIS YEAR',
144}
145
146function shortDate(ms: number, withYear = false): string {
147 const d = new Date(ms)
148 return `${MONTHS[d.getMonth()]} ${d.getDate()}${withYear ? `, ${d.getFullYear()}` : ''}`
149}
150
151export function rangeDates(tab: ReportTab, nowMs: number): string {
152 const days = rangeDays(tab, nowMs)
153 const last = days[days.length - 1] ?? nowMs
154 const first = days[0] ?? nowMs
155 if (tab === 'today') return `${WEEKDAYS[new Date(last).getDay()]}, ${shortDate(last)}`
156 const isSplit = new Date(first).getFullYear() !== new Date(last).getFullYear()
157 if (tab === 'year' || isSplit) return `${shortDate(first, isSplit)} - ${shortDate(last, true)}`
158 return `${shortDate(first)} - ${shortDate(last)}`
159}
160
161// ------------------------------------------------------------------- sums
162
163export function dayTotal(rec: DayRecord | undefined): number {
164 if (rec === undefined) return 0
165 let sum = 0
166 for (const kind of KINDS) sum += rec.kinds[kind] ?? 0
167 return sum
168}
169
170export type Totals = { tasks: number; active: number; kinds: Record<Kind, number>; models: Record<string, number> }
171
172export function addDays(days: Days, keys: readonly string[]): Totals {
173 const totals: Totals = {
174 tasks: 0,
175 active: 0,
176 kinds: { emails: 0, documents: 0, media: 0, research: 0, other: 0 },
177 models: {},
178 }
179 for (const key of keys) {
180 const rec = days[key]
181 if (rec === undefined) continue
182 const count = dayTotal(rec)
183 totals.tasks += count
184 if (count > 0) totals.active += 1
185 for (const kind of KINDS) totals.kinds[kind] += rec.kinds[kind] ?? 0
186 for (const [model, n] of Object.entries(rec.models)) totals.models[model] = (totals.models[model] ?? 0) + n
187 }
188 return totals
189}
190
191/** Adds one finished task to its day, dropping days older than KEEP_DAYS. */
192export function addTask(days: Days, nowMs: number, kind: Kind, model: string): Days {
193 const key = dayKey(nowMs)
194 const cutoff = dayKey(stepDays(nowMs, -KEEP_DAYS))
195 const out: Days = {}
196 for (const [k, rec] of Object.entries(days)) if (k >= cutoff) out[k] = rec
197 const rec = out[key] ?? { kinds: {}, models: {} }
198 out[key] = {
199 kinds: { ...rec.kinds, [kind]: (rec.kinds[kind] ?? 0) + 1 },
200 models: { ...rec.models, [model]: (rec.models[model] ?? 0) + 1 },
201 }
202 return out
203}
204
205/** Days in a row with a finished task, ending today, or yesterday while today has none yet. */
206export function streak(days: Days, nowMs: number): number {
207 let at = dayTotal(days[dayKey(nowMs)]) > 0 ? 0 : -1
208 let count = 0
209 while (dayTotal(days[dayKey(stepDays(nowMs, at))]) > 0) {
210 count += 1
211 at -= 1
212 }
213 return count
214}
215
216export function bestDay(days: Days, keys: readonly number[]): { ms: number; count: number } | undefined {
217 let best: { ms: number; count: number } | undefined
218 for (const ms of keys) {
219 const count = dayTotal(days[dayKey(ms)])
220 if (count > 0 && (best === undefined || count > best.count)) best = { ms, count }
221 }
222 return best
223}
224
225export function monthTotals(days: Days, nowMs: number): number[] {
226 const year = new Date(nowMs).getFullYear()
227 const out = Array.from({ length: 12 }, () => 0)
228 for (const [key, rec] of Object.entries(days)) {
229 if (!key.startsWith(`${year}-`)) continue
230 const month = Number(key.slice(5, 7)) - 1
231 if (month >= 0 && month < 12) out[month] = (out[month] ?? 0) + dayTotal(rec)
232 }
233 return out
234}
235
236// --------------------------------------------------------------- estimate
237
238export function minutesFor(totals: Totals, settings: Settings): Record<Kind, number> {
239 const out = { emails: 0, documents: 0, media: 0, research: 0, other: 0 }
240 for (const kind of KINDS) out[kind] = totals.kinds[kind] * settings.minutes[kind]
241 return out
242}
243
244export function estimate(totals: Totals, settings: Settings): { hours: number; dollars: number } {
245 const perKind = minutesFor(totals, settings)
246 let minutes = 0
247 for (const kind of KINDS) minutes += perKind[kind]
248 const hours = minutes / 60
249 return { hours, dollars: hours * settings.rate }
250}
251
252function withCommas(n: number): string {
253 return String(Math.round(n)).replace(/\B(?=(\d{3})+(?!\d))/g, ',')
254}
255
256export function formatHours(hrs: number): string {
257 if (hrs === 0) return '0'
258 if (hrs < 100) return hrs.toFixed(1)
259 return withCommas(hrs)
260}
261
262export function formatMoney(dollars: number): string {
263 return `$${withCommas(dollars)}`
264}
265
266// -------------------------------------------------------------- settings
267
268export function readSettings(value: unknown): Settings {
269 const raw = (typeof value === 'object' && value !== null ? value : {}) as Partial<Settings>
270 const minutes = { ...DEFAULT_SETTINGS.minutes }
271 for (const kind of KINDS) {
272 const n = raw.minutes?.[kind]
273 if (typeof n === 'number' && Number.isFinite(n) && n >= 0) minutes[kind] = n
274 }
275 const rate = typeof raw.rate === 'number' && Number.isFinite(raw.rate) && raw.rate >= 0 ? raw.rate : DEFAULT_SETTINGS.rate
276 return { rate, minutes }
277}
278
279export function readDays(value: unknown): Days {
280 if (typeof value !== 'object' || value === null) return {}
281 const out: Days = {}
282 for (const [key, rec] of Object.entries(value as Record<string, unknown>)) {
283 if (!/^\d{4}-\d{2}-\d{2}$/.test(key) || typeof rec !== 'object' || rec === null) continue
284 const r = rec as Partial<DayRecord>
285 out[key] = { kinds: { ...(r.kinds ?? {}) }, models: { ...(r.models ?? {}) } }
286 }
287 return out
288}
289
290const SETTING_KEYS: Record<string, Kind | 'rate'> = {
291 rate: 'rate',
292 emails: 'emails',
293 email: 'emails',
294 documents: 'documents',
295 docs: 'documents',
296 images: 'media',
297 video: 'media',
298 media: 'media',
299 research: 'research',
300 other: 'other',
301}
302
303export function describeSettings(settings: Settings): string {
304 const lines = [
305 'Work report settings (used for the estimate):',
306 ` rate $${settings.rate} per hour`,
307 ` emails ${settings.minutes.emails} min per task`,
308 ` documents ${settings.minutes.documents} min per task`,
309 ` images ${settings.minutes.media} min per task (images and video)`,
310 ` research ${settings.minutes.research} min per task`,
311 ` other ${settings.minutes.other} min per task`,
312 'Change one with /report settings <name> <number>, e.g. /report settings rate 75',
313 ]
314 return lines.join('\n')
315}
316
317/** Reads `settings [name number]`; answers the new settings when they change. */
318export function applySettingsArgs(words: readonly string[], settings: Settings): { settings?: Settings; text: string } {
319 if (words.length === 0) return { text: describeSettings(settings) }
320 const [name = '', raw = ''] = words
321 const target = SETTING_KEYS[name.toLowerCase()]
322 if (target === undefined) {
323 return { text: `"${name}" is not a setting. Use rate, emails, documents, images, research or other.` }
324 }
325 const value = Number(raw.replace(/^\$/, ''))
326 if (raw === '' || !Number.isFinite(value) || value < 0 || value > 100_000) {
327 return { text: `Give ${name} a number, e.g. /report settings ${name} ${target === 'rate' ? 75 : 15}` }
328 }
329 if (target === 'rate') {
330 return { settings: { ...settings, rate: value }, text: `Hourly rate is now $${value} (used for the estimate).` }
331 }
332 return {
333 settings: { ...settings, minutes: { ...settings.minutes, [target]: value } },
334 text: `${KIND_LABEL[target]} now count ${value} minutes per task (used for the estimate).`,
335 }
336}
337
338// ------------------------------------------------------------------ models
339
340export function prettyModel(id: string): string {
341 const m = /claude-(opus|sonnet|haiku|fable)-(\d+)(?:-(\d{1,2}))?(?:-\d{8})?/i.exec(id)
342 if (m === null) return id
343 const family = (m[1] ?? '').charAt(0).toUpperCase() + (m[1] ?? '').slice(1).toLowerCase()
344 return m[3] !== undefined ? `${family} ${m[2]}.${m[3]}` : `${family} ${m[2]}`
345}
346
347// ---------------------------------------------------------------- drawing
348
349export const GLYPHS: Record<string, readonly [string, string, string]> = {
350 '0': ['┏━┓', '┃ ┃', '┗━┛'],
351 '1': ['╺┓ ', ' ┃ ', '╺┻╸'],
352 '2': ['╺━┓', '┏━┛', '┗━╸'],
353 '3': ['╺━┓', ' ━┫', '╺━┛'],
354 '4': ['╻ ╻', '┗━┫', ' ╹'],
355 '5': ['┏━╸', '┗━┓', '╺━┛'],
356 '6': ['┏━╸', '┣━┓', '┗━┛'],
357 '7': ['╺━┓', ' ┃', ' ╹'],
358 '8': ['┏━┓', '┣━┫', '┗━┛'],
359 '9': ['┏━┓', '┗━┫', '╺━┛'],
360 '.': [' ', ' ', '╻'],
361 ',': [' ', ' ', '┛'],
362 $: ['┏╋╸', '┗╋┓', '╺╋┛'],
363}
364
365export function bigWidth(text: string): number {
366 let width = 0
367 for (const ch of text) width += (GLYPHS[ch]?.[0].length ?? 1) + 1
368 return Math.max(0, width - 1)
369}
370
371/** Three rows of heavy box-drawing numerals; undefined when a character has no glyph. */
372export function bigRows(text: string): [string, string, string] | undefined {
373 const rows: [string, string, string] = ['', '', '']
374 let first = true
375 for (const ch of text) {
376 const glyph = GLYPHS[ch]
377 if (glyph === undefined) return undefined
378 for (let r = 0; r < 3; r++) rows[r] += (first ? '' : ' ') + glyph[r]
379 first = false
380 }
381 return rows
382}
383
384export type Bar = { label: string; value: number; isCurrent: boolean; isFuture: boolean }
385
386export function chartBars(tab: ReportTab, days: Days, nowMs: number): Bar[] {
387 if (tab === 'year') {
388 const totals = monthTotals(days, nowMs)
389 const month = new Date(nowMs).getMonth()
390 return MONTHS.map((label, i) => ({ label, value: totals[i] ?? 0, isCurrent: i === month, isFuture: i > month }))
391 }
392 const span = tab === 'month' ? 30 : 7
393 const list = lastDays(nowMs, span)
394 return list.map((ms, i) => ({
395 label: tab === 'month' ? String(new Date(ms).getDate()) : WEEKDAYS[new Date(ms).getDay()] ?? '',
396 value: dayTotal(days[dayKey(ms)]),
397 isCurrent: i === list.length - 1,
398 isFuture: false,
399 }))
400}
401
402export type Cell = { text: string; tone: 'bar' | 'current' | 'base' | 'none' }
403
404const EIGHTHS = [' ', '▁', '▂', '▃', '▄', '▅', '▆', '▇', '█']
405
406/** The chart's three bar rows (top first) and its label row, each at most `columns` wide. */
407export function chartRows(all: readonly Bar[], columns: number): { rows: Cell[][]; labels: string } {
408 const bars = all.length > columns ? all.slice(-Math.max(1, columns)) : all
409 const n = Math.max(1, bars.length)
410 const slot = Math.max(1, Math.floor(columns / n))
411 const barWidth = slot >= 3 ? Math.min(slot - 1, 5) : 1
412 const gap = slot - barWidth
413 const peak = Math.max(1, ...bars.map(bar => bar.value))
414 const rows: Cell[][] = [[], [], []]
415 for (const bar of bars) {
416 const level = bar.value > 0 ? Math.max(1, Math.round((bar.value / peak) * 24)) : 0
417 for (let r = 0; r < 3; r++) {
418 const eighths = Math.min(8, Math.max(0, level - (2 - r) * 8))
419 let cell: Cell
420 if (bar.isFuture) cell = { text: ' '.repeat(barWidth), tone: 'none' }
421 else if (level === 0) cell = { text: (r === 2 ? '▁' : ' ').repeat(barWidth), tone: r === 2 ? 'base' : 'none' }
422 else cell = { text: (EIGHTHS[eighths] ?? ' ').repeat(barWidth), tone: bar.isCurrent ? 'current' : 'bar' }
423 rows[r]?.push(cell)
424 if (gap > 0) rows[r]?.push({ text: ' '.repeat(gap), tone: 'none' })
425 }
426 }
427 // Labels: each placed at its slot's start when it fits without touching the one before.
428 const width = slot * n
429 const chars = Array.from({ length: width }, () => ' ')
430 let freeFrom = 0
431 bars.forEach((bar, i) => {
432 const start = i * slot
433 const room = n <= 12 ? slot - (slot > 1 ? 1 : 0) : width - start
434 const label = n <= 12 ? bar.label.slice(0, Math.max(1, room)) : bar.label
435 if (start < freeFrom || start + label.length > width) return
436 for (let c = 0; c < label.length; c++) chars[start + c] = label[c] ?? ' '
437 freeFrom = start + label.length + 1
438 })
439 return { rows, labels: chars.join('').trimEnd() }
440}
441
442/** How many tiles fit across `columns`: three, then two, then one. */
443export function tileCount(columns: number): 1 | 2 | 3 {
444 if (columns >= 66) return 3
445 if (columns >= 40) return 2
446 return 1
447}
448
449export function padEnd(text: string, width: number): string {
450 if (width <= 0) return ''
451 if (text.length > width) return width > 1 ? `${text.slice(0, width - 1)}…` : text.slice(0, width)
452 return text + ' '.repeat(width - text.length)
453}
454
455export function padStart(text: string, width: number): string {
456 if (width <= 0) return ''
457 if (text.length >= width) return text.slice(0, width)
458 return ' '.repeat(width - text.length) + text
459}
460
461export function emptyText(tab: ReportTab): string {
462 if (tab === 'today') return 'Nothing finished yet today. Your first finished task shows up here.'
463 if (tab === 'week') return 'Nothing finished in the last 7 days. Your first finished task shows up here.'
464 if (tab === 'month') return 'Nothing finished in the last 30 days. Your first finished task shows up here.'
465 return 'Nothing finished yet this year. Your first finished task shows up here.'
466}
467
468/** Breaks text into rows no wider than `width`, at spaces. */
469export function wrapWords(text: string, width: number): string[] {
470 const rows: string[] = []
471 let row = ''
472 for (const word of text.split(' ')) {
473 const next = row === '' ? word : `${row} ${word}`
474 if (next.length <= width || row === '') row = next.slice(0, Math.max(1, width))
475 else {
476 rows.push(row)
477 row = word.slice(0, Math.max(1, width))
478 }
479 }
480 if (row !== '') rows.push(row)
481 return rows
482}
483
484export function monthName(index: number): string {
485 return MONTHS[index] ?? ''
486}
487
488export function shortDay(ms: number): string {
489 return shortDate(ms)
490}
491types/index.d.ts 8 lines1export type ReportTab = 'today' | 'week' | 'month' | 'year'
2
3declare module 'claude-code' {
4 interface PluginState {
5 'work-report': { tab: ReportTab; rev: number }
6 }
7}
8