SLOPSHOPPER

heatmap

A GitHub-style grid of your Claude Code time in a /heatmap pane, with streaks, peaks and top projects.

newpanecommandtoastprocess
★ 262v0.1.1MITupdated 2026-10-15hamzafer/claude-code-mods/mods/heatmap
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · heatmap
│ ┃ Heatmap ✕ › fix the failing auth test and add an audit log call │ ┃ Heatmap · all projects [p] this repo │ ┃ ⏺ Read(src/auth.ts) │ ┃ ▣ client module ./grid.tsx ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ 🔥 0-day streak · best 0 0m active · 0 se… ⎿ Added 2 lines, removed 1 line │ ┃ [ This repo ] [ Close ] ⏺ 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 │ │ › /heatmap │ ⎿ heatmap: Opened the heatmap │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Heatmap
Heatmap · all projects [p] this repo ▣ client module ./grid.tsx 🔥 0-day streak · best 0 0m active · 0 sessions [ This repo ] [ Close ]
README

<h1 align="center">Claude Code mods</h1>

<a href="https://www.npmjs.com/package/claude-code-mods"><img src="https://img.shields.io/npm/v/claude-code-mods.svg" alt="npm"></a> <a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="MIT"></a> <a href="https://github.com/hamzafer/claude-code-mods/actions/workflows/ci.yml"><img src="https://github.com/hamzafer/claude-code-mods/actions/workflows/ci.yml/badge.svg?branch=main" alt="CI"></a>

<a href="#-install">Install</a> · <a href="#-the-mods">All mods</a> · <a href="docs/mods.md">Docs</a> · <a href="https://claude.dev/blog/getting-started-with-claude-code-mods/">What are mods?</a>

<table> <tr> <td align="center" width="33%"><a href="docs/mods.md#-context-bar"><img src="images/context-bar.png" alt="context-bar: Claude Code context window usage as a stacked bar, a color per category" width="260"></a><br>📊 <b>context-bar</b><br>what fills your context</td> <td align="center" width="33%"><a href="docs/mods.md#-review-watch"><img src="images/review-watch.png" alt="review-watch: live lines for running Codex and subagent code reviews in Claude Code" width="260"></a><br>🔍 <b>review-watch</b><br>running code reviews, live</td> <td align="center" width="33%"><a href="docs/mods.md#-md-preview"><img src="images/md-preview.png" alt="md-preview: Markdown that Claude Code edits, rendered like GitHub next to the diff" width="260"></a><br>📝 <b>md-preview</b><br>Markdown rendered like GitHub</td> </tr> <tr> <td align="center" width="33%"><a href="docs/mods.md#-blast-radius"><img src="images/gallery/blast-radius.png" alt="blast-radius: a Claude Code hook holds rm -rf and lists the files it would delete" width="260"></a><br>💥 <b>blast-radius</b><br>see what <code>rm -rf</code> would delete</td> <td align="center" width="33%"><a href="docs/mods.md#-now-playing"><img src="images/now-playing.png" alt="now-playing: Spotify track, progress bar and synced lyrics inside Claude Code" width="260"></a><br>🎵 <b>now-playing</b><br>Spotify and its lyrics, live</td> <td align="center" width="33%"><a href="docs/mods.md#-reels-and-snake"><img src="images/reels-demo.gif" alt="reels: YouTube Shorts in a Claude Code pane while it works" width="260"></a><br>📱 <b>reels</b><br>Shorts while Claude works</td> </tr> <tr> <td align="center" width="33%"><a href="docs/mods.md#-where-am-i"><img src="images/gallery/where-am-i.png" alt="where-am-i: the session goal, current step and what waits on you, above the Claude Code prompt" width="260"></a><br>📍 <b>where-am-i</b><br>goal, now, waiting on you</td> <td align="center" width="33%"><a href="docs/mods.md#-lines-above-the-prompt"><img src="images/gallery/lines.png" alt="token-weather, usage-meter and other Claude Code status lines stacked above the prompt" width="260"></a><br>🌦️ <b>token-weather and friends</b><br>lines above the prompt</td> <td align="center" width="33%"><a href="#mission-control"><img src="images/gallery/mission-control.png" alt="mission-control: Claude Code subagents, tool calls and the files they touch, live" width="260"></a><br>🛰️ <b>mission-control</b><br>agents and the code they touch</td> </tr> </table>

🚀 Install

npx claude-code-mods@latest

Tick the mods you want in a checklist, see what each one adds, and press Enter to install. Untick a mod to disable it (its settings stay). Restart Claude Code after. @latest makes npx skip an old cached copy.

  • --project installs for this project only
  • --dry-run prints the claude commands instead of running them
  • --pick a,b turns on exactly these mods, no checklist

It edits the Claude profile that claude uses here, shown at the top. For another profile, set it first: CLAUDE_CONFIG_DIR=~/.claude-other npx claude-code-mods@latest.

Order your bands

Press o in the checklist to order your bands (the lines above the prompt). Move the cursor with up/down or j/k, move a band with Shift+up/down or K/J, and press Enter to save. Esc goes back without saving. The order goes to <config dir>/claude-code-mods/bands.json, where the config dir is CLAUDE_CONFIG_DIR or ~/.claude. Restart Claude Code to apply it. Bands not in the file sit on top.

Other ways

By hand:

claude plugin marketplace add hamzafer/claude-code-mods
claude plugin install context-bar@claude-code-mods

To try one without installing:

git clone https://github.com/hamzafer/claude-code-mods && cd claude-code-mods
claude --plugin-dir mods/context-bar

Needs Claude Code 2.1.287+. A few mods need more (Chrome, gh, a connector); the tables say which.

🧩 The mods

