Fazekit

Code

"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>
  );
}

More in Data Display

View all →