From f53dd96f44412b0a629e7f085ec245986552845d Mon Sep 17 00:00:00 2001 From: Mathijs Rutgers Date: Sun, 29 Mar 2026 12:36:04 +0200 Subject: [PATCH 1/5] Restructure tooltip handlers --- src/components/Heatmap.tsx | 170 +++++++++++++++++++++++++------------ src/lib/dates.ts | 10 +++ 2 files changed, 125 insertions(+), 55 deletions(-) create mode 100644 src/lib/dates.ts diff --git a/src/components/Heatmap.tsx b/src/components/Heatmap.tsx index cd02341..981d873 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)", @@ -42,6 +32,12 @@ interface HeatmapProps { export default function Heatmap({ weeks }: HeatmapProps) { const containerRef = useRef(null); const [tooltip, setTooltip] = useState(null); + const isTouchRef = useRef(false); + const layoutRef = useRef<{ + scale: number; + svgOffsetX: number; + svgOffsetY: number; + } | null>(null); const descId = useId(); const width = LABEL_WIDTH + weeks.length * (CELL_SIZE + GAP); @@ -70,71 +66,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], + ); + + 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]); + 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,8 +223,10 @@ 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} > @@ -180,21 +254,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 }; +} From d0afdbfca5886fda4794c1d476aaca8eff120a7b Mon Sep 17 00:00:00 2001 From: Mathijs Rutgers Date: Sun, 29 Mar 2026 12:47:09 +0200 Subject: [PATCH 2/5] Do not render months in which are in the future --- src/components/Heatmap.tsx | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/src/components/Heatmap.tsx b/src/components/Heatmap.tsx index 981d873..2862cea 100644 --- a/src/components/Heatmap.tsx +++ b/src/components/Heatmap.tsx @@ -29,7 +29,7 @@ interface HeatmapProps { weeks: ContributionWeek[]; } -export default function Heatmap({ weeks }: HeatmapProps) { +export default function Heatmap({ weeks: rawWeeks }: HeatmapProps) { const containerRef = useRef(null); const [tooltip, setTooltip] = useState(null); const isTouchRef = useRef(false); @@ -40,6 +40,18 @@ export default function Heatmap({ weeks }: HeatmapProps) { } | null>(null); const descId = useId(); + // Trim trailing weeks where every day is in the future + const weeks = useMemo(() => { + const today = new Date().toISOString().split("T")[0]; + let lastIdx = rawWeeks.length; + while (lastIdx > 0) { + const week = rawWeeks[lastIdx - 1]; + if (week.contributionDays.some((d) => d.date <= today)) break; + lastIdx--; + } + return rawWeeks.slice(0, lastIdx); + }, [rawWeeks]); + const width = LABEL_WIDTH + weeks.length * (CELL_SIZE + GAP); const height = 7 * (CELL_SIZE + GAP) + 20; @@ -123,8 +135,6 @@ export default function Heatmap({ weeks }: HeatmapProps) { }; }, []); - - const handleMouseEnter = useCallback(() => { isTouchRef.current = false; updateLayout(); From 5c54c3e0f043ce1a1df41f1911c647c8b7434e19 Mon Sep 17 00:00:00 2001 From: Mathijs Rutgers Date: Sun, 29 Mar 2026 13:05:09 +0200 Subject: [PATCH 3/5] Add JSDoc to explain component parts --- src/components/Heatmap.tsx | 48 ++++++++++++++++++++++++++------------ 1 file changed, 33 insertions(+), 15 deletions(-) diff --git a/src/components/Heatmap.tsx b/src/components/Heatmap.tsx index 2862cea..abe4691 100644 --- a/src/components/Heatmap.tsx +++ b/src/components/Heatmap.tsx @@ -16,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; } @@ -29,28 +32,44 @@ interface HeatmapProps { weeks: ContributionWeek[]; } -export default function Heatmap({ weeks: rawWeeks }: HeatmapProps) { +/** + * 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. + * + * Weeks that are entirely in the future are trimmed. 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: allWeeks }: HeatmapProps) { const containerRef = useRef(null); const [tooltip, setTooltip] = useState(null); - const isTouchRef = useRef(false); - const layoutRef = useRef<{ - scale: number; - svgOffsetX: number; - svgOffsetY: number; - } | null>(null); + const isTouchRef = useRef(false); // Guards against mouse events firing after touch events on hybrid devices. + const layoutRef = useRef(null); const descId = useId(); - // Trim trailing weeks where every day is in the future const weeks = useMemo(() => { const today = new Date().toISOString().split("T")[0]; - let lastIdx = rawWeeks.length; + let lastIdx = allWeeks.length; while (lastIdx > 0) { - const week = rawWeeks[lastIdx - 1]; + const week = allWeeks[lastIdx - 1]; if (week.contributionDays.some((d) => d.date <= today)) break; lastIdx--; } - return rawWeeks.slice(0, lastIdx); - }, [rawWeeks]); + return allWeeks.slice(0, lastIdx); + }, [allWeeks]); const width = LABEL_WIDTH + weeks.length * (CELL_SIZE + GAP); const height = 7 * (CELL_SIZE + GAP) + 20; @@ -100,6 +119,7 @@ export default function Heatmap({ weeks: rawWeeks }: HeatmapProps) { [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; const svg = container?.querySelector("svg"); @@ -113,6 +133,7 @@ export default function Heatmap({ weeks: rawWeeks }: HeatmapProps) { }; }, [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"); @@ -240,14 +261,12 @@ export default function Heatmap({ weeks: rawWeeks }: HeatmapProps) { onClick={handleClick} onKeyDown={handleKeyDown} > - {/* Month labels */} {monthLabels.map((m) => ( {m.month} ))} - {/* Day labels */} {DAY_LABELS.map( (label, i) => label && ( @@ -263,7 +282,6 @@ export default function Heatmap({ weeks: rawWeeks }: HeatmapProps) { ), )} - {/* Contribution cells */} {cells}
From 7cda6edc2276612770a9c22da77b7162dd061c68 Mon Sep 17 00:00:00 2001 From: Mathijs Rutgers Date: Mon, 6 Apr 2026 21:58:16 +0200 Subject: [PATCH 4/5] Add last 12 month date filter --- src/components/Heatmap.tsx | 20 ++++---------------- src/lib/useSettings.ts | 11 +++++++++-- 2 files changed, 13 insertions(+), 18 deletions(-) diff --git a/src/components/Heatmap.tsx b/src/components/Heatmap.tsx index abe4691..3d7b201 100644 --- a/src/components/Heatmap.tsx +++ b/src/components/Heatmap.tsx @@ -48,29 +48,17 @@ interface ILayout { /** * GitHub-style contribution heatmap rendered as an SVG grid. * - * Weeks that are entirely in the future are trimmed. 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. + * 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: allWeeks }: HeatmapProps) { +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 weeks = useMemo(() => { - const today = new Date().toISOString().split("T")[0]; - let lastIdx = allWeeks.length; - while (lastIdx > 0) { - const week = allWeeks[lastIdx - 1]; - if (week.contributionDays.some((d) => d.date <= today)) break; - lastIdx--; - } - return allWeeks.slice(0, lastIdx); - }, [allWeeks]); - const width = LABEL_WIDTH + weeks.length * (CELL_SIZE + GAP); const height = 7 * (CELL_SIZE + GAP) + 20; 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)); From 0adb37844aa2c892da19be3185bd8f2611628bac Mon Sep 17 00:00:00 2001 From: Mathijs Rutgers Date: Mon, 6 Apr 2026 21:58:34 +0200 Subject: [PATCH 5/5] Sync date presets across Toolbar and SettingsDrawer --- src/components/DatePresets.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) 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