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…

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.
/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 ⏱.
/plugin install time-tracker --marketplace douglasaxel/time-tracker
/config)auto (Claude Code's language setting, then the system locale), en or pt.claude plugin validate .
claude plugin test .
The function hooks API is early access and may change between Claude Code versions.
hooks/register.ts 300 lines1// 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}
300hooks/corrections.ts 46 lines1// 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}
46hooks/i18n.ts 82 lines1// 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}
82hooks/ledger.ts 61 lines1// 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}
61hooks/markdown.ts 33 lines1// 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}
33hooks/panel.tsx 189 lines1// 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}
189hooks/status.ts 9 lines1// 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)}`
9hooks/time.ts 102 lines1// 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))
102hooks/tree.ts 129 lines1// 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}
129types/index.d.ts 65 lines1/** 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