SLOPSHOPPER

jr-week

/jrweek: your week with Claude Code, per day and per project: time Claude worked, prompts and cost, with gradient bars and a Markdown export.

newpaneguardcommandtoastprompt
★ 2v0.1.0MITupdated 2026-10-15jrlprost/claude-code-mods/plugins/jr-week
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · jr-week
│ ┃ jr week ✕ › fix the failing auth test and add an audit log call │ ┃ jr week 6 Oct – 12 Oct[ This week ] [ Last │ ┃ Claude working time prompts ⏺ Read(src/auth.ts) │ ┃ cost ⎿ Read 6 lines │ ┃ Mon ················ 0h00 0 ⏺ Update(src/auth.ts) │ ┃ $0.00 ⎿ Added 2 lines, removed 1 line │ ┃ Tue ················ 0h00 0 ⏺ Bash(bun test) │ ┃ $0.00 ⎿ 3 pass, 1 fail │ ┃ Wed ················ 0h00 0 │ ┃ $0.00 ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ Thu ████████████████ 0h01 1 │ ┃ $0.00 ✻ Worked for 42s · done 4:20 PM │ ┃ Fri ················ 0h00 0 │ ┃ $0.00 › /jrweek │ ┃ Sat ················ 0h00 0 ⎿ jr-week: jr week opened. │ ┃ $0.00 │ ┃ Sun ················ 0h00 0 │ ┃ $0.00 │ ┃ Total 0h01 1 │ ┃ $0.00 │ ┃ ──────────────────────────────────────────── │ ┃ ──── │ ┃ app ██████ 0h01 1 … │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · jr week
jr week 6 Oct – 12 Oct[ This week ] [ Last week ] [ Export Claude working time prompts cost Mon ················ 0h00 0 $0.00 Tue ················ 0h00 0 $0.00 Wed ················ 0h00 0 $0.00 Thu ████████████████ 0h01 1 $0.00 Fri ················ 0h00 0 $0.00 Sat ················ 0h00 0 $0.00 Sun ················ 0h00 0 $0.00 Total 0h01 1 $0.00 ──────────────────────────────────────────────── app ██████ 0h01 1 $0.00
README

jr-week

/jrweek shows your week with Claude Code: for each day and each project, how long Claude worked, how many prompts you sent, and what it cost.

jr week   12 Oct – 18 Oct                    [This week] [Last week] [Export]
     Claude working               time  prompts     cost
Mon  ████████████████··········   2h40       34    $4.12
Tue  ██████████················   1h38       21    $2.60
...
Total                            11h05      143   $17.90
──────────────────────────────────────────────
tokenkarma         ███████████   6h12       80   $10.40
acme               ██████·····   3h01       41    $5.02
  • Claude working is the sum of the durations of Claude's turns.
  • Cost is what the session's cost grew by during each turn, as Claude Code counts it.
  • Projects are named after the folder the session runs in.

Commands

Command
/jrweekOpen the week

In the pane: t this week, l last week, e export the shown week as Markdown to ~/Downloads.

The mod records from the moment it is installed; earlier weeks stay empty.

Install

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

Access

Records prompts, turns, tool names and cost in the plugin's own store. Reads the session's cost and project folder. Writes the export to ~/Downloads when asked. Sends nothing over the network.

Source 6 files
hooks/register.tsx 124 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import { loadDays, primeCost, recordPrompt, recordTool, recordTurn } from './activity'
5import { gauge, gradientText } from './jr/draw'
6import { GRADIENTS } from './jr/gradient'
7import { hours, toMarkdown, usd, weekReport, weekStart } from './week'
8
9const PANE = 'jr-week'
10const DAY = 86_400_000
11const view = atom({ plugin: 'jr-week', key: 'view' } as const, null)
12const ready = { isCommand: false }
13
14async function ensureCommand($: EngineInterface) {
15  if (ready.isCommand) return
16  ready.isCommand = true
17  await $.command.register({ name: 'jrweek', description: 'This week with Claude: time, prompts and cost per day and project' })
18}
19
20async function refresh($: EngineInterface, back: number) {
21  const now = await $.clock.now()
22  const from = weekStart(now, back)
23  const days = await loadDays($, from, from + 6 * DAY)
24  await update($, view, () => ({ back, report: weekReport(days, from) }))
25}
26
27async function exportWeek($: EngineInterface) {
28  const v = await read($, view)
29  if (!v) return
30  const home = (await $.env.get('HOME')) ?? '.'
31  const file = `${home}/Downloads/jr-week-${new Date(v.report.from).toISOString().slice(0, 10)}.md`
32  await $.fs.write(file, toMarkdown(v.report))
33  $.ui.toast(`jr week · exported ${file}`)
34}
35
36export const register: Register = on => {
37  on('session.start', async ($, e, next) => {
38    const started = await next(e)
39    await ensureCommand($)
40    await primeCost($)
41    return started
42  })
43
44  on('prompt.submit', async ($, e, next) => {
45    await ensureCommand($)
46    if (!e.command && !e.isMeta) {
47      await primeCost($)
48      await recordPrompt($)
49    }
50    return next(e)
51  })
52
53  on('turn.complete', async ($, e, next) => {
54    const done = await next(e)
55    if (e.agentId === undefined) await recordTurn($, e.durationMs)
56    return done
57  })
58
59  on('tool.call', async ($, e, next) => {
60    const ran = await next(e)
61    await recordTool($, String(e.tool))
62    return ran
63  })
64
65  on('command.run', { command: 'jrweek' }, async $ => {
66    await refresh($, 0)
67    await $.ui.open({ id: PANE, title: 'jr week', focus: true, closeOnEscape: true, rows: 20 })
68    return { text: 'jr week opened.' }
69  })
70
71  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
72    const { Box, Text, Button } = $.ui.resolve(e)
73    const v = await read($, view)
74    if (!v) return <Text dimColor>Loading the week…</Text>
75    const r = v.report
76    const width = Math.max(50, e.props.bodyColumns ?? 80)
77    const barWidth = Math.max(8, Math.min(30, width - 40))
78    const mostDay = Math.max(1, ...r.days.map(d => d.activeMs))
79    const mostProject = Math.max(1, ...r.projects.map(p => p.activeMs))
80    return (
81      <Box flexDirection="column">
82        <Box justifyContent="space-between">
83          <Text>
84            {gradientText(Text, 'jr week', GRADIENTS.brand)}
85            <Text dimColor>{`   ${r.label}`}</Text>
86          </Text>
87          <Box columnGap={1}>
88            <Button key="this" label="This week" hotkey="t" variant={v.back === 0 ? 'primary' : 'secondary'} onPress={() => refresh($, 0)} />
89            <Button key="last" label="Last week" hotkey="l" variant={v.back === 1 ? 'primary' : 'secondary'} onPress={() => refresh($, 1)} />
90            <Button key="export" label="Export" hotkey="e" onPress={() => exportWeek($)} />
91          </Box>
92        </Box>
93        <Text dimColor>{`${''.padEnd(5)}${'Claude working'.padEnd(barWidth + 1)}${'time'.padStart(6)}  prompts     cost`}</Text>
94        {r.days.map(d => (
95          <Text>
96            <Text bold>{d.name.padEnd(5)}</Text>
97            {gauge(Text, d.activeMs / mostDay, barWidth, GRADIENTS.brand)}
98            <Text>{` ${hours(d.activeMs).padStart(6)}`}</Text>
99            <Text dimColor>{`  ${String(d.prompts).padStart(7)}`}</Text>
100            <Text color="#2cb67d">{`  ${usd(d.costUsd).padStart(7)}`}</Text>
101          </Text>
102        ))}
103        <Text>
104          <Text bold>{'Total'.padEnd(5 + barWidth)}</Text>
105          <Text bold>{` ${hours(r.total.activeMs).padStart(6)}`}</Text>
106          <Text bold>{`  ${String(r.total.prompts).padStart(7)}`}</Text>
107          <Text color="#2cb67d" bold>{`  ${usd(r.total.costUsd).padStart(7)}`}</Text>
108        </Text>
109        <Text dimColor>{'─'.repeat(Math.min(width, barWidth + 32))}</Text>
110        {r.projects.length === 0 && <Text dimColor>No activity recorded this week yet.</Text>}
111        {r.projects.slice(0, 8).map(p => (
112          <Text wrap="truncate-end">
113            <Text bold>{p.name.slice(0, 18).padEnd(19)}</Text>
114            {gauge(Text, p.activeMs / mostProject, Math.max(6, barWidth - 14), GRADIENTS.ice)}
115            <Text>{` ${hours(p.activeMs).padStart(6)}`}</Text>
116            <Text dimColor>{`  ${String(p.prompts).padStart(7)}`}</Text>
117            <Text color="#2cb67d">{`  ${usd(p.costUsd).padStart(7)}`}</Text>
118          </Text>
119        ))}
120      </Box>
121    )
122  })
123}
124
hooks/activity.ts 128 lines
1// Activity history shared by jr-week, jr-heatmap and jr-wrapped. Each mod keeps its own copy
2// of this file and of its records (a mod's store is its own); the code is identical across them.
3import type { EngineInterface } from 'claude-code'
4
5export type ProjectStats = { prompts: number; turns: number; activeMs: number; costUsd: number }
6
7export type DayStats = {
8  prompts: number
9  turns: number
10  /** Time Claude spent working (sum of turn durations). */
11  activeMs: number
12  costUsd: number
13  tools: Record<string, number>
14  /** Prompts per hour of the day, 24 entries. */
15  hours: number[]
16  projects: Record<string, ProjectStats>
17}
18
19/** Days of one month, keyed YYYY-MM-DD (local time). */
20export type MonthActivity = Record<string, DayStats>
21
22const pad = (n: number) => String(n).padStart(2, '0')
23
24export const dayKey = (t: number) => {
25  const d = new Date(t)
26  return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
27}
28
29export const monthKey = (t: number) => dayKey(t).slice(0, 7)
30
31export const emptyDay = (): DayStats => ({ prompts: 0, turns: 0, activeMs: 0, costUsd: 0, tools: {}, hours: Array.from({ length: 24 }, () => 0), projects: {} })
32
33const project = (day: DayStats, name: string) => (day.projects[name] ??= { prompts: 0, turns: 0, activeMs: 0, costUsd: 0 })
34
35export const addPrompt = (day: DayStats, at: number, name: string) => {
36  day.prompts += 1
37  day.hours[new Date(at).getHours()] = (day.hours[new Date(at).getHours()] ?? 0) + 1
38  project(day, name).prompts += 1
39}
40
41export const addTurn = (day: DayStats, durationMs: number, costUsd: number, name: string) => {
42  day.turns += 1
43  day.activeMs += durationMs
44  day.costUsd += costUsd
45  const p = project(day, name)
46  p.turns += 1
47  p.activeMs += durationMs
48  p.costUsd += costUsd
49}
50
51export const addTool = (day: DayStats, tool: string) => {
52  day.tools[tool] = (day.tools[tool] ?? 0) + 1
53}
54
55/** Every day from `from` to `to` (inclusive, local), oldest first. */
56export const daysBetween = (from: number, to: number) => {
57  const out: string[] = []
58  const d = new Date(from)
59  d.setHours(12, 0, 0, 0)
60  while (d.getTime() <= to + 12 * 3_600_000 && out.length < 400) {
61    out.push(dayKey(d.getTime()))
62    d.setDate(d.getDate() + 1)
63  }
64  return out
65}
66
67// ── Recording: one live record per session, in the mod's own store ──────────────────────────
68
69const STORE_PREFIX = 'activity:'
70const live: { project: string; lastCostUsd: number | null } = { project: '', lastCostUsd: null }
71
72export async function currentProject($: EngineInterface) {
73  if (!live.project) {
74    const root = await $.session.root()
75    live.project = root.split('/').pop() || root
76  }
77  return live.project
78}
79
80async function withDay($: EngineInterface, at: number, change: (day: DayStats) => void) {
81  const key = STORE_PREFIX + monthKey(at)
82  const month = ((await $.store.get(key)) as MonthActivity | undefined) ?? {}
83  const day = month[dayKey(at)] ?? emptyDay()
84  change(day)
85  month[dayKey(at)] = day
86  await $.store.set(key, month)
87}
88
89export async function recordPrompt($: EngineInterface) {
90  const at = await $.clock.now()
91  const name = await currentProject($)
92  await withDay($, at, day => addPrompt(day, at, name))
93}
94
95/** The session's cost so far, minus what was already counted: the cost of this turn. */
96export async function recordTurn($: EngineInterface, durationMs: number) {
97  const at = await $.clock.now()
98  const name = await currentProject($)
99  const usage = await $.session.usage()
100  const total = usage.cost?.usd ?? 0
101  const delta = live.lastCostUsd === null ? 0 : Math.max(0, total - live.lastCostUsd)
102  live.lastCostUsd = total
103  await withDay($, at, day => addTurn(day, durationMs, delta, name))
104}
105
106export async function primeCost($: EngineInterface) {
107  if (live.lastCostUsd !== null) return
108  const usage = await $.session.usage()
109  live.lastCostUsd = usage.cost?.usd ?? 0
110}
111
112export async function recordTool($: EngineInterface, tool: string) {
113  const at = await $.clock.now()
114  await withDay($, at, day => addTool(day, tool))
115}
116
117/** The recorded days between two instants, missing days filled empty. */
118export async function loadDays($: EngineInterface, from: number, to: number) {
119  const months = new Map<string, MonthActivity>()
120  const out: Record<string, DayStats> = {}
121  for (const key of daysBetween(from, to)) {
122    const m = key.slice(0, 7)
123    if (!months.has(m)) months.set(m, ((await $.store.get(STORE_PREFIX + m)) as MonthActivity | undefined) ?? {})
124    out[key] = months.get(m)![key] ?? emptyDay()
125  }
126  return out
127}
128
hooks/jr/draw.tsx 35 lines
1// Drawing helpers shared by every jr mod. Synced into each plugin's hooks/jr/.
2import type { Elements } from 'claude-code'
3
4import { bar, paint } from './gradient'
5
6type TextElement = Elements['terminal']['Text']
7
8/** `text` in a gradient, one color per character, bold by default. */
9export const gradientText = (Text: TextElement, text: string, stops: readonly string[], bold = true) => (
10  <Text bold={bold}>
11    {paint(text, stops).map(({ char, color }) => (
12      <Text color={color}>{char}</Text>
13    ))}
14  </Text>
15)
16
17/** A gauge: filled cells in the gradient, empty cells dim. */
18export const gauge = (Text: TextElement, ratio: number, width: number, stops: readonly string[]) => (
19  <Text>
20    {bar(ratio, width, stops).map(cell =>
21      cell.empty ? (
22        <Text color={cell.color} dimColor>
23          {cell.char}
24        </Text>
25      ) : (
26        <Text color={cell.color}>{cell.char}</Text>
27      ),
28    )}
29  </Text>
30)
31
32/** The jr tag that opens every band line: a small gradient label. */
33export const tag = (Text: TextElement, label: string, stops: readonly string[]) =>
34  gradientText(Text, `${label} `, stops)
35
hooks/jr/gradient.ts 75 lines
1// Shared look of every jr mod: one palette, gradients drawn one cell at a time.
2// Synced into each plugin's hooks/jr/ by scripts/sync-shared.sh: edit it here only.
3
4export type Rgb = readonly [number, number, number]
5
6const hexToRgb = (hex: string): Rgb => {
7  const n = Number.parseInt(hex.replace('#', ''), 16)
8  return [(n >> 16) & 255, (n >> 8) & 255, n & 255]
9}
10
11const rgbToHex = ([r, g, b]: Rgb) =>
12  `#${[r, g, b].map(v => Math.round(v).toString(16).padStart(2, '0')).join('')}`
13
14export const GRADIENTS = {
15  // The jr signature: violet to pink to amber.
16  brand: ['#7f5af0', '#ff5ca8', '#ffb86b'],
17  // Calm to alarm, for gauges where full is bad.
18  heat: ['#2cb67d', '#f4d35e', '#ff8c42', '#ef4565'],
19  // Cool, for neutral figures.
20  ice: ['#3da9fc', '#7f5af0'],
21  // Money and growth.
22  money: ['#2cb67d', '#a8e6cf'],
23} as const satisfies Record<string, readonly string[]>
24
25export type GradientName = keyof typeof GRADIENTS
26
27/** The color at `t` (0..1) along the stops. */
28export const colorAt = (stops: readonly string[], t: number): string => {
29  if (stops.length === 1) return stops[0]!
30  const clamped = Math.min(1, Math.max(0, t))
31  const span = clamped * (stops.length - 1)
32  const i = Math.min(stops.length - 2, Math.floor(span))
33  const a = hexToRgb(stops[i]!)
34  const b = hexToRgb(stops[i + 1]!)
35  const local = span - i
36  return rgbToHex([0, 1, 2].map(k => a[k]! + (b[k]! - a[k]!) * local) as unknown as Rgb)
37}
38
39/** One color per character of `text`, spread across the stops. */
40export const paint = (text: string, stops: readonly string[]) => {
41  const chars = [...text]
42  return chars.map((char, i) => ({ char, color: colorAt(stops, chars.length > 1 ? i / (chars.length - 1) : 0) }))
43}
44
45const EIGHTHS = ['', '▏', '▎', '▍', '▌', '▋', '▊', '▉']
46
47/**
48 * A bar `width` cells wide filled to `ratio` (0..1), with eighth-cell precision.
49 * Each filled cell takes the gradient color of its position along the whole bar,
50 * so a nearly full heat bar ends red and a low one stays green.
51 */
52export const bar = (ratio: number, width: number, stops: readonly string[]) => {
53  const filled = Math.min(1, Math.max(0, ratio)) * width
54  const full = Math.floor(filled)
55  const partial = EIGHTHS[Math.round((filled - full) * 7)] ?? ''
56  const cells: { char: string; color: string; empty?: true }[] = []
57  for (let i = 0; i < width; i++) {
58    const color = colorAt(stops, width > 1 ? i / (width - 1) : 0)
59    if (i < full) cells.push({ char: '█', color })
60    else if (i === full && partial) cells.push({ char: partial, color })
61    else cells.push({ char: '·', color, empty: true })
62  }
63  return cells
64}
65
66export const formatTokens = (n: number) =>
67  n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)}M` : n >= 10_000 ? `${Math.round(n / 1000)}k` : n >= 1000 ? `${(n / 1000).toFixed(1)}k` : `${n}`
68
69export const formatDuration = (ms: number) => {
70  const minutes = Math.max(0, Math.round(ms / 60_000))
71  const h = Math.floor(minutes / 60)
72  const m = minutes % 60
73  return h > 0 ? `${h}h${String(m).padStart(2, '0')}` : `${m}min`
74}
75
hooks/week.ts 78 lines
1import type { DayStats } from './activity'
2import { dayKey } from './activity'
3
4const DAY = 86_400_000
5const NAMES = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
6
7/** Monday 00:00 local of the week containing `now`, `back` weeks earlier. */
8export const weekStart = (now: number, back = 0) => {
9  const d = new Date(now)
10  d.setHours(0, 0, 0, 0)
11  d.setDate(d.getDate() - ((d.getDay() + 6) % 7) - back * 7)
12  return d.getTime()
13}
14
15export type Totals = { activeMs: number; prompts: number; costUsd: number }
16export type WeekReport = {
17  from: number
18  label: string
19  days: (Totals & { key: string; name: string })[]
20  projects: (Totals & { name: string })[]
21  total: Totals
22}
23
24const zero = (): Totals => ({ activeMs: 0, prompts: 0, costUsd: 0 })
25
26export const weekReport = (days: Record<string, DayStats>, from: number): WeekReport => {
27  const total = zero()
28  const projects = new Map<string, Totals>()
29  const rows = NAMES.map((name, i) => {
30    const key = dayKey(from + i * DAY + 12 * 3_600_000)
31    const d = days[key]
32    const row = { key, name, activeMs: d?.activeMs ?? 0, prompts: d?.prompts ?? 0, costUsd: d?.costUsd ?? 0 }
33    total.activeMs += row.activeMs
34    total.prompts += row.prompts
35    total.costUsd += row.costUsd
36    for (const [p, s] of Object.entries(d?.projects ?? {})) {
37      const acc = projects.get(p) ?? zero()
38      acc.activeMs += s.activeMs
39      acc.prompts += s.prompts
40      acc.costUsd += s.costUsd
41      projects.set(p, acc)
42    }
43    return row
44  })
45  const end = new Date(from + 6 * DAY)
46  const start = new Date(from)
47  const fmt = (d: Date) => d.toLocaleDateString('en-GB', { day: 'numeric', month: 'short' })
48  return {
49    from,
50    label: `${fmt(start)} – ${fmt(end)}`,
51    days: rows,
52    projects: [...projects.entries()].map(([name, t]) => ({ name, ...t })).filter(p => p.prompts + p.activeMs > 0).sort((a, b) => b.activeMs - a.activeMs || b.prompts - a.prompts),
53    total,
54  }
55}
56
57export const hours = (ms: number) => {
58  const m = Math.round(ms / 60_000)
59  return `${Math.floor(m / 60)}h${String(m % 60).padStart(2, '0')}`
60}
61
62export const usd = (n: number) => `$${n.toFixed(2)}`
63
64export const toMarkdown = (r: WeekReport) =>
65  [
66    `# Week ${r.label}`,
67    '',
68    '| Day | Claude working | Prompts | Cost |',
69    '| --- | --- | --- | --- |',
70    ...r.days.map(d => `| ${d.name} | ${hours(d.activeMs)} | ${d.prompts} | ${usd(d.costUsd)} |`),
71    `| **Total** | **${hours(r.total.activeMs)}** | **${r.total.prompts}** | **${usd(r.total.costUsd)}** |`,
72    '',
73    '| Project | Claude working | Prompts | Cost |',
74    '| --- | --- | --- | --- |',
75    ...r.projects.map(p => `| ${p.name} | ${hours(p.activeMs)} | ${p.prompts} | ${usd(p.costUsd)} |`),
76    '',
77  ].join('\n')
78
types/index.d.ts 19 lines
1export type JrWeekTotals = { activeMs: number; prompts: number; costUsd: number }
2
3export type JrWeekView = {
4  back: number
5  report: {
6    from: number
7    label: string
8    days: (JrWeekTotals & { key: string; name: string })[]
9    projects: (JrWeekTotals & { name: string })[]
10    total: JrWeekTotals
11  }
12}
13
14declare module 'claude-code' {
15  interface PluginState {
16    'jr-week': { view: JrWeekView | null }
17  }
18}
19