From 7fbde1bc83439176cdc45b3c97cc4cb0bf829066 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 8 Oct 2026 15:19:09 +0200 Subject: [PATCH] feat: open the day from every chart drawn in days Move the chart's doors to the day (click, the touch tooltip's button, the dashed line through the open day, the row of day dots, the caption) into one hook and give it to every chart with a date axis: recovery, sleep duration and stages, the mood line and calendar, the intake calendar, water and caffeine, lab markers, custom metrics, questionnaires and basal temperature. Heatmaps share one adapter; every chart's keyboard way to a day is its data table or the dated list beside it. --- src/app/insights/mood/page.tsx | 2 +- src/components/charts/chart-data-table.tsx | 9 +- src/components/charts/compliance-heatmap.tsx | 243 ++++------ src/components/charts/health-chart.tsx | 107 ++--- src/components/charts/mood-chart.tsx | 91 +++- src/components/charts/mood-heatmap.tsx | 231 ++++------ .../charts/nutrient-daily-bar-chart.tsx | 193 +++++--- .../custom-metrics/custom-metric-chart.tsx | 245 ++++++---- .../custom-metrics/custom-metric-detail.tsx | 1 + src/components/cycle/bbt-chart.tsx | 79 +++- src/components/day/chart-day-links.tsx | 213 +++++++++ src/components/day/chart-day.tsx | 89 +++- src/components/day/heatmap-day.tsx | 159 +++++++ .../insights/nutrients/caffeine-card.tsx | 1 + .../insights/nutrients/hydration-card.tsx | 1 + .../recovery/recovery-metric-block.tsx | 3 + .../insights/sleep-duration-chart.tsx | 3 + .../insights/sleep-stage-stacked-bar.tsx | 403 ++++++++++------ src/components/labs/lab-biomarker-chart.tsx | 429 +++++++++--------- .../assessment-history-chart.tsx | 215 +++++---- .../mental-health/assessment-history.tsx | 8 +- 21 files changed, 1725 insertions(+), 1000 deletions(-) create mode 100644 src/components/day/chart-day-links.tsx create mode 100644 src/components/day/heatmap-day.tsx diff --git a/src/app/insights/mood/page.tsx b/src/app/insights/mood/page.tsx index 50886b099..f6af7cf94 100644 --- a/src/app/insights/mood/page.tsx +++ b/src/app/insights/mood/page.tsx @@ -148,7 +148,7 @@ export default function InsightsStimmungPage() { MeasurementType series, so the period-over-period range read (`/api/analytics/range`, keyed on a MeasurementType enum) has nothing to aggregate. */} - + diff --git a/src/components/charts/chart-data-table.tsx b/src/components/charts/chart-data-table.tsx index 185d00dae..98e1aee0d 100644 --- a/src/components/charts/chart-data-table.tsx +++ b/src/components/charts/chart-data-table.tsx @@ -186,8 +186,13 @@ export function ChartDataTable({ - {rows.map((point) => ( - + {rows.map((point, index) => ( + // Two readings can share a day (a list of entries, not of + // days), so the timestamp alone is not a key. + {/* The date is the row's header, so a screen reader reads "12 July, systolic 128" rather than a bare number. */} (null); const [containerWidth, setContainerWidth] = useState(0); - const [tooltip, setTooltip] = useState<{ - 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; - /** v1.42 — the day a pinned (touch) tooltip offers to open. */ - day?: string; - } | null>(null); - // v1.42 — a click opens the day on a fine pointer; a tap pins the tooltip, - // which then offers the day. The pointer type of the press decides. - const lastPointer = useRef("mouse"); - const today = useTodayKey(); - - // 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]); - - // 2026-07-17 a11y audit (M2) — Escape dismisses any open tooltip - // (pinned touch tooltip or keyboard-focus tooltip), matching 1.4.13's - // "dismissible" requirement. - useEffect(() => { - if (!tooltip) return; - const handleKeyDown = (event: KeyboardEvent) => { - if (event.key === "Escape") setTooltip(null); - }; - document.addEventListener("keydown", handleKeyDown); - return () => { - document.removeEventListener("keydown", handleKeyDown); - }; - }, [tooltip]); + // v1.42 — the cells are doors to their days, the same doors every chart + // has: a click (fine pointer), the pinned tooltip's button (touch), a + // dashed outline on the open day, and the data table for the keyboard. + const heatmapDay = useHeatmapDay(containerRef); const WEEKDAY_LABELS = [ t("charts.weekdays.mon"), @@ -262,9 +215,8 @@ export function ComplianceHeatmap({ const svgWidth = labelWidth + weeks * cellSize + Math.max(0, weeks - 1) * GAP; const svgHeight = headerHeight + 7 * cellSize + 6 * GAP; - // Per-day text alternative, shared by the pointer tooltip and the - // visually-hidden day list below the grid (M2). One source of truth so - // the sr-only list never drifts from what a pointer user reads. + // Per-day text for the pointer tooltip; the data table below the grid + // carries each active day's rate (M2). const describeCell = (cell: (typeof cells)[number]): string => { const rate = cell.data.expected > 0 @@ -282,8 +234,8 @@ export function ComplianceHeatmap({ : ""; return `${formatDay(cell.dateKey)}: ${cell.data.taken}/${cell.data.expected} (${rate}%)${timingInfo}`; }; - // Only days that carried a scheduled or taken dose go into the sr-only - // list; empty days are covered by the aggregate summary on the SVG. + // Only days that carried a scheduled or taken dose go into the data + // table; empty days are covered by the aggregate summary on the SVG. const activeCells = cells.filter( (cell) => cell.data.expected > 0 || cell.data.taken > 0, ); @@ -313,9 +265,7 @@ export function ComplianceHeatmap({ // off `containerWidth`, so a full window still fills the row) rather // than CSS-stretching ~5 columns of capped cells into wide rectangles. className={stretch ? "block max-w-full" : "block"} - onMouseLeave={() => - setTooltip((prev) => (prev?.pinned ? prev : null)) - } + {...heatmapDay.svgProps} > {/* Month labels */} {monthMarkers.map((m, i) => ( @@ -354,114 +304,47 @@ export function ComplianceHeatmap({ 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 => describeCell(cell); - return ( + {cells.map((cell) => ( + 0 || cell.data.taken > 0, + describe: () => describeCell(cell), + })} + /> + ))} + {cells + .filter((cell) => cell.dateKey === heatmapDay.openKey) + .map((cell) => ( { - // 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) => { - lastPointer.current = e.pointerType; - if (e.pointerType !== "touch") return; - setTooltip({ - x: e.clientX, - y: e.clientY, - text: buildText(), - pinned: true, - day: - cell.data.expected > 0 && - isOpenableDay(cell.dateKey, today) - ? cell.dateKey - : undefined, - }); - }} - onClick={() => { - if (lastPointer.current === "touch") return; - if (cell.data.expected === 0) return; - if (!isOpenableDay(cell.dateKey, today)) return; - setTooltip(null); - openDay(cell.dateKey, { trigger: containerRef.current }); - }} + key={`open-${cell.dateKey}`} + data-slot="heatmap-open-day" + x={labelWidth + cell.col * step - 1.5} + y={headerHeight + cell.row * step - 1.5} + width={cellSize + 3} + height={cellSize + 3} + rx={3} + pointerEvents="none" + {...OPEN_DAY_CELL} /> - ); - })} + ))} - {/* 2026-07-17 a11y audit (M2) — visually-hidden per-day list. The SVG - carries an aggregate `role="img"` summary; this list is the - keyboard/screen-reader path to the same granular values the pointer - tooltip shows, without adding a tab stop per cell. */} - {activeCells.length > 0 && ( -
    - {activeCells.map((cell) => ( -
  • {describeCell(cell)}
  • - ))} -
