diff --git a/src/components/charts/compliance-heatmap.tsx b/src/components/charts/compliance-heatmap.tsx index 6aff1bad6..938b901b2 100644 --- a/src/components/charts/compliance-heatmap.tsx +++ b/src/components/charts/compliance-heatmap.tsx @@ -20,6 +20,10 @@ interface ComplianceHeatmapProps { const CELL_SIZE = 18; const GAP = 3; +// v1.4.27 MB7 / CF-10 — cell-floor so the static heatmap never paints +// below the touch-friendly 14 px square on narrow viewports. The +// stretch branch computes an adaptive size; both branches clamp here. +const CELL_FLOOR_PX = 14; function getColor(data: DailyData): string { if (data.expected === 0) return "var(--secondary)"; @@ -78,8 +82,36 @@ export function ComplianceHeatmap({ x: number; y: number; text: string; + /** + * v1.4.27 MB7 / CF-10 — when `pinned` the tooltip stays mounted + * across `onPointerLeave` so a touch user sees the per-cell + * breakdown after lifting their finger. Mouse + pen users get the + * existing hover-only experience because their interactions never + * set `pinned`. A second tap on a different cell repositions the + * tooltip; a tap outside any cell clears it (wired below). + */ + pinned?: boolean; } | null>(null); + // v1.4.27 MB7 / CF-10 — outside-click dismisses a pinned tooltip so a + // touch user can clear the per-cell detail without scrolling the + // pinned label off-screen. The listener is gated on `tooltip?.pinned` + // so the non-touch hover flow never pays the indirection cost. + useEffect(() => { + if (!tooltip?.pinned) return; + const handlePointer = (event: PointerEvent) => { + const container = containerRef.current; + if (!container) return; + if (!container.contains(event.target as Node)) { + setTooltip(null); + } + }; + document.addEventListener("pointerdown", handlePointer, true); + return () => { + document.removeEventListener("pointerdown", handlePointer, true); + }; + }, [tooltip?.pinned]); + const WEEKDAY_LABELS = [ t("charts.weekdays.mon"), "", @@ -188,10 +220,13 @@ export function ComplianceHeatmap({ const labelWidth = stretch ? 0 : 76; const headerHeight = 18; + // v1.4.27 MB7 / CF-10 — clamp the stretch-branch adaptive cell to the + // 14 px floor so the heatmap stays tap-friendly when the container + // narrows. Static (non-stretch) instances use the canonical 18 px. const cellSize = stretch && containerWidth > 0 ? Math.max( - 8, + CELL_FLOOR_PX, (containerWidth - labelWidth - Math.max(0, weeks - 1) * GAP) / Math.max(weeks, 1), ) @@ -202,12 +237,20 @@ export function ComplianceHeatmap({ return (
-
+ {/* v1.4.27 MB7 / CF-10 — `overflow-x-auto` on `=sm`, so the scroll branch + only kicks in for the non-stretch (static 18 px cell) case + when the parent column is narrower than `weeks * 21 px`. */} +
setTooltip(null)} + onMouseLeave={() => setTooltip((prev) => (prev?.pinned ? prev : null))} > {/* Month labels */} {monthMarkers.map((m, i) => ( @@ -240,43 +283,67 @@ export function ComplianceHeatmap({ ), )} - {/* Cells */} - {cells.map((cell) => ( - { - const rate = - cell.data.expected > 0 - ? Math.min( - 100, - Math.round( - (cell.data.taken / cell.data.expected) * 100, - ), - ) - : 0; - const hasTimingData = - cell.data.onTime !== undefined || - cell.data.late !== undefined || - cell.data.veryLate !== undefined; - const timingInfo = hasTimingData - ? ` | ${cell.data.onTime ?? 0} ${t("charts.heatmapOnTime")}, ${cell.data.late ?? 0} ${t("charts.heatmapLate")}, ${cell.data.veryLate ?? 0} ${t("charts.heatmapVeryLate")}` - : ""; - setTooltip({ - x: e.clientX, - y: e.clientY, - text: `${formatDateDE(cell.dateKey)}: ${cell.data.taken}/${cell.data.expected} (${rate}%)${timingInfo}`, - }); - }} - onMouseLeave={() => setTooltip(null)} - /> - ))} + {/* Cells — v1.4.27 MB7 / CF-10: tap-to-pin tooltip in + parallel with the existing hover affordance. Mouse + pen + users keep `onPointerEnter` / `onPointerLeave`; touch + users tap the cell to pin and tap outside (or another + cell) to move/clear. `pointerType === "touch"` discriminates + so a hover dismiss never wipes a pinned tooltip. */} + {cells.map((cell) => { + const buildText = (): string => { + const rate = + cell.data.expected > 0 + ? Math.min( + 100, + Math.round((cell.data.taken / cell.data.expected) * 100), + ) + : 0; + const hasTimingData = + cell.data.onTime !== undefined || + cell.data.late !== undefined || + cell.data.veryLate !== undefined; + const timingInfo = hasTimingData + ? ` | ${cell.data.onTime ?? 0} ${t("charts.heatmapOnTime")}, ${cell.data.late ?? 0} ${t("charts.heatmapLate")}, ${cell.data.veryLate ?? 0} ${t("charts.heatmapVeryLate")}` + : ""; + return `${formatDateDE(cell.dateKey)}: ${cell.data.taken}/${cell.data.expected} (${rate}%)${timingInfo}`; + }; + return ( + { + // Touch enter fires synthetically immediately before + // `pointerdown`; skip it so the pinned tooltip below + // takes precedence with its real coordinates. + if (e.pointerType === "touch") return; + setTooltip({ + x: e.clientX, + y: e.clientY, + text: buildText(), + }); + }} + onPointerLeave={(e) => { + if (e.pointerType === "touch") return; + setTooltip((prev) => (prev?.pinned ? prev : null)); + }} + onPointerDown={(e) => { + if (e.pointerType !== "touch") return; + setTooltip({ + x: e.clientX, + y: e.clientY, + text: buildText(), + pinned: true, + }); + }} + /> + ); + })}