"use client"; import { useMemo, useRef, useState, type CSSProperties, type KeyboardEvent, type PointerEvent } from "react"; import { motion, useInView, useReducedMotion } from "motion/react"; type ActivityPulseProps = { /** One entry per day, oldest first, top to bottom then left to right. */ data: { label: string; value: number }[]; /** Title. Also the name for screen readers. */ label: string; /** Singular name of what is counted, like "commit". */ unit?: string; /** Cells in each column. */ rows?: number; /** Pulse on the newest cell. */ live?: boolean; /** Cell colour. */ accent?: string; /** Wrapper look (text colour, width). */ className?: string; }; const SHADES = [0, 30, 50, 75, 100]; const shade = (level: number) => (level ? `color-mix(in srgb, var(--ap) ${SHADES[level]}%, transparent)` : "rgb(255 255 255 / 0.06)"); /* ActivityPulse: a calendar heatmap. Cells pop in as a wave from the left, the newest one keeps pulsing, and pointer, touch or arrow keys read any day. */ export function ActivityPulse({ data, label, unit = "action", rows = 7, live = true, accent = "#a78bfa", className = "text-white" }: ActivityPulseProps) { const reduce = useReducedMotion(); const box = useRef(null); const seen = useInView(box, { once: true, amount: 0.4 }); const [active, setActive] = useState(null); const last = data.length - 1; const columns = Math.ceil(data.length / rows); const total = data.reduce((sum, day) => sum + day.value, 0); const says = (value: number) => `${value.toLocaleString("en-US")} ${unit}${value === 1 ? "" : "s"}`; // Kept out of the render path so moving the pointer never touches every cell. const cells = useMemo(() => { const max = Math.max(1, ...data.map((day) => day.value)); return data.map((day, i) => ( {live && i === data.length - 1 && !reduce && ( )} )); }, [data, live, reduce, rows, seen]); if (!data.length) return null; const current = active ?? last; const day = data[current]; const column = Math.floor(current / rows); const row = current % rows; const pick = (e: PointerEvent) => { const grid = e.currentTarget.getBoundingClientRect(); const at = (spot: number, size: number, count: number) => Math.min(count - 1, Math.max(0, Math.floor((spot / size) * count))); setActive(Math.min(last, at(e.clientX - grid.left, grid.width, columns) * rows + at(e.clientY - grid.top, grid.height, rows))); }; const onKey = (e: KeyboardEvent) => { const moves: Record = { ArrowRight: current + rows, ArrowLeft: current - rows, ArrowDown: current + 1, ArrowUp: current - 1, Home: 0, End: last }; if (!(e.key in moves)) return; e.preventDefault(); setActive(Math.min(last, Math.max(0, moves[e.key]))); }; return (
{label}
e.pointerType === "mouse" && setActive(null)} onFocus={() => setActive((a) => a ?? last)} onBlur={() => setActive(null)} onKeyDown={onKey} className="relative touch-pan-y rounded-sm outline-none focus-visible:ring-2 focus-visible:ring-white/70" >
{cells}
{active !== null && (
0.75 ? -100 : -50}%, ${row < 3 ? "6px" : "calc(-100% - 6px)"})`, }} className="absolute left-0 rounded-lg border border-white/10 bg-[#17151f] px-2.5 py-1.5 text-xs whitespace-nowrap text-white shadow-lg" > {says(day.value)} {day.label}
)}
{`${says(total)} in total`} Less {SHADES.map((_, level) => ( ))} More
); }