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 (