import { useTheme } from "../ThemeProvider"; import { habitProgressColor, themedHabitShade } from "../../lib/habit-palette"; import { useId, useMemo, useLayoutEffect, useRef, useState, type CSSProperties, type ReactNode } from "react"; import { CalendarLegend } from "./CalendarLegend"; import { Field } from "./Field"; import { Button } from "./primitives"; import { CalendarDays } from "lucide-react"; import { describeDay, calendarTimeline, MONTH_VIEWS, visibleCalendarDays, monthWindow, type CalendarDay, type MonthView, } from "./calendar-model"; export function CalendarHeatmap({ days: allDays, unit, label, color = "#111111", compact = false, initialMonths = 12, selectedDate: controlledDate, onSelectDate, historyLabel, legend, selectedDayAction, availableRange, onRangeChange, loading = false, }: { availableRange?: { from: string; to: string }; onRangeChange?: (range: { from: string; to: string }) => void; loading?: boolean; days: CalendarDay[]; unit: string; label: string; color?: string; emptyColor?: string; compact?: boolean; initialMonths?: MonthView; selectedDate?: string; onSelectDate?: (date: string) => void; historyLabel?: string; legend?: ReactNode; selectedDayAction?: (date: string) => ReactNode; }) { const { theme } = useTheme(); const [months, setMonths] = useState(initialMonths); const [customRange, setCustomRange] = useState<{ from: string; to: string } | null>(null); const [rangeError, setRangeError] = useState(""); const latestDate = allDays.findLast((day) => day.state !== "future")?.date ?? allDays.at(-1)?.date; // Inspecting a square changes selection, not the window. Only an external // date outside the visible preset needs a new anchor to bring it into view. const [windowAnchor, setWindowAnchor] = useState({ latestDate, date: latestDate }); let anchor = windowAnchor.latestDate === latestDate ? windowAnchor.date : latestDate; if (months !== "custom" && anchor && controlledDate && allDays.some(day => day.date === controlledDate) && !visibleCalendarDays(allDays, months, anchor).some(day => day.date === controlledDate)) { anchor = controlledDate; } if (windowAnchor.latestDate !== latestDate || windowAnchor.date !== anchor) { setWindowAnchor({ latestDate, date: anchor }); } const [localSelectedDate, setLocalSelectedDate] = useState(anchor); const selectedDate = controlledDate ?? localSelectedDate; function setSelectedDate(date: string) { if (onSelectDate) onSelectDate(date); else setLocalSelectedDate(date); } const days = useMemo(() => months === "custom" && customRange ? allDays.filter((day) => day.date >= customRange.from && day.date <= customRange.to) : anchor ? visibleCalendarDays(allDays, months === "custom" ? 12 : months, anchor) : [], [allDays, months, customRange, anchor]); const selected = days.find((day) => day.date === selectedDate) ?? days.find((day) => day.date === anchor) ?? days[0]; // Date keys and the shared timeline remain stable when the chart resizes. const timeline = useMemo(() => calendarTimeline(days), [days]); const buttons = useRef<(HTMLButtonElement | null)[]>([]); const inspectorId = useId(); const instructionsId = useId(); const rangeErrorId = useId(); const pendingFocusDate = useRef(null); const [focusRequest, setFocusRequest] = useState(0); const selectedIndex = selected ? days.indexOf(selected) : -1; // Controlled editors may reject a selection to preserve an unsaved draft. // Only move focus after that date is actually selected, using the new window. useLayoutEffect(() => { if (pendingFocusDate.current !== null) buttons.current[selectedIndex]?.focus(); pendingFocusDate.current = null; }, [focusRequest, selectedIndex, selected?.date]); function selectAndFocus(date: string) { pendingFocusDate.current = date; // A rejected controlled selection still needs a render to restore click // focus and consume the request, so a later update cannot reuse it. setFocusRequest((request) => request + 1); setSelectedDate(date); } if (!selected) return

No calendar dates to display.

; return (
{new Date(`${days[0]!.date}T12:00:00Z`).toLocaleDateString("en", { month: "short", day: months === "custom" ? "numeric" : undefined, year: "numeric", timeZone: "UTC", })}{" "} {months !== 1 && <>–{" "} {new Date(`${days.at(-1)!.date}T12:00:00Z`).toLocaleDateString("en", { month: "short", day: months === "custom" ? "numeric" : undefined, year: "numeric", timeZone: "UTC", })} }
{MONTH_VIEWS.map((value) => ( ))}
{months === "custom" && customRange && (
{ event.preventDefault(); const form = event.currentTarget; const start = form.elements.namedItem("from") as HTMLInputElement; const end = form.elements.namedItem("to") as HTMLInputElement; const isAvailable = (input: HTMLInputElement) => input.validity.valid && input.value !== "" && input.value >= (availableRange?.from ?? allDays[0]!.date) && input.value <= (availableRange?.to ?? allDays.at(-1)!.date); if (!isAvailable(start) || !isAvailable(end)) { setRangeError("Choose both dates within the available history."); (!isAvailable(start) ? start : end).focus(); return; } if (start.value > end.value) { setRangeError("The end date must be on or after the start date."); end.focus(); return; } if (!availableRange && !allDays.some((day) => day.date >= start.value && day.date <= end.value)) { setRangeError("There are no calendar dates in this range."); return; } setCustomRange({ from: start.value, to: end.value }); onRangeChange?.({ from: start.value, to: end.value }); setRangeError(""); }} > {(["from", "to"] as const).map((name) => ( {(id) => ( setRangeError("")} /> )} ))} {rangeError && }
)} {loading &&

Loading history…

}
{timeline.entries.map(({ day, index, column, row }) => (
{legend ?? }

Select a day. Up/down: one day. Left/right: one week. Home/end: first/last date.

{!historyLabel &&

Demo history

}
{selectedDayAction &&
{selectedDayAction(selected.date)}
}
); }