diff --git a/src/components/DatePresets.tsx b/src/components/DatePresets.tsx index 22c459e..730e911 100644 --- a/src/components/DatePresets.tsx +++ b/src/components/DatePresets.tsx @@ -23,10 +23,10 @@ export default function DatePresets({ onSelect, }: DatePresetsProps) { const presets = getDatePresets(); - const initialPreset = presets.find((p) => fromDate === p.from && toDate === p.to); - const [active, setActive] = useState( - (initialPreset?.id as DatePresetId) ?? "custom", - ); + const matchedPreset = presets.find((p) => fromDate === p.from && toDate === p.to); + const derived = (matchedPreset?.id as DatePresetId) ?? "custom"; + const [showCustom, setShowCustom] = useState(derived === "custom"); + const active = showCustom ? "custom" : derived; return (
@@ -36,7 +36,7 @@ export default function DatePresets({ key={p.id} active={active === p.id} onClick={() => { - setActive(p.id); + setShowCustom(false); setFromDate(p.from); setToDate(p.to); onSelect?.(p.from, p.to); @@ -45,7 +45,7 @@ export default function DatePresets({ {p.label} ))} - setActive("custom")}> + setShowCustom(true)}> Custom
diff --git a/src/components/Heatmap.tsx b/src/components/Heatmap.tsx index cd02341..3d7b201 100644 --- a/src/components/Heatmap.tsx +++ b/src/components/Heatmap.tsx @@ -1,21 +1,11 @@ -import { useCallback, useId, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react"; +import { formatTooltipDate } from "../lib/dates"; import type { ContributionLevel, ContributionWeek } from "../lib/types"; const CELL_SIZE = 13; const GAP = 3; const LABEL_WIDTH = 28; -function formatTooltipDate(dateStr: string): { dayName: string; formatted: string } { - const date = new Date(`${dateStr}T00:00:00`); - const dayName = date.toLocaleDateString(undefined, { weekday: "short" }); - const formatted = date.toLocaleDateString(undefined, { - month: "short", - day: "numeric", - year: "numeric", - }); - return { dayName, formatted }; -} - const LEVEL_COLORS: Record = { NONE: "var(--contrib-none)", FIRST_QUARTILE: "var(--contrib-q1)", @@ -26,12 +16,15 @@ const LEVEL_COLORS: Record = { const DAY_LABELS = ["", "Mon", "", "Wed", "", "Fri", ""]; +/** Tooltip state including content and container-relative position. */ interface TooltipData { date: string; count: number; dayName: string; formatted: string; + /** Horizontal offset (px) from the container's left edge. */ x: number; + /** Vertical offset (px) from the container's top edge. */ y: number; } @@ -39,9 +32,31 @@ interface HeatmapProps { weeks: ContributionWeek[]; } +/** + * Cached SVG layout measurements used to convert SVG coordinates + * to container-relative pixel positions for tooltip placement. + */ +interface ILayout { + /** Ratio of rendered SVG width to its viewBox width. */ + scale: number; + /** SVG element's left offset relative to the container. */ + svgOffsetX: number; + /** SVG element's top offset relative to the container. */ + svgOffsetY: number; +} + +/** + * GitHub-style contribution heatmap rendered as an SVG grid. + * + * Cells are colored by contribution quartile using CSS custom properties + * for theme support. Supports mouse hover, touch tap (toggle), click, + * and keyboard interaction for tooltip display. + */ export default function Heatmap({ weeks }: HeatmapProps) { const containerRef = useRef(null); const [tooltip, setTooltip] = useState(null); + const isTouchRef = useRef(false); // Guards against mouse events firing after touch events on hybrid devices. + const layoutRef = useRef(null); const descId = useId(); const width = LABEL_WIDTH + weeks.length * (CELL_SIZE + GAP); @@ -70,71 +85,147 @@ export default function Heatmap({ weeks }: HeatmapProps) { return labels; }, [weeks]); - const showTooltipForTarget = useCallback((target: Element) => { + const cells = useMemo( + () => + weeks.map((week, wi) => + week.contributionDays.map((day) => ( + + )), + ), + [weeks], + ); + + /** Snapshot the SVG's position and scale so tooltip coordinates can be computed without repeated DOM reads. */ + const updateLayout = useCallback(() => { const container = containerRef.current; - if (!container) return; + const svg = container?.querySelector("svg"); + if (!container || !svg) return; + const containerRect = container.getBoundingClientRect(); + const svgRect = svg.getBoundingClientRect(); + layoutRef.current = { + scale: svgRect.width / width, + svgOffsetX: svgRect.left - containerRect.left, + svgOffsetY: svgRect.top - containerRect.top, + }; + }, [width]); + /** Read data-* attributes from a heatmap cell and return tooltip content + position. */ + const computeTooltip = useCallback((target: Element): TooltipData | null => { const dateVal = target.getAttribute("data-date"); const countVal = target.getAttribute("data-count"); - if (!dateVal || countVal == null) { - setTooltip(null); - return; - } + const wiVal = target.getAttribute("data-wi"); + const wdVal = target.getAttribute("data-wd"); + if (!dateVal || countVal == null || !wiVal || !wdVal || !layoutRef.current) return null; - const cellRect = target.getBoundingClientRect(); - const containerRect = container.getBoundingClientRect(); + const wi = Number(wiVal); + const wd = Number(wdVal); + const { scale, svgOffsetX, svgOffsetY } = layoutRef.current; const { dayName, formatted } = formatTooltipDate(dateVal); - setTooltip({ + return { date: dateVal, count: Number(countVal), dayName, formatted, - x: cellRect.left - containerRect.left + cellRect.width / 2, - y: cellRect.top - containerRect.top - 6, - }); + x: svgOffsetX + (LABEL_WIDTH + wi * (CELL_SIZE + GAP) + CELL_SIZE / 2) * scale, + y: svgOffsetY + (24 + wd * (CELL_SIZE + GAP)) * scale - 6, + }; }, []); + const handleMouseEnter = useCallback(() => { + isTouchRef.current = false; + updateLayout(); + }, [updateLayout]); + const handleMouseMove = useCallback( - (e: React.MouseEvent) => showTooltipForTarget(e.target as Element), - [showTooltipForTarget], + (e: React.MouseEvent) => { + if (isTouchRef.current) return; + setTooltip(computeTooltip(e.target as Element)); + }, + [computeTooltip], ); - const handleMouseLeave = useCallback(() => setTooltip(null), []); + const handleMouseLeave = useCallback(() => { + if (isTouchRef.current) return; + setTooltip(null); + }, []); - const toggleTooltip = useCallback( - (target: Element, stopPropagation: () => void) => { + const handleTouchEnd = useCallback( + (e: React.TouchEvent) => { + isTouchRef.current = true; + updateLayout(); + const touch = e.changedTouches[0]; + const target = document.elementFromPoint(touch.clientX, touch.clientY); + if (!target) return; + e.preventDefault(); const dateVal = target.getAttribute("data-date"); if (!dateVal) { setTooltip(null); return; } - stopPropagation(); - if (tooltip?.date === dateVal) { - setTooltip(null); - } else { - showTooltipForTarget(target); - } + setTooltip((prev) => (prev?.date === dateVal ? null : computeTooltip(target))); }, - [showTooltipForTarget, tooltip?.date], + [computeTooltip, updateLayout], ); const handleClick = useCallback( (e: React.MouseEvent) => { - toggleTooltip(e.target as Element, () => e.stopPropagation()); + if (isTouchRef.current) { + isTouchRef.current = false; + return; + } + const target = e.target as Element; + const dateVal = target.getAttribute("data-date"); + if (!dateVal) { + setTooltip(null); + return; + } + setTooltip((prev) => (prev?.date === dateVal ? null : computeTooltip(target))); }, - [toggleTooltip], + [computeTooltip], ); const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { if (e.key === "Enter" || e.key === " ") { - toggleTooltip(e.target as Element, () => e.stopPropagation()); + const target = e.target as Element; + const dateVal = target.getAttribute("data-date"); + if (!dateVal) { + setTooltip(null); + return; + } + setTooltip((prev) => (prev?.date === dateVal ? null : computeTooltip(target))); } }, - [toggleTooltip], + [computeTooltip], ); + /** + * Dismiss tooltip on outside tap/click + */ + useEffect(() => { + if (!tooltip) return; + const handleOutside = (e: PointerEvent) => { + if (containerRef.current && !containerRef.current.contains(e.target as Node)) { + setTooltip(null); + } + }; + document.addEventListener("pointerdown", handleOutside); + return () => document.removeEventListener("pointerdown", handleOutside); + }, [tooltip]); + return (
@@ -151,19 +242,19 @@ export default function Heatmap({ weeks }: HeatmapProps) { role="img" aria-label="Contribution heatmap" aria-describedby={descId} + onMouseEnter={handleMouseEnter} onMouseMove={handleMouseMove} onMouseLeave={handleMouseLeave} + onTouchEnd={handleTouchEnd} onClick={handleClick} onKeyDown={handleKeyDown} > - {/* Month labels */} {monthLabels.map((m) => ( {m.month} ))} - {/* Day labels */} {DAY_LABELS.map( (label, i) => label && ( @@ -179,22 +270,7 @@ export default function Heatmap({ weeks }: HeatmapProps) { ), )} - {/* Contribution cells */} - {weeks.map((week, wi) => - week.contributionDays.map((day) => ( - - )), - )} + {cells}
diff --git a/src/lib/dates.ts b/src/lib/dates.ts new file mode 100644 index 0000000..8eb9cf3 --- /dev/null +++ b/src/lib/dates.ts @@ -0,0 +1,10 @@ +export function formatTooltipDate(dateStr: string): { dayName: string; formatted: string } { + const date = new Date(`${dateStr}T00:00:00`); + const dayName = date.toLocaleDateString(undefined, { weekday: "short" }); + const formatted = date.toLocaleDateString(undefined, { + month: "short", + day: "numeric", + year: "numeric", + }); + return { dayName, formatted }; +} diff --git a/src/lib/useSettings.ts b/src/lib/useSettings.ts index d7244d9..75012ef 100644 --- a/src/lib/useSettings.ts +++ b/src/lib/useSettings.ts @@ -10,8 +10,15 @@ interface UrlState { stats?: string[]; } -const DEFAULT_FROM_DATE = `${new Date().getFullYear()}-01-01`; -const DEFAULT_TO_DATE = `${new Date().getFullYear()}-12-31`; +function computeDefaults() { + const now = new Date(); + const yearAgo = new Date(now); + yearAgo.setFullYear(yearAgo.getFullYear() - 1); + const fmt = (d: Date) => d.toISOString().split("T")[0]; + return { from: fmt(yearAgo), to: fmt(now) }; +} + +const { from: DEFAULT_FROM_DATE, to: DEFAULT_TO_DATE } = computeDefaults(); function encodeState(state: UrlState): string { return btoa(JSON.stringify(state));