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 && (
-
+ ({ 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 (
@@ -173,9 +214,7 @@ export function DayRug({
? "border-muted-foreground size-[7px] border-[1.5px]"
: "bg-muted-foreground/55 size-1",
)}
- style={{
- left: `${(points.length === 1 ? 0.5 : at / maxIndex) * 100}%`,
- }}
+ style={{ left: `${at * 100}%` }}
/>
);
})}
@@ -183,16 +222,40 @@ export function DayRug({
);
}
-/** The one line under a day-linked chart: what the dots mean, and the click. */
-export function ChartDayCaption({ coarse }: { coarse: boolean }) {
+/**
+ * What a day-linked surface is made of, which names the thing to click in
+ * the caption: a point on a line, a bar, or a cell of a calendar.
+ */
+export type ChartDayMark = "point" | "bar" | "cell";
+
+const HINT_KEYS: Record = {
+ point: { fine: "day.chartHint", coarse: "day.chartHintTouch" },
+ bar: { fine: "day.chartHintBar", coarse: "day.chartHintTouchBar" },
+ cell: { fine: "day.chartHintCell", coarse: "day.chartHintTouchCell" },
+};
+
+/**
+ * The one line under a day-linked chart: what the dots mean (when the chart
+ * has the row of dots), and the click.
+ */
+export function ChartDayCaption({
+ coarse,
+ mark = "point",
+ rug = true,
+}: {
+ coarse: boolean;
+ mark?: ChartDayMark;
+ rug?: boolean;
+}) {
const { t } = useTranslations();
+ const hint = HINT_KEYS[mark];
return (