Calendar heatmap A heatmap whose x-axis is the calendar, so weekly rhythm and holidays become visible. The empty cell has to differ from the zero cell or a quiet week reads as missing data. Tokens this needs: --card, --muted, --muted-foreground, --border, --scale-seq-1, --scale-seq-2, --scale-seq-3, --scale-seq-4, --scale-seq-5 The status, chart, scale, state and direction names are an extension, not a rename. shadcn has --destructive and five --chart-* and nothing else in this territory. ──────────────────────────────────────────────────────────────────────── // CalendarHeatmap.tsx ──────────────────────────────────────────────────────────────────────── /** * The layout is the encoding. Every row is one weekday and every column is * one week, so a weekend effect and a quiet fortnight are both visible from * the arrangement alone. Colour is bucketed on purpose: a handful of steps * makes the rhythm readable and makes individual days approximate, and the * exact value lives in the hover. * * `null` is a day with no data. It is drawn as an outline, because a day that * was not measured is a different fact from a day whose value was zero. */ const CELL = 10, GAP = 2, STEP = CELL + GAP; const DAY = 86_400_000; const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; const DAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; /** Full class names, because Tailwind only ships the ones it can see. */ const FILL = ["fill-muted", "fill-scale-seq-1", "fill-scale-seq-2", "fill-scale-seq-3", "fill-scale-seq-4", "fill-scale-seq-5"]; export function CalendarHeatmap({ start, values, thresholds, unit, weekStart = 0, onSelect }: { /** ISO date of values[0]. */ start: string; /** One entry per consecutive day. `null` means no sample arrived. */ values: (number | null)[]; /** Lower bound of each coloured step, ascending: five of them for a five-step ramp. * Required, so the legend can show real values rather than "less" and "more" alone. */ thresholds: number[]; unit: string; weekStart?: 0 | 1; onSelect?: (date: string, value: number | null) => void; }) { const t0 = Date.parse(start); const row = (t: number) => (new Date(t).getUTCDay() - weekStart + 7) % 7; const gridStart = t0 - row(t0) * DAY; const days = values.map((value, i) => { const t = t0 + i * DAY; return { t, value, col: Math.floor((t - gridStart) / (7 * DAY)), row: row(t) }; }); const cols = days[days.length - 1].col + 1; const bucket = (v: number) => thresholds.filter((th) => v >= th).length; const iso = (t: number) => new Date(t).toISOString().slice(0, 10); const monthStarts = days.filter((d) => new Date(d.t).getUTCDate() === 1 || d === days[0]); const left = 30, top = 16; const width = left + cols * STEP, height = top + 7 * STEP; return (
{monthStarts.map((d) => ( {MONTHS[new Date(d.t).getUTCMonth()]} ))} {[0, 2, 4, 6].map((r) => ( {DAYS[(r + weekStart) % 7]} ))} {days.map((d) => ( onSelect(iso(d.t), d.value))} > {`${iso(d.t)}: ${d.value === null ? "no data" : `${d.value} ${unit}`}`} ))}
Less {FILL.map((cls, i) => ( ))} More No data
); } ──────────────────────────────────────────────────────────────────────── // deploys.ts ──────────────────────────────────────────────────────────────────────── /** * Deploys per day for 2023, one row per week starting on Sunday, 1 January. * The two quiet columns in July are the fortnight the team was off. The * weekend rows are quiet all year, and nothing has to be labelled for the * reader to see either. */ export const START = "2023-01-01"; export const WEEKS: number[][] = [ [ 0, 0, 2, 0, 0, 2, 0], [ 0, 1, 3, 1, 1, 3, 0], [ 0, 1, 1, 3, 3, 3, 0], [ 0, 2, 2, 1, 3, 1, 0], [ 0, 1, 3, 3, 8, 3, 0], [ 0, 4, 2, 4, 7, 2, 0], [ 0, 1, 3, 2, 5, 2, 0], [ 2, 1, 8, 7, 2, 7, 0], [ 0, 2, 5, 1, 6, 6, 0], [ 1, 12, 1, 3, 5, 4, 3], [ 0, 7, 8, 5, 2, 11, 2], [ 3, 10, 10, 2, 4, 10, 3], [ 3, 12, 11, 12, 5, 5, 3], [ 1, 6, 16, 8, 3, 6, 1], [ 3, 11, 5, 6, 27, 11, 1], [ 2, 4, 8, 10, 6, 6, 3], [ 3, 30, 5, 10, 23, 11, 3], [ 2, 8, 14, 6, 15, 11, 2], [ 3, 12, 5, 14, 15, 8, 1], [ 1, 5, 4, 18, 25, 28, 1], [ 2, 20, 18, 25, 13, 8, 3], [ 1, 26, 17, 14, 21, 20, 3], [ 1, 28, 21, 20, 13, 23, 2], [ 3, 22, 30, 17, 6, 25, 1], [ 2, 27, 6, 4, 29, 23, 2], [ 3, 14, 4, 29, 5, 17, 1], [ 1, 27, 27, 24, 16, 11, 3], [ 2, 18, 19, 10, 13, 6, 1], [ 0, 0, 0, 0, 0, 0, 0], [ 0, 0, 0, 0, 0, 0, 0], [ 1, 12, 4, 15, 20, 4, 1], [ 1, 23, 12, 15, 26, 24, 1], [ 1, 7, 7, 23, 9, 14, 3], [ 1, 4, 5, 10, 5, 13, 2], [ 3, 2, 8, 12, 4, 13, 2], [ 0, 7, 15, 7, 13, 15, 1], [ 3, 6, 8, 15, 8, 15, 2], [ 1, 7, 4, 7, 1, 8, 0], [ 3, 3, 1, 2, 4, 2, 1], [ 0, 5, 2, 7, 3, 3, 2], [ 0, 6, 2, 1, 2, 2, 1], [ 0, 4, 4, 5, 1, 2, 0], [ 0, 6, 3, 8, 3, 2, 0], [ 0, 1, 2, 3, 2, 1, 0], [ 0, 3, 3, 3, 2, 1, 0], [ 0, 3, 2, 2, 1, 1, 0], [ 0, 3, 1, 2, 2, 3, 0], [ 0, 0, 1, 3, 1, 2, 0], [ 0, 0, 0, 3, 2, 3, 0], [ 0, 0, 2, 0, 2, 1, 0], [ 0, 0, 3, 0, 0, 0, 0], [ 0, 0, 1, 0, 2, 1, 0], ]; ──────────────────────────────────────────────────────────────────────── // demo.tsx ──────────────────────────────────────────────────────────────────────── import { CalendarHeatmap } from "./CalendarHeatmap"; import { START, WEEKS } from "./deploys"; /** * A year of deploys. The data stops on 30 December, so the last day of the * year has no sample and draws as an outline rather than as a quiet day. */ export default function Demo() { return (
console.log(date, value)} />
); }