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 (