Bruno DelorenceBruno
Appearance
Appearance

Choose your reading theme.

Contact

Coverage heatmap

New

An 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.tsx
Live · interactive

Quill 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:

Terminal
npx shadcn@latest add https://brunodelorence.com/r/coverage-heatmap.json

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

coverage-page.tsx
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.
PropTypeDefaultDescription
servicesService[]two services, five weeks eachServices 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.
soloSoloService[]three servicesServices 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.
autoplaybooleantrueWhile 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.
classNamestring—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.

coverage-heatmap.tsx
// 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.module.css
/* 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;
chart.tsx
// 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;
data.ts
// 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;
glyphs.tsx
// 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" />
demo-pointer.tsx
// 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`. */
use-autoplay.ts
// 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.