SLOPSHOPPER

work-report

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

newpaneguardcommandprompt
v0.1.0MITupdated 2026-10-14hearsawho25/roberts-mods/work-report
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · work-report
│ ┃ Work report ✕ › fix the failing auth test and add an audit log call │ ┃ t: tab-today w: tab-week m: tab-month y: tab │ ┃ ⏺ Read(src/auth.ts) │ ┃ TODAY ⎿ Read 6 lines │ ┃ ━━━━━─────────────────────────────────────── ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ Nothing finished yet today. Your first finis ⏺ Bash(bun test) │ ┃ shows up here. ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /report │ ⎿ work-report: Work report: 0 tasks finished today, about 0 hours │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Work report
t: tab-today w: tab-week m: tab-month y: tab-year TODAY Thu, Oct 9 ━━━━━─────────────────────────────────────────────────── Nothing finished yet today. Your first finished task shows up here.
README

roberts-mods

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.

Install

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.

The mods

ModCommandWhat it does
roberts-toolbox/toolboxOne 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/betterTurns 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/challengeOne small Claude Code exercise a day, with a streak and a year of challenges that never repeat.
work-report/reportCounts 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, /keepwarmA 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/shipRepo 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-doctorChecks 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.

Credits

Several of these started as other people's ideas, and they deserve the credit:

  • Clean View, Prompt Upgrade, Daily Challenge and Work Report were built from prompts by Tristen O'Brien (AI Made Easy). The ideas are Tristen's. These are my builds of them.
  • Robert's Toolbox is based on a toolbox Tristen O'Brien showed in a video (tristenobrien.ai).
  • Relay's price table is adapted from cache-tax by Karan Bansal (MIT License).

The mods themselves were built with Claude Code.

License

MIT.

Source 3 files
hooks/register.tsx 408 lines
1import { 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}
408
hooks/report.ts 491 lines
1// 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}
491
types/index.d.ts 8 lines
1export type ReportTab = 'today' | 'week' | 'month' | 'year'
2
3declare module 'claude-code' {
4  interface PluginState {
5    'work-report': { tab: ReportTab; rev: number }
6  }
7}
8