/jrheatmap: your last 26 weeks with Claude Code as a GitHub-style heatmap, with current and longest streak and your peak hours.

/jrheatmap draws your last 26 weeks with Claude Code as a GitHub-style grid: one square per day, coloured by how many prompts you sent, from dark violet to amber.
jr heatmap last 26 weeks · 1,204 prompts on 97 days
May Jun Jul Aug Sep Oct
Mon ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■
...
Sun ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■
less ■ ■ ■ ■ ■ more
✦ 6 days current streak 21 days longest
hours ▁▁▁▁▁▁▂▅██▆▅▃▅▇█▆▄▃▂▂▃▂▁ peak 09:00
| Command | |
|---|---|
/jrheatmap | Open the heatmap |
The mod records from the moment it is installed; earlier days stay empty.
/plugin install jr-heatmap --marketplace jrlprost/claude-code-mods
Records prompts, turns, tool names and cost in the plugin's own store. Reads the session's cost and project folder. Sends nothing over the network.
hooks/register.tsx 116 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import { loadDays, primeCost, recordPrompt, recordTool, recordTurn } from './activity'
5import { gridStart, grid, LEVEL_COLORS, monthLabels, peakHours, streaks, WEEKS } from './heat'
6import { gradientText } from './jr/draw'
7import { GRADIENTS } from './jr/gradient'
8
9const PANE = 'jr-heatmap'
10const view = atom({ plugin: 'jr-heatmap', key: 'view' } as const, null)
11const ready = { isCommand: false }
12const DAYS = ['Mon', '', 'Wed', '', 'Fri', '', 'Sun']
13
14async function ensureCommand($: EngineInterface) {
15 if (ready.isCommand) return
16 ready.isCommand = true
17 await $.command.register({ name: 'jrheatmap', description: 'Your last 26 weeks with Claude as a heatmap, streaks and peak hours' })
18}
19
20async function refresh($: EngineInterface) {
21 const now = await $.clock.now()
22 const days = await loadDays($, gridStart(now), now)
23 const total = Object.values(days).reduce((n, d) => n + d.prompts, 0)
24 const activeDays = Object.values(days).filter(d => d.prompts > 0).length
25 await update($, view, () => ({
26 cells: grid(days, now).map(row => row.map(c => (c.isFuture ? -1 : c.level))),
27 months: monthLabels(now),
28 streaks: streaks(days, now),
29 hours: peakHours(days),
30 total,
31 activeDays,
32 }))
33}
34
35export const register: Register = on => {
36 on('session.start', async ($, e, next) => {
37 const started = await next(e)
38 await ensureCommand($)
39 await primeCost($)
40 return started
41 })
42
43 on('prompt.submit', async ($, e, next) => {
44 await ensureCommand($)
45 if (!e.command && !e.isMeta) {
46 await primeCost($)
47 await recordPrompt($)
48 }
49 return next(e)
50 })
51
52 on('turn.complete', async ($, e, next) => {
53 const done = await next(e)
54 if (e.agentId === undefined) await recordTurn($, e.durationMs)
55 return done
56 })
57
58 on('tool.call', async ($, e, next) => {
59 const ran = await next(e)
60 await recordTool($, String(e.tool))
61 return ran
62 })
63
64 on('command.run', { command: 'jrheatmap' }, async $ => {
65 await refresh($)
66 await $.ui.open({ id: PANE, title: 'jr heatmap', rows: 16 })
67 return { text: 'jr heatmap opened.' }
68 })
69
70 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
71 const { Box, Text } = $.ui.resolve(e)
72 const v = await read($, view)
73 if (!v) return <Text dimColor>Loading…</Text>
74 const most = Math.max(1, ...v.hours)
75 const peak = v.hours.indexOf(Math.max(...v.hours))
76 return (
77 <Box flexDirection="column">
78 <Text>
79 {gradientText(Text, 'jr heatmap', GRADIENTS.brand)}
80 <Text dimColor>{` last ${WEEKS} weeks · ${v.total} prompts on ${v.activeDays} days`}</Text>
81 </Text>
82 <Text dimColor>{` ${v.months}`}</Text>
83 {v.cells.map((row, r) => (
84 <Text>
85 <Text dimColor>{(DAYS[r] ?? '').padEnd(4)}</Text>
86 {row.map(level => (level < 0 ? <Text>{' '}</Text> : <Text color={LEVEL_COLORS[level]}>{'■ '}</Text>))}
87 </Text>
88 ))}
89 <Text>
90 <Text dimColor>{' less '}</Text>
91 {LEVEL_COLORS.map(c => (
92 <Text color={c}>{'■ '}</Text>
93 ))}
94 <Text dimColor>{'more'}</Text>
95 </Text>
96 <Text> </Text>
97 <Text>
98 <Text color="#ffb86b">{'✦ '}</Text>
99 <Text bold>{`${v.streaks.current} day${v.streaks.current === 1 ? '' : 's'}`}</Text>
100 <Text dimColor>{' current streak '}</Text>
101 <Text bold>{`${v.streaks.longest} day${v.streaks.longest === 1 ? '' : 's'}`}</Text>
102 <Text dimColor>{' longest'}</Text>
103 </Text>
104 <Text>
105 <Text dimColor>{'hours '}</Text>
106 {v.hours.map(n => (
107 <Text color={LEVEL_COLORS[Math.min(4, Math.ceil((n / most) * 4))]}>{'█'}</Text>
108 ))}
109 <Text dimColor>{v.total > 0 ? ` peak ${String(peak).padStart(2, '0')}:00` : ''}</Text>
110 </Text>
111 <Text dimColor>{' 0 6 12 18 23'}</Text>
112 </Box>
113 )
114 })
115}
116hooks/activity.ts 128 lines1// 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}
128hooks/heat.ts 80 lines1import type { DayStats } from './activity'
2import { dayKey } from './activity'
3
4export const WEEKS = 26
5const DAY = 86_400_000
6
7/** Monday 00:00 local of the first of the WEEKS columns ending with the week of `now`. */
8export const gridStart = (now: number) => {
9 const d = new Date(now)
10 d.setHours(0, 0, 0, 0)
11 d.setDate(d.getDate() - ((d.getDay() + 6) % 7) - (WEEKS - 1) * 7)
12 return d.getTime()
13}
14
15export const LEVEL_COLORS = ['#24263a', '#3b2f6e', '#7f5af0', '#ff5ca8', '#ffb86b']
16
17/** 0 for no prompt, then 1 to 4 by quarter of the busiest day. */
18export const levelOf = (prompts: number, most: number) => (prompts <= 0 ? 0 : Math.min(4, Math.ceil((prompts / Math.max(1, most)) * 4)))
19
20export type Cell = { key: string; prompts: number; level: number; isFuture: boolean }
21
22/** grid[row = weekday Mon..Sun][column = week], oldest week first. */
23export const grid = (days: Record<string, DayStats>, now: number) => {
24 const start = gridStart(now)
25 const todayKey = dayKey(now)
26 const keys = Array.from({ length: WEEKS * 7 }, (_, i) => dayKey(start + i * DAY + 12 * 3_600_000))
27 const most = Math.max(1, ...keys.map(k => days[k]?.prompts ?? 0))
28 let isAfterToday = false
29 const flat: Cell[] = keys.map(key => {
30 const cell = { key, prompts: days[key]?.prompts ?? 0, level: levelOf(days[key]?.prompts ?? 0, most), isFuture: isAfterToday }
31 if (key === todayKey) isAfterToday = true
32 return cell
33 })
34 return Array.from({ length: 7 }, (_, row) => Array.from({ length: WEEKS }, (_, col) => flat[col * 7 + row]!))
35}
36
37/** Current streak (counting from today, or yesterday when today is still empty) and longest streak. */
38export const streaks = (days: Record<string, DayStats>, now: number) => {
39 const start = gridStart(now)
40 const keys: string[] = []
41 for (let t = start + 12 * 3_600_000; dayKey(t) <= dayKey(now); t += DAY) keys.push(dayKey(t))
42 let longest = 0
43 let run = 0
44 for (const k of keys) {
45 run = (days[k]?.prompts ?? 0) > 0 ? run + 1 : 0
46 longest = Math.max(longest, run)
47 }
48 let current = 0
49 const back = [...keys].reverse()
50 if ((days[back[0] ?? '']?.prompts ?? 0) === 0) back.shift()
51 for (const k of back) {
52 if ((days[k]?.prompts ?? 0) === 0) break
53 current += 1
54 }
55 return { current, longest }
56}
57
58/** Prompts per hour of the day over all the days given. */
59export const peakHours = (days: Record<string, DayStats>) => {
60 const out = Array.from({ length: 24 }, () => 0)
61 for (const d of Object.values(days)) d.hours.forEach((n, h) => (out[h]! += n))
62 return out
63}
64
65export const monthLabels = (now: number) => {
66 // Each week column is two cells wide; a month's three letters start at its first week.
67 const start = gridStart(now)
68 const line = Array.from({ length: WEEKS * 2 }, () => ' ')
69 let last = -1
70 for (let col = 0; col < WEEKS; col++) {
71 const at = new Date(start + col * 7 * DAY)
72 if (at.getMonth() === last) continue
73 last = at.getMonth()
74 const label = at.toLocaleDateString('en-GB', { month: 'short' }).slice(0, 3)
75 if (col * 2 + 3 > line.length) continue
76 for (let i = 0; i < label.length; i++) line[col * 2 + i] = label[i]!
77 }
78 return line.join('')
79}
80hooks/jr/draw.tsx 35 lines1// 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)
35hooks/jr/gradient.ts 75 lines1// 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}
75types/index.d.ts 16 lines1export type JrHeatmapView = {
2 /** cells[weekday][week]: level 0–4, or -1 for days still to come. */
3 cells: number[][]
4 months: string
5 streaks: { current: number; longest: number }
6 hours: number[]
7 total: number
8 activeDays: number
9}
10
11declare module 'claude-code' {
12 interface PluginState {
13 'jr-heatmap': { view: JrHeatmapView | null }
14 }
15}
16