Billable time per project: every minute of a session sorted into categories, a ratio per category, an hourly rate, and an amount. /jrtime to adjust, export to…

Billable time for work done with Claude Code. Every minute of a session is recorded raw and sorted into categories; you decide afterwards how much of each category you bill, at what hourly rate, per project.
jr time today week month presence: keyboard & pointer
time ratio billable
❯ ● Prompt window ██████········ 1h12 ◂ 100% ▸ 1h12
● Claude working, you there ██████████████ 2h03 100% 2h03
● Claude working, you away ████·········· 0h40 50% 0h20
● You, between prompts ██████········ 0h55 100% 0h55
● At the computer, elsewhere ██············ 0h18 0% 0h00
● Break ███··········· 0h35 0% 0h00
Prompt window length 5 min
Idle after 5 min
──────────────────────────────────────────────────────────────────────
tokenkarma (Acme) 3h32 90€/h 318 €
Total 4h30 405 €
Each 15-second slot of a session gets exactly one category, so no minute is counted twice.
| Category | When | Default ratio |
|---|---|---|
| Prompt window | The first minutes after each prompt (5 by default), always counted. A new prompt restarts the window; it never doubles it. | 100 % |
| Claude working, you there | Claude is working and you are active on the computer | 100 % |
| Claude working, you away | Claude is working and you have not touched the keyboard or pointer | 50 % |
| You, between prompts | No turn running, you are active, and Claude's last prompt or reply is less than the idle time ago | 100 % |
| At the computer, elsewhere | You are active, but Claude's last activity is older than the idle time | 0 % |
| Break | Neither you nor Claude | 0 % |
Presence comes from the time since your last keyboard or pointer event (macOS). Where the machine does not give it, presence is inferred from your last prompt.
Everything is kept raw: change a ratio, the window or the idle time later and the past is recomputed.
| Command | |
|---|---|
/jrtime | Open the pane: billable time and amount per category and per project, ratios to adjust with the arrow keys, day/week/month, export |
/jrtime rate 90 | Hourly rate for this project |
/jrtime rate default 60 | Rate for projects without their own |
/jrtime client Acme | Client name for this project |
/jrtime currency $ | Currency symbol (default €) |
/jrtime export | Write the current period to ~/Downloads as CSV and Markdown |
/jrtime hide / /jrtime show | Hide or show the band, in every session; time keeps being recorded |
In the pane: ↑↓ pick a line, ←→ change its value (ratio by 10 %, minutes by 1, rate by 5), tab switches today / week / month, e exports. The pane opens with a "click here to use the keyboard" zone: the terminal hands keys over only after a click.
A quiet row on the right above the prompt shows today's billable time and amount for the current project, and how to set a rate until the project has one:
0h47 billable · 39 € · /jrtime
0h47 billable · set a rate: /jrtime rate 80
/jrtime hide takes it away; turn it off for good in /config.
/plugin install jr-time --marketplace jrlprost/claude-code-mods
Reads the time since the last keyboard or pointer event every 30 seconds (ioreg, macOS). Keeps its records in the plugin's own store. Writes exports to ~/Downloads when asked. Sends nothing over the network.
hooks/register.tsx 272 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { JrTimeMessage, JrTimePeriod, JrTimeSession, JrTimeSettings } from '../types'
5import { addPresence, CATEGORIES, DEFAULT_SETTINGS, formatHours, formatMoney, report, toCsv, toMarkdown } from './model'
6
7const PANE = 'jr-time'
8const SAMPLE_MS = 30_000
9const SETTINGS_KEY = 'settings'
10const SESSION_PREFIX = 'session:'
11
12const view = atom({ plugin: 'jr-time', key: 'view' } as const, null)
13const band = atom({ plugin: 'jr-time', key: 'band' } as const, null)
14const bandHidden = atom({ plugin: 'jr-time', key: 'isBandHidden' } as const, false)
15
16const bandState = { isLoaded: false }
17
18/** True while the band is hidden; the saved choice is read once per load of the mod. */
19async function isBandHidden($: EngineInterface) {
20 if (!bandState.isLoaded) {
21 bandState.isLoaded = true
22 if ((await $.store.get('isBandHidden')) === true) await update($, bandHidden, () => true)
23 }
24 return read($, bandHidden)
25}
26
27/** Hides or shows the band for every session, kept across restarts. */
28async function setBand($: EngineInterface, hide: boolean, name: string, command: string) {
29 await $.store.set('isBandHidden', hide)
30 await update($, bandHidden, () => hide)
31 return { text: hide ? `${name} · hidden (${command} to bring it back)` : `${name} · shown` }
32}
33
34// This session's record, reloaded from the store when the module reloads.
35const live: { log: JrTimeSession | null; period: JrTimePeriod; isSampling: boolean } = { log: null, period: 'today', isSampling: false }
36
37async function loadSettings($: EngineInterface): Promise<JrTimeSettings> {
38 const saved = (await $.store.get(SETTINGS_KEY)) as Partial<JrTimeSettings> | undefined
39 return { ...DEFAULT_SETTINGS, ...saved, ratios: { ...DEFAULT_SETTINGS.ratios, ...saved?.ratios } }
40}
41
42async function loadSessions($: EngineInterface) {
43 const keys = (await $.store.keys()).filter(k => k.startsWith(SESSION_PREFIX))
44 const logs: JrTimeSession[] = []
45 for (const key of keys) {
46 const log = (await $.store.get(key)) as JrTimeSession | undefined
47 if (log) logs.push(log)
48 }
49 return logs
50}
51
52async function save($: EngineInterface) {
53 if (live.log) await $.store.set(SESSION_PREFIX + live.log.id, live.log)
54}
55
56/** Seconds since the last keyboard or pointer event (macOS), or null where the machine does not say. */
57async function idleSeconds($: EngineInterface) {
58 try {
59 const { exitCode, stdout } = await $.process.run(['sh', '-c', "ioreg -c IOHIDSystem | awk '/HIDIdleTime/ {print int($NF/1000000000); exit}'"])
60 const n = Number.parseInt(stdout.trim(), 10)
61 return exitCode === 0 && Number.isFinite(n) ? n : null
62 } catch {
63 return null
64 }
65}
66
67/**
68 * Starts recording this session, once: at session.start, or at the first event after the mod
69 * was installed or reloaded mid-session, when session.start has already gone by.
70 */
71async function ensureRecording($: EngineInterface) {
72 if (!live.log) {
73 const id = await $.session.id()
74 const root = await $.session.root()
75 const now = await $.clock.now()
76 const saved = (await $.store.get(SESSION_PREFIX + id)) as JrTimeSession | undefined
77 live.log = saved ?? { id, project: root.split('/').pop() || root, start: now, last: now, prompts: [], turns: [], present: [], hasPresence: false }
78 await save($)
79 }
80 if (!live.isSampling) {
81 live.isSampling = true
82 $.clock.every(SAMPLE_MS, () => {
83 void sample($)
84 })
85 }
86}
87
88async function refresh($: EngineInterface) {
89 const settings = await loadSettings($)
90 const logs = await loadSessions($)
91 const now = await $.clock.now()
92 const current = live.log?.project ?? ''
93 await update($, view, () => ({ report: report(logs, settings, live.period, now), settings, current }))
94 const today = report(logs, settings, 'today', now).projects.find(p => p.project === current)
95 const hasRate = (settings.rates[current] ?? settings.defaultRate) > 0
96 await update($, band, () => ({ billableMs: today?.billableMs ?? 0, amount: today?.amount ?? 0, project: current, currency: settings.currency, hasRate }))
97}
98
99async function sample($: EngineInterface) {
100 if (!live.log) return
101 const now = await $.clock.now()
102 const idle = await idleSeconds($)
103 if (idle !== null && idle * 1000 < SAMPLE_MS + 5000) live.log.present = addPresence(live.log.present, now, SAMPLE_MS)
104 live.log.hasPresence = idle !== null
105 live.log.last = now
106 await save($)
107 await refresh($)
108}
109
110async function exportReport($: EngineInterface) {
111 const settings = await loadSettings($)
112 const logs = await loadSessions($)
113 const now = await $.clock.now()
114 const r = report(logs, settings, live.period, now)
115 const home = (await $.env.get('HOME')) ?? '.'
116 const day = new Date(now).toISOString().slice(0, 10)
117 const base = `${home}/Downloads/jr-time-${live.period}-${day}`
118 await $.fs.write(`${base}.csv`, toCsv(r, settings, logs, now))
119 await $.fs.write(`${base}.md`, toMarkdown(r, settings))
120 return base
121}
122
123async function applyMessage($: EngineInterface, message: JrTimeMessage) {
124 if (message.type === 'export') {
125 const base = await exportReport($)
126 $.ui.toast(`jr time · exported ${base}.csv and .md`)
127 return
128 }
129 if (message.type === 'period') {
130 live.period = message.period
131 return refresh($)
132 }
133 const s = await loadSettings($)
134 if (message.type === 'ratio') s.ratios[message.category] = Math.max(0, Math.min(200, s.ratios[message.category] + message.delta))
135 if (message.type === 'setting') s[message.key] = Math.max(message.key === 'idleMin' ? 1 : 0, Math.min(60, s[message.key] + message.delta))
136 if (message.type === 'rate') s.rates[message.project] = Math.max(0, (s.rates[message.project] ?? s.defaultRate) + message.delta)
137 await $.store.set(SETTINGS_KEY, s)
138 await refresh($)
139}
140
141/** `/jrtime client Acme`, `/jrtime rate 90`, `/jrtime rate default 60`, `/jrtime currency $`, `/jrtime export`. */
142async function runCommand($: EngineInterface, args: string) {
143 const [verb = '', ...rest] = args.trim().split(/\s+/).filter(Boolean)
144 const project = live.log?.project ?? ''
145 const s = await loadSettings($)
146 if (verb === 'client' && rest.length) {
147 s.clients[project] = rest.join(' ')
148 } else if (verb === 'rate' && rest[0] === 'default' && rest[1]) {
149 s.defaultRate = Number(rest[1]) || 0
150 } else if (verb === 'rate' && rest[0]) {
151 s.rates[project] = Number(rest[0]) || 0
152 } else if (verb === 'currency' && rest[0]) {
153 s.currency = rest[0]
154 } else if (verb === 'export') {
155 return `jr time · exported ${await exportReport($)}.csv and .md`
156 } else if (verb) {
157 return 'jr time · usage: /jrtime, /jrtime client <name>, /jrtime rate <per hour>, /jrtime rate default <per hour>, /jrtime currency <symbol>, /jrtime export, /jrtime hide, /jrtime show'
158 } else {
159 return null
160 }
161 await $.store.set(SETTINGS_KEY, s)
162 await refresh($)
163 return `jr time · ${project}: client ${s.clients[project] ?? project}, ${s.rates[project] ?? s.defaultRate} ${s.currency}/h`
164}
165
166/** What `/jrtime` answers: today's figure, then how to read and adjust the billing. */
167async function openedText($: EngineInterface) {
168 const b = await read($, band)
169 const today = b ? `today ${formatHours(b.billableMs)} billable on ${b.project}${b.hasRate ? `, ${formatMoney(b.amount, b.currency)}` : ''}` : 'nothing recorded yet'
170 const rate = b?.hasRate ? '' : ' · no rate yet: /jrtime rate 80'
171 return `jr time opened · ${today}${rate} · in the pane, click it, then ↑↓ pick a category, ←→ change its ratio, tab switch day/week/month, e export · /jrtime client <name> · /jrtime rate <per hour>`
172}
173
174export const register: Register = (on, options) => {
175 const showBand = options.showBand !== false
176
177 on('session.start', async ($, e, next) => {
178 const started = await next(e)
179 await $.command.register({ name: 'jrtime', description: 'Billable time: categories, ratios, hourly rate, export; /jrtime hide or show toggles the band' })
180 await ensureRecording($)
181 await refresh($)
182 return started
183 })
184
185 on('prompt.submit', async ($, e, next) => {
186 await ensureRecording($)
187 if (live.log && !e.command && !e.isMeta) {
188 const now = await $.clock.now()
189 live.log.prompts.push(now)
190 live.log.last = now
191 await save($)
192 }
193 return next(e)
194 })
195
196 on('turn.complete', async ($, e, next) => {
197 const done = await next(e)
198 await ensureRecording($)
199 if (live.log && e.agentId === undefined) {
200 const now = await $.clock.now()
201 live.log.turns.push([now - e.durationMs, now])
202 live.log.last = now
203 await save($)
204 await refresh($)
205 }
206 return done
207 })
208
209 on('command.run', { command: 'jrtime' }, async ($, e) => {
210 await ensureRecording($)
211 const word = e.args.trim().toLowerCase()
212 if (word === 'hide' || word === 'show') return setBand($, word === 'hide', 'jr time band', '/jrtime show')
213 const answer = await runCommand($, e.args)
214 if (answer) return { text: answer }
215 await refresh($)
216 await $.ui.open({ id: PANE, title: 'jr time', focus: true, closeOnEscape: true, rows: CATEGORIES.length + 12 })
217 return { text: await openedText($) }
218 })
219
220 on('ui.message', { requestId: PANE }, async ($, e) => {
221 await applyMessage($, e.data as JrTimeMessage)
222 return {}
223 })
224
225 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
226 const { Client } = $.ui.resolve(e) as unknown as { Client?: (p: Record<string, unknown>) => never }
227 const { Text } = $.ui.resolve(e)
228 const v = await read($, view)
229 if (!v) return <Text>No time recorded yet.</Text>
230 if (!Client) return <Text>{`Billable today: ${formatHours(v.report.billableMs)} · ${formatMoney(v.report.amount, v.settings.currency)}`}</Text>
231 return <Client key="time" module="./time.client.tsx" props={{ view: v }} height="100%" flexGrow={1} />
232 })
233
234 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
235 const below = await next(e)
236 const b = await read($, band)
237 if (!b && !live.isSampling) {
238 $.clock.after(0, () => {
239 void ensureRecording($).then(() => refresh($))
240 })
241 }
242 // Never write state while drawing: the saved choice is loaded just after.
243 if (!bandState.isLoaded) {
244 $.clock.after(0, () => {
245 void isBandHidden($)
246 })
247 }
248 if (await read($, bandHidden)) return below
249 if (!showBand || !b || e.props.hasSurvey) return below
250 const { Box, Text } = $.ui.resolve(e)
251 return (
252 <Box flexDirection="column">
253 <Box justifyContent="flex-end">
254 <Text wrap="truncate-end">
255 <Text>{formatHours(b.billableMs)}</Text>
256 <Text dimColor>{' billable · '}</Text>
257 {b.hasRate ? (
258 <Text>
259 <Text color="#2cb67d">{formatMoney(b.amount, b.currency)}</Text>
260 <Text dimColor>{' · /jrtime'}</Text>
261 </Text>
262 ) : (
263 <Text dimColor>{'set a rate: /jrtime rate 80'}</Text>
264 )}
265 </Text>
266 </Box>
267 {below}
268 </Box>
269 )
270 })
271}
272hooks/model.ts 166 lines1import type { JrTimeCategory, JrTimePeriod, JrTimeProjectRow, JrTimeReport, JrTimeSession, JrTimeSettings, JrTimeSpan } from '../types'
2
3export const CATEGORIES: JrTimeCategory[] = ['window', 'withYou', 'alone', 'between', 'elsewhere', 'break']
4
5export const LABELS: Record<JrTimeCategory, string> = {
6 window: 'Prompt window',
7 withYou: 'Claude working, you there',
8 alone: 'Claude working, you away',
9 between: 'You, between prompts',
10 elsewhere: 'At the computer, elsewhere',
11 break: 'Break',
12}
13
14export const DEFAULT_SETTINGS: JrTimeSettings = {
15 windowMin: 5,
16 idleMin: 5,
17 ratios: { window: 100, withYou: 100, alone: 50, between: 100, elsewhere: 0, break: 0 },
18 defaultRate: 0,
19 rates: {},
20 currency: '€',
21 clients: {},
22}
23
24const SLOT = 15_000
25const MINUTE = 60_000
26
27const covers = (spans: readonly JrTimeSpan[], t: number) => spans.some(([a, b]) => a <= t && t < b)
28const lastAtOrBefore = (sorted: readonly number[], t: number) => {
29 let found: number | undefined
30 for (const v of sorted) {
31 if (v > t) break
32 found = v
33 }
34 return found
35}
36
37/**
38 * Splits the part of a session inside [from, to) into categories, in milliseconds.
39 * Each 15-second slot gets exactly one category, so nothing is counted twice:
40 * prompt window first, then Claude's turns (with or without you), then you near Claude's last
41 * activity, then you at the computer but away from it, then break.
42 */
43export const classify = (log: JrTimeSession, s: JrTimeSettings, from: number, to: number) => {
44 const out: Record<JrTimeCategory, number> = { window: 0, withYou: 0, alone: 0, between: 0, elsewhere: 0, break: 0 }
45 const prompts = [...log.prompts].sort((a, b) => a - b)
46 const turnEnds = log.turns.map(([, b]) => b).sort((a, b) => a - b)
47 const begin = Math.max(from, log.start)
48 const end = Math.min(to, log.last)
49 const windowMs = s.windowMin * MINUTE
50 const idleMs = s.idleMin * MINUTE
51 for (let t = begin; t < end; t += SLOT) {
52 const slot = Math.min(SLOT, end - t)
53 const mid = t + slot / 2
54 const lastPrompt = lastAtOrBefore(prompts, mid)
55 const lastActivity = Math.max(lastPrompt ?? -Infinity, lastAtOrBefore(turnEnds, mid) ?? -Infinity)
56 const isNearActivity = mid - lastActivity < idleMs
57 const isPresent = log.hasPresence ? covers(log.present, mid) : isNearActivity
58 let category: JrTimeCategory
59 if (lastPrompt !== undefined && mid - lastPrompt < windowMs) category = 'window'
60 else if (covers(log.turns, mid)) category = isPresent ? 'withYou' : 'alone'
61 else if (isPresent && isNearActivity) category = 'between'
62 else if (isPresent) category = 'elsewhere'
63 else category = 'break'
64 out[category] += slot
65 }
66 return out
67}
68
69export const billable = (categories: Record<JrTimeCategory, number>, s: JrTimeSettings) =>
70 CATEGORIES.reduce((sum, c) => sum + (categories[c] * (s.ratios[c] ?? 0)) / 100, 0)
71
72export const rateOf = (project: string, s: JrTimeSettings) => s.rates[project] ?? s.defaultRate
73
74/** Start of the period that contains `now`, in local time: today, this week (Monday), this month. */
75export const periodStart = (period: JrTimePeriod, now: number) => {
76 const d = new Date(now)
77 d.setHours(0, 0, 0, 0)
78 if (period === 'week') d.setDate(d.getDate() - ((d.getDay() + 6) % 7))
79 if (period === 'month') d.setDate(1)
80 return d.getTime()
81}
82
83export const report = (logs: readonly JrTimeSession[], s: JrTimeSettings, period: JrTimePeriod, now: number): JrTimeReport => {
84 const from = periodStart(period, now)
85 const categories: Record<JrTimeCategory, number> = { window: 0, withYou: 0, alone: 0, between: 0, elsewhere: 0, break: 0 }
86 const byProject = new Map<string, Record<JrTimeCategory, number>>()
87 for (const log of logs) {
88 if (log.last < from) continue
89 const part = classify(log, s, from, now)
90 const acc = byProject.get(log.project) ?? { window: 0, withYou: 0, alone: 0, between: 0, elsewhere: 0, break: 0 }
91 for (const c of CATEGORIES) {
92 categories[c] += part[c]
93 acc[c] += part[c]
94 }
95 byProject.set(log.project, acc)
96 }
97 const projects: JrTimeProjectRow[] = [...byProject.entries()]
98 .map(([project, cats]) => {
99 const billableMs = billable(cats, s)
100 const rate = rateOf(project, s)
101 return { project, client: s.clients[project] ?? project, rate, billableMs, amount: (billableMs / 3_600_000) * rate }
102 })
103 .filter(p => p.billableMs > 0)
104 .sort((a, b) => b.billableMs - a.billableMs)
105 return {
106 period,
107 categories,
108 billableMs: projects.reduce((n, p) => n + p.billableMs, 0),
109 amount: projects.reduce((n, p) => n + p.amount, 0),
110 projects,
111 hasPresence: logs.some(l => l.hasPresence),
112 }
113}
114
115/** Adds a presence sample: the person was active in [at - span, at). Merges with the last span. */
116export const addPresence = (present: JrTimeSpan[], at: number, span: number): JrTimeSpan[] => {
117 const prev = present[present.length - 1]
118 if (prev && prev[1] >= at - span - 1000) return [...present.slice(0, -1), [prev[0], at]]
119 return [...present, [at - span, at]]
120}
121
122export const formatHours = (ms: number) => {
123 const minutes = Math.round(ms / MINUTE)
124 return `${Math.floor(minutes / 60)}h${String(minutes % 60).padStart(2, '0')}`
125}
126
127export const formatMoney = (amount: number, currency: string) => `${Math.round(amount).toLocaleString('en-US')} ${currency}`
128
129// A text cell opening with = + - @ (or a tab or carriage return) would run as a formula in a
130// spreadsheet: it gets a leading apostrophe, which spreadsheets show as plain text.
131const csvCell = (v: string | number) => {
132 if (typeof v === 'number') return String(v)
133 const safe = /^[=+\-@\t\r]/.test(v) ? `'${v}` : v
134 return `"${safe.replace(/"/g, '""')}"`
135}
136
137/** One row per project: client, hours per category, billable hours, rate and amount. */
138export const toCsv = (r: JrTimeReport, s: JrTimeSettings, logs: readonly JrTimeSession[], now: number) => {
139 const from = periodStart(r.period, now)
140 const head = ['project', 'client', ...CATEGORIES.map(c => `${c}_hours`), 'billable_hours', 'rate', 'amount', 'currency']
141 const rows = r.projects.map(p => {
142 const cats: Record<JrTimeCategory, number> = { window: 0, withYou: 0, alone: 0, between: 0, elsewhere: 0, break: 0 }
143 for (const log of logs.filter(l => l.project === p.project)) {
144 const part = classify(log, s, from, now)
145 for (const c of CATEGORIES) cats[c] += part[c]
146 }
147 const h = (ms: number) => Math.round((ms / 3_600_000) * 100) / 100
148 return [p.project, p.client, ...CATEGORIES.map(c => h(cats[c])), h(p.billableMs), p.rate, Math.round(p.amount * 100) / 100, s.currency]
149 })
150 return [head, ...rows].map(row => row.map(csvCell).join(',')).join('\n') + '\n'
151}
152
153export const toMarkdown = (r: JrTimeReport, s: JrTimeSettings) => {
154 const lines = [
155 `# Time report: ${r.period}`,
156 '',
157 `Prompt window ${s.windowMin} min · idle after ${s.idleMin} min · ratios ${CATEGORIES.map(c => `${LABELS[c]} ${s.ratios[c]}%`).join(', ')}`,
158 '',
159 '| Project | Client | Billable | Rate | Amount |',
160 '| --- | --- | --- | --- | --- |',
161 ...r.projects.map(p => `| ${p.project} | ${p.client} | ${formatHours(p.billableMs)} | ${p.rate} ${s.currency}/h | ${formatMoney(p.amount, s.currency)} |`),
162 `| **Total** | | **${formatHours(r.billableMs)}** | | **${formatMoney(r.amount, s.currency)}** |`,
163 ]
164 return lines.join('\n') + '\n'
165}
166hooks/time.client.tsx 181 lines1// The /jrtime pane, drawn on the surface so it reads raw keys.
2// up/down pick a line, left/right change its value (ratio, minutes or hourly rate),
3// tab switches today / week / month, e exports, esc closes.
4// The terminal hands keys over only after a click, so the pane opens with a focus zone at the bottom.
5import type { ClientElements, ClientModule } from 'claude-code'
6
7import type { JrTimeCategory, JrTimeMessage, JrTimePeriod, JrTimeView } from '../types'
8import { CATEGORIES, formatHours, formatMoney, LABELS } from './model'
9
10type Props = { view: JrTimeView }
11type State = { cursor: number; ready?: boolean }
12
13const BRAND = ['#7f5af0', '#a259e8', '#c658df', '#e85bc4', '#ff5ca8', '#ff7a90', '#ff9a7c', '#ffb86b']
14const CATEGORY_COLOR: Record<JrTimeCategory, string> = {
15 window: '#7f5af0',
16 withYou: '#3da9fc',
17 alone: '#4fd6be',
18 between: '#2cb67d',
19 elsewhere: '#f4d35e',
20 break: '#5c5f77',
21}
22const DIM = '#6b7089'
23const KEY = '#ffb86b'
24const POINTER = '#ff5ca8'
25const MONEY = '#2cb67d'
26const ZONE_ROWS = 5
27const PERIODS: JrTimePeriod[] = ['today', 'week', 'month']
28const BAR = 14
29
30const gradient = (Text: ClientElements['Text'], text: string) =>
31 [...text].map((c, i, all) => <Text color={BRAND[Math.floor((i / all.length) * BRAND.length)] ?? POINTER}>{c}</Text>)
32
33type Line =
34 | { kind: 'category'; category: JrTimeCategory }
35 | { kind: 'setting'; key: 'windowMin' | 'idleMin' }
36 | { kind: 'rate'; project: string }
37
38const Time: ClientModule<Props, State> = (props, surface) => {
39 const { Box, Text } = surface.elements
40 const { report, settings } = props.view
41 const state: State = surface.state ?? { cursor: 0 }
42 const lines: Line[] = [
43 ...CATEGORIES.map(category => ({ kind: 'category', category }) as const),
44 { kind: 'setting', key: 'windowMin' },
45 { kind: 'setting', key: 'idleMin' },
46 ...report.projects.map(p => ({ kind: 'rate', project: p.project }) as const),
47 ]
48 const cursor = Math.min(state.cursor, lines.length - 1)
49
50 const send = (message: JrTimeMessage) => surface.post(message)
51 const adjust = (sign: 1 | -1) => {
52 const line = lines[cursor]
53 if (!line) return
54 if (line.kind === 'category') send({ type: 'ratio', category: line.category, delta: sign * 10 })
55 else if (line.kind === 'setting') send({ type: 'setting', key: line.key, delta: sign })
56 else send({ type: 'rate', project: line.project, delta: sign * 5 })
57 }
58 const nextPeriod = () => send({ type: 'period', period: PERIODS[(PERIODS.indexOf(report.period) + 1) % PERIODS.length]! })
59
60 surface.onKey(({ key }) => {
61 if (!state.ready) surface.setState({ ...state, ready: true })
62 if (key === 'up' || key === 'k') surface.setState({ ready: true, cursor: (cursor - 1 + lines.length) % lines.length })
63 else if (key === 'down' || key === 'j') surface.setState({ ready: true, cursor: (cursor + 1) % lines.length })
64 else if (key === 'left' || key === 'h') adjust(-1)
65 else if (key === 'right' || key === 'l') adjust(1)
66 else if (key === 'tab' || key === 't') nextPeriod()
67 else if (key === 'e') send({ type: 'export' })
68 })
69
70 surface.onPointer(event => {
71 if (event.type !== 'down') return
72 if (!state.ready) return surface.setState({ ...state, ready: true })
73 if (event.y === 0) return nextPeriod()
74 // Rows start under the tab line and the column titles.
75 const index = event.y - 2 - (event.y - 2 >= CATEGORIES.length + 2 ? 1 : 0)
76 if (index >= 0 && index < lines.length) surface.setState({ ...state, cursor: index })
77 })
78
79 const width = Math.max(56, surface.columns)
80 const most = Math.max(1, ...CATEGORIES.map(c => report.categories[c]))
81 const pointer = (i: number) => <Text color={POINTER} bold>{i === cursor ? '❯ ' : ' '}</Text>
82 const arrows = (i: number, value: string) =>
83 i === cursor ? (
84 <Text>
85 <Text color={KEY}>{'◂ '}</Text>
86 <Text bold>{value.padStart(6)}</Text>
87 <Text color={KEY}>{' ▸'}</Text>
88 </Text>
89 ) : (
90 <Text>{` ${value.padStart(6)} `}</Text>
91 )
92
93 return (
94 <Box flexDirection="column" width={width}>
95 <Box justifyContent="space-between">
96 <Text>
97 <Text bold>{gradient(Text, 'jr time')}</Text>
98 <Text>{' '}</Text>
99 {PERIODS.map(p =>
100 p === report.period ? (
101 <Text backgroundColor={POINTER} color="black" bold>{` ${p} `}</Text>
102 ) : (
103 <Text color={DIM}>{` ${p} `}</Text>
104 ),
105 )}
106 </Text>
107 <Text color={DIM}>{report.hasPresence ? 'presence: keyboard & pointer' : 'presence: inferred'}</Text>
108 </Box>
109 <Text color={DIM}>{` ${''.padEnd(29)}${''.padEnd(BAR + 1)}${'time'.padStart(6)} ${'ratio'.padStart(6)} billable`}</Text>
110 {CATEGORIES.map((c, i) => {
111 const ms = report.categories[c]
112 const filled = Math.round((ms / most) * BAR)
113 return (
114 <Text wrap="truncate-end">
115 {pointer(i)}
116 <Text color={CATEGORY_COLOR[c]}>{'● '}</Text>
117 <Text bold={i === cursor}>{LABELS[c].padEnd(27)}</Text>
118 <Text color={CATEGORY_COLOR[c]}>{'█'.repeat(filled)}</Text>
119 <Text color={DIM}>{'·'.repeat(BAR - filled)}</Text>
120 <Text>{` ${formatHours(ms).padStart(6)} `}</Text>
121 {arrows(i, `${settings.ratios[c]}%`)}
122 <Text color={MONEY}>{` ${formatHours((ms * settings.ratios[c]) / 100)}`}</Text>
123 </Text>
124 )
125 })}
126 {(['windowMin', 'idleMin'] as const).map((key, k) => {
127 const i = CATEGORIES.length + k
128 return (
129 <Text>
130 {pointer(i)}
131 <Text color={DIM}>{' '}</Text>
132 <Text bold={i === cursor}>{(key === 'windowMin' ? 'Prompt window length' : 'Idle after').padEnd(27 + BAR + 9)}</Text>
133 {arrows(i, `${settings[key]} min`)}
134 </Text>
135 )
136 })}
137 <Text color={DIM}>{' ' + '─'.repeat(Math.max(10, width - 4))}</Text>
138 {report.projects.length === 0 && <Text color={DIM}>{' No billable time in this period yet.'}</Text>}
139 {report.projects.map((p, k) => {
140 const i = CATEGORIES.length + 2 + k
141 const name = p.client === p.project ? p.project : `${p.project} (${p.client})`
142 return (
143 <Text wrap="truncate-end">
144 {pointer(i)}
145 <Text bold={i === cursor}>{name.padEnd(29 + BAR - 6)}</Text>
146 <Text>{formatHours(p.billableMs).padStart(6)}</Text>
147 <Text>{' '}</Text>
148 {arrows(i, `${p.rate}${settings.currency}/h`)}
149 <Text color={MONEY} bold>{` ${formatMoney(p.amount, settings.currency)}`}</Text>
150 </Text>
151 )
152 })}
153 <Text>
154 <Text>{' '}</Text>
155 <Text bold>{'Total'.padEnd(29 + BAR - 6)}</Text>
156 <Text bold>{formatHours(report.billableMs).padStart(6)}</Text>
157 <Text color={MONEY} bold>{`${''.padStart(14)} ${formatMoney(report.amount, settings.currency)}`}</Text>
158 </Text>
159 <Text wrap="truncate-end">
160 <Text color={KEY} bold>↑↓</Text>
161 <Text color={DIM}>{' line '}</Text>
162 <Text color={KEY} bold>←→</Text>
163 <Text color={DIM}>{' change '}</Text>
164 <Text color={KEY} bold>tab</Text>
165 <Text color={DIM}>{' period '}</Text>
166 <Text color={KEY} bold>e</Text>
167 <Text color={DIM}>{' export CSV + Markdown '}</Text>
168 <Text color={KEY} bold>esc</Text>
169 <Text color={DIM}>{' close'}</Text>
170 </Text>
171 {!state.ready && (
172 <Box borderStyle="round" borderColor={POINTER} height={ZONE_ROWS} justifyContent="center" alignItems="center">
173 <Text bold>{gradient(Text, '⌨ click here to use the keyboard')}</Text>
174 </Box>
175 )}
176 </Box>
177 )
178}
179
180export default Time
181types/index.d.ts 63 lines1/** The ways a stretch of session time is classified; each gets its own billing ratio. */
2export type JrTimeCategory = 'window' | 'withYou' | 'alone' | 'between' | 'elsewhere' | 'break'
3
4/** [start, end) in epoch milliseconds. */
5export type JrTimeSpan = [number, number]
6
7/** Everything recorded for one session, raw: billing is computed from it later. */
8export type JrTimeSession = {
9 id: string
10 project: string
11 start: number
12 last: number
13 prompts: number[]
14 turns: JrTimeSpan[]
15 /** Spans where the person was active on the computer (keyboard or pointer). */
16 present: JrTimeSpan[]
17 /** False where the machine gives no activity signal: presence is then inferred. */
18 hasPresence: boolean
19}
20
21export type JrTimeSettings = {
22 /** Minutes always counted after each prompt. */
23 windowMin: number
24 /** Minutes without activity after which time is a break. */
25 idleMin: number
26 /** Percent of each category that is billable. */
27 ratios: Record<JrTimeCategory, number>
28 defaultRate: number
29 /** Hourly rate per project. */
30 rates: Record<string, number>
31 currency: string
32 /** Client name per project. */
33 clients: Record<string, string>
34}
35
36export type JrTimePeriod = 'today' | 'week' | 'month'
37
38export type JrTimeProjectRow = { project: string; client: string; rate: number; billableMs: number; amount: number }
39
40export type JrTimeReport = {
41 period: JrTimePeriod
42 categories: Record<JrTimeCategory, number>
43 billableMs: number
44 amount: number
45 projects: JrTimeProjectRow[]
46 hasPresence: boolean
47}
48
49export type JrTimeView = { report: JrTimeReport; settings: JrTimeSettings; current: string }
50
51export type JrTimeMessage =
52 | { type: 'ratio'; category: JrTimeCategory; delta: number }
53 | { type: 'setting'; key: 'windowMin' | 'idleMin'; delta: number }
54 | { type: 'rate'; project: string; delta: number }
55 | { type: 'period'; period: JrTimePeriod }
56 | { type: 'export' }
57
58declare module 'claude-code' {
59 interface PluginState {
60 'jr-time': { view: JrTimeView | null; band: { billableMs: number; amount: number; project: string; currency: string; hasRate: boolean } | null; isBandHidden: boolean }
61 }
62}
63