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 (