SLOPSHOPPER

jr-time

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…

newpanebandcommandtoastprompt
★ 2v0.3.0MITupdated 2026-10-16jrlprost/claude-code-mods/plugins/jr-time
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · jr-time
│ ┃ jr time ✕ › fix the failing auth test and add an audit log call │ ┃ ▣ client module ./time.client.tsx │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /jrtime │ ⎿ jr-time: jr time opened · today 0h00 billable on app · no rate y │ │ 0h00 billable · set a rate: /jrtime rate 80 ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
0h00 billable · set a rate: /jrtime rate 80 ⟨Claude Code's own drawing⟩
Pane · jr time
▣ client module ./time.client.tsx
README

jr-time

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 €

The categories

Each 15-second slot of a session gets exactly one category, so no minute is counted twice.

CategoryWhenDefault ratio
Prompt windowThe first minutes after each prompt (5 by default), always counted. A new prompt restarts the window; it never doubles it.100 %
Claude working, you thereClaude is working and you are active on the computer100 %
Claude working, you awayClaude is working and you have not touched the keyboard or pointer50 %
You, between promptsNo turn running, you are active, and Claude's last prompt or reply is less than the idle time ago100 %
At the computer, elsewhereYou are active, but Claude's last activity is older than the idle time0 %
BreakNeither you nor Claude0 %

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.

Commands

Command
/jrtimeOpen the pane: billable time and amount per category and per project, ratios to adjust with the arrow keys, day/week/month, export
/jrtime rate 90Hourly rate for this project
/jrtime rate default 60Rate for projects without their own
/jrtime client AcmeClient name for this project
/jrtime currency $Currency symbol (default €)
/jrtime exportWrite the current period to ~/Downloads as CSV and Markdown
/jrtime hide / /jrtime showHide 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.

Install

/plugin install jr-time --marketplace jrlprost/claude-code-mods

Access

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.

Source 4 files
hooks/register.tsx 272 lines
1import { 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}
272
hooks/model.ts 166 lines
1import 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}
166
hooks/time.client.tsx 181 lines
1// 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
181
types/index.d.ts 63 lines
1/** 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