SLOPSHOPPER

jr-heatmap

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

newpaneguardcommandprompt
★ 2v0.1.0MITupdated 2026-10-15jrlprost/claude-code-mods/plugins/jr-heatmap
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · jr-heatmap
│ ┃ jr heatmap ✕ › fix the failing auth test and add an audit log call │ ┃ jr heatmap last 26 weeks · 1 prompts on 1 │ ┃ days ⏺ Read(src/auth.ts) │ ┃ Apr May Jun Jul Aug ⎿ Read 6 lines │ ┃ Sep ⏺ Update(src/auth.ts) │ ┃ Mon ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ⎿ Added 2 lines, removed 1 line │ ┃ ■ ■ ■ ■ ■ ■ ⏺ Bash(bun test) │ ┃ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ⎿ 3 pass, 1 fail │ ┃ ■ ■ ■ ■ ■ ■ │ ┃ Wed ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ ■ ■ ■ ■ ■ ■ │ ┃ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ✻ Worked for 42s · done 4:20 PM │ ┃ ■ ■ ■ ■ ■ ■ │ ┃ Fri ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ › /jrheatmap │ ┃ ■ ■ ■ ■ ■ ⎿ jr-heatmap: jr heatmap opened. │ ┃ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ │ ┃ ■ ■ ■ ■ ■ │ ┃ Sun ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ │ ┃ ■ ■ ■ ■ ■ │ ┃ less ■ ■ ■ ■ ■ more │ ┃ │ ┃ ✦ 1 day current streak 1 day longest │ ┃ hours ████████████████████████ peak 08:00 │ ┃ 0 6 12 18 23 │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · jr heatmap
jr heatmap last 26 weeks · 1 prompts on 1 days Apr May Jun Jul Aug Sep Mon ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ Wed ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ Fri ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ Sun ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ less ■ ■ ■ ■ ■ more ✦ 1 day current streak 1 day longest hours ████████████████████████ peak 08:00 0 6 12 18 23
README

jr-heatmap

/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
  • Each level is a quarter of your busiest day.
  • The current streak counts back from today, or from yesterday when you have not sent a prompt yet today.
  • The hours row adds up prompts per hour of the day over the 26 weeks.

Commands

Command
/jrheatmapOpen the heatmap

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

Install

/plugin install jr-heatmap --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. Sends nothing over the network.

Source 6 files
hooks/register.tsx 116 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 { 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}
116
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/heat.ts 80 lines
1import 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}
80
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
types/index.d.ts 16 lines
1export 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