Coverage heatmap
NewAn analytics panel of AI-tool accounts: one heatmap row per week of each account’s use and of when none was free, with a step chart and per-week bars for the selected week.
coverage-heatmap/coverage-heatmap.tsxQuill had no account free for 8 working hours last week
Friday from 11:00, after Quill Max and then Quill Pro Side ran out. Loom always had an account free.
Quill
Loom
- Facet ProOne account, no cover. Never ran outOne accountNo cover
- Burrow ProOne account, no cover. Never ran outOne accountNo cover
- Ridge ProOne account, no cover. Never ran outOne accountNo cover
Useunder 25, 50, 75, 100%Runs outLockedReset
Accounts freeallonenonenone, work hoursOn paceNo reading or not yet
One row per week, Mon to Sun, 6 h cells. Select a row for detail.
The live component, numbered part by part. Every measurement is read from it as it is drawn. Point at a row to outline its part, or at a part to find its row.
When to use
- Answering “was something always available?” over weeks: a pool of accounts, seats, licences or on-call people, where the gaps matter more than the totals.
- A resource with a main supply and a back-up, where the reader needs to see when one handed over to the other and how long the back-up lasted.
- Time that repeats by the week, where a weekday-by-hour grid makes the pattern jump out: the same Thursday run-out, the same red Friday morning.
- Telling the hours that hurt (work hours with nothing free) from the ones that don’t, and forecasting the week in progress from its pace.
- A review screen read row by row, where selecting a week explains it in a sentence, a chart and three figures.
When not to use
- A single number over time (spend, requests, tokens). Use a line or area chart with a period picker.
- Live status right now: which account is free this minute. Use a status list with badges and a last-updated time.
- More than a handful of services or weeks. Rows of 28 cells stop reading past ten or so; use a table with a sparkline per row.
- Resources with no limit to run out of. Without run-outs and resets, the lanes are just greys; a usage chart says the same in less room.
- Exact hourly figures. The cells are 6-hour buckets of use by quarter; export the readings to a table for that.
- A phone-first screen. Under 940 px the weeks scroll sideways; give a phone a list of weeks with their summaries instead.
You can add this component with the shadcn CLI or by hand:
Into components/coverage-heatmap of any React project, with no setup:
npx shadcn@latest add https://brunodelorence.com/r/coverage-heatmap.json- Copy the component files
Copy these files into one folder of your project, keeping their relative imports together, or download them as a ZIP:
- Import it
As shown under Usage, below.
Either way it needs React 19 and a bundler that supports CSS Modules, such as Next.js or Vite. It has a dark colour scheme: it follows your page’s color-scheme, light unless the page sets one, or its colorScheme prop, with nothing to configure. Browsers without light-dark() show it light.
Usage
import { CoverageHeatmap } from './coverage-heatmap';export function CoveragePage() { return ( <CoverageHeatmap defaultTab="weeks" defaultSelection={{ service: 'quill', week: 'oct-5' }} onSelect={({ service, week }) => console.log('Selected', service, week)} autoplay={false} /> );}Each state below is a live instance, locked so you can study it.
The pieces it is built from, each with its measurements read live.
Each service with a main account and a back-up gets one row per week, Monday to Sunday in 6-hour cells, in three lanes: the main account’s use, the back-up’s and how many accounts were free. Greys show use by quarter, hatching a locked account waiting for its reset, red the hours none was free (bright in work hours); red dots mark run-outs, white ticks resets, and the week in progress shows its forecast as hollow dots and a dashed outline. Selecting a week fills the detail: a sentence, a step chart of both accounts with the gap hatched, three figures and a bar per week. Hover a cell for its tooltip, or switch to Hand-off for one track per week of which account was serving. Light and dark.
- A week is three strings of 28 codes, one per 6-hour cell: 1 to 4 for use by quarter, x for locked, o for the cell it ran out in, a dot for no reading; the free lane is all, one, none or none in work hours. Each code is one data attribute, so the stylesheet draws every cell: four greys, a hatch of repeating-linear-gradient, a dotted outline.
- Run-outs, resets, the present moment and the forecast are hours of the week. Each is placed with two custom properties, its day and its share of that day, so a dot sits where its time falls inside its cell and the 4 px gaps between days never shift it.
- The right column of each row is written from the data: the work hours with none free and the hand-off span, “Covered” with the peaks when nothing ran out, or “On pace” for the week in progress.
- Selecting a week (its row, or its bar in the detail) redraws the step chart: an SVG in its own 380-unit box, the main account a white line over a soft fill that turns red while it waits for its reset, the back-up a thin grey one, a red dot and a time over each run-out, work hours as lighter bands and the stretch with no account free hatched with its length written in it. The week in progress is dashed after the present moment.
- Hovering a cell (or ← and → on the focused week) shows one tooltip for the slot: both accounts’ use, locked until when, how many were free and any forecast run-out, the lane under the pointer at full strength.
- Hand-off folds each week into one track of which account was serving, hour by hour, with red dots at the hand-offs; its right column says when the back-up took over and how long it lasted.
- The panel is laid out at 1200 px. Wider, the heatmap grows; from 940 to 1200 px it is scaled whole with CSS zoom, so the two columns and their type keep their proportions; narrower, the detail goes under the weeks and the weeks scroll sideways inside their column with the labels pinned.
- While on screen and untouched, a demo pointer hovers the run-out and the red work hours, selects a week from its row and another from the bars, opens Hand-off and hovers a track, then selects the other service. Any real input hands it over; it comes back after 9 s of quiet.
| Prop | Type | Default | Description |
|---|---|---|---|
services | Service[] | two services, five weeks each | Services with a main account and a back-up: name, glyph, both accounts (name and plan) and their weeks. A week has its three lanes of 28 codes, run-outs and resets in hours from Monday 00:00, the work hours with none free and covered, peaks and the chart’s usage curves. |
solo | SoloService[] | three services | Services with a single account, one quiet row each: name, glyph and a note. |
headline | { title: string; lede: string } | — | The headline sentence and the line under it. |
defaultTab | 'weeks' | 'handoff' | 'weeks' | The view at first: the heatmap, or one hand-off track per week. |
defaultSelection | { service: string; week: string } | { service: 'quill', week: 'oct-5' } | The week whose detail shows at first. |
onSelect | (selection: { service: string; week: string }) => void | — | Called when a week is selected, from its row or its bar. |
defaultTooltip | { service: string; week: string; slot: number; lane?: 0 | 1 | 2 } | — | Opens with the tooltip of one slot showing (0 to 27, from Monday 00:00 in 6-hour steps), the lane under the pointer at full strength (0 main, 1 back-up, 2 free; all when left out). The demo stays off. |
autoplay | boolean | true | While on screen and untouched, a demo pointer shows the main interactions. Any real input stops it. |
colorScheme | 'light' | 'dark' | 'system' | — | Light or dark colours, or the reader’s; without it, the page’s color-scheme. |
className | string | — | Extra class for the root. |
The complete source behind this demo. Open source under the MIT license, © 2026 Bruno Delorence: copy it, change it, ship it.
// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import { useCallback, useEffect, useEffectEvent, useId, useLayoutEffect, useMemo, useRef, useState, type CSSProperties, type KeyboardEvent as ReactKeyboardEvent, type PointerEvent as ReactPointerEvent,} from 'react';import { StepChart } from './chart';import { DAY_NAMES, HEADLINE, SERVICES, SOLO, WORK,/* Coverage heatmap (coverage-heatmap.tsx): a near-black analytics panel by night, white by day. Use is drawn in four greys, a locked account in red hatching, the hours no account was free in a dark red (a bright one in work hours), run-outs as red dots and resets as white ticks. Colours are the tokens below, light-dark(light, dark) where the two differ: the component takes the color-scheme it inherits from the page (light unless the page says otherwise), or the one its colorScheme prop sets (data-color-scheme). Sizes are those of the panel at 1200 px: a 720 px column of weeks (135 px of labels, the heatmap, 155 px of summaries), a 380 px detail column, 46 px week rows, 10 px lanes. From 940 to 1200 px the component scales the panel as a whole (zoom, set by the script); under 940 px the detail goes under the weeks and the weeks scroll sideways, their labels pinned. Memory. The cells are plain boxes with a background; the tooltip, the dots and the ticks sit over them by position. A selection or a tab switch only restyles and repaints. */.root { --card: #fff; --edge: #e2e2e2; --line: #ececec; --rule: #ececec; --row-hover: #fafafa; --row-on: #f5f5f5; --ink: #141414;// Copyright (c) 2026 Bruno Delorence. MIT License.import { useId } from 'react';import { DAY_NAMES, WINDOW, WORK, span, when, type Knot, type Service, type Week } from './data';import styles from './coverage-heatmap.module.css';/* * The detail's step chart: both accounts' use from Wednesday to Sunday noon. The main account's * line is white over a soft fill, the back-up's a thin grey one; where the main account sits at * 100 % waiting for its reset the line turns red, a red dot marks each run-out with its time above, * and the stretch with no account free is hatched red with its length written in it. Work hours * are lighter bands. For the week in progress the lines after the present moment are dashed and * the dots hollow: a forecast. * * It is drawn in a 380 × 224 box of its own units (the panel's px at full size) and scales with * its column; the figures it shows are all in the text around it, so it is aria-hidden. */const W = 380;const H = 224;const X0 = 41;const X1 = 372.8;const Y100 = 35.3;const Y0 = 197.6;// Copyright (c) 2026 Bruno Delorence. MIT License./* * The panel's data: services with two accounts (a main one and a back-up), five weeks each, and * services with a single account. Every time is in hours from Monday 00:00 of its week (0–168). * * A week's heatmap is three lanes of 28 cells (Mon to Sun, 6 h each), written as strings of codes: * the two accounts' use, then how many accounts were free. * use: 1 2 3 4 under 25, 50, 75, 100 % o the cell where it ran out * x locked (ran out, waiting for its reset) * . no reading - not yet (later this week) * free: a all n one z none w none, during work hours * * Run-outs, resets, the present moment and the forecast are times, drawn where they fall inside * their cell. The usage curves of the detail chart are knots [hour, percent] of each account from * Wednesday 00:00 to Sunday 12:00 (the chart's window); `curve()` writes them for the weeks that * are not drawn by hand. */export type Knot = [hour: number, percent: number];export type Account = { /** Its name in the panel, e.g. "Max". */ name: string;// Copyright (c) 2026 Bruno Delorence. MIT License.import type { GlyphName } from './data';/* * The services' marks: small original glyphs, drawn on a 20-unit square. They are decorative (the * service's name is always beside them), so each is aria-hidden. Their size comes from the class * the panel gives them. */export function Glyph({ name, className }: { name: GlyphName; className?: string }) { return ( <svg viewBox="0 0 20 20" className={className} aria-hidden="true" data-glyph={name}> {name === 'spark' && ( // An eight-ray asterisk with alternating long and short rays. <g stroke="var(--spark)" strokeWidth="1.7" strokeLinecap="round"> <path d="M10 1.6v16.8M1.6 10h16.8M4.1 4.1l11.8 11.8M15.9 4.1 4.1 15.9" /> <path d="M6.6 2.6 7.6 5.6M13.4 17.4l-1-3M2.6 13.4l3-1M17.4 6.6l-3 1M13.4 2.6l-1 3M6.6 17.4l1-3M2.6 6.6l3 1M17.4 13.4l-3-1" /> </g> )} {name === 'rings' && ( // Two loops woven through each other. <g fill="none" stroke="var(--glyph)" strokeWidth="1.6"> <ellipse cx="7.6" cy="10" rx="5.4" ry="6.6" />// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import type { CSSProperties, Ref } from 'react';/* * The demo's pointer, and the steps that move it. A demo is a script of awaited steps: wait, or * glide the pointer somewhere. Stopping the demo (the reader takes over, or it scrolls away) ends * the step in progress at once: its promise rejects, which ends the script right there, and the * pointer fades out. * * Each glide is a curved flight played by the Web Animations API, so nothing runs frame by frame. * The points of the flight are handed to whoever wants to move along with it. The arrow and the hand are HTML boxes * that swap by their visibility: a change of style inside an SVG would lay the SVG out again. */export type Point = { x: number; y: number };/** A glide's path and timing, for an animation that plays along with it. */export type Flight = { points: Point[]; timing: KeyframeAnimationOptions };export type Demo = { /** Resolves after `ms`. */// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import { useCallback, useEffect, useRef, useState } from 'react';/* * When a demo may play on its own: while part of it is on screen and the tab is visible, never * under reduced motion, and not while the reader is using it. `take()` hands it to the reader; it * takes back over `idle` ms after their last input. */export function useAutoplay<T extends HTMLElement>(idle = 6000) { const ref = useRef<T>(null); const [visible, setVisible] = useState(false); const [reduced, setReduced] = useState(true); const [taken, setTaken] = useState(false); const timer = useRef(0); useEffect(() => { const element = ref.current; if (!element) return; let onScreen = false; const update = () => setVisible(onScreen && !document.hidden); const observer = new IntersectionObserver(- The headline is a heading; Weeks and Hand-off are a tablist (← and → move) controlling one tabpanel; each service is a heading over a list of its weeks, and the detail is a complementary region named after the selected week.
- Each week is a toggle button stretched over its row, named with the service, the week and its summary (“Quill, week of Oct 5: 8 h none free, Thu 17:00 → Fri 11:00”), with aria-pressed. The weeks are one tab stop: ↑ and ↓ move, Home and End jump, Enter or Space selects, ← and → walk the cells and read each slot out, Escape hides the tooltip.
- The per-week bars are toggle buttons too, each named with its figures. The cells, the chart and the swatches are drawn for the eye (aria-hidden); every figure they show is also in text. A selection and a walked slot are announced in a polite live region.
- Under reduced motion nothing animates and the demo never plays.