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 1/6] 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 ( ); }} diff --git a/src/components/labs/lab-biomarker-chart.tsx b/src/components/labs/lab-biomarker-chart.tsx index 7a8828340..97f78dcf8 100644 --- a/src/components/labs/lab-biomarker-chart.tsx +++ b/src/components/labs/lab-biomarker-chart.tsx @@ -406,7 +406,6 @@ export function LabBiomarkerChart({ active label={labShortDate(point.timestamp)} rows={rows} - action={chartDays.tooltipAction(points.indexOf(point))} /> ); }} diff --git a/src/components/medications/detail/efficacy/efficacy-chart.tsx b/src/components/medications/detail/efficacy/efficacy-chart.tsx index 0d0307570..36bab7d32 100644 --- a/src/components/medications/detail/efficacy/efficacy-chart.tsx +++ b/src/components/medications/detail/efficacy/efficacy-chart.tsx @@ -378,18 +378,11 @@ export function EfficacyChart({ color: item.color ?? "var(--chart-1)", }; }); - const index = - typeof x === "number" - ? displaySeries.findIndex((p) => p.t === x) - : -1; return ( ); }} diff --git a/src/components/medications/dose-strength-curve.tsx b/src/components/medications/dose-strength-curve.tsx index 4b08d8082..6040fb7c7 100644 --- a/src/components/medications/dose-strength-curve.tsx +++ b/src/components/medications/dose-strength-curve.tsx @@ -326,7 +326,6 @@ export function DoseStrengthCurve({ active label={fmt.dateShortSmart(new Date(point.t))} rows={rows} - action={chartDays.tooltipAction(chartData.indexOf(point))} /> ); }} diff --git a/src/components/mental-health/assessment-history-chart.tsx b/src/components/mental-health/assessment-history-chart.tsx index 1cdf57adb..548b2165b 100644 --- a/src/components/mental-health/assessment-history-chart.tsx +++ b/src/components/mental-health/assessment-history-chart.tsx @@ -183,7 +183,6 @@ export function AssessmentHistoryChart({ active label={fmt.dateShortSmart(new Date(point.timestamp))} rows={rowsOut} - action={chartDays.tooltipAction(points.indexOf(point))} /> ); }} From 8bcb3e6301f24864bb36129469c7507d144668c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 8 Oct 2026 16:04:54 +0200 Subject: [PATCH 4/6] fix: keep the two-step on chart points on touch A point and a calendar cell are small targets; one tap that opened the day opened days by accident. On a touch screen the first tap shows the value again and the tooltip's calm 44 px row opens the day, on every day-linked chart and calendar alike. A date in a list still opens in one tap; a click on a fine pointer still opens directly. The tooltip keeps not gliding into place. --- e2e/day-doors.spec.ts | 26 +++++-- e2e/day-view.spec.ts | 17 +++-- messages/de.json | 6 +- messages/en.json | 6 +- messages/es.json | 6 +- messages/fr.json | 6 +- messages/it.json | 6 +- messages/ko.json | 6 +- messages/pl.json | 6 +- src/components/charts/health-chart.tsx | 5 ++ .../charts/medication-compliance-chart.tsx | 12 +++- src/components/charts/mood-chart.tsx | 11 ++- .../charts/nutrient-daily-bar-chart.tsx | 1 + .../custom-metrics/custom-metric-chart.tsx | 1 + src/components/cycle/bbt-chart.tsx | 20 +++++- .../__tests__/day-door-coverage-guard.test.ts | 12 ++-- src/components/day/chart-day-links.tsx | 67 +++++++++++++------ src/components/day/chart-day.tsx | 31 +++++++-- src/components/day/heatmap-day.tsx | 32 ++++++--- .../insights/mood/mood-dimension-trends.tsx | 1 + .../insights/sleep-stage-stacked-bar.tsx | 4 ++ src/components/labs/lab-biomarker-chart.tsx | 1 + .../detail/efficacy/efficacy-chart.tsx | 7 ++ .../medications/dose-strength-curve.tsx | 1 + .../assessment-history-chart.tsx | 1 + 25 files changed, 213 insertions(+), 79 deletions(-) diff --git a/e2e/day-doors.spec.ts b/e2e/day-doors.spec.ts index af3b4c2ea..c83cfa352 100644 --- a/e2e/day-doors.spec.ts +++ b/e2e/day-doors.spec.ts @@ -13,11 +13,13 @@ import { expect, test } from "./setup/test"; * * The day view first reached six metric pages; the recovery page beside them * offered no day at all. These journeys walk the surfaces that came after, - * each through the same door: a click on a point (or a calendar cell) opens - * the day beside the page, and on a touch screen a tap does the same as a - * bottom sheet, the tapped value on top. The dashed line (or outline) marks - * the open day, the row of dots and the caption sit under every chart, and - * the data table's dates are the keyboard way to the same days. + * each through the same doors: on a fine pointer a click on a point (or a + * calendar cell) opens the day beside the page; on a touch screen the first + * tap shows the value and the tooltip's "View the whole day" opens it as a + * bottom sheet, the tapped value on top. A date in a list opens in one tap. + * The dashed line (or outline) marks the open day, the row of dots and the + * caption sit under every chart, and the data table's dates are the + * keyboard way to the same days. * * - recovery (the page the beta reported), sleep, a lab marker, a chart * with its comparison period on and a dashboard chart: line charts; @@ -41,8 +43,14 @@ async function openFromPlot(page: Page, plot: Locator, testInfo: TestInfo) { const x = box.x + box.width * 0.8; const y = box.y + box.height / 2; if (isPhone(testInfo)) { - // One tap, the same door as the click: no tooltip step in between. await page.touchscreen.tap(x, y); + const open = page.locator('[data-slot="chart-tooltip-open-day"]:visible'); + await expect(open).toBeVisible(); + // A tap alone never opens a sheet: a point is a small target. + await expect(panel(page)).toHaveCount(0); + // The button is a calm, full 44 px row. + expect((await open.boundingBox())!.height).toBeGreaterThanOrEqual(44); + await open.tap(); await expect(panel(page)).toHaveAttribute("data-shell", "bottom"); } else { await page.mouse.move(x, y); @@ -143,6 +151,12 @@ test.describe("every chart drawn in days opens its days", () => { const day = await cell.getAttribute("data-day"); if (isPhone(testInfo)) { await cell.tap(); + const open = page.locator( + '[data-slot="heatmap-tooltip"] [data-slot="chart-tooltip-open-day"]', + ); + await expect(open).toBeVisible(); + await expect(panel(page)).toHaveCount(0); + await open.tap(); await expect(panel(page)).toHaveAttribute("data-shell", "bottom"); } else { await cell.click(); diff --git a/e2e/day-view.spec.ts b/e2e/day-view.spec.ts index 43806f074..6a04b16f0 100644 --- a/e2e/day-view.spec.ts +++ b/e2e/day-view.spec.ts @@ -15,9 +15,8 @@ import { MOBILE_ROUTES_ANALYTE } from "./setup/mobile-routes-fixture"; * day, what the history does, and where no door is. * * - a chart point opens the day beside the chart (desktop); - * - on a touch screen a tap on a point opens the day at once, as a bottom - * sheet with the tapped value on top (the maintainer's call on the beta: - * the two-step tooltip broke the flow); + * - on a touch screen the first tap shows the value and the tooltip offers + * the day, the second opens it as a bottom sheet; * - a date in a list opens the day while the row keeps its own target; * - `?day=` opens the layer on any page, Back and Escape close it, a date in * the future is dropped; @@ -25,7 +24,7 @@ import { MOBILE_ROUTES_ANALYTE } from "./setup/mobile-routes-fixture"; * - the dashboard's today area offers no door; its charts below do. * * Stable data attributes only: `day-panel` (+ `data-shell`), `day-link`, - * `chart-plot[data-day-links]`, `day-prev` / + * `chart-plot[data-day-links]`, `chart-tooltip-open-day`, `day-prev` / * `day-next` / `day-close`. */ @@ -151,7 +150,7 @@ test.describe("the day view", () => { await expect(page).toHaveURL(/\/mood$/); }); - test("on a phone a tap on a point opens its day at once", async ({ + test("on a phone the first tap shows the value, the second opens the day", async ({ page, }, testInfo) => { test.skip(testInfo.project.name !== "chromium-mobile", "touch only"); @@ -162,11 +161,15 @@ test.describe("the day view", () => { await plot.scrollIntoViewIfNeeded(); await expect(plot).toBeVisible(); const box = (await plot.boundingBox())!; - // One tap, the same door as a click on a desktop: the day opens as a - // bottom sheet with the tapped value on top. await page.touchscreen.tap(box.x + box.width * 0.8, box.y + box.height / 2); + const open = page.locator('[data-slot="chart-tooltip-open-day"]'); + await expect(open).toBeVisible(); + // A tap alone never opens a sheet. + await expect(panel(page)).toHaveCount(0); + await open.tap(); await expect(panel(page)).toBeVisible(); await expect(panel(page)).toHaveAttribute("data-shell", "bottom"); + // The tapped value heads the day. await expect(page.locator('[data-slot="day-focus"]')).toBeVisible(); }); diff --git a/messages/de.json b/messages/de.json index 2aa8f3350..b9b9399de 100644 --- a/messages/de.json +++ b/messages/de.json @@ -12201,11 +12201,11 @@ "captureShort": "Erfassen", "inTimelineShort": "Zeitleiste", "askCoachPrompt": "Was war am {date}?", - "chartHintTouch": "Antippen eines Punkts öffnet den Tag.", + "chartHintTouch": "Antippen zeigt den Wert.", "chartHintBar": "Klick auf einen Balken öffnet den Tag.", - "chartHintTouchBar": "Antippen eines Balkens öffnet den Tag.", + "chartHintTouchBar": "Antippen zeigt die Werte.", "chartHintCell": "Klick auf einen Tag öffnet ihn.", - "chartHintTouchCell": "Antippen eines Tages öffnet ihn.", + "chartHintTouchCell": "Antippen zeigt den Wert des Tages.", "coachChips": "Tage in dieser Antwort", "description": "Alles, was deine Einträge für diesen Tag enthalten.", "openDocument": "Dokument öffnen", diff --git a/messages/en.json b/messages/en.json index f5e55df8b..7efb7a78b 100644 --- a/messages/en.json +++ b/messages/en.json @@ -12201,11 +12201,11 @@ "captureShort": "Log", "inTimelineShort": "Timeline", "askCoachPrompt": "What happened on {date}?", - "chartHintTouch": "Tap a point to open its day.", + "chartHintTouch": "Tap a point to see its value.", "chartHintBar": "Click a bar to open its day.", - "chartHintTouchBar": "Tap a bar to open its day.", + "chartHintTouchBar": "Tap a bar to see its values.", "chartHintCell": "Click a day to open it.", - "chartHintTouchCell": "Tap a day to open it.", + "chartHintTouchCell": "Tap a day to see its value.", "coachChips": "Days in this answer", "description": "Everything your records hold for this day.", "openDocument": "Open document", diff --git a/messages/es.json b/messages/es.json index 2a458aa35..e38b9e1a8 100644 --- a/messages/es.json +++ b/messages/es.json @@ -12201,11 +12201,11 @@ "captureShort": "Registrar", "inTimelineShort": "Cronología", "askCoachPrompt": "¿Qué pasó el {date}?", - "chartHintTouch": "Toca un punto para abrir su día.", + "chartHintTouch": "Toca un punto para ver su valor.", "chartHintBar": "Haz clic en una barra para abrir su día.", - "chartHintTouchBar": "Toca una barra para abrir su día.", + "chartHintTouchBar": "Toca una barra para ver sus valores.", "chartHintCell": "Haz clic en un día para abrirlo.", - "chartHintTouchCell": "Toca un día para abrirlo.", + "chartHintTouchCell": "Toca un día para ver su valor.", "coachChips": "Días en esta respuesta", "description": "Todo lo que tus registros contienen para este día.", "openDocument": "Abrir documento", diff --git a/messages/fr.json b/messages/fr.json index 70d2bd366..9070b5fdc 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -12201,11 +12201,11 @@ "captureShort": "Enregistrer", "inTimelineShort": "Chronologie", "askCoachPrompt": "Que s’est-il passé le {date} ?", - "chartHintTouch": "Touchez un point pour ouvrir son jour.", + "chartHintTouch": "Touchez un point pour voir sa valeur.", "chartHintBar": "Cliquez sur une barre pour ouvrir son jour.", - "chartHintTouchBar": "Touchez une barre pour ouvrir son jour.", + "chartHintTouchBar": "Touchez une barre pour voir ses valeurs.", "chartHintCell": "Cliquez sur un jour pour l’ouvrir.", - "chartHintTouchCell": "Touchez un jour pour l’ouvrir.", + "chartHintTouchCell": "Touchez un jour pour voir sa valeur.", "coachChips": "Jours dans cette réponse", "description": "Tout ce que vos entrées contiennent pour ce jour.", "openDocument": "Ouvrir le document", diff --git a/messages/it.json b/messages/it.json index 9dfef1da6..7da2126a2 100644 --- a/messages/it.json +++ b/messages/it.json @@ -12201,11 +12201,11 @@ "captureShort": "Registra", "inTimelineShort": "Cronologia", "askCoachPrompt": "Cosa è successo il {date}?", - "chartHintTouch": "Tocca un punto per aprire il suo giorno.", + "chartHintTouch": "Tocca un punto per vederne il valore.", "chartHintBar": "Fai clic su una barra per aprire il suo giorno.", - "chartHintTouchBar": "Tocca una barra per aprire il suo giorno.", + "chartHintTouchBar": "Tocca una barra per vederne i valori.", "chartHintCell": "Fai clic su un giorno per aprirlo.", - "chartHintTouchCell": "Tocca un giorno per aprirlo.", + "chartHintTouchCell": "Tocca un giorno per vederne il valore.", "coachChips": "Giorni in questa risposta", "description": "Tutto ciò che le tue voci contengono per questo giorno.", "openDocument": "Apri documento", diff --git a/messages/ko.json b/messages/ko.json index 484effaaa..ccd0eb935 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -12201,11 +12201,11 @@ "captureShort": "기록", "inTimelineShort": "타임라인", "askCoachPrompt": "{date}에 무슨 일이 있었나요?", - "chartHintTouch": "점을 탭하면 그날이 열려요.", + "chartHintTouch": "점을 탭하면 값이 보여요.", "chartHintBar": "막대를 클릭하면 그날이 열려요.", - "chartHintTouchBar": "막대를 탭하면 그날이 열려요.", + "chartHintTouchBar": "막대를 탭하면 값이 보여요.", "chartHintCell": "날짜 칸을 클릭하면 그날이 열려요.", - "chartHintTouchCell": "날짜 칸을 탭하면 그날이 열려요.", + "chartHintTouchCell": "날짜 칸을 탭하면 값이 보여요.", "coachChips": "이 답변에 쓰인 날", "description": "이 날에 대한 모든 기록이에요.", "openDocument": "문서 열기", diff --git a/messages/pl.json b/messages/pl.json index d07ef24d2..032c99b02 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -12201,11 +12201,11 @@ "captureShort": "Zapisz", "inTimelineShort": "Oś czasu", "askCoachPrompt": "Co się działo {date}?", - "chartHintTouch": "Dotknij punktu, aby otworzyć jego dzień.", + "chartHintTouch": "Dotknij punktu, aby zobaczyć wartość.", "chartHintBar": "Kliknij słupek, aby otworzyć jego dzień.", - "chartHintTouchBar": "Dotknij słupka, aby otworzyć jego dzień.", + "chartHintTouchBar": "Dotknij słupka, aby zobaczyć wartości.", "chartHintCell": "Kliknij dzień, aby go otworzyć.", - "chartHintTouchCell": "Dotknij dnia, aby go otworzyć.", + "chartHintTouchCell": "Dotknij dnia, aby zobaczyć jego wartość.", "coachChips": "Dni w tej odpowiedzi", "description": "Wszystko, co Twoje wpisy zawierają dla tego dnia.", "openDocument": "Otwórz dokument", diff --git a/src/components/charts/health-chart.tsx b/src/components/charts/health-chart.tsx index c31fe7eca..3f539c146 100644 --- a/src/components/charts/health-chart.tsx +++ b/src/components/charts/health-chart.tsx @@ -2404,6 +2404,11 @@ export function HealthChart({ : dateLabel } rows={rows} + action={chartDays.tooltipAction( + hoverPoint + ? dayLinkSource?.indexOf(hoverPoint) + : undefined, + )} /> ); }} diff --git a/src/components/charts/medication-compliance-chart.tsx b/src/components/charts/medication-compliance-chart.tsx index 4fb12ca5d..628ce42ce 100644 --- a/src/components/charts/medication-compliance-chart.tsx +++ b/src/components/charts/medication-compliance-chart.tsx @@ -644,8 +644,18 @@ export function MedicationComplianceChart({ delta, }, ]; + const hoverPoint = payload[0]?.payload; return ( - + ); }} /> diff --git a/src/components/charts/mood-chart.tsx b/src/components/charts/mood-chart.tsx index 5f0371278..eeb46aec8 100644 --- a/src/components/charts/mood-chart.tsx +++ b/src/components/charts/mood-chart.tsx @@ -1015,7 +1015,16 @@ export function MoodChart({ } } if (rows.length === 0) return null; - return ; + return ( + + ); }} /> ); }} diff --git a/src/components/custom-metrics/custom-metric-chart.tsx b/src/components/custom-metrics/custom-metric-chart.tsx index af828a9da..e35a77dc8 100644 --- a/src/components/custom-metrics/custom-metric-chart.tsx +++ b/src/components/custom-metrics/custom-metric-chart.tsx @@ -239,6 +239,7 @@ export function CustomMetricChart({ active label={fmt.dateShortSmart(new Date(point.timestamp))} rows={rows} + action={chartDays.tooltipAction(points.indexOf(point))} /> ); }} diff --git a/src/components/cycle/bbt-chart.tsx b/src/components/cycle/bbt-chart.tsx index 9e62520c2..b29454d08 100644 --- a/src/components/cycle/bbt-chart.tsx +++ b/src/components/cycle/bbt-chart.tsx @@ -24,7 +24,7 @@ * it surfaces a "log your morning temperature" hint rather than a flat dot. */ -import { useMemo } from "react"; +import { useMemo, type ReactNode } from "react"; import { Thermometer } from "lucide-react"; import { CartesianGrid, @@ -288,7 +288,20 @@ export function BbtChart({ strokeOpacity: 0.3, strokeDasharray: "3 3", }} - content={} + content={(props) => { + const payload = props.payload as unknown as + { payload: BbtPoint }[] | undefined; + const point = payload?.[0]?.payload; + return ( + + ); + }} /> {ovulationMs != null ? ( ) : null} + {props.action ?? null} ); } diff --git a/src/components/day/__tests__/day-door-coverage-guard.test.ts b/src/components/day/__tests__/day-door-coverage-guard.test.ts index 3d5b534e8..68dd8f2ac 100644 --- a/src/components/day/__tests__/day-door-coverage-guard.test.ts +++ b/src/components/day/__tests__/day-door-coverage-guard.test.ts @@ -9,8 +9,9 @@ import { describe, expect, it } from "vitest"; * The day view arrived on six metric pages and nowhere else, and the recovery * page beside them offered no day at all: nothing noticed, because every check * proved one chart. This guard walks `src/app` and `src/components` and holds - * three kinds of surface to the same doors (a click or a tap opens the day, - * the dashed line through the open day, the row of day dots, the caption): + * three kinds of surface to the same doors (a click opens the day, on touch + * the tooltip's "View the whole day", the dashed line through the open day, + * the row of day dots, the caption): * * A. a mount of a chart whose doors are a prop (`HealthChart`, its dynamic * wrapper, `MoodChart`, the adherence chart, the nutrient bar chart) @@ -20,7 +21,7 @@ import { describe, expect, it } from "vitest"; * B. a file that draws its own Recharts chart over a date axis (an * `` keyed on a date-shaped field, or `scale="time"`) calls * `useChartDayLinks` and renders its parts (`ChartDayFooter`, - * `OPEN_DAY_LINE`, the tooltip's props); + * `OPEN_DAY_LINE`, the tooltip's props and its touch action); * C. a calendar heatmap (anything laid out by `heatmapDays`) calls * `useHeatmapDay`. * @@ -195,14 +196,15 @@ function findMissingDoors(): { /\buseChartDayLinks\(/.test(text) && /\bChartDayFooter\b/.test(text) && /\bOPEN_DAY_LINE\b/.test(text) && - /\.tooltipProps\b/.test(text); + /\.tooltipProps\b/.test(text) && + /\.tooltipAction\(/.test(text); if (wired) { doors.charts += 1; doors.chartFiles.push(file); } else if (!listed) { missing.push({ file, - why: "a date-axis chart without the shared day doors (useChartDayLinks, ChartDayFooter, OPEN_DAY_LINE, tooltip props)", + why: "a date-axis chart without the shared day doors (useChartDayLinks, ChartDayFooter, OPEN_DAY_LINE, tooltip props and touch action)", }); } } diff --git a/src/components/day/chart-day-links.tsx b/src/components/day/chart-day-links.tsx index 5b0438f18..7e645b155 100644 --- a/src/components/day/chart-day-links.tsx +++ b/src/components/day/chart-day-links.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useMemo, useRef } from "react"; +import { useCallback, useMemo, useRef, type ReactNode } from "react"; import type { DateKey } from "@/lib/day/contract"; import { prefersReducedMotion } from "@/lib/charts/reduced-motion"; @@ -8,6 +8,7 @@ import { prefersReducedMotion } from "@/lib/charts/reduced-motion"; import { ChartDayCaption, DayRug, + TooltipDayAction, useCoarsePointer, type ChartDayAxis, type ChartDayMark, @@ -25,11 +26,13 @@ import { useTodayKey } from "./use-today-key"; * button, the dashed line through the open day and the row of dots under the * axis behave the same on every page: * - * - a click, or a tap, on the plot opens the day of the point under it. - * On a fine pointer hovering still shows the tooltip; on a touch screen - * the tap opens the day at once, and the day puts the tapped value at - * its top. A scroll that grazes the chart is a pan, not a tap, and opens - * nothing; + * - 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", a calm 44 px row) opens the day. Two steps on + * purpose (the maintainer's call, after trying one on the beta): a + * point is a small target, and one tap that opened a sheet would open + * days by accident; * - 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. @@ -91,12 +94,19 @@ export interface ChartDayLinks { onChartClick: ((state: ChartClickState | null | undefined) => void) | undefined; /** - * Spread on ``. On touch, and under reduced motion, the tooltip - * does not glide: Recharts slides it from where it last was for 400 ms, - * which on a tap drew a box sweeping across the chart while the day was - * already opening. + * Spread on ``: on touch it stays where the tap put it and takes + * taps. It does not glide there either: Recharts slides the tooltip from + * where it last was for 400 ms, which turned "View the whole day" into a + * moving target under the finger. Reduced motion stops the glide on every + * pointer. */ - tooltipProps: { isAnimationActive?: boolean }; + tooltipProps: { + trigger: "click" | "hover"; + wrapperStyle?: React.CSSProperties; + isAnimationActive?: boolean; + }; + /** The tooltip's button for point `index`, on a touch screen only. */ + tooltipAction: (index: number | undefined) => ReactNode | undefined; } export function useChartDayLinks({ @@ -134,16 +144,17 @@ export function useChartDayLinks({ return at === -1 ? undefined : at; }, [enabled, openKey, days]); - const onChartClick = enabled - ? (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; + 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, @@ -159,7 +170,19 @@ export function useChartDayLinks({ plotClassName: enabled && !coarse ? "cursor-pointer" : "", onChartClick, tooltipProps: - coarse || prefersReducedMotion() ? { isAnimationActive: false } : {}, + enabled && coarse + ? { + trigger: "click", + wrapperStyle: { pointerEvents: "auto", zIndex: 20 }, + isAnimationActive: false, + } + : prefersReducedMotion() + ? { trigger: "hover", isAnimationActive: false } + : { trigger: "hover" }, + tooltipAction: (index) => + enabled && coarse && index !== undefined && canOpen(index) ? ( + open(index)} /> + ) : undefined, }; } diff --git a/src/components/day/chart-day.tsx b/src/components/day/chart-day.tsx index 679fb690b..460d3e746 100644 --- a/src/components/day/chart-day.tsx +++ b/src/components/day/chart-day.tsx @@ -1,6 +1,7 @@ "use client"; import { useSyncExternalStore } from "react"; +import { ArrowRight } from "lucide-react"; import type { DateKey } from "@/lib/day/contract"; import { dateOnlyKey } from "@/lib/tz/date-only"; @@ -15,10 +16,12 @@ import { useTodayKey } from "./use-today-key"; /** * The day view's entry points on a value chart. * - * - A click, or a tap, on the plot opens the day of the point under it, - * with that value at the top of the day. Hovering on a fine pointer - * keeps showing the tooltip; a scroll that grazes the chart is a pan, - * not a tap, and opens nothing. + * - On a fine pointer, a click on the plot opens the day of the point under + * the cursor; hovering keeps showing the tooltip as before. + * - On a touch screen a tap shows the value, as it always did, and the + * tooltip carries "View the whole day". Two steps on purpose: a point is + * a small target, and one tap that opened a sheet would open days by + * accident. A date in a list is a bigger target and opens in one tap. * - Under the axis, a quiet row of dots marks the days that hold anything, * a ring the days with a notable observation. Decoration for the eye; the * keyboard and screen-reader way to a day is the chart's data table. @@ -57,6 +60,26 @@ export function chartPointDayKey(timestamp: number): DateKey { return dateOnlyKey(new Date(timestamp)); } +/** The tooltip's way to the day on a touch screen. */ +export function TooltipDayAction({ onOpen }: { onOpen: () => void }) { + const { t } = useTranslations(); + return ( + + ); +} + interface RugPoint { timestamp: number; } diff --git a/src/components/day/heatmap-day.tsx b/src/components/day/heatmap-day.tsx index d71f97fbc..0eb352c51 100644 --- a/src/components/day/heatmap-day.tsx +++ b/src/components/day/heatmap-day.tsx @@ -1,10 +1,10 @@ "use client"; -import { useEffect, useState, type RefObject } from "react"; +import { useEffect, useRef, useState, type RefObject } from "react"; import type { DateKey } from "@/lib/day/contract"; -import { useCoarsePointer } from "./chart-day"; +import { TooltipDayAction, useCoarsePointer } from "./chart-day"; import { openDay, useOpenDay } from "./day-layer-controller"; import { isOpenableDay } from "./day-url"; import { useTodayKey } from "./use-today-key"; @@ -13,10 +13,11 @@ import { useTodayKey } from "./use-today-key"; * The day view's doors on a calendar heatmap (mood, intake), the cell twin of * `useChartDayLinks`: * - * - a click, or a tap, on a cell opens its day; hovering a cell on a fine - * pointer shows its tooltip. A tap on a cell with nothing to open (no - * entry, or a day still ahead) pins its tooltip instead, so the cell - * still says what it is; a tap outside or Escape clears it; + * - fine pointer: hovering a cell shows its tooltip, a click opens its day; + * - touch: a tap pins the tooltip, and the tooltip's button ("View the + * whole day", the same calm 44 px row every chart's tooltip carries) + * opens it. Two steps, as on a chart: a cell is a small target. A tap + * outside or Escape clears the tooltip; * - the open day's cell carries a dashed outline, the heatmap's form of * the dashed line a chart draws through it (`OPEN_DAY_CELL`); * - the keyboard and screen-reader way to a day is the heatmap's data @@ -39,6 +40,8 @@ interface HeatmapTooltip { text: string; /** A touch tooltip stays after the finger lifts. */ pinned?: boolean; + /** The day a pinned tooltip offers to open. */ + day?: DateKey; } export interface HeatmapCellInput { @@ -51,6 +54,9 @@ export interface HeatmapCellInput { export function useHeatmapDay(containerRef: RefObject) { const [tooltip, setTooltip] = useState(null); + // The pointer type of the last press decides whether the click that + // follows opens the day (mouse, pen) or only pinned the tooltip (touch). + const lastPointer = useRef("mouse"); const today = useTodayKey(); const openKey = useOpenDay(); const coarse = useCoarsePointer(); @@ -104,17 +110,18 @@ export function useHeatmapDay(containerRef: RefObject) { setTooltip((prev) => (prev?.pinned ? prev : null)); }, onPointerDown: (e: React.PointerEvent) => { - // A touch on a cell with a day to open opens it (the click below); - // any other cell pins its tooltip so the tap still answers. - if (e.pointerType !== "touch" || openable) return; + lastPointer.current = e.pointerType; + if (e.pointerType !== "touch") return; setTooltip({ x: e.clientX, y: e.clientY, text: cell.describe(), pinned: true, + day: openable ? cell.dateKey : undefined, }); }, onClick: () => { + if (lastPointer.current === "touch") return; if (!openable) return; open(cell.dateKey); }, @@ -129,7 +136,9 @@ export function useHeatmapDay(containerRef: RefObject) { const tooltipNode = tooltip ? (
) { }} > {tooltip.text} + {tooltip.day ? ( + open(tooltip.day as DateKey)} /> + ) : null}
) : null; diff --git a/src/components/insights/mood/mood-dimension-trends.tsx b/src/components/insights/mood/mood-dimension-trends.tsx index bb20d8aff..52a860719 100644 --- a/src/components/insights/mood/mood-dimension-trends.tsx +++ b/src/components/insights/mood/mood-dimension-trends.tsx @@ -216,6 +216,7 @@ export function MoodDimensionTrends({ active label={fmt.dateShortSmartCalendar(String(row.date))} rows={rows} + action={chartDays.tooltipAction(chartData.indexOf(row))} /> ); }} diff --git a/src/components/insights/sleep-stage-stacked-bar.tsx b/src/components/insights/sleep-stage-stacked-bar.tsx index bd09ae618..ccf2e94c0 100644 --- a/src/components/insights/sleep-stage-stacked-bar.tsx +++ b/src/components/insights/sleep-stage-stacked-bar.tsx @@ -469,6 +469,7 @@ export function SleepStageStackedBar({ breakdown }: SleepStageStackedBarProps) { const row = payload[0]?.payload as Record | undefined; const label = row?.label; + const rowIndex = row ? data.indexOf(row) : -1; const napCount = typeof row?.napCount === "number" ? row.napCount : 0; // The footer's first line is the NIGHT. The nap is listed @@ -549,6 +550,9 @@ export function SleepStageStackedBar({ breakdown }: SleepStageStackedBarProps) { )} )} + {chartDays.tooltipAction( + rowIndex === -1 ? undefined : rowIndex, + )} ); }} diff --git a/src/components/labs/lab-biomarker-chart.tsx b/src/components/labs/lab-biomarker-chart.tsx index 97f78dcf8..7a8828340 100644 --- a/src/components/labs/lab-biomarker-chart.tsx +++ b/src/components/labs/lab-biomarker-chart.tsx @@ -406,6 +406,7 @@ export function LabBiomarkerChart({ active label={labShortDate(point.timestamp)} rows={rows} + action={chartDays.tooltipAction(points.indexOf(point))} /> ); }} diff --git a/src/components/medications/detail/efficacy/efficacy-chart.tsx b/src/components/medications/detail/efficacy/efficacy-chart.tsx index 36bab7d32..0d0307570 100644 --- a/src/components/medications/detail/efficacy/efficacy-chart.tsx +++ b/src/components/medications/detail/efficacy/efficacy-chart.tsx @@ -378,11 +378,18 @@ export function EfficacyChart({ color: item.color ?? "var(--chart-1)", }; }); + const index = + typeof x === "number" + ? displaySeries.findIndex((p) => p.t === x) + : -1; return ( ); }} diff --git a/src/components/medications/dose-strength-curve.tsx b/src/components/medications/dose-strength-curve.tsx index 6040fb7c7..4b08d8082 100644 --- a/src/components/medications/dose-strength-curve.tsx +++ b/src/components/medications/dose-strength-curve.tsx @@ -326,6 +326,7 @@ export function DoseStrengthCurve({ active label={fmt.dateShortSmart(new Date(point.t))} rows={rows} + action={chartDays.tooltipAction(chartData.indexOf(point))} /> ); }} diff --git a/src/components/mental-health/assessment-history-chart.tsx b/src/components/mental-health/assessment-history-chart.tsx index 548b2165b..1cdf57adb 100644 --- a/src/components/mental-health/assessment-history-chart.tsx +++ b/src/components/mental-health/assessment-history-chart.tsx @@ -183,6 +183,7 @@ export function AssessmentHistoryChart({ active label={fmt.dateShortSmart(new Date(point.timestamp))} rows={rowsOut} + action={chartDays.tooltipAction(points.indexOf(point))} /> ); }} From 4e7221f59b2ed42543bcd676898ebb633e369c7f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 8 Oct 2026 16:20:36 +0200 Subject: [PATCH 5/6] fix: spell a day's sleep and scores as their charts do The day route sends a night's length in min and a device score in score. The day read the night as a bare 412 and the score in the wire's word; it now reads a duration and the reader's word for points. The phone sheet's header takes the short date, so a long weekday and month no longer cut off the year at 360 px. --- e2e/day-doors.spec.ts | 12 ++- .../__tests__/use-day-value-format.test.tsx | 77 +++++++++++++++++++ src/components/day/day-view.tsx | 21 ++++- src/components/day/use-day-value-format.ts | 22 +++++- 4 files changed, 123 insertions(+), 9 deletions(-) create mode 100644 src/components/day/__tests__/use-day-value-format.test.tsx diff --git a/e2e/day-doors.spec.ts b/e2e/day-doors.spec.ts index c83cfa352..357e2773c 100644 --- a/e2e/day-doors.spec.ts +++ b/e2e/day-doors.spec.ts @@ -1,5 +1,6 @@ import type { Locator, Page, TestInfo } from "@playwright/test"; +import { aiBlockAvailable, serveAiBlock } from "./setup/ai-capabilities"; import { DAY_DOORS_ANALYTE } from "./setup/day-doors-fixture"; import { mockDay } from "./setup/day-mock"; import { @@ -203,6 +204,11 @@ test.describe("every chart drawn in days opens its days", () => { .locator('[data-slot="chart-overlay-comparison-lastMonth"]') .click(); await page.keyboard.press("Escape"); + // The overlay menu is closed before the chart is touched, so the tap + // lands on the chart and not on the menu's outside-dismiss. + await expect( + page.locator('[data-slot="chart-overlay-controls-content"]'), + ).toHaveCount(0); await expect( page.locator('[data-slot="chart-compare-caption"]').first(), ).toBeAttached(); @@ -265,9 +271,13 @@ test.describe("the dashboard and the list of all values", () => { }, testInfo) => { test.skip(isPhone(testInfo), "docked from 1280 px"); await page.setViewportSize({ width: 1440, height: 900 }); + // The launcher follows the `coach` capability, which needs a provider + // the suite cannot reach, so the capability reads available here; this + // journey is about where the button sits, not what it opens. + await serveAiBlock(page, aiBlockAvailable()); await page.goto("/insights/weight"); const fab = page.locator('[data-slot="coach-fab"]'); - test.skip((await fab.count()) === 0, "no Coach on this account"); + await expect(fab).toBeVisible(); const plot = page .locator('[data-slot="chart-plot"][data-day-links="true"]') .first(); diff --git a/src/components/day/__tests__/use-day-value-format.test.tsx b/src/components/day/__tests__/use-day-value-format.test.tsx new file mode 100644 index 000000000..7fbca9381 --- /dev/null +++ b/src/components/day/__tests__/use-day-value-format.test.tsx @@ -0,0 +1,77 @@ +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import deMessages from "../../../../messages/de.json"; +import enMessages from "../../../../messages/en.json"; + +/** + * A day's values read as their charts read them. The day route sends a + * night's length in "min" and a device score in "score"; the first read as a + * bare 412 and the second as an untranslated "score" before both were + * spelled the way the sleep and recovery charts spell them. + */ + +vi.mock("@/hooks/use-unit-display", async () => { + const { unitDisplayFor } = + await import("@/__tests__/helpers/unit-display-mock"); + return { useUnitDisplay: () => unitDisplayFor("metric") }; +}); + +import { I18nProvider } from "@/lib/i18n/context"; + +import { useDayValueFormat } from "../use-day-value-format"; + +function Probe({ + type, + value, + unit, +}: { + type: string; + value: number; + unit: string; +}) { + const { formatTile } = useDayValueFormat(); + const shown = formatTile({ + key: type, + values: [{ type, value, unit, at: "", source: "", band: null }], + }); + return ( + + {shown.value}|{shown.unit} + + ); +} + +function read( + type: string, + value: number, + unit: string, + locale: "en" | "de" = "en", +) { + return renderToStaticMarkup( + + + , + ) + .replace(/<[^>]+>/g, "") + .replace(//g, ""); +} + +describe("a day's values in their charts' spelling", () => { + it("reads a night sent in min as a duration", () => { + const [value, unit] = read("SLEEP_DURATION", 412, "min").split("|"); + expect(value).toMatch(/6\s*h/); + expect(value).toMatch(/52/); + expect(unit).toBe(""); + }); + + it("reads a device score in the reader's word for points", () => { + // German, where the wire's "score" and the chart's "Punkte" differ. + const [value, unit] = read("ANS_CHARGE", 8, "score", "de").split("|"); + expect(value).toBe("8"); + expect(unit).toBe("Punkte"); + }); +}); diff --git a/src/components/day/day-view.tsx b/src/components/day/day-view.tsx index 0b7ff57a1..55e317a23 100644 --- a/src/components/day/day-view.tsx +++ b/src/components/day/day-view.tsx @@ -54,14 +54,16 @@ import { useDayValueFormat } from "./use-day-value-format"; */ /** "Saturday, 3 January 2026", for the header and the spoken name. */ -export function useLongDayLabel(): (date: DateKey) => string { +export function useLongDayLabel( + length: "long" | "short" = "long", +): (date: DateKey) => string { const { locale } = useTranslations(); const intl = resolveIntlLocale(locale); return (date: DateKey) => new Intl.DateTimeFormat(intl, { - weekday: "long", + weekday: length, day: "numeric", - month: "long", + month: length, year: "numeric", timeZone: "UTC", }).format(new Date(`${date}T12:00:00.000Z`)); @@ -118,6 +120,10 @@ export function DayView({ }: DayViewProps) { const { t, tCount } = useTranslations(); const longLabel = useLongDayLabel(); + // "Mo., 5. Okt. 2026" in the phone sheet's header: a September Thursday + // spelled out in full does not fit beside the arrows at 360 px, and a + // date cut off before its year reads as the wrong day. + const shortLabel = useLongDayLabel("short"); const pathname = usePathname(); const day = useDay(date); const { labelFor, formatTile } = useDayValueFormat(); @@ -218,7 +224,14 @@ export function DayView({ tabIndex={-1} className="min-w-0 flex-1 truncate px-1 text-base leading-snug font-semibold focus-visible:outline-none" > - {longLabel(date)} + {compact ? ( + <> + + {longLabel(date)} + + ) : ( + longLabel(date) + )}