SLOPSHOPPER

stats-heatmap

A stats pane with a calendar-style heatmap of your Claude Code activity

newpanecommandtimer
v0.1.0MITupdated 2026-10-24knetic0/claude-stats-heatmap
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · stats-heatmap
│ ┃ Activity ✕ › fix the failing auth test and add an audit log call │ ┃ [ Messages ] [ Sessions ] [ Tool calls ] [ T │ ┃ ⏺ Read(src/auth.ts) │ ┃ May Jun Jul Aug ⎿ Read 6 lines │ ┃ Sep ⏺ Update(src/auth.ts) │ ┃ · · · · · · · · · · · · · · · · · · · · ⎿ Added 2 lines, removed 1 line │ ┃ Mon · · · · · · · · · · · · · · · · · · · · ⏺ Bash(bun test) │ ┃ · · · · · · · · · · · · · · · · · · · · ⎿ 3 pass, 1 fail │ ┃ Wed · · · · · · · · · · · · · · · · · · · · │ ┃ · · · · · · · · · · · · · · · · · · · · ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ Fri │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ Less ·████ More › /heatmap │ ┃ ⎿ stats-heatmap: Activity heatmap opened. │ ┃ 0 messages in 26 weeks · 0 all time │ ┃ 0 active days · streak 0 · longest 0 │ ┃ No stats-cache.json yet: counted from │ ┃ transcripts alone. │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Activity
[ Messages ] [ Sessions ] [ Tool calls ] [ Tokens ] [ Refres May Jun Jul Aug Sep · · · · · · · · · · · · · · · · · · · · · · · · · · Mon · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · Wed · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · Fri Less ·████ More 0 messages in 26 weeks · 0 all time 0 active days · streak 0 · longest 0 No stats-cache.json yet: counted from transcripts alone.
README

stats-heatmap

The /heatmap pane in Claude Code: a year of daily token use as orange cells, with metric buttons above and totals, streaks, busiest day and top model below

A Claude Code mod that adds a stats pane with a calendar-style heatmap of your activity, GitHub contribution-graph style.

Run /heatmap to open it. The pane shows:

  • A heatmap of the last weeks (up to 53, as many as the pane is wide), one cell per day, Sunday to Saturday.
  • Buttons to switch metric: Messages (m), Sessions (s), Tool calls (t), Tokens (k), and Refresh (r).
  • Totals for the range shown and all time, active days, current and longest streak, busiest day, and top model by tokens.

In the terminal the grid is drawn as colored cells; in the desktop app and VS Code it is an SVG with a tooltip per day.

Where the numbers come from

  • ~/.claude/stats-cache.json: the daily totals Claude Code computes for its own stats, up to its lastComputedDate.
  • Days after that (usually today) are counted live from the session transcripts in ~/.claude/projects/, the same way the cache counts them. Transcripts over 4 MiB are skipped and the pane says how many.

The data refreshes when you run /heatmap, press Refresh, and every 5 minutes.

Install

At the prompt of a Claude Code terminal session:

/plugin install stats-heatmap --marketplace knetic0/claude-stats-heatmap

Answer y to add the marketplace, then press Enter to pick the user scope. The mod is active right away, and in every session you start after. Then run /heatmap.

From a shell instead:

claude plugin install stats-heatmap --marketplace knetic0/claude-stats-heatmap

In the desktop app, where /plugin does not run: click + next to the prompt box, then Plugins, Add marketplace with knetic0/claude-stats-heatmap, then Add plugin.

Mods are an early-access Claude Code feature. This one was built and tested on Claude Code 2.1.310.

Developing

Clone the repository and load your working copy for one session:

claude --plugin-dir /path/to/claude-stats-heatmap

Or for every session, add the folder to CLAUDE_CODE_PLUGIN_DIRS in the env block of ~/.claude/settings.json:

{ "env": { "CLAUDE_CODE_PLUGIN_DIRS": "/path/to/claude-stats-heatmap" } }

Check and test it:

claude plugin validate .
claude plugin test .
  • hooks/register.tsx: the command, the pane and its drawing.
  • hooks/stats.ts: reading the cache and transcripts, the grid, the summary.
  • types/index.d.ts: the state the pane draws from.
Source 3 files
hooks/register.tsx 282 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Metric, Stats } from '../types'
5import {
6  METRICS,
7  addDays,
8  buildGrid,
9  dateStart,
10  emptyDay,
11  formatDate,
12  formatNumber,
13  levelsOf,
14  localDate,
15  monthRow,
16  parseCache,
17  scanTranscript,
18  summarize,
19  topModel,
20} from './stats'
21import type { Cell, Tally } from './stats'
22
23const PANE = 'stats-heatmap'
24const REFRESH_MS = 5 * 60_000
25
26// Change the tag when Stats' fields change: it declines what an earlier
27// version stored, which outlives a reload.
28const stats = atom({ plugin: 'stats-heatmap', key: 'stats' } as const, null, { shape: 'stats-1' })
29const metric = atom({ plugin: 'stats-heatmap', key: 'metric' } as const, 'messages')
30
31// Empty, then four levels of Claude's terracotta.
32const LEVELS: [string, ...string[]] = ['#6b7280', '#5a2d1f', '#93432c', '#c9603d', '#f08a62']
33const DEFAULT_COLOR = 0x01000000
34const BLOCK = 0x2588
35const DOT = 0x00b7
36const SPACE = 0x20
37
38const DAY_LABELS = [' ', 'Mon', ' ', 'Wed', ' ', 'Fri', ' ']
39
40const isJsonl = (name: string) => name.endsWith('.jsonl')
41
42const list = async ($: EngineInterface, path: string) => {
43  try {
44    return await $.fs.list(path)
45  } catch {
46    return []
47  }
48}
49
50// stats-cache.json covers whole days up to its lastComputedDate; the days
51// after it (usually today) are counted from the transcripts changed since.
52const load = async ($: EngineInterface) => {
53  const root = `${await $.env.get('HOME')}/.claude`
54  let tally: Tally = { days: new Map(), through: null, models: {} }
55  let error: string | null = null
56  try {
57    tally = parseCache(await $.fs.read(`${root}/stats-cache.json`))
58  } catch {
59    error = 'No stats-cache.json yet: counted from transcripts alone.'
60  }
61
62  const since = tally.through ? dateStart(addDays(tally.through, 1)) : 0
63  let skipped = 0
64  const scan = async (path: string, isSession: boolean) => {
65    try {
66      scanTranscript(await $.fs.read(path), tally.through, tally, isSession)
67    } catch {
68      skipped += 1
69    }
70  }
71
72  for (const project of await list($, `${root}/projects`)) {
73    if (project.kind !== 'dir') continue
74    const dir = `${root}/projects/${project.name}`
75    for (const entry of await list($, dir)) {
76      if (entry.kind === 'file' && isJsonl(entry.name) && entry.mtimeMs >= since) {
77        await scan(`${dir}/${entry.name}`, true)
78      } else if (entry.kind === 'dir') {
79        for (const sub of await list($, `${dir}/${entry.name}/subagents`)) {
80          if (sub.kind === 'file' && isJsonl(sub.name) && sub.mtimeMs >= since) {
81            await scan(`${dir}/${entry.name}/subagents/${sub.name}`, false)
82          }
83        }
84      }
85    }
86  }
87
88  const value: Stats = {
89    days: [...tally.days.values()],
90    through: tally.through,
91    models: tally.models,
92    skipped,
93    error,
94    loadedAt: await $.clock.now(),
95  }
96  await update($, stats, () => value)
97}
98
99const hex = (color: string) => parseInt(color.slice(1), 16)
100
101const toBase64 = (words: Uint32Array) => {
102  const bytes = new Uint8Array(words.buffer)
103  let binary = ''
104  for (let i = 0; i < bytes.length; i += 0x8000) {
105    binary += String.fromCharCode(...bytes.subarray(i, i + 0x8000))
106  }
107  return btoa(binary)
108}
109
110// One cell per day, a blank column between weeks.
111const rasterCells = (cells: Cell[][], level: (v: number) => number) => {
112  const columns = cells.length * 2 - 1
113  const words = new Uint32Array(columns * 7 * 3)
114  for (let d = 0; d < 7; d++) {
115    for (let x = 0; x < columns; x++) {
116      const at = (d * columns + x) * 3
117      const cell = x % 2 === 0 ? cells[x / 2]?.[d] : undefined
118      const n = cell && !cell.isFuture ? level(cell.value) : -1
119      words[at] = n < 0 ? SPACE : n === 0 ? DOT : BLOCK
120      words[at + 1] = n < 0 ? DEFAULT_COLOR : hex(LEVELS[n] ?? LEVELS[0])
121      words[at + 2] = DEFAULT_COLOR
122    }
123  }
124  return { columns, cells: toBase64(words) }
125}
126
127const svgHeatmap = (cells: Cell[][], level: (v: number) => number, unit: string) => {
128  const size = 11
129  const step = 14
130  const left = 30
131  const top = 18
132  const width = left + cells.length * step
133  const height = top + 7 * step
134  const label = (x: number, y: number, text: string) =>
135    `<text x="${x}" y="${y}" font-size="10" font-family="system-ui, sans-serif" fill="#8b949e">${text}</text>`
136
137  const parts: string[] = []
138  const months = monthRow(cells, 1)
139  for (let w = 0; w < months.length; w++) {
140    if (months[w] !== ' ' && (w === 0 || months[w - 1] === ' ')) {
141      parts.push(label(left + w * step, 11, months.slice(w, w + 3)))
142    }
143  }
144  DAY_LABELS.forEach((text, d) => {
145    if (text.trim()) parts.push(label(0, top + d * step + 9, text))
146  })
147  cells.forEach((week, w) =>
148    week.forEach((cell, d) => {
149      if (cell.isFuture) return
150      const n = level(cell.value)
151      const fill = n === 0 ? `fill="${LEVELS[0]}" fill-opacity="0.18"` : `fill="${LEVELS[n]}"`
152      parts.push(
153        `<rect x="${left + w * step}" y="${top + d * step}" width="${size}" height="${size}" rx="2" ${fill}>` +
154          `<title>${formatDate(cell.date)}: ${formatNumber(cell.value)} ${unit}</title></rect>`,
155      )
156    }),
157  )
158
159  return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" width="${width}" height="${height}">${parts.join('')}</svg>`
160}
161
162export const register: Register = on => {
163  on('session.start', async ($, e, next) => {
164    await $.command.register({
165      name: 'heatmap',
166      description: 'Show your Claude Code activity as a calendar heatmap',
167    })
168    void load($)
169    $.clock.every(REFRESH_MS, () => void load($))
170
171    return next(e)
172  })
173
174  on('command.run', { command: 'heatmap' }, async $ => {
175    await load($)
176    await $.ui.open({ id: PANE, title: 'Activity', columns: 64, rows: 15 })
177
178    return { text: 'Activity heatmap opened.' }
179  })
180
181  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
182    const { Box, Text, Button } = $.ui.resolve(e)
183    const data = await read($, stats)
184    const chosen = await read($, metric)
185
186    if (data === null) return <Text dimColor>Reading your activity…</Text>
187
188    const today = localDate(await $.clock.now())
189    const unit = (METRICS.find(m => m.id === chosen)?.label ?? chosen).toLowerCase()
190    const weeks = e.surface === 'terminal' ? Math.max(4, Math.min(53, Math.floor((e.props.bodyColumns - 3) / 2))) : 53
191    const cells = buildGrid(data.days, chosen, weeks, today)
192    const level = levelsOf(cells)
193    const all = summarize(data.days, chosen, today)
194    const shown = summarize(
195      cells.flat().map(c => ({ ...emptyDay(c.date), [chosen]: c.value })),
196      chosen,
197      today,
198    )
199    const model = topModel(data.models)
200    const pick = (id: Metric) => () => update($, metric, () => id)
201
202    let heatmap
203    if (e.surface === 'terminal') {
204      const { Raster } = $.ui.resolve(e)
205      heatmap = (
206        <Box flexDirection="column">
207          <Text dimColor>{'    ' + monthRow(cells, 2)}</Text>
208          <Box>
209            <Box flexDirection="column" width={4}>
210              {DAY_LABELS.map(text => (
211                <Text dimColor>{text}</Text>
212              ))}
213            </Box>
214            <Raster key="grid" rows={7} {...rasterCells(cells, level)} />
215          </Box>
216        </Box>
217      )
218    } else {
219      const { Svg } = $.ui.resolve(e)
220      heatmap = (
221        <Svg
222          source={svgHeatmap(cells, level, unit)}
223          alt={`${formatNumber(shown.total)} ${unit} over the last ${weeks} weeks`}
224          isInteractive
225        />
226      )
227    }
228
229    return (
230      <Box flexDirection="column" gap={1}>
231        <Box flexWrap="wrap" columnGap={1}>
232          {METRICS.map(m => (
233            <Button
234              key={m.id}
235              label={m.label}
236              hotkey={m.hotkey}
237              variant={m.id === chosen ? 'primary' : 'secondary'}
238              onPress={pick(m.id)}
239            />
240          ))}
241          <Button key="refresh" label="Refresh" hotkey="r" onPress={() => load($)} />
242        </Box>
243        {heatmap}
244        <Box>
245          <Text dimColor>Less </Text>
246          {LEVELS.map((color, n) => (
247            <Text color={color} dimColor={n === 0}>
248              {n === 0 ? '·' : '█'}
249            </Text>
250          ))}
251          <Text dimColor> More</Text>
252        </Box>
253        <Box flexDirection="column">
254          <Text bold>
255            {`${formatNumber(shown.total)} ${unit} in ${weeks} weeks · ${formatNumber(all.total)} all time`}
256          </Text>
257          <Text>
258            {all.activeDays} active days · streak {all.streak} · longest {all.longestStreak}
259          </Text>
260          {all.best && (
261            <Text>
262              Busiest day: {formatDate(all.best.date)} ({formatNumber(all.best[chosen])} {unit})
263            </Text>
264          )}
265          {model && (
266            <Text>
267              Top model: {model.name} ({model.share}% of tokens)
268            </Text>
269          )}
270          <Text dimColor>
271            {data.error ??
272              (data.through
273                ? `Cached through ${formatDate(data.through)}, later days live from transcripts.`
274                : 'Counted from transcripts.')}
275            {data.skipped > 0 ? ` ${data.skipped} transcripts too large to read were left out.` : ''}
276          </Text>
277        </Box>
278      </Box>
279    )
280  })
281}
282
hooks/stats.ts 258 lines
1import type { Day, Metric } from '../types'
2
3export const METRICS: { id: Metric; label: string; hotkey: string }[] = [
4  { id: 'messages', label: 'Messages', hotkey: 'm' },
5  { id: 'sessions', label: 'Sessions', hotkey: 's' },
6  { id: 'tools', label: 'Tool calls', hotkey: 't' },
7  { id: 'tokens', label: 'Tokens', hotkey: 'k' },
8]
9
10const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
11
12const pad = (n: number) => String(n).padStart(2, '0')
13
14// Year, month (1-12) and day of a YYYY-MM-DD date.
15const partsOf = (date: string) => {
16  const [y = 1970, m = 1, d = 1] = date.split('-').map(Number)
17  return { y, m, d }
18}
19
20export const localDate = (ms: number) => {
21  const d = new Date(ms)
22  return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
23}
24
25// Midnight local time of a YYYY-MM-DD date.
26export const dateStart = (date: string) => {
27  const { y, m, d } = partsOf(date)
28  return new Date(y, m - 1, d).getTime()
29}
30
31export const addDays = (date: string, n: number) => {
32  const { y, m, d } = partsOf(date)
33  return localDate(new Date(y, m - 1, d + n).getTime())
34}
35
36export const emptyDay = (date: string): Day => ({
37  date,
38  messages: 0,
39  sessions: 0,
40  tools: 0,
41  tokens: 0,
42})
43
44type Cache = {
45  lastComputedDate?: string
46  dailyActivity?: { date: string; messageCount?: number; sessionCount?: number; toolCallCount?: number }[]
47  dailyModelTokens?: { date: string; tokensByModel?: Record<string, number> }[]
48  modelUsage?: Record<
49    string,
50    {
51      inputTokens?: number
52      outputTokens?: number
53      cacheReadInputTokens?: number
54      cacheCreationInputTokens?: number
55    }
56  >
57}
58
59export type Tally = {
60  days: Map<string, Day>
61  through: string | null
62  models: Record<string, number>
63}
64
65const dayOf = (tally: Tally, date: string) => {
66  let day = tally.days.get(date)
67  if (!day) {
68    day = emptyDay(date)
69    tally.days.set(date, day)
70  }
71  return day
72}
73
74// Reads ~/.claude/stats-cache.json, the file Claude Code's own stats are computed into.
75export const parseCache = (text: string): Tally => {
76  const cache = JSON.parse(text) as Cache
77  const tally: Tally = { days: new Map(), through: cache.lastComputedDate ?? null, models: {} }
78
79  for (const row of cache.dailyActivity ?? []) {
80    const day = dayOf(tally, row.date)
81    day.messages = row.messageCount ?? 0
82    day.sessions = row.sessionCount ?? 0
83    day.tools = row.toolCallCount ?? 0
84  }
85  for (const row of cache.dailyModelTokens ?? []) {
86    const byModel = row.tokensByModel ?? {}
87    dayOf(tally, row.date).tokens = Object.values(byModel).reduce((a, b) => a + b, 0)
88  }
89  for (const [model, u] of Object.entries(cache.modelUsage ?? {})) {
90    tally.models[model] =
91      (u.inputTokens ?? 0) +
92      (u.outputTokens ?? 0) +
93      (u.cacheReadInputTokens ?? 0) +
94      (u.cacheCreationInputTokens ?? 0)
95  }
96
97  return tally
98}
99
100type Entry = {
101  type?: string
102  timestamp?: string
103  message?: {
104    model?: string
105    content?: unknown
106    usage?: {
107      input_tokens?: number
108      output_tokens?: number
109      cache_read_input_tokens?: number
110      cache_creation_input_tokens?: number
111    }
112  }
113}
114
115// Adds one transcript's entries dated after `after` to the tally, counting the
116// way stats-cache.json does: user, assistant and attachment rows are messages,
117// tool_use blocks are tool calls, and tokens include cache reads and writes.
118// A transcript counts as a session on the day of its first entry.
119export const scanTranscript = (text: string, after: string | null, tally: Tally, isSession: boolean) => {
120  let first: string | null = null
121
122  for (const line of text.split('\n')) {
123    if (!line.includes('"timestamp"')) continue
124    let entry: Entry
125    try {
126      entry = JSON.parse(line)
127    } catch {
128      continue
129    }
130    if (!entry.timestamp) continue
131    const ms = Date.parse(entry.timestamp)
132    if (Number.isNaN(ms)) continue
133    const date = localDate(ms)
134    first ??= date
135    if (after !== null && date <= after) continue
136    if (entry.type !== 'user' && entry.type !== 'assistant' && entry.type !== 'attachment') continue
137
138    const day = dayOf(tally, date)
139    day.messages += 1
140    if (entry.type !== 'assistant' || !entry.message) continue
141
142    const content = entry.message.content
143    if (Array.isArray(content)) {
144      day.tools += content.filter(b => b && typeof b === 'object' && b.type === 'tool_use').length
145    }
146    const u = entry.message.usage
147    if (u) {
148      const tokens =
149        (u.input_tokens ?? 0) +
150        (u.output_tokens ?? 0) +
151        (u.cache_read_input_tokens ?? 0) +
152        (u.cache_creation_input_tokens ?? 0)
153      day.tokens += tokens
154      const model = entry.message.model
155      if (model && model !== '<synthetic>') tally.models[model] = (tally.models[model] ?? 0) + tokens
156    }
157  }
158
159  if (isSession && first !== null && (after === null || first > after)) dayOf(tally, first).sessions += 1
160}
161
162export type Cell = { date: string; value: number; isFuture: boolean }
163
164// Weeks run Sunday to Saturday, the last column holding today.
165export const buildGrid = (days: Day[], metric: Metric, weeks: number, today: string): Cell[][] => {
166  const byDate = new Map(days.map(d => [d.date, d[metric]]))
167  const weekday = new Date(dateStart(today)).getDay()
168  const start = addDays(today, -weekday - (weeks - 1) * 7)
169
170  return Array.from({ length: weeks }, (_, w) =>
171    Array.from({ length: 7 }, (_, d) => {
172      const date = addDays(start, w * 7 + d)
173      return { date, value: byDate.get(date) ?? 0, isFuture: date > today }
174    }),
175  )
176}
177
178// Four levels, split at the quartiles of the days with any activity.
179export const levelsOf = (cells: Cell[][]) => {
180  const values = cells
181    .flat()
182    .map(c => c.value)
183    .filter(v => v > 0)
184    .sort((a, b) => a - b)
185  const at = (q: number) => values[Math.min(values.length - 1, Math.floor(q * values.length))] ?? 0
186  const cuts = [at(0.25), at(0.5), at(0.75)]
187
188  return (value: number) => (value <= 0 ? 0 : 1 + cuts.filter(c => value > c).length)
189}
190
191// Month names over the first week that starts in each month, where they fit.
192export const monthRow = (cells: Cell[][], stride: number) => {
193  const width = cells.length * stride
194  const row = Array<string>(width).fill(' ')
195  let free = 0
196  cells.forEach((week, w) => {
197    const { m, d } = partsOf(week[0]?.date ?? '')
198    const at = w * stride
199    if (d <= 7 && at >= free && at + 3 <= width) {
200      ;[...(MONTHS[m - 1] ?? '')].forEach((ch, i) => (row[at + i] = ch))
201      free = at + 4
202    }
203  })
204  return row.join('').trimEnd()
205}
206
207export type Summary = {
208  total: number
209  activeDays: number
210  streak: number
211  longestStreak: number
212  best: Day | null
213}
214
215export const summarize = (days: Day[], metric: Metric, today: string): Summary => {
216  const active = days.filter(d => d[metric] > 0 && d.date <= today).sort((a, b) => (a.date < b.date ? -1 : 1))
217  let longestStreak = 0
218  let run = 0
219  let prev: string | null = null
220  for (const day of active) {
221    run = prev !== null && addDays(prev, 1) === day.date ? run + 1 : 1
222    longestStreak = Math.max(longestStreak, run)
223    prev = day.date
224  }
225  // A streak still counts while today has nothing yet.
226  const last = active.at(-1)?.date
227  const streak = last === today || last === addDays(today, -1) ? run : 0
228
229  return {
230    total: active.reduce((sum, d) => sum + d[metric], 0),
231    activeDays: active.length,
232    streak,
233    longestStreak,
234    best: active.reduce<Day | null>((b, d) => (b === null || d[metric] > b[metric] ? d : b), null),
235  }
236}
237
238export const formatNumber = (n: number) => {
239  if (n >= 1e9) return `${(n / 1e9).toFixed(1)}B`
240  if (n >= 1e6) return `${(n / 1e6).toFixed(1)}M`
241  if (n >= 1e4) return `${(n / 1e3).toFixed(1)}k`
242  return String(n).replace(/\B(?=(\d{3})+(?!\d))/g, ',')
243}
244
245export const formatDate = (date: string) => {
246  const { m, d } = partsOf(date)
247  return `${MONTHS[m - 1]} ${d}`
248}
249
250export const modelName = (model: string) => model.replace(/^claude-/, '').replace(/-\d{8}$/, '')
251
252export const topModel = (models: Record<string, number>) => {
253  const total = Object.values(models).reduce((a, b) => a + b, 0)
254  const [name, tokens] = Object.entries(models).sort((a, b) => b[1] - a[1])[0] ?? []
255  if (!name || !tokens || total === 0) return null
256  return { name: modelName(name), share: Math.round((tokens / total) * 100) }
257}
258
types/index.d.ts 27 lines
1export type Metric = 'messages' | 'sessions' | 'tools' | 'tokens'
2
3export type Day = {
4  date: string
5  messages: number
6  sessions: number
7  tools: number
8  tokens: number
9}
10
11export type Stats = {
12  days: Day[]
13  // The last day stats-cache.json covers; later days are counted from transcripts.
14  through: string | null
15  models: Record<string, number>
16  // Transcripts too large to read (over 4 MiB), left out of the live count.
17  skipped: number
18  error: string | null
19  loadedAt: number
20}
21
22declare module 'claude-code' {
23  interface PluginState {
24    'stats-heatmap': { stats: Shaped<Stats | null>; metric: Metric }
25  }
26}
27