"use client";
import * as React from "react";
import { useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";
export type HeatmapDatum = { /** ISO date, YYYY-MM-DD */ date: string; value: number };
export type HeatmapCalendarProps = {
data: HeatmapDatum[];
/** Last day shown (ISO). Defaults to the latest date in `data`. */
endDate?: string;
/** Maximum weeks to show; fewer are shown when the container is narrow. */
weeks?: number;
/** 0 = Sunday, 1 = Monday. */
weekStart?: 0 | 1;
title?: string;
/** Singular / plural unit, e.g. ["contribution", "contributions"]. */
unit?: [string, string];
/** Base colour for the scale (mixed with the muted token). Defaults to the theme primary. */
color?: string;
/** Upper bounds for levels 1–3; anything above the last is level 4. Auto (quartiles of max) by default. */
thresholds?: [number, number, number];
onSelect?: (date: string, value: number) => void;
className?: string;
};
const DAY = 864e5;
const toDay = (iso: string) => {
const [y, m, d] = iso.split("-").map(Number);
return Math.floor(Date.UTC(y, m - 1, d) / DAY);
};
const toIso = (day: number) => new Date(day * DAY).toISOString().slice(0, 10);
const weekday = (day: number) => (day + 4) % 7; // 1970-01-01 was a Thursday
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
const WEEKDAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
const longDate = (day: number) =>
new Date(day * DAY).toLocaleDateString("en-US", { weekday: "short", month: "short", day: "numeric", year: "numeric", timeZone: "UTC" });
function useElementWidth<T extends HTMLElement>() {
const ref = React.useRef<T>(null);
const [width, setWidth] = React.useState(0);
React.useEffect(() => {
const el = ref.current;
if (!el) return;
const ro = new ResizeObserver((entries) => setWidth(Math.floor(entries[0].contentRect.width)));
ro.observe(el);
return () => ro.disconnect();
}, []);
return [ref, width] as const;
}
const LEVEL_MIX = [0, 32, 55, 78, 100];
export function HeatmapCalendar({
data,
endDate,
weeks: maxWeeks = 53,
weekStart = 0,
title = "Activity",
unit = ["contribution", "contributions"],
color = "var(--primary)",
thresholds,
onSelect,
className,
}: HeatmapCalendarProps) {
const reduce = useReducedMotion();
const [ref, width] = useElementWidth<HTMLDivElement>();
const [active, setActive] = React.useState<number | null>(null);
const [shown, setShown] = React.useState(false);
React.useEffect(() => {
const t = requestAnimationFrame(() => setShown(true));
return () => cancelAnimationFrame(t);
}, []);
const values = React.useMemo(() => {
const map = new Map<number, number>();
for (const d of data) map.set(toDay(d.date), (map.get(toDay(d.date)) ?? 0) + d.value);
return map;
}, [data]);
const endDay = endDate ? toDay(endDate) : Math.max(...values.keys(), 0);
const labelW = 30;
const gap = 3;
const avail = Math.max(0, width - labelW);
const fitWeeks = Math.max(8, Math.floor((avail + gap) / (10 + gap)));
const weeks = Math.min(maxWeeks, fitWeeks);
const cell = Math.min(15, Math.max(10, Math.floor((avail + gap) / weeks) - gap));
const pitch = cell + gap;
const row = (day: number) => (weekday(day) - weekStart + 7) % 7;
const startDay = endDay - row(endDay) - (weeks - 1) * 7;
const days: number[] = [];
for (let d = startDay; d <= endDay; d++) days.push(d);
const visibleVals = days.map((d) => values.get(d) ?? 0);
const max = Math.max(1, ...visibleVals);
const bounds = thresholds ?? [max * 0.25, max * 0.5, max * 0.75];
const level = (v: number) => (v <= 0 ? 0 : v <= bounds[0] ? 1 : v <= bounds[1] ? 2 : v <= bounds[2] ? 3 : 4);
const fill = (lvl: number) => (lvl === 0 ? "var(--muted)" : `color-mix(in oklch, ${color} ${LEVEL_MIX[lvl]}%, var(--muted))`);
const total = visibleVals.reduce((a, b) => a + b, 0);
let longest = 0;
let run = 0;
for (const v of visibleVals) {
run = v > 0 ? run + 1 : 0;
longest = Math.max(longest, run);
}
let current = 0;
for (let i = visibleVals.length - 1; i >= 0 && visibleVals[i] > 0; i--) current++;
// Month labels at the first column containing the 1st of a month.
const months: { col: number; label: string }[] = [];
for (let c = 0; c < weeks; c++) {
for (let r = 0; r < 7; r++) {
const d = startDay + c * 7 + r;
if (d > endDay) break;
const date = new Date(d * DAY);
if (date.getUTCDate() === 1) {
const prev = months[months.length - 1];
if (!prev || c - prev.col >= 3) months.push({ col: c, label: MONTHS[date.getUTCMonth()] });
}
}
}
if (months.length === 0 || months[0].col > 2) {
months.unshift({ col: 0, label: MONTHS[new Date(startDay * DAY).getUTCMonth()] });
}
const top = 18;
const gridW = weeks * pitch - gap;
const gridH = 7 * pitch - gap;
const svgW = labelW + gridW;
const svgH = top + gridH;
const pos = (d: number) => {
const i = d - startDay;
return { x: labelW + Math.floor(i / 7) * pitch, y: top + (i % 7) * pitch };
};
const onKeyDown = (e: React.KeyboardEvent) => {
const cur = active ?? endDay;
const step: Record<string, number> = { ArrowUp: -1, ArrowDown: 1, ArrowLeft: -7, ArrowRight: 7 };
if (e.key in step) {
e.preventDefault();
setActive(Math.min(endDay, Math.max(startDay, cur + step[e.key])));
} else if (e.key === "Home") {
e.preventDefault();
setActive(startDay);
} else if (e.key === "End") {
e.preventDefault();
setActive(endDay);
} else if ((e.key === "Enter" || e.key === " ") && active !== null) {
e.preventDefault();
onSelect?.(toIso(active), values.get(active) ?? 0);
} else if (e.key === "Escape") setActive(null);
};
const plural = (n: number) => `${n.toLocaleString("en-US")} ${n === 1 ? unit[0] : unit[1]}`;
const activeVal = active === null ? 0 : values.get(active) ?? 0;
const ap = active === null ? null : pos(active);
const tipLeft = ap ? Math.min(Math.max(ap.x + cell / 2, 70), Math.max(70, svgW - 70)) : 0;
return (
<section aria-label={title} className={cn("w-full rounded-xl border bg-card p-4 text-card-foreground shadow-xs sm:p-5", className)}>
<div className="mb-4 flex flex-wrap items-end justify-between gap-x-6 gap-y-2">
<div>
<h3 className="text-sm font-medium text-muted-foreground">{title}</h3>
<p className="mt-1 text-lg font-semibold tracking-tight">
{plural(total)} <span className="text-sm font-normal text-muted-foreground">in the last {weeks} weeks</span>
</p>
</div>
<dl className="flex gap-5 text-xs">
<div>
<dt className="text-muted-foreground">Longest streak</dt>
<dd className="mt-0.5 font-semibold tabular-nums">{longest} days</dd>
</div>
<div>
<dt className="text-muted-foreground">Current streak</dt>
<dd className="mt-0.5 font-semibold tabular-nums">{current} days</dd>
</div>
</dl>
</div>
<div ref={ref} className="w-full">
{width > 0 && (
<div
tabIndex={0}
role="group"
aria-label={`${title} calendar, ${plural(total)}. Use arrow keys to move between days.`}
onKeyDown={onKeyDown}
onBlur={() => setActive(null)}
className="relative w-fit rounded-md outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-4 focus-visible:ring-offset-card"
>
<svg width={svgW} height={svgH} aria-hidden className="block" onPointerLeave={() => setActive(null)}>
{months.map((m) => (
<text key={`${m.col}-${m.label}`} x={labelW + m.col * pitch} y={11} className="fill-muted-foreground text-[10px]">
{m.label}
</text>
))}
{[1, 3, 5].map((r) => (
<text key={r} x={0} y={top + r * pitch + cell / 2} dy="0.34em" className="fill-muted-foreground text-[10px]">
{WEEKDAYS[(r + weekStart) % 7]}
</text>
))}
{days.map((d, i) => {
const p = pos(d);
const v = values.get(d) ?? 0;
const col = Math.floor(i / 7);
return (
<rect
key={d}
x={p.x}
y={p.y}
width={cell}
height={cell}
rx={Math.min(3, cell / 4)}
fill={fill(level(v))}
onPointerEnter={() => setActive(d)}
onClick={() => onSelect?.(toIso(d), v)}
className={cn("cursor-pointer", active === d && "stroke-foreground")}
strokeWidth={active === d ? 1.5 : 0}
style={{
opacity: shown || reduce ? 1 : 0,
transform: shown || reduce ? "scale(1)" : "scale(0.4)",
transformBox: "fill-box",
transformOrigin: "center",
transition: reduce ? undefined : `opacity 400ms ease ${col * 14}ms, transform 500ms cubic-bezier(.22,1,.36,1) ${col * 14}ms`,
}}
/>
);
})}
</svg>
{ap && active !== null && (
<div
className="pointer-events-none absolute z-10 -translate-x-1/2 -translate-y-full whitespace-nowrap rounded-md border bg-popover px-2.5 py-1.5 text-xs text-popover-foreground shadow-lg"
style={{ left: tipLeft, top: ap.y - 6 }}
>
<span className="font-semibold">{activeVal === 0 ? `No ${unit[1]}` : plural(activeVal)}</span>
<span className="text-muted-foreground"> on {longDate(active)}</span>
</div>
)}
<p className="sr-only" aria-live="polite">
{active !== null ? `${plural(activeVal)} on ${longDate(active)}` : ""}
</p>
</div>
)}
</div>
<div className="mt-3 flex items-center justify-between gap-3 text-[11px] text-muted-foreground">
<span className="hidden sm:inline">Hover or use arrow keys to inspect a day</span>
<div className="ml-auto flex items-center gap-1.5" aria-label="Colour scale from less to more">
<span>Less</span>
{LEVEL_MIX.map((_, l) => (
<span key={l} className="size-2.5 rounded-[3px]" style={{ background: fill(l) }} />
))}
<span>More</span>
</div>
</div>
</section>
);
}