👀 See what's happening

ModWhat it doesCommand
🛰️mission-controlLive map of agents, tool calls and the code they touch/mission
📊context-barYour context window as one stacked bar, a color per category, with token counts and where it compacts/context-bar
🌦️token-weatherContext fill from Clear to Compact soon, plus a prompt-cache countdown
⏱️cache-clockA prompt-cache line under your status line, from Claude Code's own figures: time left, hit rate and misses, and the tokens your next message re-caches once it goes cold. Needs Node and Claude Code 2.1.251+/cache-clock setup
📍where-am-iGoal, doing now, waiting on you, next step/where
➡️next-steps2 or 3 likely next prompts after each turn, one key to draft one. language sets their language1 2 3, 0 hides
💰usage-meter5-hour and 7-day plan usage, the reset countdown and the session's cost
🔥heatmapA GitHub-style grid of your Claude Code time, with your streak, peak hours and top projects/heatmap
💳openai-balanceYour OpenAI API credit: an estimated balance with a gauge, today's spend, where the money mostly went, and the last call. Needs an OpenAI organization Admin key/openai-balance
🪙openrouter-balanceYour OpenRouter credit: the real balance with a gauge, today's spend and the model the money mostly went to. A regular API key shows the balance too when OpenRouter allows it, plus its own spend and limit/openrouter-balance
📡agent-radarOne live line per running subagent/radar
🔍review-watchOne live line per running code review (Codex or a review subagent) with the model, target, elapsed time and Codex's latest output. A toast lists the findings when it ends
🌐browser-lanesWhether this session has a browser, and who holds it/browser
🕌prayer-timesThe current prayer and how long is left, the next one, and zawal. Computed on your computer, Hanafi or standard Asr/prayers
🎬replay-theaterSteps through the last turn's edits, one diff at a time/replay
📝md-previewRenders the Markdown files Claude edits like GitHub does, with before and after side by side. Needs Chrome and a terminal that shows images/md

🛡️ Guard your repo

ModWhat it doesCommand
💥blast-radiusHolds rm -r, force pushes and migrations, shows what they'd delete, cancels after 60 s with no answer

💸 Spend less

ModWhat it doesCommand
🔀switchboardPicks the model for each subagent that doesn't name one, with OpenAI's Decisions API or Jev, from its short label only. Shows what every subagent cost/route

🔧 My setup (fork and adapt)

These are built around my own tools and rules. Fork them and change the rules to yours.

ModWhat it doesCommand
👀glanceOne line with what needs you: next meeting, PRs, Linear issues, Slack DMs. Needs gh and the Google Calendar, Linear and Slack connectors/glance
🚦merge-gateHolds gh pr merge until CI is green and Codex reviewed once. Needs gh and the Codex CLI. Reviews run on one fixed model; change it to yours/gate
📏rulebook-guardEnforces my writing and git rules: rewrites em dashes, asks before --amend, unformatted pushes, emails and phone numbers in notes
💾session-saverSaves where you left off, shows it on resume. Needs unpause/park [note]

🎮 For fun (opt-in)

ModWhat it doesCommand
📱reelsYouTube Shorts while Claude works, pauses when it's done/reels
🐍snakeSnake while Claude works/snake
🎵now-playingWhat Spotify is playing, with a progress bar, the lyric being sung, and ⏮ ⏸ ⏭ buttons. Needs macOS and the Spotify app/music

🤝 Community mods

Mods by other people, in their own marketplaces. They're not maintained here, so read the code before you install. They also show up last in the npx claude-code-mods@latest checklist.

