"use client";
import * as React from "react";
export interface HeatmapDatum {
/** ISO date, "YYYY-MM-DD" (time and zone are ignored). */
date: string;
count: number;
}
export interface ActivityHeatmapProps extends Omit<React.ComponentPropsWithoutRef<"div">, "children"> {
data: HeatmapDatum[];
/** Colour of the highest activity level; lower levels are mixed toward transparent. */
accent?: string;
/** First row of each week column. */
weekStart?: "sunday" | "monday";
/** "fit" scales cells to the container width; "scroll" keeps `cellSize` and scrolls horizontally. */
layout?: "fit" | "scroll";
/** Cell size in px (scroll layout). */
cellSize?: number;
/** Gap between cells in px. */
gap?: number;
/** Cell corner radius in px. */
radius?: number;
showMonthLabels?: boolean;
showWeekdayLabels?: boolean;
showLegend?: boolean;
/** Accessible name of the grid. */
label?: string;
/** Singular unit used in labels, e.g. "contribution". */
unit?: string;
/** Plural unit; defaults to `unit + "s"`. */
unitPlural?: string;
/** Overrides the first day shown ("YYYY-MM-DD"); defaults to the earliest date in `data`. */
startDate?: string;
/** Overrides the last day shown ("YYYY-MM-DD"); defaults to the latest date in `data`. */
endDate?: string;
/** Locale for dates and numbers. */
locale?: string;
}
const DAY_MS = 86_400_000;
const LEVEL_MIX = [0, 30, 52, 76, 100];
const LABEL_WIDTH = 28;
/** Whole days since 1970-01-01 (UTC), so dates never drift with the viewer's time zone. */
function toDayNumber(iso: string): number | null {
const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(iso);
if (!m) return null;
return Math.floor(Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3])) / DAY_MS);
}
/** 0 = Sunday … 6 = Saturday. 1970-01-01 was a Thursday. */
const weekdayOf = (day: number) => (((day + 4) % 7) + 7) % 7;
interface Cell {
day: number;
count: number;
level: number;
valid: boolean;
}
interface Model {
rows: Cell[][];
weeks: number;
total: number;
max: number;
busiest: Cell | null;
start: number;
end: number;
months: { col: number; label: string }[];
}
function buildModel(
data: HeatmapDatum[],
weekStart: "sunday" | "monday",
startDate: string | undefined,
endDate: string | undefined,
monthFormat: Intl.DateTimeFormat,
): Model | null {
const counts = new Map<number, number>();
let min = Infinity;
let max = -Infinity;
for (const d of data) {
const day = toDayNumber(d.date);
if (day === null || !Number.isFinite(d.count)) continue;
counts.set(day, (counts.get(day) ?? 0) + Math.max(0, d.count));
min = Math.min(min, day);
max = Math.max(max, day);
}
const explicitStart = startDate ? toDayNumber(startDate) : null;
const explicitEnd = endDate ? toDayNumber(endDate) : null;
const start = explicitStart ?? (Number.isFinite(min) ? min : null);
const end = explicitEnd ?? (Number.isFinite(max) ? max : null);
if (start === null || end === null || end < start) return null;
const offset = weekStart === "monday" ? 1 : 0;
const rowOf = (day: number) => (weekdayOf(day) - offset + 7) % 7;
const gridStart = start - rowOf(start);
const weeks = Math.floor((end - gridStart) / 7) + 1;
let total = 0;
let peak = 0;
for (let day = start; day <= end; day++) {
const c = counts.get(day) ?? 0;
total += c;
peak = Math.max(peak, c);
}
let busiest: Cell | null = null;
const rows: Cell[][] = [];
for (let r = 0; r < 7; r++) {
const row: Cell[] = [];
for (let c = 0; c < weeks; c++) {
const day = gridStart + c * 7 + r;
const valid = day >= start && day <= end;
const count = valid ? (counts.get(day) ?? 0) : 0;
const level = count <= 0 || peak === 0 ? 0 : Math.min(4, Math.ceil((count / peak) * 4));
const cell = { day, count, level, valid };
if (valid && count > 0 && (!busiest || count > busiest.count)) busiest = cell;
row.push(cell);
}
rows.push(row);
}
// A month label sits above the first column that starts in a new month.
const months: { col: number; label: string }[] = [];
let lastMonth = -1;
for (let c = 0; c < weeks; c++) {
const firstValid = rows.find((row) => row[c].valid)?.[c];
if (!firstValid) continue;
const month = new Date(firstValid.day * DAY_MS).getUTCMonth();
if (month === lastMonth) continue;
lastMonth = month;
const prev = months[months.length - 1];
// Avoid overlapping labels: a partial first month gives way to the next one.
if (prev && c - prev.col < 3) months.pop();
months.push({ col: c, label: monthFormat.format(new Date(firstValid.day * DAY_MS)) });
}
return { rows, weeks, total, max: peak, busiest, start, end, months };
}
interface Tip {
row: number;
col: number;
x: number;
y: number;
arrow: number;
}
/**
* A contribution-style calendar heatmap. Cells form an ARIA grid with roving focus and
* arrow-key navigation; the same tooltip appears on hover and on keyboard focus.
*/
export function ActivityHeatmap({
data,
accent = "#FF7448",
weekStart = "sunday",
layout = "fit",
cellSize = 12,
gap = 3,
radius = 2,
showMonthLabels = true,
showWeekdayLabels = true,
showLegend = true,
label = "Activity",
unit = "contribution",
unitPlural,
startDate,
endDate,
locale = "en-GB",
className = "",
style,
...props
}: ActivityHeatmapProps) {
const id = React.useId();
const summaryId = `${id}-summary`;
const wrapRef = React.useRef<HTMLDivElement>(null);
const scrollRef = React.useRef<HTMLDivElement>(null);
const cellRefs = React.useRef(new Map<string, HTMLDivElement>());
const [active, setActive] = React.useState<{ row: number; col: number } | null>(null);
const [tip, setTip] = React.useState<Tip | null>(null);
const formats = React.useMemo(() => {
const safe = (fn: (loc: string | undefined) => Intl.DateTimeFormat | Intl.NumberFormat) => {
try {
return fn(locale);
} catch {
return fn(undefined);
}
};
return {
date: safe((l) => new Intl.DateTimeFormat(l, { day: "numeric", month: "short", year: "numeric", timeZone: "UTC" })) as Intl.DateTimeFormat,
weekday: safe((l) => new Intl.DateTimeFormat(l, { weekday: "short", timeZone: "UTC" })) as Intl.DateTimeFormat,
month: safe((l) => new Intl.DateTimeFormat(l, { month: "short", timeZone: "UTC" })) as Intl.DateTimeFormat,
number: safe((l) => new Intl.NumberFormat(l)) as Intl.NumberFormat,
};
}, [locale]);
const model = React.useMemo(
() => buildModel(data, weekStart, startDate, endDate, formats.month),
[data, weekStart, startDate, endDate, formats],
);
const plural = unitPlural ?? `${unit}s`;
const describe = (cell: Cell) =>
`${cell.count === 0 ? "No" : formats.number.format(cell.count)} ${cell.count === 1 ? unit : plural} on ${formats.date.format(new Date(cell.day * DAY_MS))}`;
// Keep the most recent weeks in view when the grid scrolls.
React.useEffect(() => {
const node = scrollRef.current;
if (node && layout === "scroll") node.scrollLeft = node.scrollWidth;
}, [layout, model]);
if (!model) {
return (
<div className={`font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-500 ${className}`} {...props}>
No activity data
</div>
);
}
const { rows, weeks } = model;
const isValid = (r: number, c: number) => r >= 0 && r < 7 && c >= 0 && c < weeks && rows[r][c].valid;
// The roving tab stop defaults to the most recent day.
const lastCol = weeks - 1;
let fallbackRow = 6;
while (fallbackRow > 0 && !rows[fallbackRow][lastCol].valid) fallbackRow--;
const focusCell = active && isValid(active.row, active.col) ? active : { row: fallbackRow, col: lastCol };
const showTip = (row: number, col: number, target: HTMLElement) => {
const wrap = wrapRef.current;
if (!wrap) return;
const w = wrap.getBoundingClientRect();
const r = target.getBoundingClientRect();
const centre = r.left + r.width / 2 - w.left;
const half = 92; // approx. half the tooltip width; keeps it inside the component
const x = w.width > half * 2 ? Math.min(Math.max(centre, half), w.width - half) : w.width / 2;
setTip({ row, col, x, y: r.top - w.top, arrow: centre - x });
};
const moveFocus = (row: number, col: number, dRow: number, dCol: number) => {
let r = row + dRow;
let c = col + dCol;
// Skip padding cells outside the date range.
while (r >= 0 && r < 7 && c >= 0 && c < weeks && !rows[r][c].valid) {
r += dRow;
c += dCol;
}
if (!isValid(r, c)) return;
setActive({ row: r, col: c });
cellRefs.current.get(`${r}:${c}`)?.focus();
};
const findInRow = (row: number, from: number, step: number) => {
for (let c = from; c >= 0 && c < weeks; c += step) if (rows[row][c].valid) return c;
return -1;
};
const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
const { row, col } = focusCell;
const keys: Record<string, () => void> = {
ArrowLeft: () => moveFocus(row, col, 0, -1),
ArrowRight: () => moveFocus(row, col, 0, 1),
ArrowUp: () => moveFocus(row, col, -1, 0),
ArrowDown: () => moveFocus(row, col, 1, 0),
Home: () => {
const c = findInRow(row, 0, 1);
if (c >= 0) moveFocus(row, c, 0, 0);
},
End: () => {
const c = findInRow(row, weeks - 1, -1);
if (c >= 0) moveFocus(row, c, 0, 0);
},
Escape: () => setTip(null),
};
const action = keys[event.key];
if (!action) return;
event.preventDefault();
action();
};
const fit = layout === "fit";
const cellStyle: React.CSSProperties = fit
? { borderRadius: radius }
: { width: cellSize, height: cellSize, borderRadius: radius };
const weekdayRows = new Set(weekStart === "monday" ? [0, 2, 4] : [1, 3, 5]);
const tipCell = tip ? rows[tip.row]?.[tip.col] : undefined;
const rangeText = `${formats.date.format(new Date(model.start * DAY_MS))} – ${formats.date.format(new Date(model.end * DAY_MS))}`;
const levelColor = (level: number) =>
level === 0 ? undefined : `color-mix(in oklab, var(--hm-accent) ${LEVEL_MIX[level]}%, transparent)`;
return (
<div
ref={wrapRef}
className={`relative w-full text-zinc-900 dark:text-zinc-100 ${className}`}
style={{ ["--hm-accent" as string]: accent, ...style }}
{...props}
>
<p id={summaryId} className="sr-only">
{`${formats.number.format(model.total)} ${model.total === 1 ? unit : plural} between ${rangeText}.`}
{model.busiest ? ` Busiest day: ${describe(model.busiest)}.` : ""}
{" Use the arrow keys to move between days."}
</p>
<div
ref={scrollRef}
className={fit ? "w-full" : "w-full overflow-x-auto overscroll-x-contain pb-1"}
onScroll={() => setTip(null)}
>
<div className={fit ? "w-full" : "inline-block min-w-max"}>
{showMonthLabels ? (
<div className="flex" aria-hidden="true">
{showWeekdayLabels ? <div className="shrink-0" style={{ width: LABEL_WIDTH + gap }} /> : null}
<div className="relative h-5 flex-1">
{model.months.map((m) => (
<span
key={m.col}
className="absolute top-0 font-mono text-[10px] uppercase tracking-[0.1em] text-zinc-500 dark:text-zinc-400"
style={{ left: `calc(${m.col} * (100% + ${gap}px) / ${weeks})` }}
>
{m.label}
</span>
))}
</div>
</div>
) : null}
<div
role="grid"
aria-label={label}
aria-describedby={summaryId}
aria-readonly="true"
className="flex flex-col"
style={{ gap }}
onKeyDown={handleKeyDown}
onPointerLeave={() => setTip(null)}
>
{rows.map((row, r) => (
<div key={r} role="row" className="flex items-center" style={{ gap }}>
{showWeekdayLabels ? (
<span
aria-hidden="true"
className="shrink-0 font-mono text-[9px] uppercase leading-none tracking-[0.08em] text-zinc-500 dark:text-zinc-400"
style={{ width: LABEL_WIDTH }}
>
{weekdayRows.has(r) ? formats.weekday.format(new Date(row[0].day * DAY_MS)) : ""}
</span>
) : null}
{row.map((cell, c) => {
if (!cell.valid) {
return (
<div
key={c}
role="gridcell"
aria-hidden="true"
className={fit ? "aspect-square min-w-0 flex-1" : "shrink-0"}
style={cellStyle}
/>
);
}
const isTabStop = focusCell.row === r && focusCell.col === c;
return (
<div
key={c}
ref={(el) => {
const key = `${r}:${c}`;
if (el) cellRefs.current.set(key, el);
else cellRefs.current.delete(key);
}}
role="gridcell"
tabIndex={isTabStop ? 0 : -1}
aria-label={describe(cell)}
onFocus={(e) => {
setActive({ row: r, col: c });
showTip(r, c, e.currentTarget);
}}
onBlur={() => setTip(null)}
onPointerEnter={(e) => {
if (e.pointerType === "mouse") showTip(r, c, e.currentTarget);
}}
onPointerDown={(e) => showTip(r, c, e.currentTarget)}
className={`outline-offset-1 transition-[box-shadow] duration-150 hover:shadow-[inset_0_0_0_1px_rgba(0,0,0,0.35)] focus-visible:outline-2 focus-visible:outline-zinc-900 dark:hover:shadow-[inset_0_0_0_1px_rgba(255,255,255,0.5)] dark:focus-visible:outline-white ${
cell.level === 0 ? "bg-zinc-900/[0.07] dark:bg-white/[0.07]" : ""
} ${fit ? "aspect-square min-w-0 flex-1" : "shrink-0"}`}
style={{ ...cellStyle, backgroundColor: levelColor(cell.level) }}
/>
);
})}
</div>
))}
</div>
</div>
</div>
{showLegend ? (
<div className="mt-3 flex flex-wrap items-center justify-between gap-x-4 gap-y-2 font-mono text-[10px] uppercase tracking-[0.12em] text-zinc-500 dark:text-zinc-400">
<span aria-hidden="true">
<span className="text-zinc-900 dark:text-zinc-100">{formats.number.format(model.total)}</span> {plural} · {rangeText}
</span>
<span className="flex items-center gap-1.5" aria-hidden="true">
Less
{LEVEL_MIX.map((_, level) => (
<span
key={level}
className={`inline-block size-2.5 ${level === 0 ? "bg-zinc-900/[0.07] dark:bg-white/[0.07]" : ""}`}
style={{ borderRadius: Math.min(radius, 3), backgroundColor: levelColor(level) }}
/>
))}
More
</span>
</div>
) : null}
{tip && tipCell?.valid ? (
<div
role="presentation"
aria-hidden="true"
className="pointer-events-none absolute z-10 -translate-x-1/2 -translate-y-full whitespace-nowrap rounded-md border border-zinc-900/10 bg-white px-2.5 py-1.5 text-xs shadow-[0_8px_24px_-12px_rgba(0,0,0,0.35)] dark:border-white/10 dark:bg-zinc-900"
style={{ left: tip.x, top: tip.y - 8 }}
>
<span className="font-medium tabular-nums">
{tipCell.count === 0 ? "No" : formats.number.format(tipCell.count)} {tipCell.count === 1 ? unit : plural}
</span>
<span className="ml-2 font-mono text-[10px] uppercase tracking-[0.1em] text-zinc-500 dark:text-zinc-400">
{formats.date.format(new Date(tipCell.day * DAY_MS))}
</span>
<span
className="absolute top-full size-2 -translate-x-1/2 -translate-y-1 rotate-45 border-r border-b border-zinc-900/10 bg-white dark:border-white/10 dark:bg-zinc-900"
style={{ left: `calc(50% + ${tip.arrow}px)` }}
/>
</div>
) : null}
</div>
);
}