SLOPSHOPPER

time-tracker

Measures the time worked on each project with Claude Code, shows today's time in the status line, and opens the /horas panel with the time per folder and day…

panecommandstatusprompttimer
v0.1.0MITupdated 2026-10-18douglasaxel/time-tracker
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · time-tracker
│ ┃ Hours ✕ › fix the failing auth test and add an audit log call │ ┃ [ ⏸ Pause ] │ ┃ ⏺ Read(src/auth.ts) │ ┃ 1: filter:today 2: filter:week 3: filter:m ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ Total 0h 00min ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ · node:/work/app pick:/work/app ⎿ 3 pass, 1 fail │ ┃ Press a folder's name or time to see its │ ┃ days. ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /horas │ ⎿ time-tracker: Hours panel opened │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ time-tracker: ⏱ 0h 00min today · app

Draws

Pane · Hours
[ ⏸ Pause ] 1: filter:today 2: filter:week 3: filter:month 4: filter: Total 0h 00min · node:/work/app pick:/work/app Press a folder's name or time to see its days.
README

time-tracker

A Claude Code mod that measures the time worked on each project, on its own, and shows today's time in the status line:

⏱ 2h 14min hoje · app-a

Time counts while Claude answers, while the session's background work runs, and across short pauses between interactions; a gap longer than the inactivity limit does not count. Background work counts while a turn's end lists it in flight within the inactivity limit, or while a subagent of the session runs. Each session records its own intervals per project and local day in the plugin store (session:<id>); sessions open at the same time in one project count the period once. Once a day is closed, it is compacted into one total per project (day:<YYYY-MM-DD>) and the old session keys are removed.

The project is the session's project root: after a /cd, new time goes to the new folder. A session in a git worktree counts for the original repository, whether the worktree's gitdir is absolute or relative.

The status line shows the same time as the panel for today's project, corrections included.

The /horas panel

/horas opens and closes a panel with the tree of folders from the disk root down to each project with time. Single-child folders without time of their own fold into one node, each folder shows its folder total (its own time plus that of the folders inside it), and a small toggle (▾/▸) before each folder collapses and expands it. Windows paths read with backslashes (C:\Users\dougl\workstation).

Time is split by local day: an activity that crosses midnight counts its part on each day. The filters at the top (Today, Week from Monday to Sunday, Month, All; in Portuguese Hoje, Semana, Mês, Tudo; keys 1 to 4 while the panel has the focus) set the period the tree and the totals cover. Pressing a folder's name selects it, opens it and lists its days in the period with each day's time. Pressing its time selects it too, and pressing the selected folder's time again clears the selection.

Each day lists the time of each project inside the selected folder. Pressing a project's time on a day opens its correction: type minutes to add or subtract (+15, -30) or press Delete this day to zero it for good, time measured later that day included; an adjustment after a deletion adds from zero (mobile has the delete only). The time shown everywhere is the measured time plus the corrections, never below zero, and a corrected day is marked *. Corrections are kept per day under fix:<YYYY-MM-DD>, apart from the measured time, so compaction leaves them alone: per project, the ms added, or { "deleted": true, "ms": <ms added after the deletion> }.

Under the selected folder, Copy as Markdown copies its days in the period as a table for an hours report: a title with the folder and the period, columns Date (with the year) | Project | Hours (the project relative to the folder), one row per day and project, a Total row, and * with a footnote on corrected rows. Hours read Xh MMmin, never rounded. If the clipboard does not take it (some desktops), the pane shows the table to copy by hand.

At the top, Pause stops the count for the current session until Resume; other sessions keep counting. While paused the status line shows ⏸ instead of ⏱.

Install

/plugin install time-tracker --marketplace douglasaxel/time-tracker

