Real time per project: your work blocks, AI turn time and turns; /timesheet [week|month]

A Claude Code mod that records real time per project and prints it with one command.
/timesheet today
/timesheet week this week (Mon–Sun, local time)
/timesheet month this month
Project Bạn làm AI chạy Lượt Việc chính
acme-api 6h40 14h10 83 API checkout, fix auth, migration
acme-web 3h15 5h50 41 trang checkout, form validate
──────────────────────────────────────────────────────
Tổng 9h55 20h00
Every account writes to ~/.timesheet/YYYY-MM/<session-id>.jsonl (one file per session, so parallel sessions never overwrite each other). Summaries are cached in ~/.timesheet/summaries.json.
Tested on Claude Code 2.1.302 (Windows 11).
At the prompt of a Claude Code terminal session:
/plugin install timesheet --marketplace trongld230289/timesheet-mod
Answer y to add the marketplace, then pick a scope (user: every project). From a shell:
claude plugin install timesheet --marketplace trongld230289/timesheet-mod
In the desktop app, where /plugin does not run: click + next to the prompt box, then Plugins, Add marketplace with trongld230289/timesheet-mod, then Add plugin.
Several accounts (CLAUDE_CONFIG_DIR=~/.claude-acc2, ...): each account keeps its own plugins, so run the install once per account; they all write to the same ~/.timesheet. Sessions already open start recording after they restart (/exit, then claude --continue).
claude plugin validate .
claude plugin test .hooks/register.ts 203 lines1import type { EngineInterface, Register } from 'claude-code'
2
3import type { Ev, Interval, PeriodKind, Row } from './time'
4import { monthKey, monthsOf, parseLog, periodOf, renderTable, tally } from './time'
5
6// This session's identity and its own events, per month. Only this session writes its file, so
7// parallel sessions (any account) never overwrite each other; /timesheet reads every file.
8let session = ''
9let project = ''
10let turnStart: number | null = null
11const own = new Map<string, Ev[]>()
12
13/** Your own gestures: typed or queued at the prompt, or sent from the phone (Remote Control). */
14const USER_ORIGINS = new Set(['composer', 'bridge'])
15
16const PERIOD_ARG: Record<string, PeriodKind> = { '': 'today', today: 'today', week: 'week', month: 'month' }
17const PERIOD_NAME: Record<PeriodKind, string> = { today: 'hôm nay', week: 'tuần này', month: 'tháng này' }
18
19const shorten = (s: string, max: number) => {
20 const one = s.replace(/\s+/g, ' ').trim()
21 return one.length > max ? `${one.slice(0, max - 1)}…` : one
22}
23
24const basename = (path: string) =>
25 path
26 .replace(/[\\/]+$/, '')
27 .split(/[\\/]/)
28 .pop() || path
29
30/** Bookkeeping beside the engine's work: a failure is logged, never passed on to the call. */
31async function quietly($: EngineInterface, what: string, fn: () => Promise<unknown>) {
32 try {
33 await fn()
34 } catch (error) {
35 $.ui.log(`timesheet: ${what} failed: ${error instanceof Error ? error.message : String(error)}`, { to: 'debug' })
36 }
37}
38
39/** The shared folder, in the home folder: outside every CLAUDE_CONFIG_DIR, so all accounts write here. */
40async function baseDir($: EngineInterface): Promise<string | null> {
41 const home = (await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME'))
42 return home ? `${home.replace(/\\/g, '/')}/.timesheet` : null
43}
44
45/** The project is the git root's folder name, or the working directory's when there is no repository. */
46async function identify($: EngineInterface) {
47 if (session) return
48 const [id, repo, cwd] = await Promise.all([$.session.id(), $.session.repo().catch(() => null), $.session.cwd()])
49 session = id
50 project = basename(repo?.root ?? cwd)
51}
52
53async function ownFile($: EngineInterface, month: string): Promise<string | null> {
54 const dir = await baseDir($)
55 return dir ? `${dir}/${month}/${session}.jsonl` : null
56}
57
58/** Reads this session's file for the month back once (after a reload the module's memory is empty). */
59async function loadOwn($: EngineInterface, month: string): Promise<Ev[]> {
60 const have = own.get(month)
61 if (have) return have
62 const path = await ownFile($, month)
63 const text = path ? await $.fs.read(path).catch(() => '') : ''
64 const list = typeof text === 'string' ? parseLog(text) : []
65 own.set(month, list)
66 return list
67}
68
69async function record($: EngineInterface, make: (at: number) => Ev) {
70 await identify($)
71 const at = await $.clock.now()
72 const ev = make(at)
73 const month = monthKey(ev.t === 'turn' ? ev.end : ev.at)
74 const list = await loadOwn($, month)
75 list.push(ev)
76 const path = await ownFile($, month)
77 if (path) await $.fs.write(path, `${list.map(e => JSON.stringify(e)).join('\n')}\n`)
78}
79
80/** Every session's events for the months a period touches. */
81async function readAll($: EngineInterface, period: Interval): Promise<Ev[]> {
82 const dir = await baseDir($)
83 if (!dir) return []
84 const out: Ev[] = []
85 for (const month of monthsOf(period)) {
86 const entries = await $.fs.list(`${dir}/${month}`).catch(() => [])
87 for (const entry of entries) {
88 if (entry.kind !== 'file' || !entry.name.endsWith('.jsonl')) continue
89 const text = await $.fs.read(`${dir}/${month}/${entry.name}`).catch(() => '')
90 if (typeof text === 'string') out.push(...parseLog(text))
91 }
92 }
93 return out
94}
95
96type Cache = Record<string, { n: number; text: string }>
97
98const SUMMARY_SYSTEM = `You label a developer's work. From the prompts they sent an AI coding assistant, write ONE short phrase (at most 40 characters) naming the main things done, comma-separated, like "API checkout, fix auth, migration". Use the language most prompts are written in. Output only the phrase: no quotes, no period, no preamble.`
99
100/**
101 * A short label of what was done, from the period's prompts, by haiku. Cached per project and
102 * period, and kept while the prompt count is unchanged. Any failure leaves it empty.
103 */
104async function summarize($: EngineInterface, row: Row, kind: PeriodKind, period: Interval, cache: Cache): Promise<string> {
105 if (row.prompts.length === 0) return ''
106 const key = `${row.project}|${kind}|${period[0]}`
107 const hit = cache[key]
108 if (hit && hit.n === row.prompts.length) return hit.text
109 const prompts = row.prompts.slice(-40).map(p => `- ${shorten(p, 120)}`)
110 const result = await $.model
111 .complete({ model: 'haiku', system: SUMMARY_SYSTEM, prompt: prompts.join('\n'), maxTokens: 60, timeoutMs: 20_000 })
112 .catch(() => null)
113 if (!result?.isAnswered) return hit?.text ?? ''
114 const text = shorten(result.text.split('\n')[0]?.replace(/^["'“”\s]+|["'“”.\s]+$/g, '') ?? '', 40)
115 cache[key] = { n: row.prompts.length, text }
116 return text
117}
118
119async function report($: EngineInterface, kind: PeriodKind): Promise<string> {
120 const now = await $.clock.now()
121 const period = periodOf(kind, now)
122 const { rows, mineTotal, aiTotal } = tally(await readAll($, period), period)
123 if (rows.length === 0) return `Chưa có dữ liệu cho ${PERIOD_NAME[kind]}.`
124
125 const dir = await baseDir($)
126 const cachePath = dir ? `${dir}/summaries.json` : null
127 let cache: Cache = {}
128 try {
129 const text = cachePath ? await $.fs.read(cachePath).catch(() => '') : ''
130 cache = typeof text === 'string' && text ? (JSON.parse(text) as Cache) : {}
131 } catch {
132 cache = {}
133 }
134 const summaries = await Promise.all(rows.map(r => summarize($, r, kind, period, cache).catch(() => '')))
135 if (cachePath) await $.fs.write(cachePath, JSON.stringify(cache)).catch(() => undefined)
136
137 return renderTable(
138 rows.map((r, i) => ({ ...r, summary: summaries[i] ?? '' })),
139 mineTotal,
140 aiTotal,
141 )
142}
143
144export const register: Register = on => {
145 on('session.start', async ($, e, next) => {
146 await $.command.register({
147 name: 'timesheet',
148 description: 'Time per project: your work, AI time, turns (today, week or month)',
149 argumentHint: '[week|month]',
150 })
151 const started = await next(e)
152 await quietly($, 'load', async () => {
153 await identify($)
154 await loadOwn($, monthKey(await $.clock.now()))
155 })
156
157 return started
158 })
159
160 // Something you did: a prompt or a slash command, typed or from the phone.
161 on('prompt.submit', async ($, e, next) => {
162 if (USER_ORIGINS.has(e.origin.kind)) {
163 const text = e.command ? undefined : shorten(e.text, 200)
164 await quietly($, 'record prompt', () => record($, at => ({ t: 'act', at, project, session, ...(text ? { text } : {}) })))
165 }
166
167 return next(e)
168 })
169
170 // Answering a question is something you did too: its time is when the answer comes back.
171 on('tool.call', { tool: 'AskUserQuestion' }, async ($, e, next) => {
172 const ran = await next(e)
173 if (ran.deny === undefined) await quietly($, 'record answer', () => record($, at => ({ t: 'act', at, project, session })))
174
175 return ran
176 })
177
178 // AI time: a main-loop turn from start to end, the subagents it waits on included.
179 on('turn.start', async ($, e, next) => {
180 if (!e.agentId) turnStart = await $.clock.now()
181
182 return next(e)
183 })
184
185 on('turn.complete', async ($, e, next) => {
186 const done = await next(e)
187 const start = turnStart
188 if (!e.agentId && start !== null) {
189 turnStart = null
190 await quietly($, 'record turn', () => record($, end => ({ t: 'turn', start, end, project, session })))
191 }
192
193 return done
194 })
195
196 on('command.run', { command: 'timesheet' }, async ($, e) => {
197 const kind = PERIOD_ARG[e.args.trim().toLowerCase()]
198 if (!kind) return { text: 'Usage: /timesheet · /timesheet week · /timesheet month' }
199
200 return { text: await report($, kind) }
201 })
202}
203hooks/time.ts 169 lines1// The time math, as pure functions: periods in local time, work blocks, interval union, the table.
2// Every number comes from recorded timestamps; nothing here estimates.
3
4/** One recorded event. `act` is something you did; `turn` is one main-loop turn, start to end. */
5export type Ev =
6 | { t: 'act'; at: number; project: string; session: string; text?: string }
7 | { t: 'turn'; start: number; end: number; project: string; session: string }
8
9export type Interval = [number, number]
10export type PeriodKind = 'today' | 'week' | 'month'
11
12/** A gap longer than this between two of your activities ends a work block. */
13export const GAP_MS = 10 * 60_000
14/** [from, to) in local time: today; this week Monday to Sunday; this month. */
15export const periodOf = (kind: PeriodKind, now: number): Interval => {
16 const d = new Date(now)
17 if (kind === 'today') {
18 return [
19 new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime(),
20 new Date(d.getFullYear(), d.getMonth(), d.getDate() + 1).getTime(),
21 ]
22 }
23 if (kind === 'week') {
24 const back = (d.getDay() + 6) % 7 // Monday is 0
25 return [
26 new Date(d.getFullYear(), d.getMonth(), d.getDate() - back).getTime(),
27 new Date(d.getFullYear(), d.getMonth(), d.getDate() - back + 7).getTime(),
28 ]
29 }
30 return [new Date(d.getFullYear(), d.getMonth(), 1).getTime(), new Date(d.getFullYear(), d.getMonth() + 1, 1).getTime()]
31}
32
33/** The months a period touches, as `YYYY-MM`: the log's folder names. */
34export const monthsOf = ([from, to]: Interval): string[] => {
35 const out: string[] = []
36 const d = new Date(from)
37 let y = d.getFullYear()
38 let m = d.getMonth()
39 while (new Date(y, m, 1).getTime() < to) {
40 out.push(monthKey(new Date(y, m, 1).getTime()))
41 m += 1
42 if (m === 12) {
43 m = 0
44 y += 1
45 }
46 }
47 return out
48}
49
50export const monthKey = (at: number) => {
51 const d = new Date(at)
52 return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
53}
54
55/** Your activities, merged: consecutive ones no more than GAP_MS apart form one block, first to last. */
56export const blocksOf = (times: readonly number[], gap = GAP_MS): Interval[] => {
57 const sorted = [...times].sort((a, b) => a - b)
58 const out: Interval[] = []
59 for (const at of sorted) {
60 const last = out[out.length - 1]
61 if (last && at - last[1] <= gap) last[1] = at
62 else out.push([at, at])
63 }
64 return out
65}
66
67/** Cuts intervals at local midnights, so a block across midnight counts toward each day it covers. */
68export const splitByDay = (list: readonly Interval[]): Interval[] =>
69 list.flatMap(([a, b]) => {
70 const parts: Interval[] = []
71 let start = a
72 while (start < b) {
73 const end = Math.min(b, nextMidnight(start))
74 parts.push([start, end])
75 start = end
76 }
77 return parts.length ? parts : [[a, b] as Interval]
78 })
79
80const nextMidnight = (at: number) => {
81 const d = new Date(at)
82 return new Date(d.getFullYear(), d.getMonth(), d.getDate() + 1).getTime()
83}
84
85/** The union of intervals: overlapping or touching ones count once. */
86export const union = (list: readonly Interval[]): Interval[] => {
87 const sorted = list.filter(([a, b]) => b >= a).sort((x, y) => x[0] - y[0])
88 const out: Interval[] = []
89 for (const [a, b] of sorted) {
90 const last = out[out.length - 1]
91 if (last && a <= last[1]) last[1] = Math.max(last[1], b)
92 else out.push([a, b])
93 }
94 return out
95}
96
97export const clip = (list: readonly Interval[], [from, to]: Interval): Interval[] =>
98 list.map(([a, b]): Interval => [Math.max(a, from), Math.min(b, to)]).filter(([a, b]) => b > a)
99
100export const total = (list: readonly Interval[]) => list.reduce((s, [a, b]) => s + (b - a), 0)
101
102export type Row = { project: string; mine: number; ai: number; turns: number; prompts: string[] }
103
104/**
105 * Per project in the period: your time (each session's activities cut into blocks at 10-minute
106 * gaps, then the blocks of all sessions united, per day; the end of each AI turn is an activity
107 * too, since that is when you start reading the answer), AI time (turns summed, so parallel
108 * sessions add up), turns started, and the prompts for the summary. Also the total of your time
109 * across all projects, united once more so two projects at the same moment count once.
110 */
111export const tally = (events: readonly Ev[], period: Interval): { rows: Row[]; mineTotal: number; aiTotal: number } => {
112 const byProject = new Map<string, Ev[]>()
113 for (const e of events) byProject.set(e.project, [...(byProject.get(e.project) ?? []), e])
114
115 const allBlocks: Interval[] = []
116 const rows: Row[] = []
117 for (const [project, list] of byProject) {
118 const bySession = new Map<string, number[]>()
119 for (const e of list) {
120 const at = e.t === 'act' ? e.at : e.end
121 bySession.set(e.session, [...(bySession.get(e.session) ?? []), at])
122 }
123 const blocks = [...bySession.values()].flatMap(times => blocksOf(times))
124 const mineIntervals = clip(union(splitByDay(blocks)), period)
125 allBlocks.push(...mineIntervals)
126 const turns = list.filter((e): e is Extract<Ev, { t: 'turn' }> => e.t === 'turn')
127 const ai = turns.reduce((s, e) => s + total(clip([[e.start, e.end]], period)), 0)
128 const started = turns.filter(e => e.start >= period[0] && e.start < period[1]).length
129 const prompts = list
130 .filter((e): e is Extract<Ev, { t: 'act' }> => e.t === 'act' && typeof e.text === 'string' && e.at >= period[0] && e.at < period[1])
131 .map(e => e.text ?? '')
132 const mine = total(mineIntervals)
133 if (mine > 0 || ai > 0 || started > 0) rows.push({ project, mine, ai, turns: started, prompts })
134 }
135 rows.sort((a, b) => b.mine - a.mine || b.ai - a.ai || a.project.localeCompare(b.project))
136 return { rows, mineTotal: total(union(allBlocks)), aiTotal: rows.reduce((s, r) => s + r.ai, 0) }
137}
138
139/** `6h40`, or `25m` under an hour. */
140export const fmtDuration = (ms: number) => {
141 const m = Math.round(ms / 60_000)
142 return m < 60 ? `${m}m` : `${Math.floor(m / 60)}h${String(m % 60).padStart(2, '0')}`
143}
144
145/** The table, monospace, number columns right-aligned. */
146export const renderTable = (rows: readonly (Row & { summary: string })[], mineTotal: number, aiTotal: number): string => {
147 const name = (s: string) => (s.length > 16 ? `${s.slice(0, 15)}…` : s).padEnd(16)
148 const line = (p: string, mine: string, ai: string, turns: string, what: string) =>
149 `${name(p)} ${mine.padStart(7)} ${ai.padStart(7)} ${turns.padStart(4)} ${what}`.trimEnd()
150 const head = line('Project', 'Bạn làm', 'AI chạy', 'Lượt', 'Việc chính')
151 const body = rows.map(r => line(r.project, fmtDuration(r.mine), fmtDuration(r.ai), String(r.turns), r.summary))
152 const rule = '─'.repeat(Math.max(54, ...[head, ...body].map(l => Math.min(l.length, 80))))
153 return [head, ...body, rule, line('Tổng', fmtDuration(mineTotal), fmtDuration(aiTotal), '', '')].join('\n')
154}
155
156/** Reads one session's log file leniently: a line that does not parse is skipped. */
157export const parseLog = (text: string): Ev[] =>
158 text.split('\n').flatMap((line): Ev[] => {
159 if (!line.trim()) return []
160 try {
161 const e = JSON.parse(line) as Ev
162 if (e.t === 'act' && typeof e.at === 'number' && typeof e.project === 'string') return [e]
163 if (e.t === 'turn' && typeof e.start === 'number' && typeof e.end === 'number' && typeof e.project === 'string') return [e]
164 return []
165 } catch {
166 return []
167 }
168 })
169