ModWhat it doesInstall
🖼️image-view by @jarrodwattsSee the images you paste into Claude Code: thumbnails above the prompt instead of bare [Image #1] tagsclaude plugin marketplace add jarrodwatts/claude-image-view then claude plugin install image-view@claude-image-view
🏷️last-prompt-title by @zhangbububuSession titles that follow your latest prompt: cut for the CLI, written by Haiku for the desktop sidebarclaude plugin marketplace add zhangbububu/session-titles then claude plugin install last-prompt-title@session-titles
🌏cc-zh by @jiangnanquanSimplified Chinese UI localization via static dictionary: slash-command descriptions, /config labels, spinner, hints, notices, command output, tool summaries and turn duration. Zero tokens / network; verified on CC 2.1.296claude plugin marketplace add jiangnanquan/claude-code-zh then claude plugin install cc-zh@claude-code-zh
🎛️subagent-tuner by @KruhlikauPicks the model and effort of every subagent: a Manual/Auto band, a pick pane, review → Opus and search → Haiku, and a saved default that overrides what the agent asked forclaude plugin marketplace add Kruhlikau/subagent-tuner then claude plugin install subagent-tuner@subagent-tuner
🛒mod-store by @hellosverreAn app store in a pane: /mods browses, searches and installs about 2,700 community mods, with what each one can access shown before you install. Or ask Claude to find oneclaude plugin marketplace add hellosverre/mod-store then claude plugin install mod-store@mod-store
📈cache-meter by @T-AI-LabsThe share of each turn the prompt cache served, next to the context fill, with an auto-compact threshold and Compact and Clear buttonsclaude plugin marketplace add T-AI-Labs/claude-code-mods then claude plugin install cache-meter@T-AI-Labs
⏳usage-limits by @T-AI-Labs5-hour and weekly plan limits in percent, with the time left until each resetsclaude plugin marketplace add T-AI-Labs/claude-code-mods then claude plugin install usage-limits@T-AI-Labs

Built one? Open a PR that adds a row.

<a id="mission-control"></a>

🛰️ Flagship: mission-control

Every subagent, every tool call and every file they touch, in a pane next to the chat. Shown at 4x: two subagents building a logout feature across four files.

mission-control at 4x: two subagents and a logout feature landing across four files

Install it like any mod, restart, and type /mission (or /mission code to open the code map). q closes it.

  • 🤖 w shows the agents and every tool call, live
  • 🗺️ c shows the code map, with import arrows
  • 🔵 Blue while the agent reads a file
  • 🟠 Orange while it writes
  • 🟢 Green when done, with one line on what changed

The Code view also needs macOS, Google Chrome and a terminal that shows images (Ghostty, kitty, iTerm2). The Who view works everywhere.

📚 More

Source 4 files
hooks/register.tsx 280 lines
1// Heatmap: a GitHub-style grid of your Claude Code time, in a /heatmap pane.
2// It keeps its own daily ledger, so the grid outlives Claude Code's log cleanup.
3import { atom, read, update } from 'claude-code'
4import type { EngineInterface, Register } from 'claude-code'
5
6import type { GridProps } from './grid'
7import {
8  MILESTONES,
9  addDays,
10  aggregate,
11  buildView,
12  dayOf,
13  duration,
14  formatLedger,
15  hourLabel,
16  localOffset,
17  mergeLedger,
18  monthLabels,
19  parseLedger,
20  parseTimes,
21  projectName,
22  rampFor,
23  thousands,
24  weekdayName,
25  weeksFor,
26} from './heat'
27import type { Ramp, Row, SessionLog } from './heat'
28import type { Scope } from '../types'
29
30const PANE = 'heatmap'
31const GRID = 'grid'
32const TOASTED_KEY = 'toastedDay'
33const GREP_BATCH = 8 // logs read at once
34const BAR_COLUMNS = 12
35
36// Held by the host, so a hot reload keeps the pane's scope.
37const scope = atom({ plugin: 'heatmap', key: 'scope' } as const, 'all' as Scope)
38const version = atom({ plugin: 'heatmap', key: 'version' } as const, 0) // bumped when the ledger changes
39const isLoading = atom({ plugin: 'heatmap', key: 'isLoading' } as const, false)
40
41// The ledger as last read or written; a hot reload starts over and reads it again.
42let rows: Row[] | null = null
43let refreshing: Promise<void> | null = null
44let ramp: Ramp | null = null // from the person's theme, read once
45let failure: string | null = null // why the last refresh failed, shown while there are no rows
46
47export const register: Register = on => {
48  let here = '' // this session's project folder
49
50  on('session.start', async ($, e, next) => {
51    const r = await next(e)
52    here = projectFolder(await $.session.root())
53    await $.command.register({ name: 'heatmap', description: 'A GitHub-style grid of your Claude Code time' }).catch(() => {}) // a name Claude Code already has is refused: start anyway
54    void (async () => {
55      await refresh($)
56      await milestoneToast($, rows ?? [])
57    })()
58    return r
59  })
60
61  on('session.end', async ($, e, next) => {
62    const r = await next(e)
63    await refresh($)
64    return r
65  })
66
67  on('command.run', { command: 'heatmap' }, async $ => {
68    await update($, isLoading, () => rows === null)
69    await $.ui.open({ id: PANE, title: 'Heatmap', focus: true })
70    void refresh($).then(() => update($, isLoading, () => false)) // today stays current mid-session
71    return { text: 'Opened the heatmap' }
72  })
73
74  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
75    const { Box, Text, Button, Client } = $.ui.resolve(e)
76    await read($, version) // redraw when the ledger changes
77    const which = await read($, scope)
78    const close = <Button key="close" label="Close" hotkey="q" role="dismiss" onPress={() => $.ui.close({ id: PANE })} />
79
80    if (rows === null) {
81      if (failure) {
82        return (
83          <Box flexDirection="column">
84            <Text color="error">{`Could not read your session history: ${failure}`}</Text>
85            {close}
86          </Box>
87        )
88      }
89      if (!(await read($, isLoading))) void refresh($) // a hot reload dropped the rows
90      return (
91        <Box flexDirection="column">
92          <Text dimColor>Reading your session history… the first time takes a few seconds.</Text>
93          {close}
94        </Box>
95      )
96    }
97    if (e.surface !== 'terminal' && e.surface !== 'desktop') {
98      return <Text dimColor>The heatmap draws in the terminal or desktop app.</Text>
99    }
100
101    const today = dayOf(Date.now(), localOffset)
102    const weeks = weeksFor(e.props.bodyColumns)
103    const isHere = which === 'here'
104    const v = buildView(rows, { today, weeks, project: isHere ? here : undefined })
105    const hereName = rows.find(r => r.p === here)?.n ?? 'this repo'
106    const grid: GridProps = {
107      weeks,
108      start: v.start,
109      cells: v.days.map(d => [d.min, d.s, d.top]),
110      months: monthLabels(v.start, weeks),
111      labels: v.days.map(d => d.d),
112      ramp: (ramp ??= await themeRamp($)),
113    }
114    const toggle = () => update($, scope, s => (s === 'all' ? 'here' : 'all'))
115
116    const stats = [`🔥 ${v.streak.current}-day streak · best ${v.streak.best}`, `${hours(v.totalMin)} active · ${thousands(v.sessions)} session${v.sessions === 1 ? '' : 's'}`]
117    const peak = [v.peakDay === null ? null : weekdayName(v.peakDay), v.peakHour === null ? null : hourLabel(v.peakHour)].filter(Boolean).join(' · ')
118    const nameWidth = Math.min(16, Math.max(0, ...v.top.map(t => t.name.length)))
119
120    return (
121      <Box flexDirection="column">
122        <Text>
123          <Text bold color="claude">{'Heatmap'}</Text>
124          <Text>{` · ${isHere ? hereName : 'all projects'}`}</Text>
125          <Text dimColor>{`  [p] ${isHere ? 'all projects' : 'this repo'}`}</Text>
126        </Text>
127        <Text> </Text>
128        <Client key={GRID} module="./grid.tsx" props={grid} width={Math.max(1, e.props.bodyColumns)} height={12} />
129        <Text> </Text>
130        <Text wrap="truncate-end">{stats.join('   ')}</Text>
131        {peak ? <Text dimColor>{`peak: ${peak}`}</Text> : null}
132        {v.top.length > 0 ? <Text> </Text> : null}
133        {v.top.map(t => (
134          <Text wrap="truncate-end">
135            <Text>{fit(t.name, nameWidth).padEnd(nameWidth + 1)}</Text>
136            <Text color="claude">{'■'.repeat(Math.max(1, Math.round(t.share * BAR_COLUMNS)))}</Text>
137            <Text dimColor>{` ${Math.round(t.share * 100)}% · ${duration(t.min)}`}</Text>
138          </Text>
139        ))}
140        <Box flexDirection="row" gap={1}>
141          <Button key="scope" label={isHere ? 'All projects' : 'This repo'} hotkey="p" onPress={toggle} />
142          {close}
143        </Box>
144      </Box>
145    )
146  })
147
148  // The grid posts its keys: p switches the scope, q closes.
149  on('ui.message', async ($, e) => {
150    if (e.element !== GRID) return {}
151    const data = e.data as { scope?: unknown; close?: unknown }
152    if (data?.scope === 'toggle') await update($, scope, s => (s === 'all' ? 'here' : 'all'))
153    if (data?.close === true) await $.ui.close({ id: PANE })
154    return {}
155  })
156}
157
158/** One refresh at a time; a second caller waits on the first. */
159function refresh($: EngineInterface): Promise<void> {
160  refreshing ??= (async () => {
161    try {
162      rows = await refreshLedger($)
163      failure = null
164    } catch (err) {
165      failure = String(err instanceof Error ? err.message : err)
166      $.ui.log(`heatmap: could not read the session logs: ${failure}`, { to: 'debug' })
167    } finally {
168      refreshing = null
169      await update($, version, v => v + 1)
170    }
171  })()
172  return refreshing
173}
174
175/** The ramp for the saved theme; Claude Code keeps it in .claude.json. Auto, or unreadable: the ramp that reads on both. */
176async function themeRamp($: EngineInterface): Promise<Ramp> {
177  try {
178    const custom = await $.env.get('CLAUDE_CONFIG_DIR')
179    const file = custom ? `${custom.replace(/\/+$/, '')}/.claude.json` : `${await $.env.get('HOME')}/.claude.json`
180    return rampFor((JSON.parse(await $.fs.read(file)) as { theme?: unknown }).theme)
181  } catch {
182    return 'auto'
183  }
184}
185
186/** <projects folder>/<this>: the project root with each other character a dash, as Claude Code names it. */
187function projectFolder(root: string) {
188  return root.replace(/[^a-zA-Z0-9]/g, '-')
189}
190
191/** 412h past ten hours, 3h 12m below. */
192function hours(min: number) {
193  return min >= 600 ? `${thousands(Math.round(min / 60))}h` : duration(min)
194}
195
196function fit(name: string, width: number) {
197  return name.length <= width ? name : `${name.slice(0, Math.max(0, width - 1))}…`
198}
199
200async function configDir($: EngineInterface) {
201  const custom = await $.env.get('CLAUDE_CONFIG_DIR')
202  if (custom) return custom.replace(/\/+$/, '')
203  const home = await $.env.get('HOME')
204  if (!home) throw new Error('HOME is not set, so the Claude config folder is unknown.')
205  return `${home}/.claude`
206}
207
208/**
209 * Reads the ledger and brings it up to date from the session logs. With no ledger
210 * (or an empty one) it backfills every day the logs hold. After that it replaces
211 * the days from the ledger's last day (yesterday at the latest) on, reading only
212 * logs touched since, so older days stay as they were.
213 */
214async function refreshLedger($: EngineInterface): Promise<Row[]> {
215  const dir = await configDir($)
216  const file = `${dir}/heatmap/days.jsonl`
217  const parsed = (await $.fs.exists(file)) ? parseLedger(await $.fs.read(file)) : []
218  const old = parsed.length > 0 ? parsed : null // an empty or broken ledger backfills again
219  const yesterday = addDays(dayOf(Date.now(), localOffset), -1)
220  const last = old ? old[old.length - 1]!.d : '' // rows are sorted by day
221  const fromDay = old === null ? '' : last < yesterday ? last : yesterday
222  const since = old === null ? 0 : Date.parse(`${addDays(fromDay, -1)}T00:00:00`) // local midnight, a day early for any clock shift
223
224  const logs = await readLogs($, `${dir}/projects`, since)
225  const merged = mergeLedger(old ?? [], aggregate(logs, localOffset, fromDay), fromDay)
226  await $.fs.write(file, formatLedger(merged))
227  return merged
228}
229
230/** The session logs touched since `since`: each one's project and message times, read with grep (logs can pass the 4 MiB read limit). */
231async function readLogs($: EngineInterface, projects: string, since: number): Promise<SessionLog[]> {
232  if (!(await $.fs.exists(projects))) return []
233  const jobs: { folder: string; path: string }[] = []
234  for (const folder of await $.fs.list(projects)) {
235    if (folder.kind !== 'dir') continue
236    const files = await $.fs.list(`${projects}/${folder.name}`).catch(() => [])
237    for (const f of files) {
238      if (f.kind === 'file' && f.name.endsWith('.jsonl') && f.mtimeMs >= since) jobs.push({ folder: folder.name, path: `${projects}/${folder.name}/${f.name}` })
239    }
240  }
241
242  const names = new Map<string, string>()
243  const logs: SessionLog[] = []
244  for (let i = 0; i < jobs.length; i += GREP_BATCH) {
245    await Promise.all(
246      jobs.slice(i, i + GREP_BATCH).map(async job => {
247        if (!names.has(job.folder)) {
248          const cwd = await firstCwd($, job.path)
249          if (cwd) names.set(job.folder, projectName(cwd, job.folder)) // a log without one leaves the next log to say
250        }
251        const r = await $.process.run(['grep', '-oE', '"timestamp": *"[0-9]{4}-[^"]*"', '--', job.path], { timeoutMs: 60_000 }).catch(() => null)
252        if (!r) return
253        if (r.isStdoutTruncated) $.ui.log(`heatmap: ${job.path} has more messages than one read holds; its latest are left out`, { to: 'debug' })
254        const times = parseTimes(r.stdout)
255        if (times.length > 0) logs.push({ p: job.folder, n: names.get(job.folder) ?? job.folder, times })
256      }),
257    )
258  }
259  return logs
260}
261
262async function firstCwd($: EngineInterface, path: string): Promise<string | null> {
263  const r = await $.process.run(['grep', '-m1', '-oE', '"cwd": *"[^"]*"', '--', path], { timeoutMs: 30_000 }).catch(() => null)
264  const m = r?.stdout.match(/"cwd": *"([^"]*)"/)
265  return m ? m[1]!.replace(/\\\\/g, '\\') : null
266}
267
268/** One toast per streak as it reaches 7, 30 or 100 days. */
269async function milestoneToast($: EngineInterface, rows: readonly Row[]) {
270  const today = dayOf(Date.now(), localOffset)
271  const { streak } = buildView(rows, { today, weeks: 1 })
272  if (!MILESTONES.includes(streak.current)) return
273  // The streak and the milestone together: the next day, before any activity, it still reads the same.
274  const last = rows.some(r => r.d === today && (r.s > 0 || r.min > 0)) ? today : addDays(today, -1)
275  const mark = `${addDays(last, 1 - streak.current)}:${streak.current}`
276  if ((await $.store.get(TOASTED_KEY).catch(() => undefined)) === mark) return
277  await $.store.set(TOASTED_KEY, mark)
278  $.ui.toast(`🔥 ${streak.current}-day Claude streak · /heatmap`)
279}
280
hooks/grid.tsx 118 lines
1// The grid itself: draws the weeks and moves a day cursor with the arrow keys.
2import type { ClientModule } from 'claude-code'
3
4import { LABEL_COLUMNS, dayLabel, duration, level, thousands } from './heat'
5import type { Ramp } from './heat'
6
7/** One day: active minutes, sessions, top project. */
8export type Cell = [number, number, string | null]
9
10export type GridProps = {
11  weeks: number
12  start: string // the Monday of the first week
13  cells: Cell[] // from start to today
14  months: string // the month row, already laid out
15  labels: string[] // one per day in order, for the detail line
16  ramp: Ramp // from the person's saved theme
17}
18
19type GridText = Parameters<ClientModule<GridProps, Local>>[1]['elements']['Text']
20type Local = { cursor: string | null; live: { props: GridProps } } // the day under the cursor; null is today
21
22// Two sizes and a ramp of Claude orange: a level never depends on telling colors apart alone.
23export const GLYPHS = ['·', '▪', '▪', '■', '■']
24const RAMPS: Record<Ramp, string[]> = {
25  dark: ['#5c3426', '#8f4a33', '#c4613f', '#f08a63'], // brighter is busier
26  light: ['#f5c9b6', '#ec9c79', '#d97757', '#a8492b'], // darker is busier
27  // One lightness, so each sits as far from a light background as from a dark one;
28  // only the color strengthens (from near gray to full orange), so busier reads busier on both.
29  auto: ['#8c807b', '#a5796a', '#c26b4d', '#dc5a2e'],
30}
31const ROW_LABELS = ['Mon', '', 'Wed', '', 'Fri', '', '']
32
33const Grid: ClientModule<GridProps, Local> = (props, surface) => {
34  const { Box, Text } = surface.elements
35
36  if (surface.state === undefined) {
37    surface.onKey(e => {
38      const now = surface.state
39      if (!now) return
40      const key = e.key.toLowerCase()
41      if (key === 'p') return surface.post({ scope: 'toggle' })
42      if (key === 'q') return surface.post({ close: true })
43      const step = key === 'up' ? -1 : key === 'down' ? 1 : key === 'left' ? -7 : key === 'right' ? 7 : 0
44      if (step === 0) return
45      const { labels } = now.live.props
46      const end = labels.length - 1
47      const cursor = Math.max(0, Math.min(end, indexOf(labels, now.cursor) + step))
48      const day = cursor === end ? null : labels[cursor]!
49      if (day !== now.cursor) surface.setState({ ...now, cursor: day })
50    })
51    surface.setState({ cursor: null, live: { props } }) // starts on today
52  }
53
54  const local = surface.state ?? { cursor: null, live: { props } }
55  local.live.props = props // the keys read the latest props
56  const cursor = indexOf(props.labels, local.cursor)
57  const at = props.cells[cursor]
58
59  return (
60    <Box flexDirection="column">
61      <Text dimColor>{' '.repeat(LABEL_COLUMNS) + props.months}</Text>
62      {ROW_LABELS.map((label, row) => (
63        <Text>
64          <Text dimColor>{label.padEnd(LABEL_COLUMNS)}</Text>
65          {Array.from({ length: props.weeks }, (_, w) => {
66            const i = w * 7 + row
67            const cell = props.cells[i]
68            if (!cell) return <Text>{'  '}</Text>
69            const lv = level(cell[0], cell[1])
70            return (
71              <Text>
72                {square(Text, lv, i === cursor, props.ramp)}
73                {' '}
74              </Text>
75            )
76          })}
77        </Text>
78      ))}
79      <Text>
80        <Text dimColor>{`${' '.repeat(LABEL_COLUMNS)}less `}</Text>
81        {GLYPHS.map((_, lv) => (
82          <Text>
83            {square(Text, lv, false, props.ramp)}{' '}
84          </Text>
85        ))}
86        <Text dimColor>more</Text>
87      </Text>
88      <Text> </Text>
89      <Text wrap="truncate-end">{at ? detail(props.labels[cursor] ?? '', at) : ''}</Text>
90      <Text dimColor wrap="truncate-end">click the grid, then ←↑↓→ move · p scope · q close · Esc</Text>
91    </Box>
92  )
93}
94
95export default Grid
96
97function square(Text: GridText, level: number, isCursor: boolean, ramp: Ramp) {
98  const color = level === 0 ? 'subtle' : RAMPS[ramp][level - 1]!
99  return (
100    <Text color={color} inverse={isCursor}>
101      {GLYPHS[level]}
102    </Text>
103  )
104}
105
106// The cursor's place in the grid: its day, or today when the day is gone or unset.
107function indexOf(labels: string[], day: string | null) {
108  const i = day === null ? -1 : labels.indexOf(day)
109  return i < 0 ? labels.length - 1 : i
110}
111
112function detail(day: string, [min, s, top]: Cell) {
113  if (s === 0 && min === 0) return `${dayLabel(day)} · no activity`
114  const parts = [dayLabel(day), duration(min), `${thousands(s)} session${s === 1 ? '' : 's'}`]
115  if (top) parts.push(`top: ${top}`)
116  return parts.join(' · ')
117}
118
hooks/heat.ts 343 lines
1// The heatmap's pure parts: active minutes, the ledger, and what the pane shows.
2// No `$` here, so every rule is tested without an engine.
3
4/** One ledger row: a project's activity on one local day. */
5export type Row = {
6  d: string // the local day, YYYY-MM-DD
7  p: string // the project's folder under projects/ (its root, each other character a dash)
8  n: string // the project's display name, the last part of its root
9  min: number // active minutes
10  s: number // sessions active that day
11  h: number[] // active minutes per local hour, 24 of them
12}
13
14/** One session log: its project and the times of its messages, in ms. */
15export type SessionLog = { p: string; n: string; times: number[] }
16
17/** Minutes UTC is ahead of local time at an instant, as Date#getTimezoneOffset says it. */
18export type Offset = (ms: number) => number
19
20export const GAP_MS = 5 * 60_000 // a pause longer than this adds nothing
21export const MAX_WEEKS = 52
22export const LABEL_COLUMNS = 4 // "Mon " before each grid row
23export const MILESTONES = [7, 30, 100]
24
25const DAY_MS = 86_400_000
26const HOUR_MS = 3_600_000
27const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
28const WEEKDAYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
29
30export const localOffset: Offset = ms => new Date(ms).getTimezoneOffset()
31
32/** The local day of an instant, YYYY-MM-DD. */
33export function dayOf(ms: number, offset: Offset): string {
34  return new Date(ms - offset(ms) * 60_000).toISOString().slice(0, 10)
35}
36
37/** Adds whole days to a YYYY-MM-DD day. */
38export function addDays(day: string, n: number): string {
39  return new Date(Date.parse(`${day}T00:00:00Z`) + n * DAY_MS).toISOString().slice(0, 10)
40}
41
42/** Monday is 0, Sunday 6. */
43export function weekday(day: string): number {
44  return (new Date(`${day}T00:00:00Z`).getUTCDay() + 6) % 7
45}
46
47type DayTotals = { ms: number; hours: number[] }
48
49/**
50 * One session's activity per local day: the time between messages, where a pause
51 * over GAP_MS adds nothing, split at local midnight and at each local hour.
52 * A day with a message and no counted time is still there, with 0 ms.
53 */
54export function sessionDays(times: readonly number[], offset: Offset): Map<string, DayTotals> {
55  const sorted = [...times].sort((a, b) => a - b)
56  const days = new Map<string, DayTotals>()
57  const at = (day: string) => {
58    let t = days.get(day)
59    if (!t) days.set(day, (t = { ms: 0, hours: new Array<number>(24).fill(0) }))
60    return t
61  }
62  for (const ms of sorted) at(dayOf(ms, offset))
63  for (let i = 1; i < sorted.length; i++) {
64    const a = sorted[i - 1]!
65    const b = sorted[i]!
66    if (b - a > GAP_MS) continue
67    let cur = a
68    while (cur < b) {
69      const local = cur - offset(cur) * 60_000
70      const end = Math.min(b, cur + (Math.floor(local / HOUR_MS) + 1) * HOUR_MS - local) // the next local hour
71      const t = at(new Date(local).toISOString().slice(0, 10))
72      t.ms += end - cur
73      t.hours[new Date(local).getUTCHours()]! += end - cur
74      cur = end
75    }
76  }
77  return days
78}
79
80/**
81 * Ledger rows from session logs: one per day per project, from `fromDay` on.
82 * A resumed session's log copies the earlier messages, so within a project each
83 * message time counts once, for the log that has it first.
84 */
85export function aggregate(logs: readonly SessionLog[], offset: Offset, fromDay = ''): Row[] {
86  const acc = new Map<string, { p: string; n: string; d: string; ms: number; s: number; hours: number[] }>()
87  const seen = new Map<string, Set<number>>()
88  // Oldest log first; on a tie the shorter one, the original a resume copied from.
89  const order = logs.map(log => ({ log, first: earliest(log.times) })).sort((a, b) => a.first - b.first || a.log.times.length - b.log.times.length)
90  for (const { log } of order) {
91    let had = seen.get(log.p)
92    if (!had) seen.set(log.p, (had = new Set()))
93    const own = log.times.filter(ms => !had!.has(ms))
94    own.forEach(ms => had!.add(ms))
95    for (const [d, t] of sessionDays(own, offset)) {
96      if (d < fromDay) continue
97      const key = `${d}\u0000${log.p}`
98      let r = acc.get(key)
99      if (!r) acc.set(key, (r = { p: log.p, n: log.n, d, ms: 0, s: 0, hours: new Array<number>(24).fill(0) }))
100      r.ms += t.ms
101      r.s += 1
102      t.hours.forEach((ms, h) => (r!.hours[h]! += ms))
103    }
104  }
105  return sortRows(
106    [...acc.values()].map(r => ({ d: r.d, p: r.p, n: r.n, min: Math.round(r.ms / 60_000), s: r.s, h: r.hours.map(ms => Math.round(ms / 60_000)) })),
107  )
108}
109
110function earliest(times: readonly number[]): number {
111  let min = Infinity
112  for (const ms of times) if (ms < min) min = ms // no spread: a long log passes the argument limit
113  return min
114}
115
116/**
117 * The ledger brought up to date: from `fromDay` on, a fresh row replaces the old
118 * one for its day and project. Older days, and old rows nothing fresh replaces
119 * (their logs may be gone), stay as they were.
120 */
121export function mergeLedger(old: readonly Row[], fresh: readonly Row[], fromDay: string): Row[] {
122  const recent = fresh.filter(r => r.d >= fromDay)
123  const replaced = new Set(recent.map(r => `${r.d}\u0000${r.p}`))
124  return sortRows([...old.filter(r => !replaced.has(`${r.d}\u0000${r.p}`)), ...recent])
125}
126
127/** Reads the ledger file; a malformed line is skipped. */
128export function parseLedger(text: string): Row[] {
129  const rows: Row[] = []
130  for (const line of text.split('\n')) {
131    if (!line.trim()) continue
132    try {
133      const r = JSON.parse(line) as Partial<Row>
134      if (typeof r.d !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(r.d) || typeof r.p !== 'string') continue
135      if (typeof r.min !== 'number' || typeof r.s !== 'number') continue
136      const h = Array.isArray(r.h) && r.h.length === 24 && r.h.every(x => typeof x === 'number') ? r.h : new Array<number>(24).fill(0)
137      rows.push({ d: r.d, p: r.p, n: typeof r.n === 'string' && r.n ? r.n : r.p, min: r.min, s: r.s, h })
138    } catch {
139      // a broken line costs that line only
140    }
141  }
142  return rows
143}
144
145export function formatLedger(rows: readonly Row[]): string {
146  return rows.map(r => JSON.stringify(r)).join('\n') + (rows.length ? '\n' : '')
147}
148
149function sortRows(rows: Row[]): Row[] {
150  return rows.sort((a, b) => (a.d < b.d ? -1 : a.d > b.d ? 1 : a.p < b.p ? -1 : a.p > b.p ? 1 : 0))
151}
152
153/** How many weeks fit a pane this wide: two columns a week after the labels, up to MAX_WEEKS. */
154export function weeksFor(columns: number): number {
155  return Math.max(1, Math.min(MAX_WEEKS, Math.floor((columns - LABEL_COLUMNS) / 2)))
156}
157
158/** 0 for no activity, then 1 to 4 for under 15 minutes, under an hour, under 3 hours, 3 hours and more. */
159export function level(min: number, s: number): number {
160  if (s <= 0 && min <= 0) return 0
161  return min < 15 ? 1 : min < 60 ? 2 : min < 180 ? 3 : 4
162}
163
164export type Day = { d: string; min: number; s: number; top: string | null }
165
166export type View = {
167  start: string // the Monday the grid starts on
168  weeks: number
169  today: string
170  days: Day[] // from start to today, one per day
171  streak: { current: number; best: number }
172  totalMin: number
173  sessions: number
174  peakDay: number | null // Monday is 0
175  peakHour: number | null
176  top: { name: string; min: number; share: number }[] // all projects only
177}
178
179/**
180 * What the pane shows for one scope: `project` set keeps that project's rows alone.
181 * Streaks count over every day in the ledger; the rest over the weeks shown.
182 */
183export function buildView(rows: readonly Row[], opts: { today: string; weeks: number; project?: string }): View {
184  const scoped = opts.project === undefined ? rows : rows.filter(r => r.p === opts.project)
185  const start = addDays(opts.today, -weekday(opts.today) - (opts.weeks - 1) * 7)
186
187  const byDay = new Map<string, { min: number; s: number; projects: Map<string, number> }>()
188  for (const r of scoped) {
189    let t = byDay.get(r.d)
190    if (!t) byDay.set(r.d, (t = { min: 0, s: 0, projects: new Map() }))
191    t.min += r.min
192    t.s += r.s
193    t.projects.set(r.n, (t.projects.get(r.n) ?? 0) + r.min)
194  }
195
196  const days: Day[] = []
197  const weekdayMin = new Array<number>(7).fill(0)
198  const hourMin = new Array<number>(24).fill(0)
199  const projectMin = new Map<string, number>()
200  let totalMin = 0
201  let sessions = 0
202  for (let d = start; d <= opts.today; d = addDays(d, 1)) {
203    const t = byDay.get(d)
204    days.push({ d, min: t?.min ?? 0, s: t?.s ?? 0, top: t ? topOf(t.projects) : null })
205    if (!t) continue
206    totalMin += t.min
207    sessions += t.s
208    weekdayMin[weekday(d)]! += t.min
209  }
210  for (const r of scoped) {
211    if (r.d < start || r.d > opts.today) continue
212    r.h.forEach((m, h) => (hourMin[h]! += m))
213    projectMin.set(r.n, (projectMin.get(r.n) ?? 0) + r.min)
214  }
215
216  const top =
217    opts.project === undefined && totalMin > 0
218      ? [...projectMin.entries()]
219          .filter(([, min]) => min > 0)
220          .sort((a, b) => b[1] - a[1] || (a[0] < b[0] ? -1 : 1))
221          .slice(0, 3)
222          .map(([name, min]) => ({ name, min, share: min / totalMin }))
223      : []
224
225  return {
226    start,
227    weeks: opts.weeks,
228    today: opts.today,
229    days,
230    streak: streaks(new Set([...byDay.entries()].filter(([, t]) => t.s > 0 || t.min > 0).map(([d]) => d)), opts.today),
231    totalMin,
232    sessions,
233    peakDay: argmax(weekdayMin),
234    peakHour: argmax(hourMin),
235    top,
236  }
237}
238
239/**
240 * The current streak runs back from today, or from yesterday while today has no
241 * activity yet; the best is the longest run of active days anywhere.
242 */
243export function streaks(active: ReadonlySet<string>, today: string): { current: number; best: number } {
244  let current = 0
245  for (let d = active.has(today) ? today : addDays(today, -1); active.has(d); d = addDays(d, -1)) current++
246  let best = 0
247  for (const d of active) {
248    if (active.has(addDays(d, -1))) continue // not the start of a run
249    let n = 0
250    for (let x = d; active.has(x); x = addDays(x, 1)) n++
251    best = Math.max(best, n)
252  }
253  return { current, best: Math.max(best, current) }
254}
255
256function topOf(projects: Map<string, number>): string | null {
257  let best: string | null = null
258  let most = -1
259  for (const [name, min] of projects) {
260    if (min > most || (min === most && best !== null && name < best)) {
261      best = name
262      most = min
263    }
264  }
265  return best
266}
267
268function argmax(xs: readonly number[]): number | null {
269  let at: number | null = null
270  xs.forEach((x, i) => {
271    if (x > 0 && (at === null || x > xs[at]!)) at = i
272  })
273  return at
274}
275
276/** The month names over the grid: each sits over the first week holding the 1st of its month, when there is room. */
277export function monthLabels(start: string, weeks: number): string {
278  let out = ''
279  for (let w = 0; w < weeks; w++) {
280    const monday = addDays(start, w * 7)
281    const hasFirst = w === 0 ? false : Array.from({ length: 7 }, (_, i) => addDays(monday, i)).some(d => d.endsWith('-01'))
282    if (!hasFirst || out.length > w * 2) continue
283    const sunday = addDays(monday, 6)
284    const name = MONTHS[Number(sunday.slice(5, 7)) - 1]!
285    out = out.padEnd(w * 2) + name
286  }
287  return out.slice(0, weeks * 2)
288}
289
290export type Ramp = 'dark' | 'light' | 'auto'
291
292/**
293 * The color ramp for a saved theme (`dark…` or `light…`). Auto, or a theme we
294 * can't read, says nothing about the background, so it gets the ramp that reads
295 * the same on both.
296 */
297export function rampFor(theme: unknown): Ramp {
298  if (typeof theme !== 'string') return 'auto'
299  return theme.startsWith('light') ? 'light' : theme.startsWith('dark') ? 'dark' : 'auto'
300}
301
302/** 3h 12m, 47m, 0m. */
303export function duration(min: number): string {
304  const h = Math.floor(min / 60)
305  const m = min % 60
306  return h === 0 ? `${m}m` : m === 0 ? `${h}h` : `${h}h ${m}m`
307}
308
309/** Tue 14 Oct, from fixed names so every machine says the same. */
310export function dayLabel(day: string): string {
311  return `${WEEKDAYS[weekday(day)]} ${Number(day.slice(8, 10))} ${MONTHS[Number(day.slice(5, 7)) - 1]}`
312}
313
314export function weekdayName(i: number): string {
315  return WEEKDAYS[i]!
316}
317
318/** 22:00 */
319export function hourLabel(h: number): string {
320  return `${String(h).padStart(2, '0')}:00`
321}
322
323/** 1,204 */
324export function thousands(n: number): string {
325  return String(n).replace(/\B(?=(\d{3})+(?!\d))/g, ',')
326}
327
328/** The last part of a project's root, or its folder name when there is no root. */
329export function projectName(cwd: string | null, folder: string): string {
330  const parts = (cwd ?? '').split(/[\\/]/).filter(Boolean)
331  return parts[parts.length - 1] ?? folder
332}
333
334/** Message times from `grep -o '"timestamp":"…"'` output. */
335export function parseTimes(grepOutput: string): number[] {
336  const out: number[] = []
337  for (const m of grepOutput.matchAll(/"timestamp": *"([^"]+)"/g)) {
338    const ms = Date.parse(m[1]!)
339    if (Number.isFinite(ms)) out.push(ms)
340  }
341  return out
342}
343
types/index.d.ts 8 lines
1export type Scope = 'all' | 'here'
2
3declare module 'claude-code' {
4  interface PluginState {
5    'heatmap': { scope: Scope; version: number; isLoading: boolean }
6  }
7}
8