import { useEffect, useId, useMemo, useRef, useState, type KeyboardEvent } from "react"; import { createPortal } from "react-dom"; import { format } from "date-fns"; import { cn, parseLocalDate } from "@/lib/utils"; import { activityLevel, activityThresholds, calendarWeeks, inCalendarSelection, orderedSelection, type ActivityDay, type CalendarSelection, type FlowDirection, } from "@/lib/calendarActivity"; interface Props { year: number; days: ReadonlyMap; direction: FlowDirection; selection: CalendarSelection | null; onSelect: (selection: CalendarSelection | null) => void; formatValue: (amount: number) => string; today: string; /** A transaction ledger may contain future-dated entries; Home only plots posted activity. */ allowFuture?: boolean; } const levelClasses = [ "bg-muted/80 dark:bg-white/[0.035]", "bg-teal-200 dark:bg-teal-900/60", "bg-teal-400/80 dark:bg-teal-700", "bg-teal-500 dark:bg-teal-500/80", "bg-teal-700 dark:bg-teal-300", ]; export function CalendarHeatmap({ year, days, direction, selection, onSelect, formatValue, today, allowFuture = false }: Props) { const weeks = useMemo(() => calendarWeeks(year), [year]); const dates = useMemo(() => weeks.flat().filter((d): d is string => !!d && (allowFuture || d <= today)), [weeks, today, allowFuture]); const thresholds = useMemo(() => activityThresholds(days, direction), [days, direction]); const grid = useRef(null); const drag = useRef<{ anchor: string; end: string; id: number } | null>(null); const skipClick = useRef(false); const resetClickTimer = useRef>(); const [preview, setPreview] = useState(null); const [focused, setFocused] = useState(today.startsWith(String(year)) ? today : `${year}-01-01`); const [hover, setHover] = useState<{ date: string; x: number; y: number } | null>(null); const hintId = useId(); const tooltipId = useId(); const active = preview ?? selection; const focusDate = dates.includes(focused) ? focused : dates[0]; const lastOnSelect = useRef(onSelect); lastOnSelect.current = onSelect; useEffect(() => { const finish = (event: PointerEvent) => { const current = drag.current; if (!current || current.id !== event.pointerId) return; if (current.anchor !== current.end) { lastOnSelect.current(orderedSelection(current.anchor, current.end)); skipClick.current = true; // The browser's synthetic click follows pointerup in the same task. // A release outside the chart must not swallow the next real click. resetClickTimer.current = setTimeout(() => { skipClick.current = false; }, 0); } drag.current = null; setPreview(null); }; const cancel = () => { drag.current = null; setPreview(null); }; const escape = (e: globalThis.KeyboardEvent) => { if (e.key === "Escape") cancel(); }; const dismiss = () => setHover(null); window.addEventListener("pointerup", finish); window.addEventListener("pointercancel", cancel); window.addEventListener("blur", cancel); window.addEventListener("keydown", escape); window.addEventListener("scroll", dismiss, true); window.addEventListener("resize", dismiss); return () => { window.removeEventListener("pointerup", finish); window.removeEventListener("pointercancel", cancel); window.removeEventListener("blur", cancel); window.removeEventListener("keydown", escape); window.removeEventListener("scroll", dismiss, true); window.removeEventListener("resize", dismiss); clearTimeout(resetClickTimer.current); }; }, []); const showTooltip = (date: string, target: HTMLElement) => { const rect = target.getBoundingClientRect(); setHover({ date, x: rect.left + rect.width / 2, y: rect.top }); }; const toggleDay = (date: string) => { onSelect(selection?.from === date && selection.to === date ? null : { from: date, to: date }); }; const keyboard = (event: KeyboardEvent, date: string) => { if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); drag.current = null; setPreview(null); setHover(null); onSelect(null); return; } const index = dates.indexOf(date); const weekday = new Date(`${date}T00:00:00Z`).getUTCDay(); const next = event.key === "ArrowLeft" ? index - 7 : event.key === "ArrowRight" ? index + 7 : event.key === "ArrowUp" ? index - 1 : event.key === "ArrowDown" ? index + 1 : event.key === "Home" ? (event.ctrlKey || event.metaKey ? 0 : index - weekday) : event.key === "End" ? (event.ctrlKey || event.metaKey ? dates.length - 1 : index + 6 - weekday) : null; if (next === null) return; event.preventDefault(); event.stopPropagation(); const target = dates[Math.max(0, Math.min(dates.length - 1, next))]; setFocused(target); grid.current?.querySelector(`[data-date="${target}"]`)?.focus(); }; const hovered = hover ? days.get(hover.date) : undefined; const resetHint = selection?.from === hover?.date && selection?.to === hover?.date; const tooltip = hover && typeof document !== "undefined" ? createPortal( , document.body, ) : null; return (

Use arrow keys to explore days, Enter or Space to select a day, and Escape to clear. Drag across days or use the date picker to select a range.

setHover(null)}>
{weeks.map((week, i) => { const first = week.find(d => d?.endsWith("-01")); return {first ? format(parseLocalDate(first), "MMM") : ""}; })}
{["", "Mon", "", "Wed", "", "Fri", ""].map((label, i) => {label})}
{weeks.map((week, i) => (
{week.map((date, j) => { if (!date) return ; const future = date > today; const disabled = future && !allowFuture; const amount = days.get(date)?.[direction] ?? 0; const level = activityLevel(amount, thresholds); const selected = !!active && inCalendarSelection(date, active); return (
))}
{tooltip}
); }