Options (/config)

  • Inactivity limit (minutes): the longest gap that still counts as time worked. Default 10.
  • Language: auto (Claude Code's language setting, then the system locale), en or pt.

Development

claude plugin validate .
claude plugin test .

The function hooks API is early access and may change between Claude Code versions.

Source 10 files
hooks/register.ts 300 lines
1// Engine wiring and every `$` call (the loader follows `$` only within this file).
2// The time model is in time.ts, the status text in status.ts. The mod only observes:
3// every hook passes `next(e)` on unchanged.
4import { atom, read, update } from 'claude-code'
5import type { EngineInterface, Register, RenderSurface, Timer } from 'claude-code'
6import type { Correction, Corrections, DayTotals, Days, SessionMeasurement, SessionRecord } from '../types'
7import { applyCorrections, isSameTarget, nextCorrection, parseMinutes } from './corrections'
8import { isPortuguese, strings, type Lang } from './i18n'
9import { correctionsKey, dayOfKey, isClosedDayKey, isCorrectionsKey, isLedgerKey, ledger, sessionKey } from './ledger'
10import { markdownTable } from './markdown'
11import { panel } from './panel'
12import { statusText } from './status'
13import { MIN, dayKey, expire, liveDays, period, startManualPause, touch, type Moment } from './time'
14import { buildTree, dayTotals, folderDays, inPeriod, projectTotals, resolvePath, toSlashes } from './tree'
15
16const empty: SessionMeasurement = { id: null, days: {}, live: null, turns: [] }
17const measurementAtom = atom({ plugin: 'time-tracker', key: 'measurement' } as const, empty)
18/** When the last classic.Stop listed background work of the session; null when it listed none. */
19const backgroundSinceAtom = atom({ plugin: 'time-tracker', key: 'backgroundSince' } as const, null)
20/** Manual pause, per session in $.state (survives hot reloads): while on, activity records nothing. */
21const pausedAtom = atom({ plugin: 'time-tracker', key: 'paused' } as const, false)
22/** Every session's corrections: the `fix:<day>` keys, read at each sync. */
23const correctionsAtom = atom({ plugin: 'time-tracker', key: 'corrections' } as const, {} as Corrections)
24// The /horas panel's state.
25const collapsedAtom = atom({ plugin: 'time-tracker', key: 'collapsed' } as const, [])
26const filterAtom = atom({ plugin: 'time-tracker', key: 'filter' } as const, 'all')
27const selectedAtom = atom({ plugin: 'time-tracker', key: 'selected' } as const, null)
28const correctingAtom = atom({ plugin: 'time-tracker', key: 'correcting' } as const, null)
29const copyAtom = atom({ plugin: 'time-tracker', key: 'copy' } as const, null)
30
31const PANE = 'time-tracker'
32
33// Module variables reset on every load; register, session.start and sync set them again.
34let inactivityMs = 10 * MIN
35let lang: Lang = 'en'
36let lastSaved = ''
37let timer: Timer | undefined
38/** The other sessions' days, read from the store when a session id binds and each minute. */
39let others: Days = {}
40/** The closed days' totals, as `sync` last saw them. */
41let closed: Record<string, DayTotals> = {}
42
43const quiet = (p: Promise<unknown>) => p.catch(() => undefined)
44
45/** Time worked per day and project, every session counted once, before corrections. */
46const measuredDays = (m: SessionMeasurement) => dayTotals(liveDays(m), others, closed)
47
48async function detectLang($: EngineInterface, option: unknown): Promise<Lang> {
49  if (option === 'en' || option === 'pt') return option
50  try {
51    const settings = (await $.settings.read()) as Record<string, unknown>
52    if (typeof settings.language === 'string' && settings.language.trim()) return isPortuguese(settings.language) ? 'pt' : 'en'
53    const locale = (await $.env.get('LC_ALL')) || (await $.env.get('LC_MESSAGES')) || (await $.env.get('LANG'))
54    return isPortuguese(locale) ? 'pt' : 'en'
55  } catch {
56    return 'en'
57  }
58}
59
60const projectOf = new Map<string, string>()
61
62/**
63 * The project: the session's project root, re-read at every activity edge and minute (a /cd
64 * moves it, with no event), or the original repository when that root is a worktree, as its
65 * `.git` file's `gitdir: <original>/.git/worktrees/<name>` says (relative to the worktree, or
66 * absolute). `/` separators, no trailing one.
67 */
68async function project($: EngineInterface): Promise<string> {
69  const root = toSlashes(await $.session.root())
70  const known = projectOf.get(root)
71  if (known) return known
72  const git = await $.fs.read(`${root}/.git`).catch(() => '')
73  const gitdir = typeof git === 'string' ? /^gitdir:\s*(.+?)\s*$/m.exec(git)?.[1] : undefined
74  const original = gitdir ? /^(.+)\/\.git\/worktrees\/[^/]+$/.exec(resolvePath(root, toSlashes(gitdir)))?.[1] : undefined
75  const p = original ?? root
76  projectOf.set(root, p)
77  return p
78}
79
80const momentNow = async ($: EngineInterface): Promise<Moment> => ({ now: await $.clock.now(), project: await project($), inactivityMs })
81
82/** Binds the measurement to the current session id, picking up what that session already stored. */
83async function ensureSession($: EngineInterface) {
84  if ((await read($, measurementAtom)).id) return
85  const id = await $.session.id()
86  const stored = (await $.store.get(sessionKey(id))) as SessionRecord | undefined
87  const m = await update($, measurementAtom, r => (r.id ? r : { ...empty, id, days: stored?.days ?? {} }))
88  await quiet(sync($, m, await $.clock.now()))
89}
90
91async function persist($: EngineInterface, m: SessionMeasurement) {
92  if (!m.id) return
93  const value: SessionRecord = { days: liveDays(m) }
94  const json = JSON.stringify(value)
95  if (json === lastSaved) return
96  await $.store.set(sessionKey(m.id), value)
97  lastSaved = json
98}
99
100/** Today's time in the project the session is in, as the panel shows it: every session, corrections applied. */
101async function showStatus($: EngineInterface, m: SessionMeasurement, now: number) {
102  const p = m.live?.project ?? (await project($))
103  const today = applyCorrections(measuredDays(m), await read($, correctionsAtom))[dayKey(now)]?.[p] ?? 0
104  $.ui.status(statusText(today, p, lang, await read($, pausedAtom)))
105}
106
107/** An active instant now: a prompt, a step's start or end, a turn's start or end. */
108async function activity($: EngineInterface, edge: { turnStart?: string; turnEnd?: string } = {}) {
109  await ensureSession($)
110  const at = await momentNow($)
111  const paused = await read($, pausedAtom)
112  const m = await update($, measurementAtom, r => {
113    const turns = r.turns.filter(t => t !== edge.turnEnd)
114    if (edge.turnStart && !turns.includes(edge.turnStart)) turns.push(edge.turnStart)
115    return { ...(paused ? r : touch(r, at)), turns }
116  })
117  await showStatus($, m, at.now)
118}
119
120/**
121 * Whether background work keeps the session active: a classic.Stop listed it within the
122 * inactivity limit, or a subagent of the session still runs. Otherwise the flag is cleared, so a
123 * task whose end never reached a Stop does not count forever.
124 */
125async function isBackgroundBusy($: EngineInterface, now: number): Promise<boolean> {
126  const since = await read($, backgroundSinceAtom)
127  if (since === null) return false
128  if (now - since <= inactivityMs) return true
129  const agents = await $.agent.list().catch(() => [])
130  if (agents.some(a => a.status === 'running' || a.status === 'pending')) return true
131  await update($, backgroundSinceAtom, s => (s === since ? null : s))
132  return false
133}
134
135/** Each minute: a running turn or background work keeps the session active; otherwise the inactivity limit may end the activity. */
136async function tick($: EngineInterface) {
137  const now = await $.clock.now()
138  const busy = (await read($, measurementAtom)).turns.length > 0 || (await isBackgroundBusy($, now))
139  const at: Moment = { now, project: busy ? await project($) : '', inactivityMs }
140  const paused = await read($, pausedAtom)
141  const m = await update($, measurementAtom, r => {
142    if (paused) return r
143    return busy ? touch(r, at) : expire(r, now, inactivityMs)
144  })
145  await persist($, m)
146  await quiet(sync($, m, now))
147  await showStatus($, m, now)
148}
149
150/** Reads every session, closed-day and correction key, compacts the closed days (ledger.ts), and refreshes `others`. */
151async function sync($: EngineInterface, m: SessionMeasurement, now: number) {
152  const entries: Record<string, unknown> = {}
153  for (const k of await $.store.keys()) if (isLedgerKey(k)) entries[k] = await $.store.get(k)
154  const own = m.id ? sessionKey(m.id) : ''
155  if (own) entries[own] = { days: liveDays(m) }
156  const plan = ledger(entries, own, dayKey(now - inactivityMs - MIN))
157  for (const [k, v] of plan.set) await $.store.set(k, v)
158  for (const k of plan.remove) await $.store.delete(k)
159  others = plan.others
160  const days = [...Object.entries(entries).filter(([k]) => isClosedDayKey(k)), ...plan.set]
161  closed = Object.fromEntries(days.map(([k, v]) => [dayOfKey(k), v as DayTotals]))
162  const corrections = Object.entries(entries).filter(([k]) => isCorrectionsKey(k))
163  await update($, correctionsAtom, () => Object.fromEntries(corrections.map(([k, v]) => [dayOfKey(k), v as Record<string, Correction>])))
164}
165
166/** Pause closes the activity in progress at this moment; Resume lets the next activity count. */
167async function togglePause($: EngineInterface) {
168  await ensureSession($)
169  const at = await momentNow($)
170  const paused = await update($, pausedAtom, x => !x)
171  const m = paused ? await update($, measurementAtom, r => startManualPause(r, at)) : await read($, measurementAtom)
172  await persist($, m)
173  await showStatus($, m, at.now)
174}
175
176/** Deletes or adjusts the open project day, re-reading its key so another session's correction stays. */
177async function correct($: EngineInterface, change: number | 'delete' | undefined) {
178  const target = await read($, correctingAtom)
179  if (change === undefined || !target) return
180  const { day, project: p } = target
181  const measured = measuredDays(await read($, measurementAtom))[day]?.[p] ?? 0
182  const stored = ((await $.store.get(correctionsKey(day))) ?? {}) as Record<string, Correction>
183  const value = { ...stored, [p]: nextCorrection(stored[p], measured, change) }
184  await $.store.set(correctionsKey(day), value)
185  await update($, correctionsAtom, c => ({ ...c, [day]: value }))
186  await showStatus($, await read($, measurementAtom), await $.clock.now())
187}
188
189/** Copies the table as drawn; a desktop may refuse the copy, so the outcome is always kept to show. */
190async function copyTable($: EngineInterface, text: string, surface: RenderSurface) {
191  const r = await $.ui.copy({ text, surface }).catch(() => ({ isCopied: false }))
192  await update($, copyAtom, () => ({ ok: r.isCopied, text }))
193}
194
195const turnKey = (e: { turnId: string; agentId?: string }) => `${e.agentId ?? 'main'}:${e.turnId}`
196
197export const register: Register = (on, options) => {
198  // The userConfig key stays `idleMinutes`, as saved configs name it.
199  inactivityMs = Math.max(1, Number(options.idleMinutes) || 10) * MIN
200
201  on('session.start', async ($, e, next) => {
202    const r = await next(e)
203    await quiet((async () => {
204      lang = await detectLang($, options.language)
205      await quiet($.command.register({ name: 'horas', description: strings[lang].paneHint }))
206      await ensureSession($)
207      await showStatus($, await read($, measurementAtom), await $.clock.now())
208    })())
209    timer?.cancel()
210    timer = $.clock.every(MIN, () => void quiet(tick($)))
211    return r
212  })
213
214  // Close the activity, write the key, and forget the id: a /clear continues under a new one.
215  on('session.end', async ($, e, next) => {
216    await quiet((async () => {
217      const m = await read($, measurementAtom)
218      await persist($, { ...m, live: null, days: liveDays(m) })
219      await update($, measurementAtom, () => empty)
220      lastSaved = ''
221    })())
222    return next(e)
223  })
224
225  on('prompt.submit', async ($, e, next) => {
226    await quiet(activity($))
227    return next(e)
228  })
229
230  on('turn.start', async ($, e, next) => {
231    await quiet(activity($, { turnStart: turnKey(e) }))
232    return next(e)
233  })
234
235  on('turn.step', async function* ($, e, next) {
236    await quiet(activity($))
237    const r = yield* next(e)
238    await quiet(activity($))
239    return r
240  })
241
242  on('turn.complete', async ($, e, next) => {
243    await quiet(activity($, { turnEnd: turnKey(e) }))
244    return next(e)
245  })
246
247  // Each main-turn end lists the background work still in flight; a task that ends wakes the
248  // session (a task-notification prompt), so the next Stop lists what is left. The minute tick
249  // clears a flag no Stop confirms again (isBackgroundBusy).
250  on('classic.Stop', async ($, e, next) => {
251    await quiet((async () => {
252      const now = await $.clock.now()
253      await update($, backgroundSinceAtom, () => ((e.background_tasks?.length ?? 0) > 0 ? now : null))
254    })())
255    return next(e)
256  })
257
258  // The /horas panel: the command toggles the pane; the drawing reads the measurement, so each tick redraws it.
259  on('command.run', { command: 'horas' }, async $ => {
260    if ((await $.ui.panes()).some(p => p.id === PANE)) {
261      await $.ui.close({ id: PANE })
262      return { text: strings[lang].closed }
263    }
264    await $.ui.open({ id: PANE, title: strings[lang].pane })
265    return { text: strings[lang].opened }
266  })
267
268  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
269    // The days in the chosen period, measured plus corrections, and the selected folder's days among them.
270    const m = await read($, measurementAtom)
271    const filter = await read($, filterAtom)
272    const corrections = await read($, correctionsAtom)
273    const range = period(filter, await $.clock.now())
274    const days = inPeriod(applyCorrections(measuredDays(m), corrections), range)
275    const selected = await read($, selectedAtom)
276    const shown = selected ? folderDays(days, selected, corrections) : []
277    const clearCopy = () => update($, copyAtom, () => null)
278    return panel($.ui.resolve(e), {
279      roots: buildTree(projectTotals(days)),
280      collapsed: await read($, collapsedAtom),
281      lang,
282      onToggle: path => update($, collapsedAtom, c => (c.includes(path) ? c.filter(p => p !== path) : [...c, path])),
283      paused: await read($, pausedAtom),
284      onPause: () => quiet(togglePause($)),
285      filter,
286      selected: selected ? { path: selected, days: shown } : null,
287      onFilter: f => Promise.all([update($, filterAtom, () => f), clearCopy()]),
288      onSelect: path =>
289        Promise.all([update($, selectedAtom, () => path), update($, collapsedAtom, c => c.filter(p => p !== path)), clearCopy()]),
290      onPick: path => Promise.all([update($, selectedAtom, s => (s === path ? null : path)), clearCopy()]),
291      correcting: await read($, correctingAtom),
292      onCorrect: target => update($, correctingAtom, t => (isSameTarget(t, target) ? null : target)),
293      onAdjust: text => quiet(correct($, parseMinutes(text))),
294      onDelete: () => quiet(correct($, 'delete')),
295      copy: await read($, copyAtom),
296      onCopy: surface => quiet(copyTable($, selected ? markdownTable(selected, shown, filter, range, lang) : '', surface)),
297    })
298  })
299}
300
hooks/corrections.ts 46 lines
1// Corrections: a manual change to one project's time on one day. Pure.
2// Stored per corrected day under `fix:<YYYY-MM-DD>` (ledger.ts); compaction never touches them.
3import type { Correction, CorrectionTarget, Corrections, DayTotals } from '../types'
4import { MIN } from './time'
5
6type Deleted = Extract<Correction, object>
7
8/** Whether the correction deletes the day; the older plain number form never does. */
9const isDeleted = (c: Correction | undefined): c is Deleted => typeof c === 'object' && c !== null && c.deleted === true
10
11/** A project day's time as shown: never below zero; a deleted day counts only what was added after. */
12const shownTime = (measured: number, c: Correction) => Math.max(0, isDeleted(c) ? c.ms : measured + c)
13
14/** The time shown: measured plus the corrections; a corrected day with no time measured is shown too. */
15export function applyCorrections(days: Readonly<Record<string, DayTotals>>, corrections: Readonly<Corrections>): Record<string, DayTotals> {
16  const out: Record<string, DayTotals> = {}
17  for (const [d, projects] of Object.entries(days)) out[d] = { ...projects }
18  for (const [d, projects] of Object.entries(corrections))
19    for (const [p, c] of Object.entries(projects)) (out[d] ??= {})[p] = shownTime(out[d]?.[p] ?? 0, c)
20  return out
21}
22
23/** Whether the project's day carries a correction: a `*` in the panel and in the copied table. */
24export const isCorrected = (corrections: Readonly<Corrections>, { day, project }: CorrectionTarget) =>
25  corrections[day]?.[project] !== undefined
26
27export const isSameTarget = (a: CorrectionTarget | null, b: CorrectionTarget) => a?.day === b.day && a.project === b.project
28
29/**
30 * The correction after a change to a day measured at `measured`: `delete` zeroes it for good, a
31 * number of minutes adds to what is shown (a correction below zero is first lifted to zero).
32 */
33export function nextCorrection(current: Correction | undefined, measured: number, change: number | 'delete'): Correction {
34  if (change === 'delete') return { deleted: true, ms: 0 }
35  if (isDeleted(current)) return { deleted: true, ms: Math.max(0, current.ms) + change * MIN }
36  return Math.max(current ?? 0, -measured) + change * MIN
37}
38
39/** `+15`, `-30`, `15`, `−30 min`: the minutes typed, or undefined. */
40export function parseMinutes(text: string): number | undefined {
41  const m = /^\s*([+\-−]?)\s*(\d+)\s*(min)?\s*$/i.exec(text)
42  if (!m) return undefined
43  const sign = m[1] === '-' || m[1] === '−' ? -1 : 1
44  return sign * Number(m[2])
45}
46
hooks/i18n.ts 82 lines
1// Interface strings per language. Pure; detecting the language needs `$`, so it lives in register.ts.
2import type { Filter } from '../types'
3
4export type Lang = 'en' | 'pt'
5
6export const isPortuguese = (v: unknown): boolean => typeof v === 'string' && /^(pt|portugu)/i.test(v.trim())
7
8export const strings = {
9  en: {
10    today: 'today',
11    // The /horas panel.
12    pane: 'Hours',
13    paneHint: 'Show or hide the time worked per folder',
14    opened: 'Hours panel opened',
15    closed: 'Hours panel closed',
16    total: 'Total',
17    empty: 'No time worked yet. Time counts while you work with Claude in a project.',
18    // Days and filters.
19    noTime: 'No time worked in this period.',
20    pickHint: "Press a folder's name or time to see its days.",
21    // Manual pause.
22    pause: '⏸ Pause',
23    resume: '▶ Resume',
24    paused: 'Paused: this session is not counting time.',
25    // Corrections.
26    adjust: 'Add or subtract',
27    adjustHint: '+15 or -30 min',
28    deleteDay: 'Delete this day',
29    corrected: '* corrected by hand',
30    // Copy as Markdown.
31    copy: 'Copy as Markdown',
32    copied: 'Copied. Paste it into your hours report.',
33    copyFailed: 'The clipboard did not take it. Select the table below and copy it.',
34    colDate: 'Date',
35    colProject: 'Project',
36    colHours: 'Hours',
37    to: 'to',
38  },
39  pt: {
40    today: 'hoje',
41    pane: 'Horas',
42    paneHint: 'Mostra ou esconde o tempo trabalhado por pasta',
43    opened: 'Painel de horas aberto',
44    closed: 'Painel de horas fechado',
45    total: 'Total',
46    empty: 'Nenhum tempo trabalhado ainda. O tempo conta enquanto você trabalha com o Claude num projeto.',
47    noTime: 'Nenhum tempo trabalhado neste período.',
48    pickHint: 'Pressione o nome ou o tempo de uma pasta para ver os dias dela.',
49    pause: '⏸ Pausar',
50    resume: '▶ Retomar',
51    paused: 'Em pausa: esta sessão não está contando tempo.',
52    adjust: 'Somar ou subtrair',
53    adjustHint: '+15 ou -30 min',
54    deleteDay: 'Apagar este dia',
55    corrected: '* corrigido à mão',
56    copy: 'Copiar como Markdown',
57    copied: 'Copiado. Cole no relatório de horas.',
58    copyFailed: 'A área de transferência não aceitou. Selecione a tabela abaixo e copie.',
59    colDate: 'Data',
60    colProject: 'Projeto',
61    colHours: 'Horas',
62    to: 'a',
63  },
64} satisfies Record<Lang, Record<string, string>>
65
66export const filters: Record<Lang, Record<Filter, string>> = {
67  en: { today: 'Today', week: 'Week', month: 'Month', all: 'All' },
68  pt: { today: 'Hoje', week: 'Semana', month: 'Mês', all: 'Tudo' },
69}
70
71const weekdays = { en: ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'], pt: ['dom', 'seg', 'ter', 'qua', 'qui', 'sex', 'sáb'] }
72const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
73
74/** A day key as a row reads it: `seg 12/10` or `Mon Oct 12`; with the year, `seg 12/10/2026` or `Mon Oct 12, 2026`. */
75export function dayLabel(day: string, lang: Lang, withYear = false): string {
76  const d = new Date(`${day}T00:00`) // no offset: local time
77  const wd = weekdays[lang][d.getDay()]
78  const year = day.slice(0, 4)
79  if (lang === 'pt') return `${wd} ${day.slice(8)}/${day.slice(5, 7)}${withYear ? `/${year}` : ''}`
80  return `${wd} ${months[d.getMonth()]} ${d.getDate()}${withYear ? `, ${year}` : ''}`
81}
82
hooks/ledger.ts 61 lines
1// The store across sessions: the union of every session's key, and the compaction of closed days. Pure.
2import type { DayTotals, Days, SessionRecord } from '../types'
3import { total, union } from './time'
4
5/**
6 * Store layout, every key the mod writes: each session writes only its own `session:<id>` while it
7 * runs; a closed day is one `day:<YYYY-MM-DD>` holding a total per project, written by whichever
8 * session compacts it; corrections are one `fix:<YYYY-MM-DD>` per corrected day (corrections.ts).
9 */
10const prefix = { session: 'session:', closedDay: 'day:', corrections: 'fix:' } as const
11export const sessionKey = (id: string) => prefix.session + id
12export const closedDayKey = (day: string) => prefix.closedDay + day
13export const correctionsKey = (day: string) => prefix.corrections + day
14export const isSessionKey = (k: string) => k.startsWith(prefix.session)
15export const isClosedDayKey = (k: string) => k.startsWith(prefix.closedDay)
16export const isCorrectionsKey = (k: string) => k.startsWith(prefix.corrections)
17/** Whether the key is one of the mod's own (above). */
18export const isLedgerKey = (k: string) => Object.values(prefix).some(p => k.startsWith(p))
19/** The day a `day:` or `fix:` key is for. */
20export const dayOfKey = (k: string) => k.slice(k.indexOf(':') + 1)
21
22const daysOf = (v: unknown): Days => ((v as SessionRecord | undefined)?.days ?? {})
23
24/** The sessions' days merged into one, each project's intervals united: overlaps count once. */
25export function merge(records: readonly unknown[]): Days {
26  const out: Days = {}
27  for (const r of records)
28    for (const [d, projects] of Object.entries(daysOf(r)))
29      for (const [p, intervals] of Object.entries(projects)) {
30        const day = (out[d] ??= {})
31        day[p] = union([...(day[p] ?? []), ...intervals])
32      }
33  return out
34}
35
36/**
37 * What one session does with the store's `session:` and `day:` entries (its own key holding its
38 * current record): each day before `closedBefore` becomes a total per project under its `day:` key,
39 * and every other session key with only closed days is removed. `others` is the other sessions' days.
40 *
41 * Safe when two sessions run it at once: a total only grows to the larger of the stored one and the
42 * union seen now, and a key is removed only after its days' totals are written.
43 * ponytail: a session key written between another session's read and its removal loses that write;
44 * `closedBefore` lags midnight by more than the inactivity limit, so a closed day no longer changes.
45 */
46export function ledger(entries: Readonly<Record<string, unknown>>, own: string, closedBefore: string) {
47  const sessions = Object.keys(entries).filter(isSessionKey)
48  const all = merge(sessions.map(k => entries[k]))
49  const set: [string, DayTotals][] = []
50  for (const [d, projects] of Object.entries(all)) {
51    if (d >= closedBefore) continue
52    const stored = (entries[closedDayKey(d)] ?? {}) as DayTotals
53    const next = { ...stored }
54    for (const [p, intervals] of Object.entries(projects)) next[p] = Math.max(stored[p] ?? 0, total(intervals))
55    if (Object.keys(next).some(p => next[p] !== stored[p])) set.push([closedDayKey(d), next])
56  }
57  const remove = sessions.filter(k => k !== own && Object.keys(daysOf(entries[k])).every(d => d < closedBefore))
58  const others = merge(sessions.filter(k => k !== own && !remove.includes(k)).map(k => entries[k]))
59  return { set, remove, others }
60}
61
hooks/markdown.ts 33 lines
1// Copy as Markdown: the selected folder's days in the period as a table for the hours report. Pure.
2import type { Filter } from '../types'
3import { dayLabel, filters, strings, type Lang } from './i18n'
4import { formatDuration, formatTotal } from './time'
5import { folderName, relativeName, type FolderDay } from './tree'
6
7/** A day key as the title writes it: `12/10/2026` or `2026-10-12`. */
8const date = (day: string, lang: Lang) => (lang === 'pt' ? `${day.slice(8)}/${day.slice(5, 7)}/${day.slice(0, 4)}` : day)
9
10/**
11 * Title with the folder and the period, one row per day (with its year) and project (the path
12 * relative to the folder), a Total row, `*` on corrected rows and a footnote. Hours as `Xh MMmin`,
13 * never rounded. `range` is the filter's period; for every day, the first and last day with time.
14 */
15export function markdownTable(folder: string, days: readonly FolderDay[], filter: Filter, range: readonly [string, string], lang: Lang): string {
16  const s = strings[lang]
17  const [from, to] = filter === 'all' ? [days[0]?.day ?? '', days.at(-1)?.day ?? ''] : range
18  const when = from === to ? date(from, lang) : `${date(from, lang)} ${s.to} ${date(to, lang)}`
19  const rows = days.flatMap(d =>
20    d.entries.map(e => `| ${dayLabel(d.day, lang, true)} | ${relativeName(e.project, folder)} | ${formatDuration(e.ms)}${e.corrected ? ' *' : ''} |`),
21  )
22  return [
23    `## ${folderName(folder)} · ${filters[lang][filter]}, ${when}`,
24    '',
25    `| ${s.colDate} | ${s.colProject} | ${s.colHours} |`,
26    '| --- | --- | ---: |',
27    ...rows,
28    `| **${s.total}** | | **${formatTotal(days)}** |`,
29    '',
30    ...(days.some(d => d.corrected) ? [`\\${s.corrected}`, ''] : []),
31  ].join('\n')
32}
33
hooks/panel.tsx 189 lines
1// The /horas panel: the period filters, the total, the tree with each folder total in a right-hand
2// column, and the selected folder's days. Pure: register.ts hands it the surface's elements, the
3// tree and what a press does.
4import type { Elements, RenderElement, RenderSurface } from 'claude-code'
5import type { CorrectionTarget, Filter } from '../types'
6import { isSameTarget } from './corrections'
7import { dayLabel, filters, strings, type Lang } from './i18n'
8import { formatDuration, formatTotal } from './time'
9import { displayPath, folderName, relativeName, type FolderDay, type Node } from './tree'
10
11export type PanelArgs = {
12  roots: Node[]
13  /** Paths of the folders the person collapsed; every other folder is open. */
14  collapsed: readonly string[]
15  lang: Lang
16  /** Collapses an open folder, or opens a collapsed one. */
17  onToggle: (path: string) => unknown
18  /** This session's manual pause and what pressing Pause or Resume does. */
19  paused: boolean
20  onPause: () => unknown
21  filter: Filter
22  /** The selected folder's path and its days in the period, oldest first, each project's time on them. */
23  selected: { path: string; days: readonly FolderDay[] } | null
24  onFilter: (filter: Filter) => unknown
25  /** A folder's name pressed: selects it and opens it. */
26  onSelect: (path: string) => unknown
27  /** A folder's time pressed: selects it, or clears the selection when it is the selected one. */
28  onPick: (path: string) => unknown
29  /** The project day whose correction line is open. */
30  correcting: CorrectionTarget | null
31  /** Opens the correction line of a project day, or closes it when it is the open one. */
32  onCorrect: (target: CorrectionTarget) => unknown
33  /** The minutes typed (`+15`, `-30`) for the open project day. */
34  onAdjust: (text: string) => unknown
35  /** Zeroes the open project day for good. */
36  onDelete: () => unknown
37  /** The last copy's outcome and table, until the folder or the period changes. */
38  copy: { ok: boolean; text: string } | null
39  /** Copies the selected folder's table on the surface the press came from. */
40  onCopy: (surface: RenderSurface) => unknown
41}
42
43export function panel(ui: Elements[RenderSurface], args: PanelArgs): RenderElement {
44  const { roots, collapsed, lang, onToggle, paused, onPause, filter, selected, onFilter, onSelect, onPick } = args
45  const { correcting, onCorrect, onAdjust, onDelete, copy, onCopy } = args
46  const { Box, Text, Button } = ui
47  const s = strings[lang]
48
49  // Pause or Resume first, with the reason the count stands still while paused.
50  const control = (
51    <Box key="control" flexDirection="row" gap={2} marginBottom={1}>
52      <Button key="pause" variant={paused ? 'primary' : 'secondary'} onPress={() => void onPause()}>
53        {paused ? s.resume : s.pause}
54      </Button>
55      {paused ? <Text color="warning">{s.paused}</Text> : null}
56    </Box>
57  )
58
59  // One choice of four, so radio marks; digits 1-4 press them while the pane has the focus.
60  const bar = (
61    <Box key="filters" flexDirection="row" gap={2} marginBottom={1}>
62      {(Object.keys(filters[lang]) as Filter[]).map((f, i) => (
63        <Button key={`filter:${f}`} plain hotkey={String(i + 1)} onPress={() => void onFilter(f)}>
64          {f === filter ? <Text bold>● {filters[lang][f]}</Text> : <Text dimColor>○ {filters[lang][f]}</Text>}
65        </Button>
66      ))}
67    </Box>
68  )
69
70  if (!roots.length)
71    return (
72      <Box flexDirection="column">
73        {control}
74        {bar}
75        <Text dimColor>{filter === 'all' ? s.empty : s.noTime}</Text>
76      </Box>
77    )
78
79  // Each row: a small dim toggle (folders) or a dot (projects), the name that selects it, and its
80  // total right-aligned, pressed to select it or clear the selection.
81  const rows = (n: Node, depth: number): RenderElement[] => {
82    const isFolder = n.children.length > 0
83    const isOpen = !collapsed.includes(n.path)
84    const row = (
85      <Box key={`row:${n.path}`} flexDirection="row" justifyContent="space-between" gap={2} paddingLeft={depth * 2}>
86        <Box key={`label:${n.path}`} flexDirection="row" gap={1}>
87          {isFolder ? (
88            <Button key={`toggle:${n.path}`} plain onPress={() => void onToggle(n.path)}>
89              <Text dimColor>{isOpen ? '▾' : '▸'}</Text>
90            </Button>
91          ) : (
92            <Text dimColor>·</Text>
93          )}
94          <Button key={`node:${n.path}`} plain onPress={() => void onSelect(n.path)}>
95            <Text bold={isFolder}>{displayPath(n.name || '/', n.path)}</Text>
96          </Button>
97        </Box>
98        <Button key={`pick:${n.path}`} plain onPress={() => void onPick(n.path)}>
99          <Text bold={isFolder} inverse={selected?.path === n.path}>
100            {formatDuration(n.total)}
101          </Text>
102        </Button>
103      </Box>
104    )
105    return [row, ...(isFolder && isOpen ? n.children.flatMap(c => rows(c, depth + 1)) : [])]
106  }
107
108  // A project's time on a day, pressed to open its correction line; `*` marks a corrected one.
109  const mark = (corrected: boolean) => (corrected ? <Text color="warning"> *</Text> : '')
110  const correctable = (target: CorrectionTarget, ms: number, corrected: boolean) => (
111    <Button key={`fix:${target.day}|${target.project}`} plain onPress={() => void onCorrect(target)}>
112      <Text inverse={isSameTarget(correcting, target)}>{formatDuration(ms)}</Text>
113      {mark(corrected)}
114    </Button>
115  )
116  // The correction line under the open row: minutes to add or subtract (no field on mobile), and delete.
117  const correctionLine = (indent: number) => (
118    <Box key="correction" flexDirection="row" gap={2} paddingLeft={indent}>
119      {'Input' in ui ? <ui.Input key="adjust" label={s.adjust} placeholder={s.adjustHint} onSubmit={v => void onAdjust(v)} /> : null}
120      <Button key="delete" onPress={() => void onDelete()}>
121        {s.deleteDay}
122      </Button>
123    </Box>
124  )
125
126  // A day of the selected project takes the correction on its own row; a folder's day lists its projects.
127  const dayRows = (d: FolderDay, folder: string): RenderElement[] => {
128    const own = d.entries.length === 1 && d.entries[0]?.project === folder ? d.entries[0] : undefined
129    const isOpen = (project: string) => isSameTarget(correcting, { day: d.day, project })
130    const head = (
131      <Box key={`day:${d.day}`} flexDirection="row" justifyContent="space-between" gap={2} paddingLeft={2}>
132        <Text>{dayLabel(d.day, lang)}</Text>
133        {own ? correctable({ day: d.day, project: own.project }, own.ms, own.corrected) : <Text>{formatDuration(d.total)}{mark(d.corrected)}</Text>}
134      </Box>
135    )
136    if (own) return [head, ...(isOpen(own.project) ? [correctionLine(4)] : [])]
137    return [
138      head,
139      ...d.entries.flatMap(e => [
140        <Box key={`entry:${d.day}|${e.project}`} flexDirection="row" justifyContent="space-between" gap={2} paddingLeft={4}>
141          <Text dimColor wrap="truncate-end">{displayPath(relativeName(e.project, folder), e.project)}</Text>
142          {correctable({ day: d.day, project: e.project }, e.ms, e.corrected)}
143        </Box>,
144        ...(isOpen(e.project) ? [correctionLine(6)] : []),
145      ]),
146    ]
147  }
148
149  // The copy and what came of it, said next to the button; a refused copy shows the table to copy by hand.
150  const copyRow = (
151    <Box key="copy-row" flexDirection="row" gap={2} marginTop={1}>
152      <Button key="copy" onPress={e => void onCopy(e.surface)}>
153        {s.copy}
154      </Button>
155      {copy ? <Text color={copy.ok ? 'success' : 'warning'}>{copy.ok ? s.copied : s.copyFailed}</Text> : null}
156    </Box>
157  )
158
159  const picked = selected && (
160    <Box key="days" flexDirection="column" marginTop={1}>
161      <Box key="days-head" flexDirection="row" justifyContent="space-between" gap={2}>
162        <Text bold wrap="truncate-end">{folderName(selected.path)}</Text>
163        <Text bold>{formatTotal(selected.days)}</Text>
164      </Box>
165      {selected.days.length ? selected.days.flatMap(d => dayRows(d, selected.path)) : <Text dimColor>{s.noTime}</Text>}
166      {selected.days.some(d => d.corrected) ? <Text dimColor>{s.corrected}</Text> : null}
167      {selected.days.length ? copyRow : null}
168      {copy && !copy.ok ? (
169        <Box key="copied-table" marginTop={1}>
170          <ui.Code source={copy.text} language="markdown" />
171        </Box>
172      ) : null}
173    </Box>
174  )
175
176  return (
177    <Box flexDirection="column">
178      {control}
179      {bar}
180      <Box key="total" flexDirection="row" justifyContent="space-between" gap={2} marginBottom={1}>
181        <Text bold>{s.total}</Text>
182        <Text bold>{formatTotal(roots)}</Text>
183      </Box>
184      {roots.flatMap(n => rows(n, 0))}
185      {picked || <Text dimColor>{s.pickHint}</Text>}
186    </Box>
187  )
188}
189
hooks/status.ts 9 lines
1// The status line text: `⏱ <time today> today · <project folder>`, `⏸` while paused. Pure.
2import { strings, type Lang } from './i18n'
3import { formatDuration } from './time'
4import { folderName } from './tree'
5
6/** `today` is the project's time shown for today: every session's, corrections applied, as the panel shows it. */
7export const statusText = (today: number, project: string, lang: Lang, paused: boolean) =>
8  `${paused ? '⏸' : '⏱'} ${formatDuration(today)} ${strings[lang].today} · ${folderName(project)}`
9
hooks/time.ts 102 lines
1// The time model: pure functions over intervals, days and the session record. No `$` here.
2import type { Days, Filter, Interval, Live, SessionMeasurement } from '../types'
3
4export const MIN = 60_000
5
6const pad = (n: number) => String(n).padStart(2, '0')
7
8/** The local day an instant falls on, as `YYYY-MM-DD`. */
9export function dayKey(ms: number): string {
10  const d = new Date(ms)
11  return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
12}
13
14/** The first and last local day of the period holding `now`. The week runs Monday to Sunday. */
15export function period(filter: Filter, now: number): [string, string] {
16  const d = new Date(now)
17  const day = (offset: number, month = d.getMonth()) => dayKey(new Date(d.getFullYear(), month, offset).getTime())
18  if (filter === 'today') return [dayKey(now), dayKey(now)]
19  if (filter === 'month') return [day(1), day(0, d.getMonth() + 1)]
20  if (filter === 'week') {
21    const monday = d.getDate() - ((d.getDay() + 6) % 7)
22    return [day(monday), day(monday + 6)]
23  }
24  return ['', '9999-12-31']
25}
26
27const nextMidnight = (ms: number) => {
28  const d = new Date(ms)
29  return new Date(d.getFullYear(), d.getMonth(), d.getDate() + 1).getTime()
30}
31
32/** Overlapping or touching intervals merged into one, sorted. Overlaps count once. */
33export function union(intervals: readonly Interval[]): Interval[] {
34  const out: Interval[] = []
35  for (const [s, e] of [...intervals].sort((a, b) => a[0] - b[0])) {
36    const last = out.at(-1)
37    if (last && s <= last[1]) last[1] = Math.max(last[1], e)
38    else out.push([s, e])
39  }
40  return out
41}
42
43export const total = (intervals: readonly Interval[] = []) => union(intervals).reduce((n, [s, e]) => n + e - s, 0)
44
45/** `days` with [start, end] added under `project`, cut at each local midnight. */
46export function addInterval(days: Days, project: string, start: number, end: number): Days {
47  const out: Days = { ...days }
48  for (let s = start; s < end; ) {
49    const e = Math.min(end, nextMidnight(s))
50    const day = dayKey(s)
51    const projects = { ...out[day] }
52    projects[project] = union([...(projects[project] ?? []), [s, e]])
53    out[day] = projects
54    s = e
55  }
56  return out
57}
58
59/** The days with the activity in progress closed at its last active instant. */
60export const withLive = (days: Days, live: Live | null): Days =>
61  live && live.last > live.start ? addInterval(days, live.project, live.start, live.last) : days
62
63/** The session's days up to now: its closed intervals and the activity in progress. */
64export const liveDays = (m: SessionMeasurement): Days => withLive(m.days, m.live)
65
66/** An instant of the session: when, in which project, and the inactivity limit in force. */
67export type Moment = { now: number; project: string; inactivityMs: number }
68
69/**
70 * An active instant. A gap since the last active instant up to the inactivity limit is a short
71 * pause and counts; a longer gap starts a new activity. Another project (a /cd) within the limit
72 * closes the old project's activity at `now` and opens the new one there.
73 */
74export function touch(m: SessionMeasurement, { now, project, inactivityMs }: Moment): SessionMeasurement {
75  const { live } = m
76  const isOn = live && now >= live.last && now - live.last <= inactivityMs
77  if (isOn && live.project === project) return { ...m, live: { ...live, last: now } }
78  const ended = isOn ? { ...live, last: now } : live
79  return { ...m, days: withLive(m.days, ended), live: { project, start: now, last: now } }
80}
81
82/** A manual pause starting at this moment: the activity in progress closes there. */
83export function startManualPause(m: SessionMeasurement, at: Moment): SessionMeasurement {
84  const r = touch(m, at)
85  return { ...r, days: liveDays(r), live: null }
86}
87
88/** With no activity for longer than the inactivity limit, the activity ends at its last active instant. */
89export function expire(m: SessionMeasurement, now: number, inactivityMs: number): SessionMeasurement {
90  if (!m.live || now - m.live.last <= inactivityMs) return m
91  return { ...m, days: liveDays(m), live: null }
92}
93
94/** `Xh MMmin`, never rounded up. */
95export function formatDuration(ms: number): string {
96  const m = Math.floor(ms / MIN)
97  return `${Math.floor(m / 60)}h ${pad(m % 60)}min`
98}
99
100/** The items' times summed, as `Xh MMmin`. */
101export const formatTotal = (items: readonly { total: number }[]) => formatDuration(items.reduce((t, i) => t + i.total, 0))
102
hooks/tree.ts 129 lines
1// The tree: folders from the disk root down to each project with time, single-child
2// folders without time of their own folded into one node. Paths and their names. Pure.
3import type { Corrections, DayTotals, Days } from '../types'
4import { isCorrected } from './corrections'
5import { merge } from './ledger'
6import { total } from './time'
7
8export type Node = {
9  /** The folder's path, `/`-separated: the key that selects and collapses it. */
10  path: string
11  /** What the row shows: the folder's name, or the folded run of names, `/`-separated. */
12  name: string
13  /** The folder total: its own time plus that of every folder inside it. */
14  total: number
15  children: Node[]
16}
17
18/**
19 * Time worked per day and project: this session's and the other sessions' intervals united
20 * (overlaps count once), and a closed day's stored total where it is the larger.
21 */
22export function dayTotals(own: Days, others: Days, closed: Readonly<Record<string, DayTotals>>): Record<string, DayTotals> {
23  const out: Record<string, DayTotals> = {}
24  for (const [day, projects] of Object.entries(closed)) out[day] = { ...projects }
25  for (const [day, projects] of Object.entries(merge([{ days: own }, { days: others }]))) {
26    const d = (out[day] ??= {})
27    for (const [p, intervals] of Object.entries(projects)) d[p] = Math.max(d[p] ?? 0, total(intervals))
28  }
29  return out
30}
31
32/** Time worked per project over the days. */
33export function projectTotals(days: Readonly<Record<string, DayTotals>>): Record<string, number> {
34  const out: Record<string, number> = {}
35  for (const projects of Object.values(days)) for (const [p, ms] of Object.entries(projects)) out[p] = (out[p] ?? 0) + ms
36  return out
37}
38
39/** The days from `from` to `to`, both included. */
40export const inPeriod = (days: Readonly<Record<string, DayTotals>>, [from, to]: readonly [string, string]) =>
41  Object.fromEntries(Object.entries(days).filter(([d]) => d >= from && d <= to))
42
43/** A path from the engine as the mod keeps paths: `/` separators, no trailing one. */
44export function toSlashes(path: string): string {
45  const s = path.trim().split('\\').join('/')
46  return s.length > 3 ? s.replace(/\/+$/, '') : s
47}
48
49/** `path` resolved against the folder `base` when it is relative, `.` and `..` worked out; both `/`-separated. */
50export function resolvePath(base: string, path: string): string {
51  const joined = /^([a-z]:)?\//i.test(path) ? path : `${base}/${path}`
52  const out: string[] = []
53  for (const part of joined.split('/')) {
54    if (part === '..') {
55      if (out.length > 1) out.pop()
56    } else if (part !== '.' && (part || !out.length)) out.push(part)
57  }
58  return out.join('/')
59}
60
61/** How the panel shows `text`, a part of `path`: with backslashes when `path` is a Windows path. */
62export const displayPath = (text: string, path: string) => (/^[a-z]:/i.test(path) ? text.split('/').join('\\') : text)
63
64const withSlash = (folder: string) => (folder.endsWith('/') ? folder : `${folder}/`)
65
66/** The last name in a path: what the status line and the copied table call a project or folder. */
67export const folderName = (path: string) => path.split('/').filter(Boolean).at(-1) ?? path
68
69/** How a row inside `folder` names `project`: its path relative to the folder, or the folder's name. */
70export const relativeName = (project: string, folder: string) =>
71  project === folder ? folderName(folder) : project.slice(withSlash(folder).length)
72
73/** One project's time on one day, inside the selected folder; `corrected` marks a correction. */
74export type Entry = { project: string; ms: number; corrected: boolean }
75export type FolderDay = { day: string; total: number; corrected: boolean; entries: Entry[] }
76
77/**
78 * Each day in `folder` and every folder inside it, oldest first, with each project's time on it
79 * (corrections applied). Days and projects without time are left out unless corrected.
80 */
81export function folderDays(days: Readonly<Record<string, DayTotals>>, folder: string, corrections: Readonly<Corrections> = {}): FolderDay[] {
82  const inside = withSlash(folder)
83  return Object.entries(days)
84    .map(([day, projects]): FolderDay => {
85      const entries = Object.entries(projects)
86        .filter(([p]) => p === folder || p.startsWith(inside))
87        .map(([project, ms]) => ({ project, ms, corrected: isCorrected(corrections, { day, project }) }))
88        .filter(e => e.ms > 0 || e.corrected)
89        .sort((a, b) => a.project.localeCompare(b.project))
90      return { day, total: entries.reduce((n, e) => n + e.ms, 0), corrected: entries.some(e => e.corrected), entries }
91    })
92    .filter(d => d.entries.length)
93    .sort((a, b) => (a.day < b.day ? -1 : 1))
94}
95
96/** A folder while the tree is built: its own time, and the folders inside it by name. */
97type DraftFolder = { path: string; name: string; own: number; children: Map<string, DraftFolder> }
98
99const byName = (a: Node, b: Node) => a.name.localeCompare(b.name)
100
101const folderTotal = (n: DraftFolder): number => [...n.children.values()].reduce((t, c) => t + folderTotal(c), n.own)
102
103function foldSingleChildFolders(n: DraftFolder): Node {
104  while (n.own === 0 && n.children.size === 1) {
105    const onlyChild = n.children.values().next().value as DraftFolder
106    n = { ...onlyChild, name: `${n.name}/${onlyChild.name}` }
107  }
108  const children = [...n.children.values()].map(foldSingleChildFolders).sort(byName)
109  return { path: n.path, name: n.name, total: folderTotal(n), children }
110}
111
112/** The roots of the tree (one per disk), folders without time left out. */
113export function buildTree(totals: Record<string, number>): Node[] {
114  const top: DraftFolder = { path: '', name: '', own: 0, children: new Map() }
115  for (const [project, ms] of Object.entries(totals)) {
116    if (ms <= 0) continue
117    const [first = '', ...rest] = project.split('/')
118    let n = top
119    for (const name of [first, ...rest.filter(Boolean)]) {
120      const path = n === top ? name || '/' : `${n.path === '/' ? '' : n.path}/${name}`
121      let child = n.children.get(name)
122      if (!child) n.children.set(name, (child = { path, name, own: 0, children: new Map() }))
123      n = child
124    }
125    n.own += ms
126  }
127  return [...top.children.values()].map(foldSingleChildFolders).sort(byName)
128}
129
types/index.d.ts 65 lines
1/** Epoch ms, start and end of one stretch of activity, inside one local day. */
2export type Interval = [number, number]
3
4/** Day key `YYYY-MM-DD` (local) -> project path -> intervals, sorted and disjoint. */
5export type Days = Record<string, Record<string, Interval[]>>
6
7/** What a session writes to `$.store` under `session:<sessionId>`. */
8export type SessionRecord = { days: Days }
9
10/** What `$.store` holds under `day:<YYYY-MM-DD>` for a closed day: project path -> ms worked, all sessions united. */
11export type DayTotals = Record<string, number>
12
13/**
14 * One project day's correction: ms added to the measured time (negative subtracts), or the day
15 * deleted, so measured time no longer counts, with `ms` added from zero after the deletion.
16 */
17export type Correction = number | { deleted: true; ms: number }
18
19/** Corrections, as the `fix:<YYYY-MM-DD>` keys hold them: day -> project path -> correction. */
20export type Corrections = Record<string, Record<string, Correction>>
21
22/** The project day a correction applies to. */
23export type CorrectionTarget = { day: string; project: string }
24
25/** The activity in progress: from `start` to the last active instant `last`. */
26export type Live = { project: string; start: number; last: number }
27
28/** The session's own measurement, held for the session in `$.state`. */
29export type SessionMeasurement = {
30  /** The session id the measurement belongs to; null after a /clear until the next activity. */
31  id: string | null
32  /** Closed intervals of this session. */
33  days: Days
34  live: Live | null
35  /** Turns running now (main loop and subagents): while any runs, the session is active. */
36  turns: string[]
37}
38
39/** The /horas panel's period: today, this week (Monday to Sunday), this month, or every day. */
40export type Filter = 'today' | 'week' | 'month' | 'all'
41
42declare module 'claude-code' {
43  interface PluginState {
44    'time-tracker': {
45      measurement: SessionMeasurement
46      /** When the last classic.Stop listed background work of the session in flight; null when none. */
47      backgroundSince: number | null
48      /** Folder paths collapsed in the /horas panel. */
49      collapsed: string[]
50      /** The /horas panel's period. */
51      filter: Filter
52      /** The folder whose days the /horas panel lists, if any. */
53      selected: string | null
54      /** Manual pause of this session: while on, no activity is recorded. */
55      paused: boolean
56      /** Every session's corrections, read from the store. */
57      corrections: Corrections
58      /** The project day whose correction line is open. */
59      correcting: CorrectionTarget | null
60      /** The last Copy as Markdown: whether the clipboard took it, and the table copied. */
61      copy: { ok: boolean; text: string } | null
62    }
63  }
64}
65