- )} - - {/* Tooltip */} - {tooltip && ( -
- {tooltip.text} - {tooltip.day ? ( - - ) : null} -
- )} + {heatmapDay.tooltipNode} {/* Legend */}
))}
+ {activeCells.length > 0 ? ( + <> + + {/* The keyboard and screen-reader way to each active day's doses, + and to the day itself. */} + ({ + date: cell.dateKey, + timestamp: dayAnchor(cell.dateKey), + rate: + cell.data.expected > 0 + ? Math.min( + 100, + Math.round((cell.data.taken / cell.data.expected) * 100), + ) + : undefined, + }))} + columns={[{ key: "rate", label: t("medications.compliance") }]} + formatValue={(value) => `${value} %`} + formatDate={(date) => formatDay(dateOnlyKey(date))} + bucket="day" + metricLabel={t("insights.medicationCompliance")} + dayLinks + /> + + ) : null} ); } diff --git a/src/components/charts/health-chart.tsx b/src/components/charts/health-chart.tsx index 06d7050fd..2d74f3c89 100644 --- a/src/components/charts/health-chart.tsx +++ b/src/components/charts/health-chart.tsx @@ -77,15 +77,12 @@ import { } from "@/lib/charts/window-stats"; import { shouldFireDataReady } from "@/lib/charts/data-ready-latch"; import { axisUnitSuffix } from "@/lib/charts/axis-unit"; +import { chartPointDayKey } from "@/components/day/chart-day"; import { - ChartDayCaption, - DayRug, - TooltipDayAction, - chartPointDayKey, - openChartDay, - useCoarsePointer, -} from "@/components/day/chart-day"; -import { useOpenDay } from "@/components/day/day-layer-controller"; + ChartDayFooter, + OPEN_DAY_LINE, + useChartDayLinks, +} from "@/components/day/chart-day-links"; import { isWholeNumberType } from "@/components/day/use-day-value-format"; // The range tabs select a CALENDAR-DAY window ending now — `days: 7` is @@ -1732,19 +1729,10 @@ export function HealthChart({ // v1.42 — the chart as a door to its days (`dayLinks`). Daily points only: // a week or month point averages many days and has no one day to open. const dayLinksActive = dayLinks && !mini && activeBucket === "day"; - const openDayKey = useOpenDay(); - const coarsePointer = useCoarsePointer(); - const plotRef = useRef(null); - const openDayIndex = - dayLinksActive && openDayKey - ? resolveVerticalMarkerPositions([{ date: openDayKey }], chartData)[0] - ?.pointIndex - : undefined; - const openDayPoint = - openDayIndex !== undefined ? chartData?.[openDayIndex] : undefined; // What the day shows at its top when it is opened from a point: the // chart's own name and the point's value as the tooltip reads it. - const dayFocusFor = (point: ChartDataPoint) => { + const dayFocusFor = (point: ChartDataPoint | undefined) => { + if (!point) return null; const parts = types .map((type) => ({ type, v: point[type] })) .filter((p): p is { type: string; v: number } => typeof p.v === "number"); @@ -1764,10 +1752,18 @@ export function HealthChart({ types: valueMode === "raw" ? types : [], }; }; - const openPointDay = (point: ChartDataPoint | undefined) => { - if (!point) return; - openChartDay(point.timestamp, dayFocusFor(point), plotRef.current); - }; + const dayLinkSource = chartDataWithCompare ?? chartData; + const chartDays = useChartDayLinks({ + enabled: dayLinksActive, + days: dayLinksActive + ? (dayLinkSource ?? []).map((point) => chartPointDayKey(point.timestamp)) + : [], + focusFor: (index) => dayFocusFor(dayLinkSource?.[index]), + }); + const coarsePointer = chartDays.coarse; + const openDayIndex = chartDays.openIndex; + const openDayPoint = + openDayIndex !== undefined ? chartData?.[openDayIndex] : undefined; const showContextDetails = showMA || showTrend || showBands; const animationsEnabled = !prefersReducedMotion(); @@ -2025,11 +2021,8 @@ export function HealthChart({ // gate on; this slot is rendered by the data branch only, so it // says the same thing without depending on Recharts' markup. data-slot="chart-plot" - data-day-links={dayLinksActive ? "true" : undefined} - ref={plotRef} - className={`relative z-10 h-full touch-pan-y ${ - dayLinksActive && !coarsePointer ? "cursor-pointer" : "" - }`} + {...chartDays.plotProps} + className={`relative z-10 h-full touch-pan-y ${chartDays.plotClassName}`} role="img" aria-label={chartAriaLabel} > @@ -2038,17 +2031,7 @@ export function HealthChart({ data={chartDataWithCompare ?? chartData} margin={{ top: 10, right: 8, bottom: 8, left: 8 }} accessibilityLayer - onClick={ - dayLinksActive && !coarsePointer - ? (state) => { - const index = Number(state?.activeTooltipIndex); - if (!Number.isInteger(index)) return; - openPointDay( - (chartDataWithCompare ?? chartData)?.[index], - ); - } - : undefined - } + onClick={chartDays.onChartClick} > ) : null} {openDayIndex !== undefined && openDayPoint @@ -2281,14 +2261,7 @@ export function HealthChart({ filterNull={false} // v1.42 — on a touch screen the tooltip is the way to the // day: it stays where the tap put it and takes taps. - trigger={ - dayLinksActive && coarsePointer ? "click" : "hover" - } - wrapperStyle={ - dayLinksActive && coarsePointer - ? { pointerEvents: "auto", zIndex: 20 } - : undefined - } + {...chartDays.tooltipProps} cursor={{ stroke: "var(--muted-foreground)", strokeOpacity: 0.3, @@ -2432,13 +2405,11 @@ export function HealthChart({ : dateLabel } rows={rows} - action={ - dayLinksActive && coarsePointer && hoverPoint ? ( - openPointDay(hoverPoint)} - /> - ) : undefined - } + action={chartDays.tooltipAction( + hoverPoint + ? dayLinkSource?.indexOf(hoverPoint) + : undefined, + )} /> ); }} @@ -2574,17 +2545,15 @@ export function HealthChart({

) : null} - {dayLinksActive && chartData && chartData.length > 0 ? ( - <> - - - + {chartData ? ( + ) : null} {/* The points the chart just drew, as a table. Reads the SAME expression `` is handed above, so the two diff --git a/src/components/charts/mood-chart.tsx b/src/components/charts/mood-chart.tsx index 269bfed78..eeb46aec8 100644 --- a/src/components/charts/mood-chart.tsx +++ b/src/components/charts/mood-chart.tsx @@ -31,6 +31,13 @@ import { type ChartBucketType, } from "@/lib/charts/bucket-time-series"; import { RichChartTooltip, type RichTooltipRow } from "./chart-tooltip"; +import { ChartDataTable } from "./chart-data-table"; +import { chartPointDayKey } from "@/components/day/chart-day"; +import { + ChartDayFooter, + OPEN_DAY_LINE, + useChartDayLinks, +} from "@/components/day/chart-day-links"; import { ChartEmptyState } from "./chart-empty-state"; import { ChartErrorState } from "./chart-error-state"; import { prefersReducedMotion } from "@/lib/charts/reduced-motion"; @@ -102,6 +109,14 @@ interface MoodChartProps { * visibly before its measurement siblings. Optional and repeat-safe. */ onDataReady?: () => void; + /** + * v1.42 — the chart is a door to its days, exactly as `HealthChart`'s + * `dayLinks`: a click on a point (the tooltip's button on touch) opens the + * day, the open day carries a dashed line, a row of dots marks the days + * with entries, and the data table under the chart links each date. Daily + * points only, never in `mini` mode. + */ + dayLinks?: boolean; } // --- Constants --- @@ -317,6 +332,7 @@ export function MoodChart({ compareBaseline = "none", chartKey, onDataReady, + dayLinks = false, }: MoodChartProps) { const { isAuthenticated } = useAuth(); const { t, locale } = useTranslations(); @@ -654,6 +670,27 @@ export function MoodChart({ : sorted[mid]; }, [chartData]); + // v1.42 — the chart as a door to its days (`dayLinks`), through the same + // hook every day-linked chart uses. + const dayLinksActive = dayLinks && !mini && activeBucket === "day"; + const dayLinkSource = chartDataWithCompare ?? chartData; + const chartDays = useChartDayLinks({ + enabled: dayLinksActive, + days: dayLinksActive + ? (dayLinkSource ?? []).map((point) => chartPointDayKey(point.timestamp)) + : [], + focusFor: (index) => { + const point = dayLinkSource?.[index]; + return point + ? { + label: displayTitle, + value: formatTooltipValue(point.score), + types: ["MOOD"], + } + : null; + }, + }); + const animationsEnabled = !prefersReducedMotion(); // v1.16.8 — only an EMPTY success hides the card (the dashboard gates @@ -766,7 +803,8 @@ export function MoodChart({ mini ? "h-[var(--chart-height,140px)]" : "h-[var(--chart-height,200px)] md:h-[var(--chart-height-md,220px)]" - } touch-pan-y`} + } touch-pan-y ${chartDays.plotClassName}`} + {...chartDays.plotProps} // Same stable hook as the measurement chart: rendered by the data // branch only, so the browser suite can gate on it instead of on // Recharts' own class names. @@ -779,6 +817,7 @@ export function MoodChart({ data={chartDataWithCompare ?? chartData} margin={{ top: 10, right: 8, bottom: 8, left: 8 }} accessibilityLayer + onClick={chartDays.onChartClick} > {/* v1.4.25 W3 — the mood chart's YAxis is pinned to five mood-score ticks ([1,2,3,4,5]), which Recharts @@ -868,7 +907,15 @@ export function MoodChart({ tickMargin={10} tickFormatter={formatMoodTick} /> + {chartDays.openIndex !== undefined ? ( + + ) : null} ; + return ( + + ); }} /> - {chartBody} + + {chartBody} + {!isLoading && !isError && chartData && chartData.length >= 3 ? ( + <> + + {dayLinksActive ? ( + ({ + date: point.date, + timestamp: point.timestamp, + score: point.score, + }))} + columns={[{ key: "score", label: t("charts.moodScore") }]} + formatValue={formatTooltipValue} + formatDate={tzFmt.date} + bucket="day" + metricLabel={displayTitle} + dayLinks + /> + ) : null} + + ) : null} + ); } diff --git a/src/components/charts/mood-heatmap.tsx b/src/components/charts/mood-heatmap.tsx index 39841e476..de1c082a2 100644 --- a/src/components/charts/mood-heatmap.tsx +++ b/src/components/charts/mood-heatmap.tsx @@ -1,9 +1,11 @@ "use client"; -import { openDay } from "@/components/day/day-layer-controller"; -import { isOpenableDay } from "@/components/day/day-url"; -import { useTodayKey } from "@/components/day/use-today-key"; +import { ChartDayCaption } from "@/components/day/chart-day"; +import { dayAnchor } from "@/components/day/chart-day-links"; +import { OPEN_DAY_CELL, useHeatmapDay } from "@/components/day/heatmap-day"; import { useCalendarDate } from "@/hooks/use-calendar-date"; +import { dateOnlyKey } from "@/lib/tz/date-only"; +import { ChartDataTable } from "./chart-data-table"; import { useState, useMemo, useEffect, useRef } from "react"; import { useDisplayTimezone, useTranslations } from "@/lib/i18n/context"; import { heatmapDays } from "@/lib/charts/heatmap-days"; @@ -73,47 +75,10 @@ export function MoodHeatmap({ const formatDay = useCalendarDate(); const containerRef = useRef(null); const [containerWidth, setContainerWidth] = useState(0); - const [tooltip, setTooltip] = useState<{ - x: number; - y: number; - text: string; - pinned?: boolean; - /** v1.42 — the day a pinned (touch) tooltip offers to open. */ - day?: string; - } | null>(null); - // v1.42 — a click opens the day on a fine pointer; a tap pins the tooltip, - // which then offers the day. The pointer type of the press decides. - const lastPointer = useRef("mouse"); - const today = useTodayKey(); - - 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]); - - // 2026-07-17 a11y audit (M2) — Escape dismisses any open tooltip - // (pinned touch tooltip or keyboard-focus tooltip), matching 1.4.13's - // "dismissible" requirement. - useEffect(() => { - if (!tooltip) return; - const handleKeyDown = (event: KeyboardEvent) => { - if (event.key === "Escape") setTooltip(null); - }; - document.addEventListener("keydown", handleKeyDown); - return () => { - document.removeEventListener("keydown", handleKeyDown); - }; - }, [tooltip]); + // v1.42 — the cells are doors to their days, the same doors every chart + // has: a click (fine pointer), the pinned tooltip's button (touch), a + // dashed outline on the open day, and the data table for the keyboard. + const heatmapDay = useHeatmapDay(containerRef); const WEEKDAY_LABELS = [ t("charts.weekdays.mon"), @@ -225,9 +190,8 @@ export function MoodHeatmap({ const svgWidth = labelWidth + weeks * cellSize + Math.max(0, weeks - 1) * GAP; const svgHeight = headerHeight + 7 * cellSize + 6 * GAP; - // Per-day text alternative, shared by the pointer tooltip and the - // visually-hidden day list below the grid (M2). One source of truth so - // the sr-only list never drifts from what a pointer user reads. + // Per-day text for the pointer tooltip; the data table below the grid + // prints the same score with the same label (M2). const describeCell = (cell: (typeof cells)[number]): string => { if (!cell.cell) { return `${formatDay(cell.dateKey)}: ${t("insights.mood.heatmapNoEntry")}`; @@ -236,7 +200,7 @@ export function MoodHeatmap({ const moodLabel = labelKey ? t(labelKey) : ""; return `${formatDay(cell.dateKey)}: ${cell.cell.score.toFixed(1)}${moodLabel ? ` · ${moodLabel}` : ""}`; }; - // Only the logged days go into the sr-only list; the empty-tinted cells + // Only the logged days go into the data table; the empty-tinted cells // are already covered by the aggregate summary on the SVG, so a rotor // user hears the days that carry a value rather than a wall of "no entry". const loggedCells = cells.filter((cell) => cell.cell !== null); @@ -259,9 +223,7 @@ export function MoodHeatmap({ // natural, square, left-aligned grid rather than CSS-stretching a few // capped columns into wide rectangles. Mirrors `compliance-heatmap`. className={stretch ? "block max-w-full" : "block"} - onMouseLeave={() => - setTooltip((prev) => (prev?.pinned ? prev : null)) - } + {...heatmapDay.svgProps} > {monthMarkers.map((m, i) => ( { - const buildText = (): string => describeCell(cell); - return ( + {cells.map((cell) => ( + describeCell(cell), + })} + /> + ))} + {cells + .filter((cell) => cell.dateKey === heatmapDay.openKey) + .map((cell) => ( { - 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) => { - lastPointer.current = e.pointerType; - if (e.pointerType !== "touch") return; - setTooltip({ - x: e.clientX, - y: e.clientY, - text: buildText(), - pinned: true, - day: - cell.cell !== null && isOpenableDay(cell.dateKey, today) - ? cell.dateKey - : undefined, - }); - }} - onClick={() => { - if (lastPointer.current === "touch") return; - if (cell.cell === null) return; - if (!isOpenableDay(cell.dateKey, today)) return; - setTooltip(null); - openDay(cell.dateKey, { trigger: containerRef.current }); - }} + key={`open-${cell.dateKey}`} + data-slot="heatmap-open-day" + x={labelWidth + cell.col * step - 1.5} + y={headerHeight + cell.row * step - 1.5} + width={cellSize + 3} + height={cellSize + 3} + rx={3} + pointerEvents="none" + {...OPEN_DAY_CELL} /> - ); - })} + ))} - {/* 2026-07-17 a11y audit (M2) — visually-hidden per-day list. The SVG - carries an aggregate `role="img"` summary; this list is the - keyboard/screen-reader path to the same granular values the pointer - tooltip shows, without adding a tab stop per cell. */} - {loggedCells.length > 0 && ( -
    - {loggedCells.map((cell) => ( -
  • {describeCell(cell)}
  • - ))} -
- )} - - {tooltip && ( -
- {tooltip.text} - {tooltip.day ? ( - - ) : null} -
- )} + {heatmapDay.tooltipNode}
))}
+ {loggedCells.length > 0 ? ( + <> + + {/* The keyboard and screen-reader way to each logged day's value, + and to the day itself. */} + ({ + date: cell.dateKey, + timestamp: dayAnchor(cell.dateKey), + score: cell.cell?.score, + }))} + columns={[{ key: "score", label: t("charts.moodScore") }]} + formatValue={(value) => { + const labelKey = moodLabelKeyForScore(Math.round(value)); + return labelKey + ? `${value.toFixed(1)} · ${t(labelKey)}` + : value.toFixed(1); + }} + formatDate={(date) => formatDay(dateOnlyKey(date))} + bucket="day" + metricLabel={t("insights.mood.heatmapTitle")} + dayLinks + /> + + ) : null} ); } diff --git a/src/components/charts/nutrient-daily-bar-chart.tsx b/src/components/charts/nutrient-daily-bar-chart.tsx index f66395121..749f3d988 100644 --- a/src/components/charts/nutrient-daily-bar-chart.tsx +++ b/src/components/charts/nutrient-daily-bar-chart.tsx @@ -12,10 +12,17 @@ import { YAxis, } from "recharts"; +import { + ChartDayFooter, + OPEN_DAY_LINE, + dayAnchor, + useChartDayLinks, +} from "@/components/day/chart-day-links"; import { CHART_HEIGHT_PX } from "@/lib/charts/constants"; import { prefersReducedMotion } from "@/lib/charts/reduced-motion"; import { useFormatters } from "@/lib/i18n/context"; +import { ChartDataTable } from "./chart-data-table"; import { RichChartTooltip, type RichTooltipRow } from "./chart-tooltip"; /** @@ -36,6 +43,13 @@ interface NutrientDailyBarChartProps { valueLabel: string; /** Optional dashed reference line value (EFSA target / ceiling). */ referenceValue?: number | null; + /** + * v1.42 — each bar opens its day, through the doors every day-linked + * chart has (click, the tooltip's button on touch, the dashed line, the + * row of dots, the data table). A day logged as nothing has no bar to + * open. + */ + dayLinks?: boolean; } export function NutrientDailyBarChart({ @@ -43,6 +57,7 @@ export function NutrientDailyBarChart({ unit, valueLabel, referenceValue, + dayLinks = false, }: NutrientDailyBarChartProps) { const fmt = useFormatters(); const points = useMemo( @@ -60,71 +75,125 @@ export function NutrientDailyBarChart({ const animate = !prefersReducedMotion(); const barColor = "var(--info)"; + const chartDays = useChartDayLinks({ + enabled: dayLinks, + days: points.map((p) => (p.amount > 0 ? p.day : null)), + focusFor: (index) => { + const point = points[index]; + return point + ? { label: valueLabel, value: fmt.integer(point.amount), unit } + : null; + }, + }); + const logged = points.filter((p) => p.amount > 0); + return ( - - +
- - fmt.dateShortSmartCalendar(day)} - tick={{ fontSize: 11, fill: "var(--muted-foreground)" }} - stroke="var(--border)" - minTickGap={24} - /> - - {referenceValue != null ? ( - - ) : null} - { - const active = props.active ?? false; - const payload = props.payload as - | ReadonlyArray<{ payload?: { day: string; amount: number } }> - | undefined; - const point = payload?.[0]?.payload; - if (!active || !point) { - return ; - } - const rows: RichTooltipRow[] = [ - { - name: valueLabel, - value: `${point.amount} ${unit}`, - color: barColor, - }, - ]; - return ( - + + + fmt.dateShortSmartCalendar(day)} + tick={{ fontSize: 11, fill: "var(--muted-foreground)" }} + stroke="var(--border)" + minTickGap={24} + /> + + {referenceValue != null ? ( + - ); - }} + ) : null} + {chartDays.openIndex !== undefined ? ( + + ) : null} + { + const active = props.active ?? false; + const payload = props.payload as + | ReadonlyArray<{ payload?: { day: string; amount: number } }> + | undefined; + const point = payload?.[0]?.payload; + if (!active || !point) { + return ; + } + const rows: RichTooltipRow[] = [ + { + name: valueLabel, + value: `${point.amount} ${unit}`, + color: barColor, + }, + ]; + return ( + + ); + }} + /> + + + +
+ ({ timestamp: dayAnchor(p.day) }))} + axis="band" + mark="bar" + // The y axis (44) on the left, the margin (12) on the right. + insetLeft={44} + insetRight={12} + /> + {chartDays.active && logged.length > 0 ? ( + ({ + date: p.day, + timestamp: dayAnchor(p.day), + amount: p.amount, + }))} + columns={[{ key: "amount", label: valueLabel }]} + unit={unit} + formatValue={(value) => fmt.integer(value)} + formatDate={(date) => fmt.dateShortSmartCalendar(date)} + bucket="day" + metricLabel={valueLabel} + dayLinks /> - -
-
+ ) : null} + ); } diff --git a/src/components/custom-metrics/custom-metric-chart.tsx b/src/components/custom-metrics/custom-metric-chart.tsx index 9cfadde5e..e35a77dc8 100644 --- a/src/components/custom-metrics/custom-metric-chart.tsx +++ b/src/components/custom-metrics/custom-metric-chart.tsx @@ -16,9 +16,20 @@ import { import { Button } from "@/components/ui/button"; import { CHART_HEIGHT_PX } from "@/lib/charts/constants"; import { prefersReducedMotion } from "@/lib/charts/reduced-motion"; -import { useTranslations, useFormatters } from "@/lib/i18n/context"; - +import { + useDisplayTimezone, + useTranslations, + useFormatters, +} from "@/lib/i18n/context"; +import { ChartDataTable } from "../charts/chart-data-table"; import { RichChartTooltip, type RichTooltipRow } from "../charts/chart-tooltip"; +import { + ChartDayFooter, + OPEN_DAY_LINE, + dayAnchor, + useChartDayLinks, +} from "../day/chart-day-links"; +import { dateKeyOfInstant } from "../day/day-url"; import { formatMetricValue } from "./format-value"; import type { CustomMetricEntryDto } from "./types"; @@ -48,11 +59,14 @@ interface ChartPoint { export function CustomMetricChart({ entries, + name, unit, targetLow, targetHigh, decimals, }: { + /** The metric's name: the day's focus label and the data table's caption. */ + name: string; /** Entries for this metric, any order (sorted internally). */ entries: CustomMetricEntryDto[]; unit: string; @@ -95,6 +109,25 @@ export function CustomMetricChart({ return [min - pad, max + pad]; }, [points, targetLow, targetHigh]); + // v1.42 — each value opens its day (the calendar day of its instant in + // the record's zone) through the doors every day-linked chart has. + const timeZone = useDisplayTimezone(); + const pointDays = points.map((point) => + dateKeyOfInstant(point.timestamp, timeZone), + ); + const chartDays = useChartDayLinks({ + enabled: true, + days: pointDays, + focusFor: (index) => { + const point = points[index]; + return point + ? { label: name, value: formatMetricValue(point.value, decimals), unit } + : null; + }, + }); + const openPoint = + chartDays.openIndex !== undefined ? points[chartDays.openIndex] : undefined; + const animate = !prefersReducedMotion(); const primary = "var(--primary)"; @@ -121,94 +154,130 @@ export function CustomMetricChart({

) : (
- - - - fmt.dateShortSmart(new Date(ts))} - tick={{ fontSize: 11, fill: "var(--muted-foreground)" }} - stroke="var(--border)" - minTickGap={32} - /> - formatMetricValue(v, decimals)} - /> - {targetLow != null && targetHigh != null ? ( - + + + - ) : null} - {targetLow != null ? ( - - ) : null} - {targetHigh != null ? ( - - ) : null} - { - const active = props.active ?? false; - const payload = props.payload as - ReadonlyArray<{ payload?: ChartPoint }> | undefined; - const point = payload?.[0]?.payload; - if (!active || !point) { - return ; + + fmt.dateShortSmart(new Date(ts)) } - const rows: RichTooltipRow[] = [ - { - name: t("customMetrics.chart.valueLabel"), - value: `${formatMetricValue(point.value, decimals)} ${unit}`, - color: primary, - }, - ]; - return ( - - ); - }} - /> - - - + tick={{ fontSize: 11, fill: "var(--muted-foreground)" }} + stroke="var(--border)" + minTickGap={32} + /> + formatMetricValue(v, decimals)} + /> + {targetLow != null && targetHigh != null ? ( + + ) : null} + {targetLow != null ? ( + + ) : null} + {targetHigh != null ? ( + + ) : null} + {openPoint ? ( + + ) : null} + { + const active = props.active ?? false; + const payload = props.payload as + ReadonlyArray<{ payload?: ChartPoint }> | undefined; + const point = payload?.[0]?.payload; + if (!active || !point) { + return ; + } + const rows: RichTooltipRow[] = [ + { + name: t("customMetrics.chart.valueLabel"), + value: `${formatMetricValue(point.value, decimals)} ${unit}`, + color: primary, + }, + ]; + return ( + + ); + }} + /> + + + +
+ + ({ + date: pointDays[index]!, + timestamp: dayAnchor(pointDays[index]!), + value: point.value, + }))} + columns={[{ key: "value", label: name }]} + unit={unit} + formatValue={(value) => formatMetricValue(value, decimals)} + formatDate={(date) => fmt.dateShortSmartCalendar(date)} + bucket="day" + metricLabel={name} + dayLinks + /> )} diff --git a/src/components/custom-metrics/custom-metric-detail.tsx b/src/components/custom-metrics/custom-metric-detail.tsx index 894366eac..485f4dde3 100644 --- a/src/components/custom-metrics/custom-metric-detail.tsx +++ b/src/components/custom-metrics/custom-metric-detail.tsx @@ -287,6 +287,7 @@ export function CustomMetricDetail({ = 2; + // v1.42 — each logged temperature opens its day through the doors every + // day-linked chart has. Tapping a cycle-calendar cell still logs the day; + // the chart is where a day is read. + const chartDays = useChartDayLinks({ + enabled: hasCurve, + days: points.map((p) => dateOnlyKey(new Date(p.t))), + focusFor: (index) => { + const p = points[index]; + return p + ? { + label: t("cycle.bbt.title"), + value: fmt.number(p.temp), + unit: unitDisplay.unitFor("BODY_TEMPERATURE"), + types: ["BODY_TEMPERATURE"], + } + : null; + }, + }); + const openPoint = + chartDays.openIndex !== undefined ? points[chartDays.openIndex] : undefined; + return ( @@ -213,14 +241,16 @@ export function BbtChart({ {t("cycle.bbt.caption")}

`${fmt.number(v as number)}°`} /> + {openPoint ? ( + + ) : null} } + content={(props) => { + const payload = props.payload as unknown as + { payload: BbtPoint }[] | undefined; + const point = payload?.[0]?.payload; + return ( + + ); + }} /> {ovulationMs != null ? (
+ ({ timestamp: p.t }))} + axis="time" + // The margin (4) + the y axis (40) on the left, the margin (14) + // on the right. + insetLeft={4 + 40} + insetRight={14} + /> + ({ + date: dateOnlyKey(new Date(p.t)), + timestamp: p.t, + temp: p.temp, + }))} + columns={[{ key: "temp", label: t("cycle.bbt.title") }]} + unit={unitDisplay.unitFor("BODY_TEMPERATURE")} + formatValue={(value) => fmt.number(value)} + formatDate={(date) => fmt.dateShortSmartCalendar(date)} + bucket="day" + metricLabel={t("cycle.bbt.title")} + dayLinks + /> )}
@@ -317,6 +387,8 @@ function PhaseDot(props: { cx?: number; cy?: number; payload?: BbtPoint }) { export function BbtTooltip(props: { active?: boolean; payload?: { payload: BbtPoint }[]; + /** v1.42 — the tooltip's way to the day on a touch screen. */ + action?: ReactNode; }) { const { t } = useTranslations(); const fmt = useFormatters(); @@ -350,6 +422,7 @@ export function BbtTooltip(props: { {t(`cycle.ovulationTest.${p.ovulationTest}`)}

) : null} + {props.action ?? null} ); } diff --git a/src/components/day/chart-day-links.tsx b/src/components/day/chart-day-links.tsx new file mode 100644 index 000000000..12f8c70d5 --- /dev/null +++ b/src/components/day/chart-day-links.tsx @@ -0,0 +1,213 @@ +"use client"; + +import { useCallback, useMemo, useRef, type ReactNode } from "react"; + +import type { DateKey } from "@/lib/day/contract"; + +import { + ChartDayCaption, + DayRug, + TooltipDayAction, + useCoarsePointer, + type ChartDayAxis, + type ChartDayMark, +} from "./chart-day"; +import { openDay, useOpenDay, type DayFocus } from "./day-layer-controller"; +import { isOpenableDay } from "./day-url"; +import { useTodayKey } from "./use-today-key"; + +/** + * The day view's doors on any Recharts chart drawn in days, in one place. + * + * `HealthChart` was the first chart to open its days; every other chart with + * a date axis (the sleep stages, a lab marker, the mood line, a bar per day) + * takes the same doors through this hook, so a click, a tap, the tooltip's + * button, the dashed line through the open day and the row of dots under the + * axis behave the same on every page: + * + * - fine pointer: a click on the plot opens the day of the point under the + * cursor; hovering shows the tooltip as before; + * - touch: a tap shows the value and pins the tooltip, whose button + * ("View the whole day") opens the day. Two steps, so a scroll that + * grazes the chart never opens a sheet; + * - the open day is marked with `OPEN_DAY_LINE` (a dashed vertical line) + * at `openIndex`; + * - `ChartDayFooter` draws the row of day dots and the one-line caption. + * + * The caller says, per drawn point and in data order, which calendar day the + * point stands for, or `null` when it stands for none (a week or month + * average, a synthetic "now" point, a day with nothing on it). A day in the + * future never opens. + */ + +/** The props of the dashed line through the open day, for ``. */ +export const OPEN_DAY_LINE = { + stroke: "var(--muted-foreground)", + strokeDasharray: "3 3", + strokeOpacity: 0.7, + ifOverflow: "discard", +} as const; + +/** What a chart's click handler receives from Recharts. */ +interface ChartClickState { + activeTooltipIndex?: unknown; + activeLabel?: unknown; +} + +export interface ChartDayLinksOptions { + /** Off for a mini chart, a coarse bucket, or a surface on the NOT-here list. */ + enabled: boolean; + /** Per drawn point, in data order: its calendar day, or null. */ + days: ReadonlyArray; + /** What the day shows at its top when opened from point `index`. */ + focusFor?: (index: number) => Omit | null; + /** + * For a chart whose plotted series are not the chart's `data` (several + * `` arrays on one time axis): the point index of the click's + * x value. Defaults to Recharts' `activeTooltipIndex`. + */ + indexOfClick?: (state: ChartClickState) => number | null; +} + +export interface ChartDayLinks { + active: boolean; + coarse: boolean; + /** Index of the open day's point, for the dashed line. */ + openIndex: number | undefined; + /** The open day itself, for charts that mark it by value. */ + openDay: DateKey | null; + /** Open the day of point `index` (no-op without a day). */ + open: (index: number) => void; + /** True when point `index` has a day that opens. */ + canOpen: (index: number) => boolean; + /** Spread on the element around ``. */ + plotProps: { + ref: React.RefObject; + "data-day-links"?: "true"; + }; + /** Class for the same element: a pointer cursor on a fine pointer. */ + plotClassName: string; + /** Pass as the Recharts chart's `onClick`. */ + onChartClick: + ((state: ChartClickState | null | undefined) => void) | undefined; + /** Spread on ``: on touch it stays put and takes taps. */ + tooltipProps: { + trigger: "click" | "hover"; + wrapperStyle?: React.CSSProperties; + }; + /** The tooltip's button for point `index`, on a touch screen only. */ + tooltipAction: (index: number | undefined) => ReactNode | undefined; +} + +export function useChartDayLinks({ + enabled, + days, + focusFor, + indexOfClick, +}: ChartDayLinksOptions): ChartDayLinks { + const coarse = useCoarsePointer(); + const openKey = useOpenDay(); + const today = useTodayKey(); + const plotRef = useRef(null); + + const canOpen = useCallback( + (index: number) => enabled && isOpenableDay(days[index] ?? null, today), + [enabled, days, today], + ); + + const open = useCallback( + (index: number) => { + if (!canOpen(index)) return; + const date = days[index] as DateKey; + const focus = focusFor?.(index) ?? null; + openDay(date, { + focus: focus ? { ...focus, date } : null, + trigger: plotRef.current, + }); + }, + [canOpen, days, focusFor], + ); + + const openIndex = useMemo(() => { + if (!enabled || openKey === null) return undefined; + const at = days.lastIndexOf(openKey); + return at === -1 ? undefined : at; + }, [enabled, openKey, days]); + + const onChartClick = + enabled && !coarse + ? (state: ChartClickState | null | undefined) => { + if (!state) return; + const index = indexOfClick + ? indexOfClick(state) + : Number(state.activeTooltipIndex); + if (index === null || !Number.isInteger(index)) return; + open(index); + } + : undefined; + + return { + active: enabled, + coarse, + openIndex, + openDay: enabled ? openKey : null, + open, + canOpen, + plotProps: { + ref: plotRef, + "data-day-links": enabled ? "true" : undefined, + }, + plotClassName: enabled && !coarse ? "cursor-pointer" : "", + onChartClick, + tooltipProps: + enabled && coarse + ? { + trigger: "click", + wrapperStyle: { pointerEvents: "auto", zIndex: 20 }, + } + : { trigger: "hover" }, + tooltipAction: (index) => + enabled && coarse && index !== undefined && canOpen(index) ? ( + open(index)} /> + ) : undefined, + }; +} + +/** + * The row of day dots and the caption under a day-linked chart. Nothing when + * the chart is not day-linked or draws no points. + */ +export function ChartDayFooter({ + links, + points, + axis = "index", + insetLeft, + insetRight, + mark = "point", +}: { + links: Pick; + /** The drawn points' noon-UTC day anchors (or instants), in data order. */ + points: ReadonlyArray<{ timestamp: number }>; + axis?: ChartDayAxis; + insetLeft: number; + insetRight: number; + mark?: ChartDayMark; +}) { + if (!links.active || points.length === 0) return null; + return ( + <> + + + + ); +} + +/** A day key's noon-UTC anchor, the timestamp a daily point is drawn at. */ +export function dayAnchor(day: DateKey): number { + return Date.parse(`${day}T12:00:00.000Z`); +} diff --git a/src/components/day/chart-day.tsx b/src/components/day/chart-day.tsx index 7983a6604..34b5ec77c 100644 --- a/src/components/day/chart-day.tsx +++ b/src/components/day/chart-day.tsx @@ -119,19 +119,62 @@ export function rugPosition( return points.length - 1; } +/** + * How a chart spreads its points along the x axis, which decides where a + * day's dot sits under it: + * + * - `index`: the axis is the point index, the first point at the left edge + * of the plot and the last at the right (`HealthChart`, the mood line, a + * category line chart); + * - `band`: one band per point, the point at the band's centre (a bar + * chart); + * - `time`: the axis is the timestamp itself, from the first point to the + * last (a lab chart, any `scale="time"` axis). + */ +export type ChartDayAxis = "index" | "band" | "time"; + +/** + * Where along the plot (0 at the left edge, 1 at the right) a day's dot + * belongs, or null outside the drawn span. + */ +export function rugFraction( + day: DateKey, + points: readonly RugPoint[], + axis: ChartDayAxis, +): number | null { + const at = rugPosition(day, points); + if (at === null) return null; + const n = points.length; + if (axis === "band") return (at + 0.5) / n; + if (n === 1) return 0.5; + if (axis === "time") { + const first = points[0]!.timestamp; + const last = points[n - 1]!.timestamp; + const t = Date.parse(`${day}T12:00:00.000Z`); + return last === first ? 0.5 : (t - first) / (last - first); + } + return at / (n - 1); +} + /** * The row of day dots under a daily chart. `insetLeft` / `insetRight` are the * pixels between the chart box and the first and last point (margin, axis, * padding), so a dot lands under its day. + * + * The row is always drawn at its full height, dots or not: the index that + * fills it arrives after the chart, and a row that appeared then would push + * everything under the chart down by its height. */ export function DayRug({ points, insetLeft, insetRight, + axis = "index", }: { points: readonly RugPoint[]; insetLeft: number; insetRight: number; + axis?: ChartDayAxis; }) { const today = useTodayKey(); const open = useOpenDay(); @@ -141,18 +184,16 @@ export function DayRug({ const to = last ? chartPointDayKey(last.timestamp) : null; const index = useDayIndex(from, to); const data = index.data; - if (!data?.days || points.length === 0) return null; - const notable = new Set(data.notable ?? []); - const maxIndex = Math.max(points.length - 1, 1); - const days = Object.keys(data.days) + const notable = new Set(data?.notable ?? []); + const days = Object.keys(data?.days ?? {}) .filter((day) => isOpenableDay(day, today)) - .map((day) => ({ day, at: rugPosition(day, points) })) + .map((day) => ({ day, at: rugFraction(day, points, axis) })) .filter((entry): entry is { day: string; at: number } => entry.at !== null); - if (days.length === 0) return null; return (