Merge remote-tracking branch 'origin/release/v1.42.0' into v142/qol-1

# Conflicts:
#	src/app/insights/mood/page.tsx
#	src/components/charts/mood-heatmap.tsx
#	src/components/documents/document-bulk-bar.tsx
This commit is contained in:
Marc-André Bombeck committed 2026-10-08 16:49:03 +02:00
commit 17b8b35397
67 files changed
+3990 -1675

No files matched your search

@@ -7,11 +7,13 @@ import { ComplianceHeatmap } from "../compliance-heatmap";
/**
* 2026-07-17 a11y audit (M2) — medication-compliance heatmap day cells are
* pointer-only SVG `<rect>`s. The keyboard/screen-reader path to the
* per-day values is a visually-hidden day list beside the grid, not
* focusable rects (a cell subtree nested under the SVG's `role="img"` is
* pruned from the a11y tree, and per-cell tab stops would flood the
* keyboard order). This pins that list plus the aggregate `role="img"`
* summary, and that the rects stay a pure pointer affordance.
* per-day values is the data table under the grid (v1.42: the same
* `ChartDataTable` every day-linked chart carries, collapsed until asked
* for), not focusable rects (a cell subtree nested under the SVG's
* `role="img"` is pruned from the a11y tree, and per-cell tab stops would
* flood the keyboard order). This pins that table, each row's date as a link
* to its day, the aggregate `role="img"` summary, and that the rects stay a
* pure pointer affordance.
*/
// The grid window is the last `days` days ending today, so seed relative
// dates (UTC-sliced, matching the component) rather than fixed ones that
@@ -50,18 +52,19 @@ describe("<ComplianceHeatmap> — keyboard-reachable day values", () => {
}
});
it("exposes each active day through a visually-hidden day list", () => {
it("exposes each active day, and its day, through the data table", () => {
const html = render();
// The sr-only list carries one entry per day that had a scheduled or
// taken dose — here the two seeded days.
const listMatch = html.match(
/<ul[^>]*data-slot="compliance-heatmap-day-list"[^>]*>([\s\S]*?)<\/ul>/,
const table = html.match(
/<div[^>]*data-slot="chart-data-table"[^>]*>([\s\S]*?)<\/table>/,
);
expect(listMatch).not.toBeNull();
const items = listMatch![1].match(/<li[^>]*>/g) ?? [];
expect(items.length).toBe(2);
// Each entry pairs the day with its taken/expected + rate string.
expect(listMatch![1]).toContain("2/2 (100%)");
expect(listMatch![1]).toContain("1/2 (50%)");
expect(table).not.toBeNull();
// One row per day that had a scheduled or taken dose — the two seeded.
const rows = table![1].match(/data-slot="chart-data-table-row"/g) ?? [];
expect(rows.length).toBe(2);
// Taken, expected and the rate, per day.
expect(table![1]).toContain("100 %");
expect(table![1]).toContain("50 %");
expect(table![1]).toContain(`data-day="${D0}"`);
expect(table![1]).toContain(`data-day="${D1}"`);
});
});
@@ -7,11 +7,12 @@ import { MoodHeatmap } from "../mood-heatmap";
/**
* 2026-07-17 a11y audit (M2) — mood heatmap day cells are pointer-only SVG
* `<rect>`s. The keyboard/screen-reader path to the per-day values is a
* visually-hidden day list beside the grid, not focusable rects (a cell
* subtree nested under the SVG's `role="img"` is pruned from the a11y
* tree, and per-cell tab stops would flood the keyboard order). This pins
* that list plus the aggregate `role="img"` summary, and that the rects
* stay a pure pointer affordance.
* the data table under the grid (v1.42: the same `ChartDataTable` every
* day-linked chart carries, collapsed until asked for), not focusable rects
* (a cell subtree nested under the SVG's `role="img"` is pruned from the
* a11y tree, and per-cell tab stops would flood the keyboard order). This
* pins that table, each row's date as a link to its day, the aggregate
* `role="img"` summary, and that the rects stay a pure pointer affordance.
*/
// The grid window is the last `days` days ending today, so seed relative
// dates (UTC-sliced, matching the component) rather than fixed ones that
@@ -50,16 +51,20 @@ describe("<MoodHeatmap> — keyboard-reachable day values", () => {
}
});
it("exposes each logged day through a visually-hidden day list", () => {
it("exposes each logged day, and its day, through the data table", () => {
const html = render();
const listMatch = html.match(
/<ul[^>]*data-slot="mood-heatmap-day-list"[^>]*>([\s\S]*?)<\/ul>/,
const table = html.match(
/<div[^>]*data-slot="chart-data-table"[^>]*>([\s\S]*?)<\/table>/,
);
expect(listMatch).not.toBeNull();
const items = listMatch![1].match(/<li[^>]*>/g) ?? [];
// One entry per logged day — the two seeded days, not every cell.
expect(items.length).toBe(2);
expect(listMatch![1]).toContain("4.2");
expect(listMatch![1]).toContain("2.0");
expect(table).not.toBeNull();
const rows = table![1].match(/data-slot="chart-data-table-row"/g) ?? [];
// One row per logged day — the two seeded days, not every cell.
expect(rows.length).toBe(2);
expect(table![1]).toContain("4.2");
expect(table![1]).toContain("2.0");
// Each date opens its day: the keyboard way to the day a click opens.
expect(table![1]).toContain(`data-day="${D0}"`);
expect(table![1]).toContain(`data-day="${D1}"`);
expect(table![1]).toMatch(/data-slot="day-link"/);
});
});
+13 -3
View File
@@ -63,6 +63,11 @@ export interface ChartDataTableColumn {
key: string;
/** Resolved column label, from the chart's own series-label helper. */
label: string;
/**
* The column's own formatter, where its values are not the chart's (a
* count beside a rate). Defaults to the table's `formatValue`.
*/
format?: (value: number) => string;
}
export interface ChartDataTableProps {
@@ -186,8 +191,13 @@ export function ChartDataTable({
</TableRow>
</TableHeader>
<TableBody>
{rows.map((point) => (
<TableRow key={point.timestamp} data-slot="chart-data-table-row">
{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.
<TableRow
key={`${point.timestamp}-${index}`}
data-slot="chart-data-table-row"
>
{/* The date is the row's header, so a screen reader reads
"12 July, systolic 128" rather than a bare number. */}
<TableHead
@@ -224,7 +234,7 @@ export function ChartDataTable({
</span>
</>
) : (
formatValue(numeric)
(column.format ?? formatValue)(numeric)
)}
</TableCell>
);
+86 -167
View File
@@ -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";
@@ -90,59 +92,10 @@ export function ComplianceHeatmap({
const formatDay = useCalendarDate();
const containerRef = useRef<HTMLDivElement | null>(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<string>("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) => (
<rect
key={cell.dateKey}
x={labelWidth + cell.col * step}
y={headerHeight + cell.row * step}
width={cellSize}
height={cellSize}
rx={2}
fill={cell.color}
// 2026-07-17 a11y audit (M2) — the per-day values reach
// assistive tech through the data table below the grid, not
// through the rects: a cell subtree nested under the SVG's
// `role="img"` is pruned from the a11y tree, and per-cell tab
// stops would flood the keyboard order on a year-long grid.
// The rects stay a pure pointer affordance.
{...heatmapDay.cellProps({
dateKey: cell.dateKey,
hasEntry: cell.data.expected > 0 || cell.data.taken > 0,
describe: () => describeCell(cell),
})}
/>
))}
{cells
.filter((cell) => cell.dateKey === heatmapDay.openKey)
.map((cell) => (
<rect
key={cell.dateKey}
data-day={cell.dateKey}
x={labelWidth + cell.col * step}
y={headerHeight + cell.row * step}
width={cellSize}
height={cellSize}
rx={2}
fill={cell.color}
className="cursor-pointer"
// 2026-07-17 a11y audit (M2) — the per-day values reach
// assistive tech through the visually-hidden day list below
// the grid, not through the rects: a cell subtree nested
// under the SVG's `role="img"` is pruned from the a11y tree,
// and per-cell tab stops would flood the keyboard order on a
// year-long grid. The rects stay a pure pointer affordance.
onPointerEnter={(e) => {
// 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}
/>
);
})}
))}
</svg>
</div>
{/* 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 && (
<ul className="sr-only" data-slot="compliance-heatmap-day-list">
{activeCells.map((cell) => (
<li key={cell.dateKey}>{describeCell(cell)}</li>
))}
</ul>
)}
{/* Tooltip */}
{tooltip && (
<div
data-slot="compliance-heatmap-tooltip"
className={`bg-popover text-popover-foreground border-border fixed z-50 rounded-md border px-2 py-1 text-xs shadow-md ${
tooltip.day ? "pointer-events-auto" : "pointer-events-none"
}`}
// Clamp the left edge so a tap near the right border doesn't push
// the pinned label off-screen on a narrow viewport.
style={{
left:
typeof window !== "undefined"
? Math.min(tooltip.x + 10, window.innerWidth - 180 - 8)
: tooltip.x + 10,
top: tooltip.y - 30,
}}
>
{tooltip.text}
{tooltip.day ? (
<button
type="button"
data-slot="heatmap-open-day"
onClick={() => {
const day = tooltip.day;
setTooltip(null);
if (day) openDay(day, { trigger: containerRef.current });
}}
className="bg-muted hover:bg-muted/80 focus-visible:ring-ring/50 mt-1.5 flex min-h-11 w-full items-center justify-center rounded-md px-3 text-sm font-medium focus-visible:ring-[3px] focus-visible:outline-none"
>
{t("day.openDay")}
</button>
) : null}
</div>
)}
{heatmapDay.tooltipNode}
{/* Legend */}
<div
@@ -483,6 +366,42 @@ export function ComplianceHeatmap({
</span>
))}
</div>
{activeCells.length > 0 ? (
<>
<ChartDayCaption coarse={heatmapDay.coarse} mark="cell" rug={false} />
{/* The keyboard and screen-reader way to each active day's doses,
and to the day itself. */}
<ChartDataTable
points={activeCells.map((cell) => ({
date: cell.dateKey,
timestamp: dayAnchor(cell.dateKey),
taken: cell.data.taken,
expected: cell.data.expected,
rate:
cell.data.expected > 0
? Math.min(
100,
Math.round((cell.data.taken / cell.data.expected) * 100),
)
: undefined,
}))}
columns={[
{ key: "taken", label: t("medications.taken") },
{ key: "expected", label: t("doctorReport.colExpected") },
{
key: "rate",
label: t("medications.compliance"),
format: (value) => `${value} %`,
},
]}
formatValue={(value) => String(value)}
formatDate={(date) => formatDay(dateOnlyKey(date))}
bucket="day"
metricLabel={t("insights.medicationCompliance")}
dayLinks
/>
</>
) : null}
</div>
);
}
+37 -69
View File
@@ -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<HTMLDivElement>(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,17 @@ 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 openDayIndex = chartDays.openIndex;
const openDayPoint =
openDayIndex !== undefined ? chartData?.[openDayIndex] : undefined;
const showContextDetails = showMA || showTrend || showBands;
const animationsEnabled = !prefersReducedMotion();
@@ -2025,11 +2020,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 +2030,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}
>
<CartesianGrid
strokeDasharray="3 3"
@@ -2218,10 +2200,7 @@ export function HealthChart({
<ReferenceLine
key="open-day"
x={openDayIndex}
stroke="var(--muted-foreground)"
strokeDasharray="3 3"
strokeOpacity={0.7}
ifOverflow="discard"
{...OPEN_DAY_LINE}
/>
) : null}
{openDayIndex !== undefined && openDayPoint
@@ -2281,14 +2260,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 +2404,11 @@ export function HealthChart({
: dateLabel
}
rows={rows}
action={
dayLinksActive && coarsePointer && hoverPoint ? (
<TooltipDayAction
onOpen={() => openPointDay(hoverPoint)}
/>
) : undefined
}
action={chartDays.tooltipAction(
hoverPoint
? dayLinkSource?.indexOf(hoverPoint)
: undefined,
)}
/>
);
}}
@@ -2574,17 +2544,15 @@ export function HealthChart({
</p>
) : null}
</div>
{dayLinksActive && chartData && chartData.length > 0 ? (
<>
<DayRug
points={chartData}
// The plot's margin (8) + the y axis + the x axis padding
// (10) on the left, margin + padding on the right.
insetLeft={8 + yAxisWidth + 10}
insetRight={8 + 10}
/>
<ChartDayCaption coarse={coarsePointer} />
</>
{chartData ? (
<ChartDayFooter
links={chartDays}
points={chartData}
// The plot's margin (8) + the y axis + the x axis padding
// (10) on the left, margin + padding on the right.
insetLeft={8 + yAxisWidth + 10}
insetRight={8 + 10}
/>
) : null}
{/* The points the chart just drew, as a table. Reads the SAME
expression `<ComposedChart data>` is handed above, so the two
@@ -70,6 +70,12 @@ import { chooseTickInterval } from "@/lib/charts/x-axis-density";
import { apiGet } from "@/lib/api/api-fetch";
import { shouldFireDataReady } from "@/lib/charts/data-ready-latch";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
import { chartPointDayKey } from "@/components/day/chart-day";
import {
ChartDayFooter,
OPEN_DAY_LINE,
useChartDayLinks,
} from "@/components/day/chart-day-links";
interface DailyCompliancePoint {
/** Berlin calendar day, "YYYY-MM-DD". */
@@ -205,6 +211,11 @@ interface MedicationComplianceChartProps {
* Optional and repeat-safe.
*/
onDataReady?: () => void;
/**
* v1.42 — each day's point opens that day, through the doors every
* day-linked chart has (`useChartDayLinks`).
*/
dayLinks?: boolean;
}
export function MedicationComplianceChart({
@@ -212,6 +223,7 @@ export function MedicationComplianceChart({
userTimezone = DEFAULT_TIMEZONE,
compareBaseline,
onDataReady,
dayLinks = false,
}: MedicationComplianceChartProps) {
// v1.4.27 R4 RC3 — the prop is accepted so the dashboard can pass
// `compareBaseline={compareBaseline}` uniformly across every chart.
@@ -343,6 +355,17 @@ export function MedicationComplianceChart({
};
const displayTitle = title ?? t("dashboard.medications");
const chartDays = useChartDayLinks({
enabled: dayLinks && chartData.length >= 3,
days: chartData.map((point) => chartPointDayKey(point.timestamp)),
focusFor: (index) => {
const point = chartData[index];
return point
? { label: displayTitle, value: `${fmt.integer(point.rate)} %` }
: null;
},
});
const yAxisFormatter = (value: number) => `${fmt.integer(value)} %`;
const animationsEnabled = !prefersReducedMotion();
@@ -535,121 +558,155 @@ export function MedicationComplianceChart({
/>
)
) : (
<div
className="h-[var(--chart-height,240px)] touch-pan-y md:h-[var(--chart-height-md,280px)]"
role="img"
aria-label={complianceAriaLabel}
>
<ResponsiveContainer width="100%" height="100%">
<ComposedChart
data={chartData}
margin={{ top: 10, right: 8, bottom: 8, left: 8 }}
>
<CartesianGrid
strokeDasharray="3 3"
stroke="var(--border)"
opacity={0.5}
/>
<XAxis
dataKey="date"
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
tickLine={false}
axisLine={false}
interval={chooseTickInterval(chartData.length, viewportWidth)}
/>
<YAxis
domain={[0, 100]}
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
tickLine={false}
axisLine={false}
width={48}
tickFormatter={yAxisFormatter}
/>
<Tooltip
cursor={{
stroke: "var(--muted-foreground)",
strokeOpacity: 0.3,
strokeDasharray: "3 3",
}}
content={(props) => {
const { active, payload } = props as unknown as {
active?: boolean;
payload?: Array<{
value?: number;
color?: string;
payload?: ChartPoint;
}>;
};
if (!active || !payload?.length) return null;
const ts = payload[0]?.payload?.timestamp;
const dateLabel = ts ? tzFmt.date(new Date(ts)) : "";
const rate = payload[0]?.value;
if (typeof rate !== "number") return null;
// Delta vs. the 100 % goal — a positive delta means
// "100 % target hit" and reads as success; a
// negative delta is the gap to close.
const gap = 100 - rate;
let delta: string | undefined;
if (gap < 0.5) {
delta = t("charts.deltaUnchanged");
} else {
const formatted = `−${fmt.integer(gap)} pp`;
delta = t("charts.deltaVsTarget").replace(
"{delta}",
formatted,
<>
<div
className={cn(
"h-[var(--chart-height,240px)] touch-pan-y md:h-[var(--chart-height-md,280px)]",
chartDays.plotClassName,
)}
role="img"
aria-label={complianceAriaLabel}
data-slot="chart-plot"
{...chartDays.plotProps}
>
<ResponsiveContainer width="100%" height="100%">
<ComposedChart
data={chartData}
margin={{ top: 10, right: 8, bottom: 8, left: 8 }}
onClick={chartDays.onChartClick}
>
<CartesianGrid
strokeDasharray="3 3"
stroke="var(--border)"
opacity={0.5}
/>
<XAxis
dataKey="date"
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
tickLine={false}
axisLine={false}
interval={chooseTickInterval(chartData.length, viewportWidth)}
/>
<YAxis
domain={[0, 100]}
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
tickLine={false}
axisLine={false}
width={48}
tickFormatter={yAxisFormatter}
/>
{chartDays.openIndex !== undefined ? (
<ReferenceLine
x={chartData[chartDays.openIndex]?.date}
{...OPEN_DAY_LINE}
/>
) : null}
<Tooltip
{...chartDays.tooltipProps}
cursor={{
stroke: "var(--muted-foreground)",
strokeOpacity: 0.3,
strokeDasharray: "3 3",
}}
content={(props) => {
const { active, payload } = props as unknown as {
active?: boolean;
payload?: Array<{
value?: number;
color?: string;
payload?: ChartPoint;
}>;
};
if (!active || !payload?.length) return null;
const ts = payload[0]?.payload?.timestamp;
const dateLabel = ts ? tzFmt.date(new Date(ts)) : "";
const rate = payload[0]?.value;
if (typeof rate !== "number") return null;
// Delta vs. the 100 % goal — a positive delta means
// "100 % target hit" and reads as success; a
// negative delta is the gap to close.
const gap = 100 - rate;
let delta: string | undefined;
if (gap < 0.5) {
delta = t("charts.deltaUnchanged");
} else {
const formatted = `−${fmt.integer(gap)} pp`;
delta = t("charts.deltaVsTarget").replace(
"{delta}",
formatted,
);
}
const rows: RichTooltipRow[] = [
{
name: t("dashboard.compliance7d"),
value: `${fmt.integer(rate)} %`,
color: payload[0]?.color ?? COLOR_LINE,
delta,
},
];
const hoverPoint = payload[0]?.payload;
return (
<RichChartTooltip
active
label={dateLabel}
rows={rows}
action={chartDays.tooltipAction(
hoverPoint
? chartData.indexOf(hoverPoint)
: undefined,
)}
/>
);
}
const rows: RichTooltipRow[] = [
{
name: t("dashboard.compliance7d"),
value: `${fmt.integer(rate)} %`,
color: payload[0]?.color ?? COLOR_LINE,
delta,
},
];
return (
<RichChartTooltip active label={dateLabel} rows={rows} />
);
}}
/>
{/* v1.4.18 — minimum-acceptable threshold + goal line
}}
/>
{/* v1.4.18 — minimum-acceptable threshold + goal line
are now opt-in via the "Target range" overlay
toggle. Default OFF; chart renders as a clean line
until the user activates the overlay from the
settings popover. */}
{showTargetRange ? (
<>
<ReferenceLine
y={80}
stroke={COLOR_THRESHOLD}
strokeDasharray="3 5"
strokeOpacity={0.6}
data-slot="medication-threshold-line"
/>
<ReferenceLine
y={100}
stroke={COLOR_GOAL}
strokeDasharray="5 5"
strokeOpacity={0.85}
data-slot="medication-goal-line"
/>
</>
) : null}
<Line
type="monotone"
dataKey="rate"
stroke={COLOR_LINE}
strokeWidth={2}
dot={{ r: 2, fill: COLOR_LINE }}
activeDot={{ r: 4 }}
connectNulls
isAnimationActive={animationsEnabled}
animationDuration={animationsEnabled ? 600 : 0}
animationEasing="ease-out"
/>
</ComposedChart>
</ResponsiveContainer>
</div>
{showTargetRange ? (
<>
<ReferenceLine
y={80}
stroke={COLOR_THRESHOLD}
strokeDasharray="3 5"
strokeOpacity={0.6}
data-slot="medication-threshold-line"
/>
<ReferenceLine
y={100}
stroke={COLOR_GOAL}
strokeDasharray="5 5"
strokeOpacity={0.85}
data-slot="medication-goal-line"
/>
</>
) : null}
<Line
type="monotone"
dataKey="rate"
stroke={COLOR_LINE}
strokeWidth={2}
dot={{ r: 2, fill: COLOR_LINE }}
activeDot={{ r: 4 }}
connectNulls
isAnimationActive={animationsEnabled}
animationDuration={animationsEnabled ? 600 : 0}
animationEasing="ease-out"
/>
</ComposedChart>
</ResponsiveContainer>
</div>
<ChartDayFooter
links={chartDays}
points={chartData}
axis="band"
// The margin (8) + the y axis (48) on the left, the margin (8)
// on the right.
insetLeft={8 + 48}
insetRight={8}
/>
</>
)}
</div>
);
+88 -3
View File
@@ -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 ? (
<ReferenceLine
key="open-day"
x={chartDays.openIndex}
{...OPEN_DAY_LINE}
/>
) : null}
<Tooltip
{...chartDays.tooltipProps}
cursor={{
stroke: "var(--muted-foreground)",
strokeOpacity: 0.3,
@@ -968,7 +1015,16 @@ export function MoodChart({
}
}
if (rows.length === 0) return null;
return <RichChartTooltip active label={dateLabel} rows={rows} />;
return (
<RichChartTooltip
active
label={dateLabel}
rows={rows}
action={chartDays.tooltipAction(
hoverPoint ? dayLinkSource?.indexOf(hoverPoint) : undefined,
)}
/>
);
}}
/>
<Line
@@ -1142,7 +1198,36 @@ export function MoodChart({
</div>
</div>
</CardHeader>
<CardContent>{chartBody}</CardContent>
<CardContent>
{chartBody}
{!isLoading && !isError && chartData && chartData.length >= 3 ? (
<>
<ChartDayFooter
links={chartDays}
points={chartData}
// The plot's margin (8) + the y axis (65) + the x axis padding
// (10) on the left, margin + padding on the right.
insetLeft={8 + 65 + 10}
insetRight={8 + 10}
/>
{dayLinksActive ? (
<ChartDataTable
points={chartData.map((point) => ({
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}
</CardContent>
</Card>
);
}
+88 -158
View File
@@ -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, useCoarsePointer } 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";
@@ -98,14 +100,15 @@ export function MoodHeatmapBody({
}
/**
* The loading shape of the stretched heatmap: the reserved grid box and the
* legend line, at exactly the height the painted heatmap will take.
* `days` is the window when it is already known; without it the shape
* assumes a window short enough to reach the cell ceiling, which every
* window under a quarter year does on any width.
* The loading shape of the stretched heatmap, at exactly the height the
* painted heatmap takes: the reserved grid box, the legend line, the day
* caption and the data-table toggle. `days` is the window when it is already
* known; without it the shape assumes a window short enough to reach the cell
* ceiling, which every window under a quarter year does on any width.
*/
export function MoodHeatmapSkeleton({ days = 84 }: { days?: number }) {
const timeZone = useDisplayTimezone();
const coarse = useCoarsePointer();
return (
<div
data-slot="mood-heatmap-skeleton"
@@ -116,6 +119,10 @@ export function MoodHeatmapSkeleton({ days = 84 }: { days?: number }) {
<div className="bg-muted/50 h-full w-full animate-pulse rounded-md motion-reduce:animate-none" />
</MoodHeatmapBody>
<div className="mt-2 h-4" />
<ChartDayCaption coarse={coarse} mark="cell" rug={false} />
<div className="border-border mt-3 border-t pt-3">
<div className="min-h-11" />
</div>
</div>
);
}
@@ -146,47 +153,10 @@ export function MoodHeatmap({
const formatDay = useCalendarDate();
const containerRef = useRef<HTMLDivElement | null>(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<string>("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"),
@@ -298,9 +268,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")}`;
@@ -309,7 +278,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);
@@ -333,9 +302,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) => (
<text
@@ -366,114 +333,52 @@ export function MoodHeatmap({
),
)}
{cells.map((cell) => {
const buildText = (): string => describeCell(cell);
return (
{cells.map((cell) => (
<rect
key={cell.dateKey}
x={labelWidth + cell.col * step}
y={headerHeight + cell.row * step}
width={cellSize}
height={cellSize}
rx={2}
fill={cell.color}
// v1.19.1 — populated days render at full saturation so the
// mood band reads clearly; only the no-entry cells stay the
// quiet `--secondary` empty-state tint.
fillOpacity={1}
// 2026-07-17 a11y audit (M2) — the per-day values reach
// assistive tech through the data table below the grid, not
// through the rects: a cell subtree nested under the SVG's
// `role="img"` is pruned from the a11y tree, and per-cell tab
// stops would flood the keyboard order on a year-long grid.
// The rects stay a pure pointer affordance.
{...heatmapDay.cellProps({
dateKey: cell.dateKey,
hasEntry: cell.cell !== null,
describe: () => describeCell(cell),
})}
/>
))}
{cells
.filter((cell) => cell.dateKey === heatmapDay.openKey)
.map((cell) => (
<rect
key={cell.dateKey}
x={labelWidth + cell.col * step}
y={headerHeight + cell.row * step}
width={cellSize}
height={cellSize}
rx={2}
fill={cell.color}
// v1.19.1 — populated days render at full saturation so the
// mood band reads clearly; only the no-entry cells stay the
// quiet `--secondary` empty-state tint.
fillOpacity={1}
className="cursor-pointer"
data-day={cell.dateKey}
// 2026-07-17 a11y audit (M2) — the per-day values reach
// assistive tech through the visually-hidden day list below
// the grid, not through the rects: a cell subtree nested
// under the SVG's `role="img"` is pruned from the a11y tree,
// and per-cell tab stops would flood the keyboard order on a
// year-long grid. The rects stay a pure pointer affordance.
onPointerEnter={(e) => {
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}
/>
);
})}
))}
</svg>
</div>
</StretchFrame>
{/* 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 && (
<ul className="sr-only" data-slot="mood-heatmap-day-list">
{loggedCells.map((cell) => (
<li key={cell.dateKey}>{describeCell(cell)}</li>
))}
</ul>
)}
{tooltip && (
<div
data-slot="mood-heatmap-tooltip"
className={`bg-popover text-popover-foreground border-border fixed z-50 rounded-md border px-2 py-1 text-xs shadow-md ${
tooltip.day ? "pointer-events-auto" : "pointer-events-none"
}`}
// Clamp the left edge so a tap near the right border doesn't push
// the pinned label off-screen on a narrow viewport.
style={{
left:
typeof window !== "undefined"
? Math.min(tooltip.x + 10, window.innerWidth - 180 - 8)
: tooltip.x + 10,
top: tooltip.y - 30,
}}
>
{tooltip.text}
{tooltip.day ? (
<button
type="button"
data-slot="heatmap-open-day"
onClick={() => {
const day = tooltip.day;
setTooltip(null);
if (day) openDay(day, { trigger: containerRef.current });
}}
className="bg-muted hover:bg-muted/80 focus-visible:ring-ring/50 mt-1.5 flex min-h-11 w-full items-center justify-center rounded-md px-3 text-sm font-medium focus-visible:ring-[3px] focus-visible:outline-none"
>
{t("day.openDay")}
</button>
) : null}
</div>
)}
{heatmapDay.tooltipNode}
<div
className="text-muted-foreground mt-2 flex flex-wrap items-center gap-3 text-xs"
@@ -503,6 +408,31 @@ export function MoodHeatmap({
</span>
))}
</div>
{loggedCells.length > 0 ? (
<>
<ChartDayCaption coarse={heatmapDay.coarse} mark="cell" rug={false} />
{/* The keyboard and screen-reader way to each logged day's value,
and to the day itself. */}
<ChartDataTable
points={loggedCells.map((cell) => ({
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}
</div>
);
}
@@ -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 (
<ResponsiveContainer width="100%" height={CHART_HEIGHT_PX}>
<ComposedChart
data={points}
margin={{ top: 8, right: 12, bottom: 4, left: 0 }}
<>
<div
data-slot="chart-plot"
{...chartDays.plotProps}
className={chartDays.plotClassName}
>
<CartesianGrid
strokeDasharray="3 3"
stroke="var(--border)"
vertical={false}
/>
<XAxis
dataKey="day"
tickFormatter={(day: string) => fmt.dateShortSmartCalendar(day)}
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
stroke="var(--border)"
minTickGap={24}
/>
<YAxis
domain={yDomain}
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
stroke="var(--border)"
width={44}
/>
{referenceValue != null ? (
<ReferenceLine
y={referenceValue}
stroke="var(--muted-foreground)"
strokeOpacity={0.6}
strokeDasharray="4 4"
/>
) : null}
<Tooltip
content={(props) => {
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 <RichChartTooltip active={false} rows={[]} />;
}
const rows: RichTooltipRow[] = [
{
name: valueLabel,
value: `${point.amount} ${unit}`,
color: barColor,
},
];
return (
<RichChartTooltip
active
label={fmt.dateShortSmartCalendar(point.day)}
rows={rows}
<ResponsiveContainer width="100%" height={CHART_HEIGHT_PX}>
<ComposedChart
data={points}
margin={{ top: 8, right: 12, bottom: 4, left: 0 }}
onClick={chartDays.onChartClick}
>
<CartesianGrid
strokeDasharray="3 3"
stroke="var(--border)"
vertical={false}
/>
<XAxis
dataKey="day"
tickFormatter={(day: string) => fmt.dateShortSmartCalendar(day)}
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
stroke="var(--border)"
minTickGap={24}
/>
<YAxis
domain={yDomain}
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
stroke="var(--border)"
width={44}
/>
{referenceValue != null ? (
<ReferenceLine
y={referenceValue}
stroke="var(--muted-foreground)"
strokeOpacity={0.6}
strokeDasharray="4 4"
/>
);
}}
) : null}
{chartDays.openIndex !== undefined ? (
<ReferenceLine
x={points[chartDays.openIndex]?.day}
{...OPEN_DAY_LINE}
/>
) : null}
<Tooltip
{...chartDays.tooltipProps}
content={(props) => {
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 <RichChartTooltip active={false} rows={[]} />;
}
const rows: RichTooltipRow[] = [
{
name: valueLabel,
value: `${point.amount} ${unit}`,
color: barColor,
},
];
return (
<RichChartTooltip
active
label={fmt.dateShortSmartCalendar(point.day)}
rows={rows}
action={chartDays.tooltipAction(points.indexOf(point))}
/>
);
}}
/>
<Bar
dataKey="amount"
fill={barColor}
radius={[2, 2, 0, 0]}
isAnimationActive={animate}
/>
</ComposedChart>
</ResponsiveContainer>
</div>
<ChartDayFooter
links={chartDays}
points={points.map((p) => ({ 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 ? (
<ChartDataTable
points={logged.map((p) => ({
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
/>
<Bar
dataKey="amount"
fill={barColor}
radius={[2, 2, 0, 0]}
isAnimationActive={animate}
/>
</ComposedChart>
</ResponsiveContainer>
) : null}
</>
);
}
@@ -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({
</p>
) : (
<div>
<ResponsiveContainer width="100%" height={CHART_HEIGHT_PX}>
<ComposedChart
data={points}
margin={{ top: 8, right: 12, bottom: 4, left: 0 }}
>
<CartesianGrid
strokeDasharray="3 3"
stroke="var(--border)"
vertical={false}
/>
<XAxis
dataKey="timestamp"
type="number"
scale="time"
domain={["dataMin", "dataMax"]}
tickFormatter={(ts: number) => fmt.dateShortSmart(new Date(ts))}
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
stroke="var(--border)"
minTickGap={32}
/>
<YAxis
domain={yDomain}
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
stroke="var(--border)"
width={44}
tickFormatter={(v: number) => formatMetricValue(v, decimals)}
/>
{targetLow != null && targetHigh != null ? (
<ReferenceArea
y1={targetLow}
y2={targetHigh}
fill={primary}
fillOpacity={0.1}
stroke="none"
<div
data-slot="chart-plot"
{...chartDays.plotProps}
className={chartDays.plotClassName}
>
<ResponsiveContainer width="100%" height={CHART_HEIGHT_PX}>
<ComposedChart
data={points}
margin={{ top: 8, right: 12, bottom: 4, left: 0 }}
onClick={chartDays.onChartClick}
>
<CartesianGrid
strokeDasharray="3 3"
stroke="var(--border)"
vertical={false}
/>
) : null}
{targetLow != null ? (
<ReferenceLine
y={targetLow}
stroke={primary}
strokeOpacity={0.4}
strokeDasharray="4 4"
/>
) : null}
{targetHigh != null ? (
<ReferenceLine
y={targetHigh}
stroke={primary}
strokeOpacity={0.4}
strokeDasharray="4 4"
/>
) : null}
<Tooltip
content={(props) => {
const active = props.active ?? false;
const payload = props.payload as
ReadonlyArray<{ payload?: ChartPoint }> | undefined;
const point = payload?.[0]?.payload;
if (!active || !point) {
return <RichChartTooltip active={false} rows={[]} />;
<XAxis
dataKey="timestamp"
type="number"
scale="time"
domain={["dataMin", "dataMax"]}
tickFormatter={(ts: number) =>
fmt.dateShortSmart(new Date(ts))
}
const rows: RichTooltipRow[] = [
{
name: t("customMetrics.chart.valueLabel"),
value: `${formatMetricValue(point.value, decimals)} ${unit}`,
color: primary,
},
];
return (
<RichChartTooltip
active
label={fmt.dateShortSmart(new Date(point.timestamp))}
rows={rows}
/>
);
}}
/>
<Line
type="monotone"
dataKey="value"
stroke={primary}
strokeWidth={2}
dot={{ r: 3, fill: primary }}
activeDot={{ r: 5 }}
isAnimationActive={animate}
/>
</ComposedChart>
</ResponsiveContainer>
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
stroke="var(--border)"
minTickGap={32}
/>
<YAxis
domain={yDomain}
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
stroke="var(--border)"
width={44}
tickFormatter={(v: number) => formatMetricValue(v, decimals)}
/>
{targetLow != null && targetHigh != null ? (
<ReferenceArea
y1={targetLow}
y2={targetHigh}
fill={primary}
fillOpacity={0.1}
stroke="none"
/>
) : null}
{targetLow != null ? (
<ReferenceLine
y={targetLow}
stroke={primary}
strokeOpacity={0.4}
strokeDasharray="4 4"
/>
) : null}
{targetHigh != null ? (
<ReferenceLine
y={targetHigh}
stroke={primary}
strokeOpacity={0.4}
strokeDasharray="4 4"
/>
) : null}
{openPoint ? (
<ReferenceLine x={openPoint.timestamp} {...OPEN_DAY_LINE} />
) : null}
<Tooltip
{...chartDays.tooltipProps}
content={(props) => {
const active = props.active ?? false;
const payload = props.payload as
ReadonlyArray<{ payload?: ChartPoint }> | undefined;
const point = payload?.[0]?.payload;
if (!active || !point) {
return <RichChartTooltip active={false} rows={[]} />;
}
const rows: RichTooltipRow[] = [
{
name: t("customMetrics.chart.valueLabel"),
value: `${formatMetricValue(point.value, decimals)} ${unit}`,
color: primary,
},
];
return (
<RichChartTooltip
active
label={fmt.dateShortSmart(new Date(point.timestamp))}
rows={rows}
action={chartDays.tooltipAction(points.indexOf(point))}
/>
);
}}
/>
<Line
type="monotone"
dataKey="value"
stroke={primary}
strokeWidth={2}
dot={{ r: 3, fill: primary }}
activeDot={{ r: 5 }}
isAnimationActive={animate}
/>
</ComposedChart>
</ResponsiveContainer>
</div>
<ChartDayFooter
links={chartDays}
points={points}
axis="time"
// The y axis (44) on the left, the margin (12) on the right.
insetLeft={44}
insetRight={12}
/>
<ChartDataTable
points={points.map((point, index) => ({
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
/>
</div>
)}
</div>
@@ -287,6 +287,7 @@ export function CustomMetricDetail({
<CardContent>
<CustomMetricChart
entries={entries}
name={metric?.name ?? ""}
unit={metric?.unit ?? ""}
targetLow={metric?.targetLow ?? null}
targetHigh={metric?.targetHigh ?? null}
@@ -1,5 +1,6 @@
import { describe, it, expect, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
// `useUnitDisplay` reads the account through react-query; these static
// renders have no QueryClient, so the hook is a real display for a fixed
@@ -18,9 +19,13 @@ import { unitDisplayFor } from "@/__tests__/helpers/unit-display-mock";
import { I18nProvider } from "@/lib/i18n/context";
import type { CalendarDay } from "../types";
// v1.42 — the chart's row of day dots reads the day index through
// react-query, so the static render carries a client (it never fetches).
function render(node: React.ReactNode) {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">{node}</I18nProvider>,
<QueryClientProvider client={new QueryClient()}>
<I18nProvider initialLocale="en">{node}</I18nProvider>
</QueryClientProvider>,
);
}
+76 -3
View File
@@ -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,
@@ -46,6 +46,13 @@ import { cn } from "@/lib/utils";
import type { CalendarDay, CervicalMucus, OvulationTest } from "./types";
import { PHASE_HUE, OVULATION_HUE } from "./phase-tokens";
import { shiftDateKey } from "@/lib/tz/format";
import { dateOnlyKey } from "@/lib/tz/date-only";
import { ChartDataTable } from "@/components/charts/chart-data-table";
import {
ChartDayFooter,
OPEN_DAY_LINE,
useChartDayLinks,
} from "@/components/day/chart-day-links";
/** Trailing fallback window (days) when no MENSTRUAL-anchored cycle is active. */
const FALLBACK_WINDOW_DAYS = 35;
@@ -190,6 +197,27 @@ export function BbtChart({
const hasCurve = points.length >= 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 (
<Card data-slot="cycle-bbt-chart">
<CardHeader>
@@ -213,14 +241,16 @@ export function BbtChart({
{t("cycle.bbt.caption")}
</p>
<div
className="touch-pan-y"
className={cn("touch-pan-y", chartDays.plotClassName)}
style={{ height: "210px" }}
data-slot="cycle-bbt-area"
{...chartDays.plotProps}
>
<ResponsiveContainer width="100%" height="100%">
<LineChart
data={points}
margin={{ top: 10, right: 14, bottom: 16, left: 4 }}
onClick={chartDays.onChartClick}
>
<CartesianGrid
strokeDasharray="3 3"
@@ -248,13 +278,30 @@ export function BbtChart({
axisLine={false}
tickFormatter={(v) => `${fmt.number(v as number)}°`}
/>
{openPoint ? (
<ReferenceLine x={openPoint.t} {...OPEN_DAY_LINE} />
) : null}
<Tooltip
{...chartDays.tooltipProps}
cursor={{
stroke: "var(--muted-foreground)",
strokeOpacity: 0.3,
strokeDasharray: "3 3",
}}
content={<BbtTooltip />}
content={(props) => {
const payload = props.payload as unknown as
{ payload: BbtPoint }[] | undefined;
const point = payload?.[0]?.payload;
return (
<BbtTooltip
active={props.active}
payload={payload}
action={chartDays.tooltipAction(
point ? points.indexOf(point) : undefined,
)}
/>
);
}}
/>
{ovulationMs != null ? (
<ReferenceLine
@@ -290,6 +337,29 @@ export function BbtChart({
</LineChart>
</ResponsiveContainer>
</div>
<ChartDayFooter
links={chartDays}
points={points.map((p) => ({ 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}
/>
<ChartDataTable
points={points.map((p) => ({
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
/>
</>
)}
</CardContent>
@@ -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}`)}
</p>
) : null}
{props.action ?? null}
</div>
);
}
+13 -1
View File
@@ -20,6 +20,11 @@ import { useQuery } from "@tanstack/react-query";
import { CloudSun } from "lucide-react";
import { TagChip } from "@/components/ui/tag-chip";
import {
DAY_LINK_SLOT,
DayLink,
withDayLinkSlot,
} from "@/components/day/day-link";
import { useAuth } from "@/hooks/use-auth";
import { apiGet } from "@/lib/api/api-fetch";
import {
@@ -91,7 +96,14 @@ export function EnvironmentChip() {
>
<CloudSun className="text-muted-foreground size-4" aria-hidden />
<span className="text-muted-foreground text-xs">
{t("environment.chip.day", { date })}
{/* v1.42 — the date opens the day it names, the one thing every
date in the app does. */}
{withDayLinkSlot(
t("environment.chip.day", { date: DAY_LINK_SLOT }),
<DayLink date={day.date} size="xs">
{date}
</DayLink>,
)}
</span>
{flags.map((flag) => (
<TagChip key={flag.kind} data-flag={flag.kind}>
+75 -14
View File
@@ -1,8 +1,8 @@
import { describe, expect, it } from "vitest";
import { chartPointDayKey, rugPosition } from "../chart-day";
import { chartPointDayKey, rugFraction, rugPosition } from "../chart-day";
import { daysFromCoachSteps } from "../coach-day-chips";
import { pickPreparationDays } from "../since-last-visit";
import { preparationRows } from "../since-last-visit";
import type { CoachStep } from "@/lib/ai/coach/types";
const noon = (key: string) => Date.parse(`${key}T12:00:00.000Z`);
@@ -74,20 +74,81 @@ describe("Coach day chips", () => {
});
describe("visit preparation", () => {
it("reads notable days first, then context days, newest kept, in date order", () => {
const days = pickPreparationDays(
const spell = {
notableTitle: () => "Blood pressure",
notableText: () => "Highest daily value since March",
countMeta: (count: number) => (count > 1 ? `${count} entries` : null),
};
it("lists every change the server names, a dose change on a quiet day included", () => {
const rows = preparationRows(
{
days: {
"2026-08-03": ["medications", "values"],
"2026-08-19": ["illness", "values"],
"2026-09-14": ["labs"],
"2026-09-20": ["values", "mood"],
},
notable: ["2026-09-02"],
changes: [
{
date: "2026-08-03",
kind: "doseChange",
section: "medications",
id: "d1",
title: "Ramipril 5 mg",
count: 1,
href: null,
},
{
date: "2026-09-14",
kind: "labResult",
section: "labs",
id: "l1",
title: "Lab results",
count: 4,
href: null,
},
],
observations: [
{
date: "2026-09-02",
kind: "extremeHigh",
type: "BLOOD_PRESSURE_SYS",
params: { value: 152 },
},
// A first reading says nothing about the stretch since the visit.
{
date: "2026-08-20",
kind: "firstValue",
type: "WEIGHT",
params: {},
},
],
},
3,
spell,
);
expect(rows.map((row) => [row.date, row.title, row.meta])).toEqual([
["2026-08-03", "Ramipril 5 mg", null],
["2026-09-02", "Blood pressure", "Highest daily value since March"],
["2026-09-14", "Lab results", "4 entries"],
]);
});
});
describe("rug placement per axis", () => {
const points = [
{ timestamp: noon("2026-01-01") },
{ timestamp: noon("2026-01-02") },
{ timestamp: noon("2026-01-05") },
];
it("spreads an index axis edge to edge, a band axis at band centres", () => {
expect(rugFraction("2026-01-01", points, "index")).toBe(0);
expect(rugFraction("2026-01-05", points, "index")).toBe(1);
expect(rugFraction("2026-01-01", points, "band")).toBeCloseTo(1 / 6);
expect(rugFraction("2026-01-05", points, "band")).toBeCloseTo(5 / 6);
});
it("places a day on a time axis by its date, not by its neighbours", () => {
// Day 3 of a 4-day span: three quarters along, although it sits between
// the second and third point by index.
expect(rugFraction("2026-01-04", points, "time")).toBeCloseTo(0.75);
expect(rugFraction("2026-01-04", points, "index")).toBeCloseTo(
(1 + 2 / 3) / 2,
);
// A day with only values and mood is not a context change.
expect(days).toEqual(["2026-08-19", "2026-09-02", "2026-09-14"]);
});
});
@@ -0,0 +1,271 @@
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join, relative } from "node:path";
import { describe, expect, it } from "vitest";
/**
* Every chart drawn in days is a door to its days, or it says why not.
*
* 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 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)
* passes `dayLinks`, or is
* a tile's `mini` chart (never a door, by the primitive's contract), or
* forwards its props unchanged (a lazy wrapper);
* B. a file that draws its own Recharts chart over a date axis (an
* `<XAxis>` keyed on a date-shaped field, or `scale="time"`) calls
* `useChartDayLinks` and renders its parts (`ChartDayFooter`,
* `OPEN_DAY_LINE`, the tooltip's props and its touch action);
* C. a calendar heatmap (anything laid out by `heatmapDays`) calls
* `useHeatmapDay`.
*
* A surface that must not open a day stands on `NOT_HERE` with its reason,
* the integration plan's §1.2 list made concrete. An entry that no longer
* names a chart fails, so the list cannot rot into a silent allowlist.
*
* The matcher's limits, stated so nobody mistakes green for complete:
* - a hand-rolled SVG chart that does not use `heatmapDays` (the cycle
* history bars, the lab list's sparkline, the timeline) is invisible;
* - a date carried on an axis under another key (`label`, `x`) slips rule
* B; `DATE_AXIS_KEYS` is the closed set it knows;
* - a new component that wraps `HealthChart` under another name slips rule
* A until it is added to `PROP_GATED`;
* - `dayLinks={someFlag}` counts as on: the guard reads the source, not
* the value at run time.
* The floors at the bottom fail an empty match set, so a matcher that stops
* matching cannot pass by finding nothing.
*/
const ROOT = join(__dirname, "..", "..", "..", "..");
const SCAN = ["src/app", "src/components"];
/** Charts whose doors are switched on per mount. */
const PROP_GATED = [
"HealthChart",
"HealthChartDynamic",
"MoodChart",
"MedicationComplianceChart",
"NutrientDailyBarChart",
"NutrientDailyBarChartDynamic",
] as const;
/** `<XAxis dataKey>` values that mean the axis runs over days or instants. */
const DATE_AXIS_KEYS = [
"date",
"day",
"dayKey",
"dayOffset",
"timestamp",
"t",
"pointIndex",
] as const;
/**
* Where a chart deliberately opens no day. Path (a file) → why.
* The integration plan's §1.2 and its table are the source of each reason.
*/
const NOT_HERE: Record<string, string> = {
"src/components/insights/coach-panel/chat-bubble.tsx":
"A Coach answer reaches its days through the chips built from the tool calls (`CoachDayChips`), never from what the answer draws or writes.",
"src/components/insights/coach-panel/result-chart.tsx":
"Same as the Coach answer: the result table's days arrive as tool-derived chips, and a period row may be a month with no single day.",
"src/components/medications/drug-level-chart.tsx":
"A modelled estimate, not a record: every point is computed, none was measured. The doses it is drawn from open their days in the intake history.",
"src/components/charts/scatter-correlation-chart.tsx":
"A pairing of two values per day with no date axis; the pairs carry no date to open.",
"src/components/admin/host-metrics-chart.tsx":
"The server's own resource use, not a health record.",
};
function walk(path: string): string[] {
const abs = join(ROOT, path);
if (statSync(abs).isFile()) return [abs];
return readdirSync(abs).flatMap((name) => {
if (name === "__tests__" || name === "ui") return [];
return walk(join(path, name));
});
}
const files = SCAN.flatMap(walk).filter((f) => /\.tsx?$/.test(f));
const rel = (abs: string) => relative(ROOT, abs);
/**
* The code without its comments: a docblock that names `<HealthChart>` is
* not a mount. Block comments (JSX ones included) go whole; a line comment
* goes from `//` to the line end unless the slashes follow a colon (a URL).
*/
export function stripComments(text: string): string {
return text
.replace(/\/\*[\s\S]*?\*\//g, "")
.replace(/(^|[^:"'`])\/\/.*$/gm, "$1");
}
const source = new Map(
files.map((f) => [rel(f), stripComments(readFileSync(f, "utf8"))]),
);
/**
* The text of every JSX opening tag named `name` in `text`: from `<Name` to
* the `>` that closes it at brace depth zero, so an arrow function or a
* comparison inside a prop does not end the tag early.
*/
export function jsxTags(text: string, name: string): string[] {
const out: string[] = [];
const opener = new RegExp(`<${name}(?=[\\s/>])`, "g");
let match: RegExpExecArray | null;
while ((match = opener.exec(text)) !== null) {
let depth = 0;
let quote: string | null = null;
let i = match.index + match[0].length;
for (; i < text.length; i += 1) {
const c = text[i]!;
if (quote) {
if (c === quote && text[i - 1] !== "\\") quote = null;
continue;
}
if (c === '"' || c === "'" || c === "`") quote = c;
else if (c === "{") depth += 1;
else if (c === "}") depth -= 1;
else if (c === ">" && depth === 0) break;
}
out.push(text.slice(match.index, i + 1));
}
return out;
}
/** True when a prop-gated mount opens its days. */
function mountOpensDays(tag: string): boolean {
return /\bdayLinks\b(?!\s*=\s*\{\s*false\s*\})/.test(tag);
}
function isDateAxis(tag: string): boolean {
if (/scale="time"/.test(tag)) return true;
const key = tag.match(/dataKey="([^"]+)"/)?.[1];
return (
key !== undefined && (DATE_AXIS_KEYS as readonly string[]).includes(key)
);
}
interface Finding {
file: string;
why: string;
}
function findMissingDoors(): {
missing: Finding[];
doors: {
mounts: number;
charts: number;
chartFiles: string[];
heatmaps: number;
};
} {
const missing: Finding[] = [];
const doors = {
mounts: 0,
charts: 0,
chartFiles: [] as string[],
heatmaps: 0,
};
for (const [file, text] of source) {
const listed = file in NOT_HERE;
// A. prop-gated mounts.
for (const name of PROP_GATED) {
for (const tag of jsxTags(text, name)) {
if (/\{\s*\.\.\.props\s*\}/.test(tag)) continue; // a lazy wrapper
if (/\bmini\b/.test(tag)) continue; // a tile's sparkline
if (mountOpensDays(tag)) {
doors.mounts += 1;
continue;
}
if (!listed) missing.push({ file, why: `<${name}> without dayLinks` });
}
}
// B. a Recharts chart over a date axis, drawn in this file.
if (/from\s+["']recharts["']/.test(text)) {
const dated = jsxTags(text, "XAxis").some(isDateAxis);
if (dated) {
const wired =
/\buseChartDayLinks\(/.test(text) &&
/\bChartDayFooter\b/.test(text) &&
/\bOPEN_DAY_LINE\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 and touch action)",
});
}
}
}
// C. a calendar heatmap.
if (/\bheatmapDays\(/.test(text) && !file.startsWith("src/lib/")) {
if (/\buseHeatmapDay\(/.test(text)) doors.heatmaps += 1;
else if (!listed) {
missing.push({ file, why: "a calendar heatmap without useHeatmapDay" });
}
}
}
return { missing, doors };
}
describe("every chart drawn in days opens its days, or says why not", () => {
const { missing, doors } = findMissingDoors();
it("finds no date-axis chart, heatmap or chart mount without the day doors", () => {
expect(missing).toEqual([]);
});
it("still finds the doors it is meant to find (an empty match set fails)", () => {
// HealthChart on the metric pages (two mounts in the HealthKit page),
// blood pressure, weight, pulse, BMI, recovery, sleep, the mood line,
// water and caffeine; on the dashboard its seven charts, the mood line
// and the adherence chart.
expect(doors.mounts).toBeGreaterThanOrEqual(20);
// HealthChart, the mood line and the nutrient bars themselves, sleep
// stages, mood dimensions, lab marker, custom metric, questionnaire,
// basal temperature, dose strength, efficacy, the adherence chart:
// twelve.
expect(
doors.chartFiles.length,
doors.chartFiles.join("\n"),
).toBeGreaterThanOrEqual(12);
// The mood and the intake calendars.
expect(doors.heatmaps).toBeGreaterThanOrEqual(2);
});
it("every NOT_HERE entry names an existing chart and gives a reason", () => {
for (const [file, reason] of Object.entries(NOT_HERE)) {
const text = source.get(file);
expect(text, `${file} is listed but does not exist`).toBeDefined();
const charts =
/from\s+["']recharts["']/.test(text!) ||
PROP_GATED.some((name) => jsxTags(text!, name).length > 0);
expect(charts, `${file} is listed but draws no chart`).toBe(true);
expect(reason.length).toBeGreaterThan(30);
}
});
it("reads a JSX tag to its real end, past arrow functions in its props", () => {
const [tag] = jsxTags(
`<HealthChart onVisibleStats={(s) => s > 1} dayLinks />`,
"HealthChart",
);
expect(tag).toContain("dayLinks");
expect(mountOpensDays("<HealthChart dayLinks={false} />")).toBe(false);
expect(isDateAxis('<XAxis dataKey="t" type="number" />')).toBe(true);
expect(isDateAxis('<XAxis dataKey="label" />')).toBe(false);
});
});
@@ -7,9 +7,12 @@ import { describe, expect, it } from "vitest";
* Where a date must NOT open the day (integration plan §1.2), frozen.
*
* - the dashboard's today area: the dashboard is today; past days open as
* a layer over it, never as date navigation inside it;
* - a tile's mini chart: too small a target, and the tile has its own
* destination (the chart side is pinned in `health-chart-day-links`);
* a layer over it, never as date navigation inside it. The dashboard's
* charts below it do open their days (the maintainer's call on the
* beta), like every chart drawn in days (`day-door-coverage-guard`);
* - a trend card: it shows the latest value and a direction, no day, and
* the whole tile leads to its metric page (the mini-chart side is
* pinned in `health-chart-day-links`);
* - the cycle calendar: a tap there has meant "log this day" for years;
* - model-written text (the briefing, the Coach's prose): never parsed for
* dates; the Coach's days come from its tool calls as chips;
@@ -64,11 +67,14 @@ describe("the day view stays out of the surfaces it must not reach", () => {
expect(offenders.map((f) => relative(ROOT, f))).toEqual([]);
});
it("the dashboard's charts never ask for day links", () => {
it("the dashboard's today area hands no day door to its hero", () => {
// The charts below the hero open their days; the hero itself is today
// and stays on the list above. Pinned from the other side: the hero is
// still mounted by the dashboard, so the list entry is not stale.
const dashboard = readFileSync(
join(ROOT, "src/app/page-client.tsx"),
"utf8",
);
expect(dashboard).not.toMatch(/\bdayLinks\b/);
expect(dashboard).toMatch(/<TodayHero\b/);
});
});
@@ -108,6 +108,7 @@ let canWrite = true;
vi.mock("../use-day", () => ({
useDay: () => ({ ...dayQuery, refetch: () => undefined }),
usePrefetchDay: () => () => undefined,
}));
vi.mock("next/navigation", () => ({ usePathname: () => pathname }));
vi.mock("@/hooks/use-auth", () => ({
@@ -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 (
<span>
{shown.value}|{shown.unit}
</span>
);
}
function read(
type: string,
value: number,
unit: string,
locale: "en" | "de" = "en",
) {
return renderToStaticMarkup(
<I18nProvider
initialLocale={locale}
initialMessages={locale === "de" ? deMessages : enMessages}
>
<Probe type={type} value={value} unit={unit} />
</I18nProvider>,
)
.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");
});
});
+226
View File
@@ -0,0 +1,226 @@
"use client";
import { useCallback, useMemo, useRef, type ReactNode } from "react";
import type { DateKey } from "@/lib/day/contract";
import { prefersReducedMotion } from "@/lib/charts/reduced-motion";
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", 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.
*
* 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 `<ReferenceLine>`. */
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<DateKey | null>;
/** What the day shows at its top when opened from point `index`. */
focusFor?: (index: number) => Omit<DayFocus, "date"> | null;
/**
* For a chart whose plotted series are not the chart's `data` (several
* `<Line data>` 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 `<ResponsiveContainer>`. */
plotProps: {
ref: React.RefObject<HTMLDivElement | null>;
"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 `<Tooltip>`: 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: {
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({
enabled,
days,
focusFor,
indexOfClick,
}: ChartDayLinksOptions): ChartDayLinks {
const coarse = useCoarsePointer();
const openKey = useOpenDay();
const today = useTodayKey();
const plotRef = useRef<HTMLDivElement>(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 },
isAnimationActive: false,
}
: prefersReducedMotion()
? { trigger: "hover", isAnimationActive: false }
: { trigger: "hover" },
tooltipAction: (index) =>
enabled && coarse && index !== undefined && canOpen(index) ? (
<TooltipDayAction onOpen={() => 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<ChartDayLinks, "active" | "coarse">;
/** 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 (
<>
<DayRug
points={points}
axis={axis}
insetLeft={insetLeft}
insetRight={insetRight}
/>
<ChartDayCaption coarse={links.coarse} mark={mark} />
</>
);
}
/** 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`);
}
+80 -29
View File
@@ -8,7 +8,7 @@ import { dateOnlyKey } from "@/lib/tz/date-only";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import { openDay, useOpenDay, type DayFocus } from "./day-layer-controller";
import { useOpenDay } from "./day-layer-controller";
import { isOpenableDay } from "./day-url";
import { useDayIndex } from "./use-day";
import { useTodayKey } from "./use-today-key";
@@ -19,8 +19,9 @@ import { useTodayKey } from "./use-today-key";
* - 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: one tap
* that opened a sheet would fire on every scroll that grazed the chart.
* 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.
@@ -59,19 +60,6 @@ export function chartPointDayKey(timestamp: number): DateKey {
return dateOnlyKey(new Date(timestamp));
}
/** Open the day of a chart point, carrying the value as the day's focus. */
export function openChartDay(
timestamp: number,
focus: Omit<DayFocus, "date"> | null,
trigger?: HTMLElement | null,
): void {
const date = chartPointDayKey(timestamp);
openDay(date, {
focus: focus ? { ...focus, date } : null,
trigger: trigger ?? null,
});
}
/** The tooltip's way to the day on a touch screen. */
export function TooltipDayAction({ onOpen }: { onOpen: () => void }) {
const { t } = useTranslations();
@@ -119,19 +107,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 +172,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 (
<div
aria-hidden="true"
data-slot="day-rug"
data-axis={axis}
className="pointer-events-none relative h-3"
style={{ marginLeft: insetLeft, marginRight: insetRight }}
>
@@ -173,9 +202,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 +210,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<ChartDayMark, { fine: string; coarse: string }> = {
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 (
<p
data-slot="chart-day-caption"
className="text-muted-foreground mt-1 flex flex-wrap justify-between gap-x-4 gap-y-1 text-xs"
>
<span>{t("day.rugLegend")}</span>
<span>{coarse ? t("day.chartHintTouch") : t("day.chartHint")}</span>
{rug ? <span>{t("day.rugLegend")}</span> : null}
<span>{coarse ? t(hint.coarse) : t(hint.fine)}</span>
</p>
);
}
+22 -1
View File
@@ -51,9 +51,16 @@ interface LayerMemory {
trigger: HTMLElement | null;
/** The day the layer shows, as the mounted layer resolved it. */
open: DateKey | null;
/** The trigger's top edge on screen when it opened the layer. */
triggerTop: number | null;
}
const memory: LayerMemory = { focus: null, trigger: null, open: null };
const memory: LayerMemory = {
focus: null,
trigger: null,
open: null,
triggerTop: null,
};
const listeners = new Set<() => void>();
function emit() {
@@ -95,6 +102,17 @@ export function publishOpenDay(date: DateKey | null): void {
emit();
}
/**
* The element that opened the layer and where it stood on screen at that
* moment, without handing it over: the docked column keeps it there while
* the page beside it reflows.
*/
export function peekDayTriggerAt(): { el: HTMLElement; top: number } | null {
return memory.trigger && memory.triggerTop !== null
? { el: memory.trigger, top: memory.triggerTop }
: null;
}
/** The element focus returns to when the layer closes. */
export function takeDayTrigger(): HTMLElement | null {
const trigger = memory.trigger;
@@ -124,6 +142,9 @@ export function openDay(date: DateKey, options: OpenDayOptions = {}): void {
? active
: null;
memory.trigger = options.trigger ?? focused ?? memory.trigger;
memory.triggerTop = memory.trigger
? memory.trigger.getBoundingClientRect().top
: null;
emit();
const { pathname, search } = currentLocation();
const href = withDayHref(pathname, search, date);
+74 -4
View File
@@ -6,6 +6,7 @@ import {
useCallback,
useEffect,
useId,
useLayoutEffect,
useRef,
useState,
useSyncExternalStore,
@@ -26,13 +27,15 @@ import { cn } from "@/lib/utils";
import {
closeDay,
peekDayTriggerAt,
publishOpenDay,
stepDay,
takeDayTrigger,
useDayFocus,
} from "./day-layer-controller";
import { parseDayParam, withDayHref } from "./day-url";
import { parseDayParam, shiftDateKey, withDayHref } from "./day-url";
import { DayView, useLongDayLabel } from "./day-view";
import { usePrefetchDay } from "./use-day";
import { useTodayKey } from "./use-today-key";
/**
@@ -125,6 +128,12 @@ function DayLayer() {
? "docked"
: "sheet";
// The docked column narrows the page beside it, and the page reflows. Keep
// what opened the day where it was on screen, through the open and the
// close, so the chart under the pointer does not jump away from it.
useKeepAnchorInView(date !== null && shell === "docked");
const prefetchDay = usePrefetchDay();
const focus = useDayFocus(date);
const longLabel = useLongDayLabel();
const titleId = useId();
@@ -163,6 +172,13 @@ function DayLayer() {
if (date === null) return;
const onKey = (event: KeyboardEvent) => {
if (event.defaultPrevented) return;
// Alt is the first half of the step shortcut: read both neighbours
// while the arrow is still on its way, so the step paints a day.
if (event.key === "Alt") {
prefetchDay(shiftDateKey(date, -1));
if (date < today) prefetchDay(shiftDateKey(date, 1));
return;
}
const inside =
panelRef.current?.contains(event.target as Node | null) ?? false;
if (event.key === "Escape" && shell === "docked") {
@@ -184,7 +200,7 @@ function DayLayer() {
};
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [date, shell, onStep]);
}, [date, shell, onStep, prefetchDay, today]);
const live = (
<p
@@ -227,7 +243,10 @@ function DayLayer() {
aria-labelledby={titleId}
data-slot="day-panel"
data-shell="docked"
className="bg-card text-card-foreground border-border flex h-full w-105 shrink-0 flex-col border-l"
// A short fade, not a slide: the column takes its width at once
// (the page beside it reflows in the same frame either way), so
// only its content eases in. None under reduced motion.
className="bg-card text-card-foreground border-border motion-safe:animate-in motion-safe:fade-in-0 flex h-full w-105 shrink-0 flex-col border-l motion-safe:duration-150"
>
{view(DockedTitle, {
headerClassName: cn(
@@ -254,6 +273,55 @@ function DayLayer() {
);
}
/**
* Keep the element that opened the day at the same place on screen while the
* page beside the docked column reflows (the column taking or giving back its
* width). Chromium does this itself (CSS scroll anchoring); Safari does not,
* and there the chart the person clicked slid away under the pointer by the
* height the reflow added above it. Measured after layout, so where the
* browser already anchored, the correction is zero and nothing moves twice.
*/
function useKeepAnchorInView(active: boolean) {
const anchor = useRef<{ el: HTMLElement; top: number } | null>(null);
const wasActive = useRef(false);
// Before paint, so the corrected position is the first one drawn.
useLayoutEffect(() => {
if (active === wasActive.current) return;
wasActive.current = active;
const main = document.getElementById("main-content");
if (!main) return;
if (active) {
// Where the opener was when it was clicked, before the column came.
const opened = peekDayTriggerAt();
anchor.current =
opened && main.contains(opened.el)
? { el: opened.el, top: opened.top }
: null;
}
const a = anchor.current;
if (a && a.el.isConnected) {
const delta = a.el.getBoundingClientRect().top - a.top;
if (Math.abs(delta) > 1) main.scrollTop += delta;
a.top = a.el.getBoundingClientRect().top;
}
if (!active) anchor.current = null;
}, [active]);
// While the column is open, the page may scroll; the anchor follows.
useEffect(() => {
if (!active) return;
const main = document.getElementById("main-content");
if (!main) return;
const record = () => {
const a = anchor.current;
if (a && a.el.isConnected) a.top = a.el.getBoundingClientRect().top;
};
main.addEventListener("scroll", record, { passive: true });
return () => main.removeEventListener("scroll", record);
}, [active]);
}
function DockedTitle({
ref,
...props
@@ -362,7 +430,9 @@ function DaySheet({
returnFocus();
}}
className={cn(
"bg-card gap-0 p-0",
// Quicker than the primitive's 500 ms, which read as lag on a
// layer opened from a tap; no slide at all under reduced motion.
"bg-card gap-0 p-0 data-[state=closed]:duration-200 data-[state=open]:duration-300 motion-reduce:animate-none",
bottom
? cn(
"rounded-t-2xl transition-[height] duration-200 motion-reduce:transition-none",
+86 -36
View File
@@ -8,8 +8,9 @@ import {
ChevronRight,
History,
MessageCircle,
PanelBottomClose,
PanelRightClose,
Plus,
X,
} from "lucide-react";
import {
@@ -38,7 +39,8 @@ import {
DayValues,
} from "./day-sections";
import { curateDayValues, tileKeyOf } from "./day-values-model";
import { useDay } from "./use-day";
import { shiftDateKey } from "./day-url";
import { useDay, usePrefetchDay } from "./use-day";
import { useDayValueFormat } from "./use-day-value-format";
/**
@@ -52,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`));
@@ -91,6 +95,16 @@ export interface DayViewProps {
const ICON_BUTTON =
"text-muted-foreground hover:text-foreground size-11 shrink-0 pointer-fine:size-9";
/**
* The close control is the Coach panel's own toggle, in the same place: the
* panel's top-left corner, the panel icon mirrored so it points the way the
* panel goes. Same size and glyph as `PANEL_HEADER_BUTTON` there (kept as a
* copy so the day layer does not pull the Coach's panel into the shell).
*/
const PANEL_TOGGLE =
"text-muted-foreground hover:text-foreground size-11 shrink-0 pointer-fine:size-7";
const PANEL_TOGGLE_ICON = "size-5 pointer-fine:size-4";
export function DayView({
date,
today,
@@ -106,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();
@@ -114,6 +132,15 @@ export function DayView({
const capabilities = useRecordCapabilities();
const { user } = useAuth();
const [captureOpen, setCaptureOpen] = useState(false);
// The neighbouring day is read while the pointer rests on its arrow (or
// the keyboard reaches it, or a finger lands on it), so the step paints a
// day, not a skeleton.
const prefetchDay = usePrefetchDay();
const warm = (delta: number) => () => {
const next = shiftDateKey(date, delta);
if (delta > 0 && next > today) return;
prefetchDay(next);
};
const canCapture =
visibleCaptureKinds(capabilities, CAPTURE_KIND_ORDER, user?.modules)
@@ -163,28 +190,49 @@ export function DayView({
data-slot="day-header"
className={cn(
"flex shrink-0 items-center gap-1",
compact
? "pt-1.5 pr-2 pb-2.5 pl-4"
: "border-border border-b pr-3 pl-6",
compact ? "pt-0.5 pr-2 pb-2 pl-2" : "border-border border-b px-3",
headerClassName,
)}
>
<div className="min-w-0 flex-1">
<Title
id={titleId}
ref={titleRef}
tabIndex={-1}
className="truncate text-base leading-snug font-semibold focus-visible:outline-none"
>
{longLabel(date)}
</Title>
<p
className="text-muted-foreground truncate text-xs tabular-nums"
data-slot="day-meta"
>
{meta ?? <span className="invisible">·</span>}
</p>
</div>
{/* Top left, where the Coach panel keeps its toggle: the way out. */}
<Button
type="button"
variant="ghost"
size="icon"
data-slot="day-close"
aria-label={t("day.close")}
title={t("day.close")}
onClick={onClose}
className={PANEL_TOGGLE}
>
{compact ? (
<PanelBottomClose
className={PANEL_TOGGLE_ICON}
aria-hidden="true"
/>
) : (
<PanelRightClose
className={cn(PANEL_TOGGLE_ICON, "-scale-x-100")}
aria-hidden="true"
/>
)}
</Button>
{/* One line, centred in the band: the date and nothing under it. */}
<Title
id={titleId}
ref={titleRef}
tabIndex={-1}
className="min-w-0 flex-1 truncate px-1 text-base leading-snug font-semibold focus-visible:outline-none"
>
{compact ? (
<>
<span aria-hidden="true">{shortLabel(date)}</span>
<span className="sr-only">{longLabel(date)}</span>
</>
) : (
longLabel(date)
)}
</Title>
<Button
type="button"
variant={compact ? "ghost" : "outline"}
@@ -192,6 +240,9 @@ export function DayView({
data-slot="day-prev"
aria-label={t("day.previousDay")}
title={t("day.previousDayShortcut")}
onPointerEnter={warm(-1)}
onPointerDown={warm(-1)}
onFocus={warm(-1)}
onClick={() => onStep(-1)}
className={ICON_BUTTON}
>
@@ -208,6 +259,9 @@ export function DayView({
aria-label={t("day.nextDay")}
title={t("day.nextDayShortcut")}
disabled={date >= today}
onPointerEnter={warm(1)}
onPointerDown={warm(1)}
onFocus={warm(1)}
onClick={() => onStep(1)}
className={ICON_BUTTON}
>
@@ -216,18 +270,6 @@ export function DayView({
aria-hidden="true"
/>
</Button>
<Button
type="button"
variant="ghost"
size="icon"
data-slot="day-close"
aria-label={t("day.close")}
title={t("day.close")}
onClick={onClose}
className={ICON_BUTTON}
>
<X className="size-5 pointer-fine:size-4" aria-hidden="true" />
</Button>
</div>
<div
@@ -237,6 +279,14 @@ export function DayView({
compact ? "gap-6 px-4 pt-0.5 pb-4" : "gap-6 px-6 pt-5 pb-6",
)}
>
{/* What the day holds, as meta under the header. The line keeps its
height while the day loads, so nothing below it moves. */}
<p
className="text-muted-foreground -mb-3 text-xs tabular-nums"
data-slot="day-meta"
>
{meta ?? <span className="invisible">·</span>}
</p>
{focus ? (
<div className="space-y-2.5">
<DayFocusCard
+160
View File
@@ -0,0 +1,160 @@
"use client";
import { useEffect, useRef, useState, type RefObject } from "react";
import type { DateKey } from "@/lib/day/contract";
import { TooltipDayAction, useCoarsePointer } from "./chart-day";
import { openDay, useOpenDay } from "./day-layer-controller";
import { isOpenableDay } from "./day-url";
import { useTodayKey } from "./use-today-key";
/**
* The day view's doors on a calendar heatmap (mood, intake), the cell twin of
* `useChartDayLinks`:
*
* - 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
* table (`ChartDataTable` with `dayLinks`), as for every chart.
*
* A cell opens only when its day holds an entry and is not in the future.
*/
/** The dashed outline drawn over the open day's cell. */
export const OPEN_DAY_CELL = {
fill: "none",
stroke: "var(--foreground)",
strokeWidth: 1.5,
strokeDasharray: "3 2",
} as const;
interface HeatmapTooltip {
x: number;
y: number;
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 {
dateKey: string;
/** True when the day holds an entry this heatmap shows. */
hasEntry: boolean;
/** The cell's text, for the tooltip. */
describe: () => string;
}
export function useHeatmapDay(containerRef: RefObject<HTMLElement | null>) {
const [tooltip, setTooltip] = useState<HeatmapTooltip | null>(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<string>("mouse");
const today = useTodayKey();
const openKey = useOpenDay();
const coarse = useCoarsePointer();
// A tap outside the heatmap clears a pinned tooltip.
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, containerRef]);
// Escape dismisses any open tooltip.
useEffect(() => {
if (!tooltip) return;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") setTooltip(null);
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [tooltip]);
const canOpen = (cell: HeatmapCellInput): boolean =>
cell.hasEntry && isOpenableDay(cell.dateKey, today);
const open = (day: DateKey) => {
setTooltip(null);
openDay(day, { trigger: containerRef.current as HTMLElement | null });
};
/** Spread on a cell's `<rect>`. */
const cellProps = (cell: HeatmapCellInput) => {
const openable = canOpen(cell);
return {
"data-day": cell.dateKey,
"data-open": openable && openKey === cell.dateKey ? "true" : undefined,
className: openable ? "cursor-pointer" : undefined,
onPointerEnter: (e: React.PointerEvent) => {
// A touch enter fires just before `pointerdown`; the pinned
// tooltip below takes over with its real coordinates.
if (e.pointerType === "touch") return;
setTooltip({ x: e.clientX, y: e.clientY, text: cell.describe() });
},
onPointerLeave: (e: React.PointerEvent) => {
if (e.pointerType === "touch") return;
setTooltip((prev) => (prev?.pinned ? prev : null));
},
onPointerDown: (e: React.PointerEvent) => {
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);
},
};
};
/** Spread on the heatmap's `<svg>`. */
const svgProps = {
onMouseLeave: () => setTooltip((prev) => (prev?.pinned ? prev : null)),
};
const tooltipNode = tooltip ? (
<div
data-slot="heatmap-tooltip"
className={`bg-popover text-popover-foreground border-border fixed z-50 max-w-[calc(100vw-1rem)] rounded-md border px-2.5 py-1.5 text-xs shadow-md ${
tooltip.day ? "pointer-events-auto" : "pointer-events-none"
}`}
// Clamp the left edge so a tap near the right border doesn't push
// the pinned label off-screen on a narrow viewport.
style={{
left:
typeof window !== "undefined"
? Math.max(8, Math.min(tooltip.x + 10, window.innerWidth - 200 - 8))
: tooltip.x + 10,
top: tooltip.y - 30,
}}
>
{tooltip.text}
{tooltip.day ? (
<TooltipDayAction onOpen={() => open(tooltip.day as DateKey)} />
) : null}
</div>
) : null;
return { cellProps, svgProps, tooltipNode, openKey, coarse };
}
+61 -99
View File
@@ -2,7 +2,7 @@
import Link from "next/link";
import { useState } from "react";
import { useQueries } from "@tanstack/react-query";
import { useQuery } from "@tanstack/react-query";
import {
Activity,
CircleDashed,
@@ -24,8 +24,7 @@ import type {
DateKey,
DayEventKind,
DayNotable,
DayResponse,
DaySectionKey,
DayNotableWindowResponse,
} from "@/lib/day/contract";
import {
useDisplayTimezone,
@@ -37,9 +36,8 @@ import { queryKeys } from "@/lib/query-keys";
import { DAY_SECTION_LABEL, useNotableText } from "./day-sections";
import { DayLink } from "./day-link";
import { dateKeyOfInstant, shiftDateKey, daysBetween } from "./day-url";
import { dateKeyOfInstant } from "./day-url";
import { tileKeyOf } from "./day-values-model";
import { useDayIndex } from "./use-day";
import { useDayValueFormat } from "./use-day-value-format";
import { useTodayKey } from "./use-today-key";
@@ -50,35 +48,12 @@ import { useTodayKey } from "./use-today-key";
* opening its day. Compiled from the record without any judgement; the
* wording is the day view's own.
*
* Built from what the day routes already answer: the index names the days
* that carry a context change or a notable observation, and those days (at
* most {@link MAX_DAYS_READ}) are read for their entries. Nothing is
* recomputed here.
* One read, `GET /api/day/notable` for the window from the last visit to
* today: the server lists every context change in it (a dose change always,
* however quiet the day around it) and every notable observation. Nothing is
* recomputed here; this only spells the rows.
*/
/** Sections whose presence alone makes a day worth reading here. */
const CONTEXT_SECTIONS: readonly DaySectionKey[] = [
"illness",
"labs",
"vaccinations",
"visits",
"allergies",
];
/** Entry kinds that are a change of context, the rows this block lists. */
const CONTEXT_EVENTS: ReadonlySet<DayEventKind> = new Set<DayEventKind>([
"doseChange",
"medicationStart",
"medicationEnd",
"pauseStart",
"courseStart",
"illnessOnset",
"allergyOnset",
"labResult",
"procedure",
"vaccination",
]);
const EVENT_ICON: Partial<Record<DayEventKind, LucideIcon>> = {
doseChange: Pill,
medicationStart: Pill,
@@ -92,12 +67,10 @@ const EVENT_ICON: Partial<Record<DayEventKind, LucideIcon>> = {
vaccination: Syringe,
};
/** Days read for their entries, newest kept when there are more. */
export const MAX_DAYS_READ = 12;
/** Rows shown before "All". */
export const VISIBLE_ROWS = 5;
interface PrepRow {
export interface PrepRow {
key: string;
date: DateKey;
Icon: LucideIcon;
@@ -105,28 +78,41 @@ interface PrepRow {
meta: string | null;
}
/** The days to read: notable ones first, then context days, newest first. */
export function pickPreparationDays(
index: {
days: Record<string, readonly string[]>;
notable: readonly string[];
/**
* The window's rows, oldest first: each context change as the server named
* it, each notable observation spelled by the caller. A first reading of a
* kind is left out here (it says nothing about the stretch since the visit).
*/
export function preparationRows(
window: Pick<DayNotableWindowResponse, "observations" | "changes">,
spell: {
notableTitle: (notable: DayNotable) => string;
notableText: (notable: DayNotable) => string | null;
countMeta: (count: number) => string | null;
},
max = MAX_DAYS_READ,
): DateKey[] {
const notable = [...new Set(index.notable)].sort().reverse();
const context = Object.entries(index.days)
.filter(([, sections]) =>
sections.some((s) => CONTEXT_SECTIONS.includes(s as DaySectionKey)),
)
.map(([day]) => day)
.sort()
.reverse();
const picked: DateKey[] = [];
for (const day of [...notable, ...context]) {
if (!picked.includes(day)) picked.push(day);
if (picked.length >= max) break;
): PrepRow[] {
const rows: PrepRow[] = [];
for (const change of window.changes) {
rows.push({
key: `${change.kind}-${change.id}-${change.date}`,
date: change.date,
Icon: EVENT_ICON[change.kind] ?? FileText,
title: change.title,
meta: spell.countMeta(change.count),
});
}
return picked.sort();
for (const notable of window.observations) {
if (notable.kind === "firstValue") continue;
rows.push({
key: `notable-${notable.date}-${notable.kind}-${notable.type ?? ""}`,
date: notable.date,
Icon: notable.kind === "gap" ? CircleDashed : Activity,
title: spell.notableTitle(notable),
meta: spell.notableText(notable),
});
}
// A stable sort: the server's order holds within a day.
return rows.sort((a, b) => (a.date < b.date ? -1 : a.date > b.date ? 1 : 0));
}
export function SinceLastVisit({
@@ -147,20 +133,16 @@ export function SinceLastVisit({
const [showAll, setShowAll] = useState(false);
const lastDay = dateKeyOfInstant(last.occurredAt, timeZone);
// The window starts the day after the last visit and ends today; the index
// answers at most a year at a time, which is the stretch that matters here.
const from =
daysBetween(shiftDateKey(lastDay, 1), today) > 365
? shiftDateKey(today, -365)
: shiftDateKey(lastDay, 1);
const index = useDayIndex(from, today, from <= today);
const days = index.data ? pickPreparationDays(index.data) : [];
const reads = useQueries({
queries: days.map((day) => ({
queryKey: queryKeys.day(day),
queryFn: () => apiGet<DayResponse>(`/api/day/${day}`),
staleTime: 5 * 60_000,
})),
// From the last visit's own day (a dose changed at the visit belongs to
// what the next one should know) to today. The server caps the span.
const window = useQuery({
queryKey: queryKeys.dayNotable(lastDay, today),
queryFn: () => {
const params = new URLSearchParams({ from: lastDay, to: today });
return apiGet<DayNotableWindowResponse>(`/api/day/notable?${params}`);
},
enabled: lastDay <= today,
staleTime: 5 * 60_000,
});
// "Blood pressure 152/94 mmHg": the reading a notable day is about, in the
@@ -186,37 +168,17 @@ export function SinceLastVisit({
return `${label} ${shown.value}${shown.unit ? ` ${shown.unit}` : ""}`;
};
const rows: PrepRow[] = [];
reads.forEach((read, i) => {
const day = days[i];
if (!day || !read.data) return;
for (const event of read.data.events) {
if (!CONTEXT_EVENTS.has(event.kind)) continue;
rows.push({
key: `${event.kind}-${event.id}`,
date: day,
Icon: EVENT_ICON[event.kind] ?? FileText,
title: event.title,
meta: event.meta,
});
}
for (const notable of read.data.notable) {
if (notable.kind === "firstValue") continue;
rows.push({
key: `notable-${day}-${notable.kind}-${notable.type ?? ""}`,
date: day,
Icon: notable.kind === "gap" ? CircleDashed : Activity,
title: notableTitle(notable),
meta: notableText(notable),
});
}
});
rows.sort((a, b) => (a.date < b.date ? -1 : a.date > b.date ? 1 : 0));
const rows = window.data
? preparationRows(window.data, {
notableTitle,
notableText,
countMeta: (count) =>
count > 1 ? tCount("day.countEntries", count) : null,
})
: [];
const loading =
index.isPending ||
reads.some((read) => read.isPending && read.fetchStatus !== "idle");
const failed = index.isError || reads.some((read) => read.isError);
const loading = window.isPending && window.fetchStatus !== "idle";
const failed = window.isError;
if (!loading && !failed && rows.length === 0) return null;
// A preparation that could not be read is left out rather than shown half:
// the visit card stands on its own, and the day links stay everywhere else.
+18 -4
View File
@@ -27,6 +27,19 @@ const WHOLE_NUMBER_TYPES: ReadonlySet<string> = new Set([
"RESPIRATORY_RATE",
]);
/**
* Two spellings of one unit: the day route sends a duration in "min", the
* client's unit map knows it as "minutes". Read as the same unit, so a night
* reads as "6 h 52 min" and not as a bare 412.
*/
const UNIT_ALIASES: Readonly<Record<string, string>> = { min: "minutes" };
/** True when the row carries the type's canonical unit, in either spelling. */
function isCanonicalUnit(row: Pick<DayValue, "type" | "unit">): boolean {
const canonical = getUnitForType(row.type);
return row.unit === canonical || UNIT_ALIASES[row.unit] === canonical;
}
/** True for a type read in whole numbers (counts, pressures, pulses). */
export function isWholeNumberType(type: string): boolean {
return WHOLE_NUMBER_TYPES.has(type);
@@ -68,7 +81,7 @@ export function useDayValueFormat() {
const number = useCallback(
(row: Pick<DayValue, "type" | "unit">, raw: number): string => {
const canonical = row.unit === getUnitForType(row.type);
const canonical = isCanonicalUnit(row);
const shown = canonical ? units.toDisplay(row.type, raw) : raw;
if (row.type === "SLEEP_DURATION" && canonical) {
return formatDurationMinutes(shown, t);
@@ -89,10 +102,11 @@ export function useDayValueFormat() {
const unitFor = useCallback(
(row: Pick<DayValue, "type" | "unit">): string => {
if (row.type === "SLEEP_DURATION") return "";
if (row.unit === getUnitForType(row.type)) return units.unitFor(row.type);
return row.unit;
const unit = isCanonicalUnit(row) ? units.unitFor(row.type) : row.unit;
// A device score reads in points, as on its chart.
return unit === "score" ? t("insights.deviceScore.unitScore") : unit;
},
[units],
[t, units],
);
const formatTile = useCallback(
+26 -6
View File
@@ -1,6 +1,7 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { useCallback } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { apiGet } from "@/lib/api/api-fetch";
import type {
@@ -18,15 +19,34 @@ import { queryKeys } from "@/lib/query-keys";
* running-day counts and the notable observations. Nothing here recomputes
* any of it.
*/
export function useDay(date: DateKey | null) {
return useQuery({
queryKey: queryKeys.day(date ?? ""),
function dayQuery(date: DateKey) {
return {
queryKey: queryKeys.day(date),
queryFn: () => apiGet<DayResponse>(`/api/day/${date}`),
enabled: date !== null,
// A past day changes only when something is written into it, and every
// record write evicts the `["day"]` root.
staleTime: 5 * 60_000,
});
};
}
export function useDay(date: DateKey | null) {
return useQuery({ ...dayQuery(date ?? ""), enabled: date !== null });
}
/**
* Read a day ahead of time, so the step to it paints the day instead of a
* skeleton: the neighbouring day when the pointer rests on, or the keyboard
* reaches, the arrow that leads there. A day already read (and fresh) is not
* read again; the query layer dedupes one that is already on its way.
*/
export function usePrefetchDay(): (date: DateKey) => void {
const queryClient = useQueryClient();
return useCallback(
(date: DateKey) => {
void queryClient.prefetchQuery(dayQuery(date));
},
[queryClient],
);
}
/**
+11 -1
View File
@@ -58,7 +58,13 @@ export interface BulkEncounterOption {
const VERB_CLASS = "min-h-11 min-w-11 sm:min-h-9 sm:min-w-9";
function VerbLabel({ children }: { children: string }) {
return <span className="hidden lg:inline">{children}</span>;
// Icon-only again while a day is docked: the bar then shares the width
// with the day panel and the labelled row would no longer fit one line.
return (
<span className="hidden lg:inline [body:has([data-slot=day-panel][data-shell=docked])_&]:hidden">
{children}
</span>
);
}
export function DocumentBulkBar({
@@ -107,6 +113,10 @@ export function DocumentBulkBar({
aria-label={t("documents.bulk.barLabel")}
className={cn(
"bg-card border-border fixed bottom-20 left-1/2 z-40 -translate-x-1/2 md:bottom-6",
// v1.42 — while a day is docked on the right, the bar sits against
// the page's right edge instead of the window's centre, clear of the
// day's footer actions and of an expanded sidebar.
"[body:has([data-slot=day-panel][data-shell=docked])_&]:right-[calc(26.25rem+1rem)] [body:has([data-slot=day-panel][data-shell=docked])_&]:left-auto [body:has([data-slot=day-panel][data-shell=docked])_&]:max-w-[calc(100vw-44.25rem)] [body:has([data-slot=day-panel][data-shell=docked])_&]:translate-x-0",
"flex w-[calc(100%-2rem)] max-w-3xl flex-col gap-2 rounded-xl border p-3 shadow-lg",
)}
>
@@ -641,7 +641,10 @@ describe("<HealthScoreCard> composite states", () => {
const rest = atRest(html);
// The onset is an instant; it reads as the day it falls on in the
// profile timezone, in the user's date format, never as raw ISO.
expect(rest).toMatch(/Rest Mode active since \d{2}\/\d{2}\/2026\./);
// v1.42 — the date is a link to its day; the sentence reads the same.
expect(rest.replace(/<[^>]+>/g, "")).toMatch(
/Rest Mode active since \d{2}\/\d{2}\/2026\./,
);
expect(rest).not.toContain("T23:30:00");
expect(rest).toContain('data-slot="health-score-tension"');
expect(rest).toContain('data-slot="health-score-return-to-band"');
@@ -669,7 +672,11 @@ describe("<HealthScoreCard> composite states", () => {
/>,
);
const rest = atRest(html);
expect(rest).toMatch(/Rest Mode active since (07\/20\/2026|20\/07\/2026)/);
expect(rest.replace(/<[^>]+>/g, "")).toMatch(
/Rest Mode active since (07\/20\/2026|20\/07\/2026)/,
);
// The date opens the day it names, in the profile timezone.
expect(rest).toContain('data-day="2026-07-20"');
expect(rest).not.toContain("2026-07-19T");
});
@@ -1,5 +1,6 @@
import { describe, it, expect } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nProvider } from "@/lib/i18n/context";
import {
@@ -31,9 +32,13 @@ import {
* path stays render-safe)
*/
// v1.42 — the chart's row of day dots reads the day index through
// react-query, so the static render carries a client (it never fetches).
function render(node: React.ReactNode, locale: "en" | "de" = "en") {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
<QueryClientProvider client={new QueryClient()}>
<I18nProvider initialLocale={locale}>{node}</I18nProvider>
</QueryClientProvider>,
);
}
+10 -2
View File
@@ -1,7 +1,10 @@
"use client";
import type { ReactNode } from "react";
import { useQuery } from "@tanstack/react-query";
import { DayLinkAt } from "@/components/day/day-link";
import { queryKeys } from "@/lib/query-keys";
import { apiGet } from "@/lib/api/api-fetch";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
@@ -111,7 +114,12 @@ export function EcgDetail({ recordingId }: { recordingId: string }) {
>
<MetaItem
label={t("insights.ecg.meta.recorded")}
value={fmt.dateTime(new Date(data.recordedAt))}
// v1.42 — the date opens the day around the recording.
value={
<DayLinkAt at={data.recordedAt}>
{fmt.dateTime(new Date(data.recordedAt))}
</DayLinkAt>
}
/>
<MetaItem
label={t("insights.ecg.meta.duration")}
@@ -149,7 +157,7 @@ export function EcgDetail({ recordingId }: { recordingId: string }) {
);
}
function MetaItem({ label, value }: { label: string; value: string }) {
function MetaItem({ label, value }: { label: string; value: ReactNode }) {
return (
<div className="space-y-0.5">
<dt className="text-muted-foreground text-xs">{label}</dt>
+30 -11
View File
@@ -15,6 +15,8 @@ import {
type EcgClassification,
} from "@/lib/insights/ecg-classification";
import { ECG_OVERVIEW_LIMIT } from "@/lib/insights/ecg-overview-limit";
import { DayLinkAt } from "@/components/day/day-link";
import { InsightSectionCard } from "./insight-section-card";
// Re-exported so the tests (and any teaser call site) can keep importing the
@@ -140,7 +142,11 @@ export function EcgSection({
the recording, it is not a second fact about it. */}
<div className="flex flex-wrap items-center gap-2">
<p className="text-foreground text-sm font-medium">
{fmt.dateTime(new Date(rec.recordedAt))}
{/* v1.42 — the date opens the day; the rest of the
row opens the recording. Above the row's link. */}
<DayLinkAt at={rec.recordedAt} className="z-10">
{fmt.dateTime(new Date(rec.recordedAt))}
</DayLinkAt>
</p>
{label && (
<span
@@ -166,19 +172,32 @@ export function EcgSection({
key={rec.id}
data-slot="ecg-row"
data-classification={rec.classification ?? "NONE"}
className="border-border/60 border-b pb-3 last:border-b-0 last:pb-0"
className="border-border/60 relative border-b pb-3 last:border-b-0 last:pb-0"
>
{rec.hasWaveform ? (
<Link
href={`/insights/ecg/${rec.id}`}
className="hover:bg-muted/40 -m-1 flex w-full items-start gap-3 rounded-md p-1 text-left transition-colors"
>
// v1.42 — two targets in one row: the date opens the day,
// the row opens the recording. The row's link is stretched
// under the content (the workout list's pattern), so the
// date can sit above it instead of inside it.
<div className="has-[a[data-slot=ecg-row-link]:hover]:bg-muted/40 has-[a[data-slot=ecg-row-link]:focus-visible]:ring-ring/50 -m-1 flex w-full items-start gap-3 rounded-md p-1 text-left transition-colors has-[a[data-slot=ecg-row-link]:focus-visible]:ring-2">
{rowInner}
<ChevronRight
className="text-muted-foreground mt-1.5 size-4 shrink-0"
aria-hidden="true"
/>
</Link>
<Link
href={`/insights/ecg/${rec.id}`}
data-slot="ecg-row-link"
aria-label={[
fmt.dateTime(new Date(rec.recordedAt)),
label,
]
.filter(Boolean)
.join(", ")}
className="mt-1.5 shrink-0 after:absolute after:inset-0 after:content-[''] focus-visible:outline-none"
>
<ChevronRight
className="text-muted-foreground size-4"
aria-hidden="true"
/>
</Link>
</div>
) : (
<div className="flex items-start gap-3">{rowInner}</div>
)}
+27 -10
View File
@@ -24,6 +24,11 @@ import {
type PillarDetailContext,
} from "@/components/insights/health-score-pillar-detail";
import { useAuth } from "@/hooks/use-auth";
import {
DAY_LINK_SLOT,
DayLink,
withDayLinkSlot,
} from "@/components/day/day-link";
import { formatDate } from "@/lib/date-format";
import { DEFAULT_TIMEZONE, userDayKey } from "@/lib/tz/format";
import { useUnitDisplay } from "@/hooks/use-unit-display";
@@ -530,18 +535,30 @@ export function HealthScoreCard({
data-slot="health-score-rest-mode"
className="text-muted-foreground text-xs"
>
{t("insights.healthScore.restMode", {
since: report.restMode.since
? formatDate(
userDayKey(
{/* v1.42 — the date opens the day rest mode began. */}
{report.restMode.since
? withDayLinkSlot(
t("insights.healthScore.restMode", { since: DAY_LINK_SLOT }),
<DayLink
date={userDayKey(
new Date(report.restMode.since),
user?.timezone || DEFAULT_TIMEZONE,
),
dateFormat,
locale,
)
: t("insights.healthScore.none"),
})}
)}
size="xs"
>
{formatDate(
userDayKey(
new Date(report.restMode.since),
user?.timezone || DEFAULT_TIMEZONE,
),
dateFormat,
locale,
)}
</DayLink>,
)
: t("insights.healthScore.restMode", {
since: t("insights.healthScore.none"),
})}
</p>
) : null}
{tensionLine ? (
@@ -21,6 +21,7 @@ import { queryKeys } from "@/lib/query-keys";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import { userDayKey, shiftDateKey, DEFAULT_TIMEZONE } from "@/lib/tz/format";
import { TileHeader } from "@/components/insights/tile-header";
import { DayLink } from "@/components/day/day-link";
import { Button } from "@/components/ui/button";
import { QueryErrorCard } from "@/components/ui/query-error-card";
import { Skeleton } from "@/components/ui/skeleton";
@@ -219,8 +220,12 @@ export function IntradayPulseChart({
>
<ChevronLeft className="size-4" aria-hidden="true" />
</Button>
<span className="text-muted-foreground min-w-14 text-center text-xs tabular-nums">
{dayLabel}
{/* v1.42 — the date opens the whole day this chart is one part
of, the one thing every date in the app does. */}
<span className="min-w-14 text-center text-xs">
<DayLink date={dateKey} size="xs">
{dayLabel}
</DayLink>
</span>
<Button
type="button"
+6 -1
View File
@@ -250,7 +250,12 @@ export function LayoutCoachFab() {
// The default ring alone is hard to see against the gradient;
// the offset ring draws a clear halo around the circle.
"focus-visible:ring-offset-background focus-visible:ring-offset-2",
"transition-opacity duration-150 motion-reduce:transition-none",
"transition-[opacity,right] duration-150 motion-reduce:transition-none",
// v1.42 — while a day is docked on the right (from 1280 px), the
// launcher moves left with the page's own edge, so it stays over
// the page and never over the day's footer actions. The `:has()`
// gate keys off the docked panel's slot, like the yields below.
"[body:has([data-slot=day-panel][data-shell=docked])_&]:right-[calc(26.25rem+2rem)]",
// Yield to the data-list selection bar: its delete action lands
// in the same lower-right band, and the destructive control
// wins. The `:has()` gate keys off the bar's `data-slot`.
@@ -5,13 +5,25 @@ import {
CartesianGrid,
Line,
LineChart,
ReferenceLine,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import { useTranslations } from "@/lib/i18n/context";
import { ChartDataTable } from "@/components/charts/chart-data-table";
import {
RichChartTooltip,
type RichTooltipRow,
} from "@/components/charts/chart-tooltip";
import {
ChartDayFooter,
OPEN_DAY_LINE,
dayAnchor,
useChartDayLinks,
} from "@/components/day/chart-day-links";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import {
MOOD_DIMENSIONS,
MOOD_DIMENSION_WINDOWS,
@@ -78,6 +90,7 @@ export function MoodDimensionTrends({
dimensions: MoodDimensionSummaryData[];
}) {
const { t } = useTranslations();
const fmt = useFormatters();
const [window, setWindow] = useState<MoodDimensionWindow>(30);
const present = dimensions.filter((d) => d.present);
@@ -105,6 +118,14 @@ export function MoodDimensionTrends({
return dimension ? t(dimension.labelKey) : key;
};
// v1.42 — each answered day opens its day, through the doors every
// day-linked chart has.
const rowDays = chartData.map((row) => String(row.date));
const chartDays = useChartDayLinks({
enabled: present.length > 0 && chartData.length > 0,
days: rowDays,
});
if (present.length === 0) {
return (
<p className="text-muted-foreground text-sm">
@@ -134,17 +155,27 @@ export function MoodDimensionTrends({
))}
</div>
<div className="h-[clamp(160px,34vh,220px)] w-full">
<div
data-slot="chart-plot"
{...chartDays.plotProps}
className={cn(
"h-[clamp(160px,34vh,220px)] w-full",
chartDays.plotClassName,
)}
>
<ResponsiveContainer width="100%" height="100%">
<LineChart
data={chartData}
margin={{ top: 4, right: 8, bottom: 0, left: -20 }}
onClick={chartDays.onChartClick}
>
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
<XAxis
dataKey="date"
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
tickFormatter={(value: string) => value.slice(5)}
tickFormatter={(value: string) =>
fmt.dateShortSmartCalendar(value)
}
minTickGap={24}
/>
<YAxis
@@ -152,18 +183,43 @@ export function MoodDimensionTrends({
ticks={[0, 5, 10]}
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
/>
{chartDays.openIndex !== undefined ? (
<ReferenceLine
x={rowDays[chartDays.openIndex]}
{...OPEN_DAY_LINE}
/>
) : null}
<Tooltip
contentStyle={{
background: "var(--popover)",
border: "1px solid var(--border)",
borderRadius: "0.5rem",
fontSize: "0.75rem",
{...chartDays.tooltipProps}
content={(props) => {
const payload = props.payload as unknown as
| ReadonlyArray<{
dataKey?: string | number;
value?: number;
color?: string;
payload?: Record<string, number | string>;
}>
| undefined;
const row = payload?.[0]?.payload;
if (!props.active || !row) {
return <RichChartTooltip active={false} rows={[]} />;
}
const rows: RichTooltipRow[] = (payload ?? [])
.filter((item) => typeof item.value === "number")
.map((item) => ({
name: labelFor(String(item.dataKey)),
value: String(item.value),
color: item.color ?? "var(--chart-1)",
}));
return (
<RichChartTooltip
active
label={fmt.dateShortSmartCalendar(String(row.date))}
rows={rows}
action={chartDays.tooltipAction(chartData.indexOf(row))}
/>
);
}}
labelStyle={{ color: "var(--muted-foreground)" }}
formatter={(value, name) => [
String(value ?? ""),
labelFor(String(name)),
]}
/>
{present.map((summary) => (
<Line
@@ -183,6 +239,32 @@ export function MoodDimensionTrends({
</LineChart>
</ResponsiveContainer>
</div>
<ChartDayFooter
links={chartDays}
points={rowDays.map((day) => ({ timestamp: dayAnchor(day) }))}
// The y axis (60) less the plot's negative left margin (20); the
// margin (8) on the right.
insetLeft={60 - 20}
insetRight={8}
/>
{chartDays.active ? (
<ChartDataTable
points={chartData.map((row) => ({
...row,
date: String(row.date),
timestamp: dayAnchor(String(row.date)),
}))}
columns={present.map((summary) => ({
key: summary.key,
label: labelFor(summary.key),
}))}
formatValue={(value) => String(value)}
formatDate={(date) => fmt.dateShortSmartCalendar(date)}
bucket="day"
metricLabel={t("insights.mood.dimensions.title")}
dayLinks
/>
) : null}
<ul className="space-y-1.5">
{present.map((summary) => {
@@ -81,6 +81,7 @@ export function CaffeineCard() {
unit={data.unit}
valueLabel={t("nutrients.names.caffeine")}
referenceValue={data.reference?.value ?? null}
dayLinks
/>
</CardContent>
</Card>
@@ -78,6 +78,7 @@ export function HydrationCard() {
unit={data.unit}
valueLabel={t("nutrients.names.water")}
referenceValue={data.reference?.value ?? null}
dayLinks
/>
</>
)}
@@ -99,6 +99,9 @@ export function RecoveryMetricBlock({
compareBaseline={compareBaseline}
userTimezone={user?.timezone}
onVisibleStats={onVisibleStats}
// v1.42 — the same doors to the day every metric page's chart has.
showDataTable
dayLinks
/>
<MetricStatusCard
+13 -2
View File
@@ -2,6 +2,11 @@
import { HeartPulse } from "lucide-react";
import {
DAY_LINK_SLOT,
DayLinkStated,
withDayLinkSlot,
} from "@/components/day/day-link";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import type { RestModeAnnotation } from "@/lib/analytics/score/types";
@@ -62,8 +67,14 @@ export function RestModeBanner({
{t("insights.restMode.title")}
</p>
<p className="text-muted-foreground text-xs leading-relaxed">
{sinceLabel
? t("insights.restMode.bodySince", { since: sinceLabel })
{/* v1.42 — the onset date opens the day the episode began. */}
{sinceLabel && since
? withDayLinkSlot(
t("insights.restMode.bodySince", { since: DAY_LINK_SLOT }),
<DayLinkStated at={since} size="xs">
{sinceLabel}
</DayLinkStated>,
)
: t("insights.restMode.body")}
</p>
</div>
@@ -12,6 +12,7 @@ import { cn } from "@/lib/utils";
import { SectionHeading } from "@/components/ui/section-heading";
import { InsightSectionCard } from "./insight-section-card";
import { QueryErrorRow } from "@/components/ui/query-error-row";
import { DayLinkAt } from "@/components/day/day-link";
/**
* v1.10.0 — device-flagged event awareness surface (categorical events,
@@ -174,7 +175,10 @@ export function RhythmEventsCard({
</p>
)}
<p className="text-muted-foreground text-xs">
{fmt.dateTime(new Date(event.occurredAt))}
{/* v1.42 — the date opens the day around the event. */}
<DayLinkAt at={event.occurredAt} size="xs">
{fmt.dateTime(new Date(event.occurredAt))}
</DayLinkAt>
</p>
</div>
</li>
@@ -47,6 +47,9 @@ export function SleepDurationChart({
yAxisUnit="h"
compareBaseline={compareBaseline}
userTimezone={userTimezone}
// v1.42 — the same doors to the day every metric page's chart has.
showDataTable
dayLinks
/>
);
}
@@ -7,6 +7,7 @@ import {
CartesianGrid,
Cell,
Legend,
ReferenceLine,
ResponsiveContainer,
Tooltip,
XAxis,
@@ -14,9 +15,18 @@ import {
} from "recharts";
import { Button } from "@/components/ui/button";
import { ChartDataTable } from "@/components/charts/chart-data-table";
import {
ChartDayFooter,
OPEN_DAY_LINE,
dayAnchor,
useChartDayLinks,
} from "@/components/day/chart-day-links";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { TileHeader } from "@/components/insights/tile-header";
import { useTranslations } from "@/lib/i18n/context";
import { useCalendarDate } from "@/hooks/use-calendar-date";
import { dateOnlyKey } from "@/lib/tz/date-only";
import { formatDurationMinutes } from "@/lib/i18n/duration";
import { resolveIntlLocale } from "@/lib/format-locale";
import type { Locale } from "@/lib/i18n/config";
@@ -260,6 +270,8 @@ export function compositionTotals(row: CompositionRow | undefined): {
export function SleepStageStackedBar({ breakdown }: SleepStageStackedBarProps) {
const { t, locale } = useTranslations();
const formatCalendar = useCalendarDate();
const fmtDay = (date: Date) => formatCalendar(dateOnlyKey(date));
// v1.4.25 W3f — window toggle (7 / 14 / 30). Default 7d so the user
// sees their most recent week with maximal per-bar resolution.
@@ -313,6 +325,36 @@ export function SleepStageStackedBar({ breakdown }: SleepStageStackedBarProps) {
),
);
// v1.42 — each night's column opens the day it ended on (the day the
// per-night key names, as `/api/sleep/night` does), through the same doors
// every day-linked chart has.
const nightDays = data.map((row) =>
typeof row.dayKey === "string" && row.dayKey !== "aggregate"
? row.dayKey
: null,
);
const chartDays = useChartDayLinks({
enabled: hasData && nightDays.some((day) => day !== null),
days: nightDays,
focusFor: (index) => {
const totals = compositionTotals(data[index]);
return totals.nightMinutes > 0
? {
label: t("insights.sleep.mainSleep"),
value: formatDurationMinutes(totals.nightMinutes, t),
types: ["SLEEP_DURATION"],
}
: null;
},
});
const tableColumns = [...STAGE_ORDER, ...(hasNap ? [NAP_KEY] : [])]
.filter((stage) =>
data.some(
(row) => typeof row[stage] === "number" && (row[stage] as number) > 0,
),
)
.map((stage) => ({ key: stage, label: stageLabels[stage] ?? stage }));
const ariaLabel = t("insights.sleep.compositionAriaLabel", {
nights: breakdown.nights,
});
@@ -369,157 +411,228 @@ export function SleepStageStackedBar({ breakdown }: SleepStageStackedBarProps) {
{t("insights.sleep.stages.unavailable")}
</p>
) : (
<div role="img" aria-label={ariaLabel}>
<ResponsiveContainer width="100%" height={220}>
<BarChart
data={data}
margin={{ top: 8, right: 16, left: 8, bottom: 8 }}
>
<CartesianGrid
strokeDasharray="3 3"
stroke="var(--border)"
opacity={0.3}
/>
<XAxis
type="category"
dataKey="label"
stroke="var(--muted-foreground)"
fontSize={10}
interval="preserveStartEnd"
/>
<YAxis
type="number"
stroke="var(--muted-foreground)"
fontSize={11}
tickFormatter={(v: number) => {
// Render the y-axis as hours so 480 min reads as 8h.
if (v <= 0) return "0";
return `${Math.round(v / 60)}h`;
}}
/>
<Tooltip
cursor={{ fill: "transparent" }}
content={({ active, payload, label }) => {
if (!active || !payload || payload.length === 0)
return null;
const row = payload[0]?.payload as
Record<string, number | string> | undefined;
const napCount =
typeof row?.napCount === "number" ? row.napCount : 0;
// The footer's first line is the NIGHT. The nap is listed
// above it as its own line and stays out of that sum, so
// the per-stage percentages keep meaning "of this night".
// On a day with a nap a second line carries the whole
// column — main sleep plus naps — the Main sleep / Naps /
// Total split issue #611 asked for.
const totals = compositionTotals(row);
return (
<div className="bg-popover text-popover-foreground rounded-md border p-2 text-xs shadow-md">
<div className="border-border mb-1 border-b pb-1 font-medium">
{label}
</div>
{payload.map((entry) => {
const stage = String(entry.dataKey ?? "");
const minutes =
typeof entry.value === "number" ? entry.value : 0;
if (minutes === 0) return null;
const isNap = stage === NAP_KEY;
const label = isNap
? napCount > 1
? t("insights.sleep.stages.napCount", {
count: String(napCount),
})
: stageLabels[NAP_KEY]
: (stageLabels[stage] ?? stage);
const pct =
totals.nightMinutes > 0
? Math.round(
(minutes / totals.nightMinutes) * 100,
)
: 0;
return (
<div
key={stage}
className="flex items-center justify-between gap-3"
>
<span className="flex items-center gap-1.5">
<span
aria-hidden="true"
className="inline-block h-2 w-2 rounded-sm"
style={{ background: STAGE_COLORS[stage] }}
/>
{label}
</span>
<span className="text-muted-foreground">
{/* The nap is not part of the night, so it
<>
<div
role="img"
aria-label={ariaLabel}
data-slot="chart-plot"
{...chartDays.plotProps}
className={chartDays.plotClassName}
>
<ResponsiveContainer width="100%" height={220}>
<BarChart
data={data}
margin={{ top: 8, right: 16, left: 8, bottom: 8 }}
onClick={chartDays.onChartClick}
>
<CartesianGrid
strokeDasharray="3 3"
stroke="var(--border)"
opacity={0.3}
/>
<XAxis
type="category"
// The day key, so the open day's line can name its column;
// the tick prints the window's label for it.
dataKey="dayKey"
tickFormatter={(dayKey: string) =>
dayKey === "aggregate"
? t("insights.sleep.compositionTitle")
: formatDayTick(dayKey, windowDays, locale)
}
stroke="var(--muted-foreground)"
fontSize={10}
interval="preserveStartEnd"
/>
<YAxis
type="number"
stroke="var(--muted-foreground)"
fontSize={11}
tickFormatter={(v: number) => {
// Render the y-axis as hours so 480 min reads as 8h.
if (v <= 0) return "0";
return `${Math.round(v / 60)}h`;
}}
/>
{chartDays.openIndex !== undefined ? (
<ReferenceLine
x={nightDays[chartDays.openIndex] ?? undefined}
{...OPEN_DAY_LINE}
/>
) : null}
<Tooltip
{...chartDays.tooltipProps}
cursor={{ fill: "transparent" }}
content={({ active, payload }) => {
if (!active || !payload || payload.length === 0)
return null;
const row = payload[0]?.payload as
Record<string, number | string> | 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
// above it as its own line and stays out of that sum, so
// the per-stage percentages keep meaning "of this night".
// On a day with a nap a second line carries the whole
// column — main sleep plus naps — the Main sleep / Naps /
// Total split issue #611 asked for.
const totals = compositionTotals(row);
return (
<div className="bg-popover text-popover-foreground rounded-md border p-2 text-xs shadow-md">
<div className="border-border mb-1 border-b pb-1 font-medium">
{label}
</div>
{payload.map((entry) => {
const stage = String(entry.dataKey ?? "");
const minutes =
typeof entry.value === "number" ? entry.value : 0;
if (minutes === 0) return null;
const isNap = stage === NAP_KEY;
const label = isNap
? napCount > 1
? t("insights.sleep.stages.napCount", {
count: String(napCount),
})
: stageLabels[NAP_KEY]
: (stageLabels[stage] ?? stage);
const pct =
totals.nightMinutes > 0
? Math.round(
(minutes / totals.nightMinutes) * 100,
)
: 0;
return (
<div
key={stage}
className="flex items-center justify-between gap-3"
>
<span className="flex items-center gap-1.5">
<span
aria-hidden="true"
className="inline-block h-2 w-2 rounded-sm"
style={{ background: STAGE_COLORS[stage] }}
/>
{label}
</span>
<span className="text-muted-foreground">
{/* The nap is not part of the night, so it
gets no share-of-night percentage. */}
{isNap
? formatDurationMinutes(minutes, t)
: `${formatDurationMinutes(minutes, t)} · ${pct}%`}
</span>
</div>
);
})}
{totals.nightMinutes > 0 && (
<div className="border-border mt-1 border-t pt-1 font-medium">
<div className="flex items-center justify-between gap-3">
<span>{t("insights.sleep.mainSleep")}</span>
<span>
{formatDurationMinutes(totals.nightMinutes, t)}
</span>
</div>
{totals.napMinutes > 0 && (
<div className="flex items-center justify-between gap-3">
<span>{t("insights.sleep.totalSleep")}</span>
<span>
{formatDurationMinutes(totals.dayMinutes, t)}
{isNap
? formatDurationMinutes(minutes, t)
: `${formatDurationMinutes(minutes, t)} · ${pct}%`}
</span>
</div>
)}
</div>
)}
</div>
);
}}
/>
<Legend
wrapperStyle={{ fontSize: 11 }}
formatter={(value: string) => stageLabels[value] ?? value}
/>
{STAGE_ORDER.map((stage) => (
<Bar
key={stage}
dataKey={stage}
stackId="stages"
fill={STAGE_COLORS[stage]}
isAnimationActive={false}
>
<Cell fill={STAGE_COLORS[stage]} />
</Bar>
))}
{hasNap && (
<Bar
key={NAP_KEY}
dataKey={NAP_KEY}
stackId="stages"
fill={STAGE_COLORS[NAP_KEY]}
// The outline is what separates the nap from the stage
// beneath it; the two fills alone measure as low as
// 1.04:1. See NAP_SEPARATOR.
stroke={NAP_SEPARATOR.stroke}
strokeWidth={NAP_SEPARATOR.strokeWidth}
isAnimationActive={false}
>
<Cell
);
})}
{totals.nightMinutes > 0 && (
<div className="border-border mt-1 border-t pt-1 font-medium">
<div className="flex items-center justify-between gap-3">
<span>{t("insights.sleep.mainSleep")}</span>
<span>
{formatDurationMinutes(
totals.nightMinutes,
t,
)}
</span>
</div>
{totals.napMinutes > 0 && (
<div className="flex items-center justify-between gap-3">
<span>{t("insights.sleep.totalSleep")}</span>
<span>
{formatDurationMinutes(
totals.dayMinutes,
t,
)}
</span>
</div>
)}
</div>
)}
{chartDays.tooltipAction(
rowIndex === -1 ? undefined : rowIndex,
)}
</div>
);
}}
/>
<Legend
wrapperStyle={{ fontSize: 11 }}
formatter={(value: string) => stageLabels[value] ?? value}
/>
{STAGE_ORDER.map((stage) => (
<Bar
key={stage}
dataKey={stage}
stackId="stages"
fill={STAGE_COLORS[stage]}
isAnimationActive={false}
>
<Cell fill={STAGE_COLORS[stage]} />
</Bar>
))}
{hasNap && (
<Bar
key={NAP_KEY}
dataKey={NAP_KEY}
stackId="stages"
fill={STAGE_COLORS[NAP_KEY]}
// The outline is what separates the nap from the stage
// beneath it; the two fills alone measure as low as
// 1.04:1. See NAP_SEPARATOR.
stroke={NAP_SEPARATOR.stroke}
strokeWidth={NAP_SEPARATOR.strokeWidth}
/>
</Bar>
)}
</BarChart>
</ResponsiveContainer>
</div>
isAnimationActive={false}
>
<Cell
fill={STAGE_COLORS[NAP_KEY]}
stroke={NAP_SEPARATOR.stroke}
strokeWidth={NAP_SEPARATOR.strokeWidth}
/>
</Bar>
)}
</BarChart>
</ResponsiveContainer>
</div>
<ChartDayFooter
links={chartDays}
points={data.map((row) => ({
timestamp:
typeof row.dayKey === "string" && row.dayKey !== "aggregate"
? dayAnchor(row.dayKey)
: 0,
}))}
axis="band"
mark="bar"
// The plot's margin (8) + the y axis (60) on the left, the
// margin (16) on the right.
insetLeft={8 + 60}
insetRight={16}
/>
{chartDays.active ? (
<ChartDataTable
points={data.map((row) => ({
date: String(row.dayKey),
timestamp: dayAnchor(String(row.dayKey)),
...Object.fromEntries(
tableColumns.map(({ key }) => [
key,
typeof row[key] === "number" && (row[key] as number) > 0
? (row[key] as number)
: undefined,
]),
),
}))}
columns={tableColumns}
formatValue={(minutes) => formatDurationMinutes(minutes, t)}
formatDate={(date) => fmtDay(date)}
bucket="day"
metricLabel={t("insights.sleep.compositionTitle")}
dayLinks
/>
) : null}
</>
)}
</CardContent>
</Card>
+218 -211
View File
@@ -26,8 +26,11 @@ import {
import { dateOnlyKey, isNoonUtcAnchor } from "@/lib/tz/date-only";
import { RichChartTooltip, type RichTooltipRow } from "../charts/chart-tooltip";
import { TooltipDayAction, useCoarsePointer } from "../day/chart-day";
import { openDay, useOpenDay } from "../day/day-layer-controller";
import {
ChartDayFooter,
OPEN_DAY_LINE,
useChartDayLinks,
} from "../day/chart-day-links";
import { dateKeyOfInstant } from "../day/day-url";
import type { LabResultDto } from "./types";
import { useLabNumber } from "./use-lab-format";
@@ -114,45 +117,13 @@ export function LabBiomarkerChart({
: fmt.dateShortSmart(instant);
};
const [range, setRange] = useState<RangeKey>("365");
// v1.42 — a reading opens its day: a click on a fine pointer, the
// tooltip's button on a touch screen. The open day's reading is ringed.
const timeZone = useDisplayTimezone();
const coarse = useCoarsePointer();
const openDayKey = useOpenDay();
const dayOf = (timestamp: number) => {
const instant = new Date(timestamp);
return isNoonUtcAnchor(instant)
? dateOnlyKey(instant)
: dateKeyOfInstant(instant, timeZone);
};
const openReadingDay = (point: ChartPoint | undefined) => {
if (!point) return;
const ordered = [...readings]
.filter((r) => r.value !== null)
.sort(
(a, b) => new Date(a.takenAt).getTime() - new Date(b.takenAt).getTime(),
);
const at = ordered.findIndex((r) => r.id === point.id);
const before = at > 0 ? ordered[at - 1] : undefined;
const date = dayOf(point.timestamp);
openDay(date, {
focus: {
date,
label: readings[0]?.analyte ?? "",
value: labNumber(point.value),
unit,
compare:
before && before.value !== null
? {
label: t("day.previousReading", {
date: labShortDate(before.takenAt),
}),
value: `${labNumber(before.value)} ${unit}`.trim(),
}
: undefined,
},
});
};
// Capture "now" once at mount so the recency filter stays pure across
// re-renders (an inline `Date.now()` in render is impure).
const [nowMs] = useState(() => Date.now());
@@ -230,6 +201,43 @@ export function LabBiomarkerChart({
return [min >= 0 ? Math.max(0, min - pad) : min - pad, max + pad];
}, [points, lowerBound, upperBound, sourceBand]);
// v1.42 — a reading opens its day through the doors every day-linked
// chart has; the day opens with the reading on top and the one before it
// beside it. The open day's reading is ringed and its line dashed.
const chartDays = useChartDayLinks({
enabled: true,
days: points.map((point) => dayOf(point.timestamp)),
focusFor: (index) => {
const point = points[index];
if (!point) return null;
// The reading before it, in or out of the range in view.
const ordered = [...readings]
.filter((r) => r.value !== null)
.sort(
(x, y) =>
new Date(x.takenAt).getTime() - new Date(y.takenAt).getTime(),
);
const at = ordered.findIndex((r) => r.id === point.id);
const before = at > 0 ? ordered[at - 1] : undefined;
return {
label: readings[0]?.analyte ?? "",
value: labNumber(point.value),
unit,
compare:
before && before.value !== null
? {
label: t("day.previousReading", {
date: labShortDate(before.takenAt),
}),
value: `${labNumber(before.value)} ${unit}`.trim(),
}
: undefined,
};
},
});
const openPoint =
chartDays.openIndex !== undefined ? points[chartDays.openIndex] : undefined;
const animate = !prefersReducedMotion();
const primary = "var(--primary)";
// A second muted hue from the chart palette. Distinct from the primary band
@@ -260,188 +268,187 @@ export function LabBiomarkerChart({
</p>
) : (
<div>
<ResponsiveContainer width="100%" height={CHART_HEIGHT_PX}>
<ComposedChart
data={points}
margin={{ top: 8, right: 12, bottom: 4, left: 0 }}
className={coarse ? undefined : "cursor-pointer"}
onClick={
coarse
? undefined
: (state) => {
const index = Number(state?.activeTooltipIndex);
if (Number.isInteger(index))
openReadingDay(points[index]);
}
}
>
<CartesianGrid
strokeDasharray="3 3"
stroke="var(--border)"
vertical={false}
/>
<XAxis
dataKey="timestamp"
type="number"
scale="time"
domain={["dataMin", "dataMax"]}
tickFormatter={(ts: number) => labShortDate(ts)}
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
stroke="var(--border)"
minTickGap={32}
/>
<YAxis
domain={yDomain}
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
stroke="var(--border)"
width={44}
tickFormatter={(v: number) => labNumber(v)}
/>
{/* Reference window — muted band + dashed bounds. No alarm
<div
data-slot="chart-plot"
{...chartDays.plotProps}
className={chartDays.plotClassName}
>
<ResponsiveContainer width="100%" height={CHART_HEIGHT_PX}>
<ComposedChart
data={points}
margin={{ top: 8, right: 12, bottom: 4, left: 0 }}
onClick={chartDays.onChartClick}
>
<CartesianGrid
strokeDasharray="3 3"
stroke="var(--border)"
vertical={false}
/>
<XAxis
dataKey="timestamp"
type="number"
scale="time"
domain={["dataMin", "dataMax"]}
tickFormatter={(ts: number) => labShortDate(ts)}
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
stroke="var(--border)"
minTickGap={32}
/>
<YAxis
domain={yDomain}
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
stroke="var(--border)"
width={44}
tickFormatter={(v: number) => labNumber(v)}
/>
{/* Reference window — muted band + dashed bounds. No alarm
colour: the in/out verdict is shown by the neutral badge,
not by painting the chart red. */}
{lowerBound != null && upperBound != null ? (
<ReferenceArea
y1={lowerBound}
y2={upperBound}
fill={primary}
fillOpacity={0.1}
stroke="none"
/>
) : null}
{lowerBound != null ? (
<ReferenceLine
y={lowerBound}
stroke={primary}
strokeOpacity={0.4}
strokeDasharray="4 4"
/>
) : null}
{upperBound != null ? (
<ReferenceLine
y={upperBound}
stroke={primary}
strokeOpacity={0.4}
strokeDasharray="4 4"
/>
) : null}
{/* The source window — outlined, not filled, so it reads as a
{lowerBound != null && upperBound != null ? (
<ReferenceArea
y1={lowerBound}
y2={upperBound}
fill={primary}
fillOpacity={0.1}
stroke="none"
/>
) : null}
{lowerBound != null ? (
<ReferenceLine
y={lowerBound}
stroke={primary}
strokeOpacity={0.4}
strokeDasharray="4 4"
/>
) : null}
{upperBound != null ? (
<ReferenceLine
y={upperBound}
stroke={primary}
strokeOpacity={0.4}
strokeDasharray="4 4"
/>
) : null}
{/* The source window — outlined, not filled, so it reads as a
second band over the catalog one rather than a second fill
competing with it. Same no-alarm-colour rule. */}
{sourceBand &&
sourceBand.low != null &&
sourceBand.high != null ? (
<ReferenceArea
y1={sourceBand.low}
y2={sourceBand.high}
fill={secondary}
fillOpacity={0.06}
stroke={secondary}
strokeOpacity={0.5}
strokeDasharray="2 3"
/>
) : null}
{sourceBand &&
sourceBand.low != null &&
sourceBand.high == null ? (
<ReferenceLine
y={sourceBand.low}
stroke={secondary}
strokeOpacity={0.6}
strokeDasharray="2 3"
/>
) : null}
{sourceBand &&
sourceBand.high != null &&
sourceBand.low == null ? (
<ReferenceLine
y={sourceBand.high}
stroke={secondary}
strokeOpacity={0.6}
strokeDasharray="2 3"
/>
) : null}
<Tooltip
trigger={coarse ? "click" : "hover"}
wrapperStyle={
coarse ? { pointerEvents: "auto", zIndex: 20 } : undefined
}
content={(props) => {
const active = props.active ?? false;
const payload = props.payload as
ReadonlyArray<{ payload?: ChartPoint }> | undefined;
const point = payload?.[0]?.payload;
if (!active || !point) {
return <RichChartTooltip active={false} rows={[]} />;
}
const rows: RichTooltipRow[] = [
{
name: t("labs.chart.valueLabel"),
value: `${labNumber(point.value)} ${unit}`,
color: primary,
},
];
// Name the window THIS reading was judged against. Two
// readings on one chart can carry different printed windows,
// so the band alone cannot answer it per point.
if (point.referenceOrigin === "source") {
rows.push({
name: t("labs.chart.sourceRangeLabel"),
value:
point.sourceReferenceText ??
`${formatReferenceRange(
point.referenceLow,
point.referenceHigh,
labNumber,
)} ${unit}`.trim(),
color: secondary,
});
}
return (
<RichChartTooltip
active
label={labShortDate(point.timestamp)}
rows={rows}
action={
coarse ? (
<TooltipDayAction
onOpen={() => openReadingDay(point)}
/>
) : undefined
}
/>
);
}}
/>
{points
.filter(
(point) =>
openDayKey !== null &&
dayOf(point.timestamp) === openDayKey,
)
.map((point) => (
<ReferenceDot
key={`open-day-${point.id}`}
x={point.timestamp}
y={point.value}
r={6.5}
fill="var(--card)"
stroke={primary}
strokeWidth={2.5}
ifOverflow="discard"
{sourceBand &&
sourceBand.low != null &&
sourceBand.high != null ? (
<ReferenceArea
y1={sourceBand.low}
y2={sourceBand.high}
fill={secondary}
fillOpacity={0.06}
stroke={secondary}
strokeOpacity={0.5}
strokeDasharray="2 3"
/>
))}
<Line
type="monotone"
dataKey="value"
stroke={primary}
strokeWidth={2}
dot={{ r: 3, fill: primary }}
activeDot={{ r: 5 }}
isAnimationActive={animate}
/>
</ComposedChart>
</ResponsiveContainer>
) : null}
{sourceBand &&
sourceBand.low != null &&
sourceBand.high == null ? (
<ReferenceLine
y={sourceBand.low}
stroke={secondary}
strokeOpacity={0.6}
strokeDasharray="2 3"
/>
) : null}
{sourceBand &&
sourceBand.high != null &&
sourceBand.low == null ? (
<ReferenceLine
y={sourceBand.high}
stroke={secondary}
strokeOpacity={0.6}
strokeDasharray="2 3"
/>
) : null}
{openPoint ? (
<ReferenceLine x={openPoint.timestamp} {...OPEN_DAY_LINE} />
) : null}
<Tooltip
{...chartDays.tooltipProps}
content={(props) => {
const active = props.active ?? false;
const payload = props.payload as
ReadonlyArray<{ payload?: ChartPoint }> | undefined;
const point = payload?.[0]?.payload;
if (!active || !point) {
return <RichChartTooltip active={false} rows={[]} />;
}
const rows: RichTooltipRow[] = [
{
name: t("labs.chart.valueLabel"),
value: `${labNumber(point.value)} ${unit}`,
color: primary,
},
];
// Name the window THIS reading was judged against. Two
// readings on one chart can carry different printed windows,
// so the band alone cannot answer it per point.
if (point.referenceOrigin === "source") {
rows.push({
name: t("labs.chart.sourceRangeLabel"),
value:
point.sourceReferenceText ??
`${formatReferenceRange(
point.referenceLow,
point.referenceHigh,
labNumber,
)} ${unit}`.trim(),
color: secondary,
});
}
return (
<RichChartTooltip
active
label={labShortDate(point.timestamp)}
rows={rows}
action={chartDays.tooltipAction(points.indexOf(point))}
/>
);
}}
/>
{points
.filter(
(point) =>
chartDays.openDay !== null &&
dayOf(point.timestamp) === chartDays.openDay,
)
.map((point) => (
<ReferenceDot
key={`open-day-${point.id}`}
x={point.timestamp}
y={point.value}
r={6.5}
fill="var(--card)"
stroke={primary}
strokeWidth={2.5}
ifOverflow="discard"
/>
))}
<Line
type="monotone"
dataKey="value"
stroke={primary}
strokeWidth={2}
dot={{ r: 3, fill: primary }}
activeDot={{ r: 5 }}
isAnimationActive={animate}
/>
</ComposedChart>
</ResponsiveContainer>
</div>
<ChartDayFooter
links={chartDays}
points={points}
axis="time"
// The y axis (44) on the left, the margin (12) on the right.
insetLeft={44}
insetRight={12}
/>
{sourceBand ? (
<p className="text-muted-foreground mt-2 text-xs">
{sourceBand.mixed
@@ -23,10 +23,22 @@ import {
Tooltip,
ReferenceLine,
ReferenceArea,
ReferenceDot,
} from "recharts";
import { makeFormatters } from "@/lib/format-locale";
import { useDateFormatPreference, useTranslations } from "@/lib/i18n/context";
import { axisUnitSuffix } from "@/lib/charts/axis-unit";
import {
RichChartTooltip,
type RichTooltipRow,
} from "@/components/charts/chart-tooltip";
import {
ChartDayFooter,
OPEN_DAY_LINE,
useChartDayLinks,
} from "@/components/day/chart-day-links";
import { dateKeyOfInstant } from "@/components/day/day-url";
import { dateOnlyKey, isNoonUtcAnchor } from "@/lib/tz/date-only";
export interface EfficacyChartTarget {
label: string;
@@ -137,6 +149,40 @@ export function EfficacyChart({
return [Math.floor(min - pad), Math.ceil(max + pad)];
}, [displaySeries]);
// v1.42 — each value of the target series opens its day, through the doors
// every day-linked chart has. The series and the adherence lane are two
// `<Line data>` arrays on one time axis, so a click is placed by its x
// value, not by an index into the chart's data.
const seriesDays = displaySeries.map((p) => {
const instant = new Date(p.t);
return isNoonUtcAnchor(instant)
? dateOnlyKey(instant)
: dateKeyOfInstant(instant, timezone);
});
const chartDays = useChartDayLinks({
enabled: displaySeries.length > 0,
days: seriesDays,
focusFor: (index) => {
const point = displaySeries[index];
if (!point) return null;
return {
label: target.label,
value: mode === "percent" ? `${point.value}%` : String(point.value),
unit: mode === "percent" ? undefined : (target.unit ?? undefined),
};
},
indexOfClick: (state) => {
const x = Number(state.activeLabel);
if (!Number.isFinite(x)) return null;
const at = displaySeries.findIndex((p) => p.t === x);
return at === -1 ? null : at;
},
});
const openPoint =
chartDays.openIndex !== undefined
? displaySeries[chartDays.openIndex]
: undefined;
if (seriesData.length === 0 || !domain) return null;
const unitSuffix = mode === "percent" ? "%" : target.unit;
@@ -145,153 +191,221 @@ export function EfficacyChart({
// domain max keeps the render pure (no `Date.now()` in the render body).
const rightEdge = domain[1];
return (
<div className="touch-pan-y">
<ResponsiveContainer width="100%" height={220}>
<LineChart margin={{ top: 8, right: 8, bottom: 0, left: 0 }}>
<CartesianGrid
strokeDasharray="3 3"
stroke="var(--border)"
opacity={0.5}
/>
<XAxis
dataKey="t"
type="number"
scale="time"
domain={domain}
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
tickLine={false}
axisLine={false}
tickFormatter={(v: number) => fmt.date(new Date(v))}
/>
<YAxis
yAxisId="target"
domain={targetDomain}
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
tickLine={false}
axisLine={false}
width={40}
unit={axisUnitSuffix(unitSuffix)}
/>
<YAxis
yAxisId="adherence"
orientation="right"
domain={[0, 100]}
hide
/>
<div>
<div
className={`touch-pan-y ${chartDays.plotClassName}`}
data-slot="chart-plot"
{...chartDays.plotProps}
>
<ResponsiveContainer width="100%" height={220}>
<LineChart
margin={{ top: 8, right: 8, bottom: 0, left: 0 }}
onClick={chartDays.onChartClick}
>
<CartesianGrid
strokeDasharray="3 3"
stroke="var(--border)"
opacity={0.5}
/>
<XAxis
dataKey="t"
type="number"
scale="time"
domain={domain}
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
tickLine={false}
axisLine={false}
tickFormatter={(v: number) => fmt.date(new Date(v))}
/>
<YAxis
yAxisId="target"
domain={targetDomain}
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
tickLine={false}
axisLine={false}
width={40}
unit={axisUnitSuffix(unitSuffix)}
/>
<YAxis
yAxisId="adherence"
orientation="right"
domain={[0, 100]}
hide
/>
{/* Population reference band — faint context, labelled "typical
{/* Population reference band — faint context, labelled "typical
range", explicitly NOT a target line. Hidden in percent mode:
an absolute band is meaningless once the series is normalized. */}
{mode === "absolute" && target.referenceBand ? (
<ReferenceArea
yAxisId="target"
y1={target.referenceBand.low}
y2={target.referenceBand.high}
fill="var(--muted-foreground)"
fillOpacity={0.08}
stroke="none"
label={{
value: typicalRangeLabel,
position: "insideTopLeft",
fill: "var(--muted-foreground)",
fontSize: 10,
}}
/>
) : null}
{mode === "absolute" && target.referenceBand ? (
<ReferenceArea
yAxisId="target"
y1={target.referenceBand.low}
y2={target.referenceBand.high}
fill="var(--muted-foreground)"
fillOpacity={0.08}
stroke="none"
label={{
value: typicalRangeLabel,
position: "insideTopLeft",
fill: "var(--muted-foreground)",
fontSize: 10,
}}
/>
) : null}
{/* Shaded pause bands. An open pause runs to now. */}
{pauses.map((p, i) => (
<ReferenceArea
key={`pause-${i}`}
yAxisId="target"
x1={toMs(p.from)}
x2={p.to ? toMs(p.to) : rightEdge}
fill="var(--foreground)"
fillOpacity={0.06}
stroke="none"
/>
))}
{/* Shaded pause bands. An open pause runs to now. */}
{pauses.map((p, i) => (
<ReferenceArea
key={`pause-${i}`}
yAxisId="target"
x1={toMs(p.from)}
x2={p.to ? toMs(p.to) : rightEdge}
fill="var(--foreground)"
fillOpacity={0.06}
stroke="none"
/>
))}
{/* Adherence lane — faint, hidden right axis. */}
<Line
yAxisId="adherence"
data={adherenceData}
dataKey="rate"
name={adherenceLabel}
type="stepAfter"
stroke="var(--chart-4)"
strokeWidth={1.5}
strokeOpacity={0.5}
dot={false}
connectNulls
isAnimationActive={false}
/>
{/* The target series. */}
<Line
yAxisId="target"
data={displaySeries}
dataKey="value"
name={target.label}
type="monotone"
stroke="var(--chart-1)"
strokeWidth={2}
dot={{ r: 2, fill: "var(--chart-1)" }}
activeDot={{ r: 4 }}
connectNulls
isAnimationActive={false}
/>
{/* Start marker. */}
{startMs !== null ? (
<ReferenceLine
yAxisId="target"
x={startMs}
stroke="var(--primary)"
{/* Adherence lane — faint, hidden right axis. */}
<Line
yAxisId="adherence"
data={adherenceData}
dataKey="rate"
name={adherenceLabel}
type="stepAfter"
stroke="var(--chart-4)"
strokeWidth={1.5}
label={{
value: startLabel,
position: "insideTopRight",
fill: "var(--foreground)",
fontSize: 10,
strokeOpacity={0.5}
dot={false}
connectNulls
isAnimationActive={false}
/>
{/* The target series. */}
<Line
yAxisId="target"
data={displaySeries}
dataKey="value"
name={target.label}
type="monotone"
stroke="var(--chart-1)"
strokeWidth={2}
dot={{ r: 2, fill: "var(--chart-1)" }}
activeDot={{ r: 4 }}
connectNulls
isAnimationActive={false}
/>
{/* Start marker. */}
{startMs !== null ? (
<ReferenceLine
yAxisId="target"
x={startMs}
stroke="var(--primary)"
strokeWidth={1.5}
label={{
value: startLabel,
position: "insideTopRight",
fill: "var(--foreground)",
fontSize: 10,
}}
/>
) : null}
{/* Dose-change markers — quiet, dashed. */}
{doseChanges.map((d, i) => (
<ReferenceLine
key={`dose-${i}`}
yAxisId="target"
x={toMs(d.at)}
stroke="var(--muted-foreground)"
strokeDasharray="4 4"
strokeOpacity={0.6}
/>
))}
{/* The open day: the dashed line every chart draws through it,
and a ring on its value, which keeps it apart from the quiet
dose-change lines. */}
{openPoint ? (
<ReferenceLine
yAxisId="target"
x={openPoint.t}
{...OPEN_DAY_LINE}
/>
) : null}
{openPoint ? (
<ReferenceDot
yAxisId="target"
x={openPoint.t}
y={openPoint.value}
r={6.5}
fill="var(--card)"
stroke="var(--chart-1)"
strokeWidth={2.5}
ifOverflow="discard"
/>
) : null}
<Tooltip
{...chartDays.tooltipProps}
content={(props) => {
const payload = props.payload as unknown as
| ReadonlyArray<{
name?: string;
value?: number;
color?: string;
payload?: { t: number };
}>
| undefined;
if (!props.active || !payload || payload.length === 0) {
return <RichChartTooltip active={false} rows={[]} />;
}
const x = payload[0]?.payload?.t;
const rows: RichTooltipRow[] = payload
.filter((item) => typeof item.value === "number")
.map((item) => {
const num = Number(item.value);
const value =
item.name === adherenceLabel
? `${Math.round(num)}%`
: mode === "percent"
? `${num}%`
: `${num}${target.unit ? ` ${target.unit}` : ""}`;
return {
name: String(item.name ?? ""),
value,
color: item.color ?? "var(--chart-1)",
};
});
const index =
typeof x === "number"
? displaySeries.findIndex((p) => p.t === x)
: -1;
return (
<RichChartTooltip
active
label={typeof x === "number" ? fmt.date(new Date(x)) : ""}
rows={rows}
action={chartDays.tooltipAction(
index === -1 ? undefined : index,
)}
/>
);
}}
/>
) : null}
{/* Dose-change markers — quiet, dashed. */}
{doseChanges.map((d, i) => (
<ReferenceLine
key={`dose-${i}`}
yAxisId="target"
x={toMs(d.at)}
stroke="var(--muted-foreground)"
strokeDasharray="4 4"
strokeOpacity={0.6}
/>
))}
<Tooltip
contentStyle={{
backgroundColor: "var(--card)",
border: "1px solid var(--border)",
borderRadius: "0.5rem",
fontSize: "0.8125rem",
}}
labelFormatter={(v) => fmt.date(new Date(Number(v)))}
formatter={(value, name) => {
const num = Number(value);
if (name === adherenceLabel) {
return [`${Math.round(num)}%`, String(name)];
}
const text =
mode === "percent"
? `${num}%`
: `${num}${target.unit ? ` ${target.unit}` : ""}`;
return [text, String(name)];
}}
/>
</LineChart>
</ResponsiveContainer>
</LineChart>
</ResponsiveContainer>
</div>
<ChartDayFooter
links={chartDays}
// The axis spans the series and the adherence lane together.
points={[{ timestamp: domain[0] }, { timestamp: domain[1] }]}
axis="time"
// The y axis (40) on the left, the margin (8) on the right.
insetLeft={40}
insetRight={8}
/>
</div>
);
}
@@ -39,13 +39,31 @@ import {
CartesianGrid,
Line,
LineChart,
ReferenceLine,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import {
useDisplayTimezone,
useTranslations,
useFormatters,
} from "@/lib/i18n/context";
import { ChartDataTable } from "@/components/charts/chart-data-table";
import {
RichChartTooltip,
type RichTooltipRow,
} from "@/components/charts/chart-tooltip";
import {
ChartDayFooter,
OPEN_DAY_LINE,
dayAnchor,
useChartDayLinks,
} from "@/components/day/chart-day-links";
import { dateKeyOfInstant } from "@/components/day/day-url";
import { dateOnlyKey, isNoonUtcAnchor } from "@/lib/tz/date-only";
import { queryKeys } from "@/lib/query-keys";
import { apiGet } from "@/lib/api/api-fetch";
import { prefersReducedMotion } from "@/lib/charts/reduced-motion";
@@ -175,6 +193,44 @@ export function DoseStrengthCurve({
const unit = latest?.doseUnit ?? "mg";
// v1.42 — each dose change opens the day it took effect, through the doors
// every day-linked chart has. The point carried forward to today is not a
// change and opens nothing.
const timeZone = useDisplayTimezone();
const changeCount = Math.min(
doseChanges.filter(
(dc) =>
Number.isFinite(Date.parse(dc.effectiveFrom)) &&
Number.isFinite(dc.doseValue),
).length,
chartData.length,
);
const pointDays = chartData.map((p, index) => {
if (index >= changeCount) return null;
const instant = new Date(p.t);
return isNoonUtcAnchor(instant)
? dateOnlyKey(instant)
: dateKeyOfInstant(instant, timeZone);
});
const chartDays = useChartDayLinks({
enabled: hasCurve,
days: pointDays,
focusFor: (index) => {
const point = chartData[index];
return point
? {
label: t("medications.doseStrength.tooltipLabel"),
value: fmt.number(point.dose),
unit,
}
: null;
},
});
const openPoint =
chartDays.openIndex !== undefined
? chartData[chartDays.openIndex]
: undefined;
const body = isLoading ? (
<div
className="flex h-[200px] min-h-[200px] items-center justify-center"
@@ -203,72 +259,123 @@ export function DoseStrengthCurve({
<p className="text-xs italic">{t("medications.doseStrength.empty")}</p>
</div>
) : (
<div
className="touch-pan-y"
style={{ height: "200px" }}
data-slot="dose-strength-curve-area"
>
<ResponsiveContainer width="100%" height="100%">
<LineChart
data={chartData}
margin={{ top: 10, right: 14, bottom: 16, left: 4 }}
>
<CartesianGrid
strokeDasharray="3 3"
stroke="var(--border)"
opacity={0.5}
/>
<XAxis
dataKey="t"
type="number"
scale="time"
domain={["dataMin", "dataMax"]}
ticks={ticks}
tickFormatter={(v) => fmt.dateShortSmart(new Date(v as number))}
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
tickLine={false}
axisLine={false}
/>
<YAxis
domain={[0, "auto"]}
width={34}
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
tickLine={false}
axisLine={false}
tickFormatter={(v) => fmt.number(v as number)}
/>
<Tooltip
cursor={{
stroke: "var(--muted-foreground)",
strokeOpacity: 0.3,
strokeDasharray: "3 3",
}}
labelFormatter={(v) => fmt.dateShortSmart(new Date(v as number))}
formatter={(value) => [
`${fmt.number(value as number)} ${unit}`,
t("medications.doseStrength.tooltipLabel"),
]}
contentStyle={{
backgroundColor: "var(--popover)",
border: "1px solid var(--border)",
borderRadius: "0.375rem",
fontSize: "0.75rem",
}}
/>
<Line
type="stepAfter"
dataKey="dose"
stroke={CURVE_COLOR}
strokeWidth={2}
dot={{ r: 3, fill: CURVE_COLOR, strokeWidth: 0 }}
activeDot={{ r: 4 }}
isAnimationActive={animationsEnabled}
animationDuration={animationsEnabled ? 600 : 0}
animationEasing="ease-out"
/>
</LineChart>
</ResponsiveContainer>
</div>
<>
<div
className={`touch-pan-y ${chartDays.plotClassName}`}
style={{ height: "200px" }}
data-slot="dose-strength-curve-area"
{...chartDays.plotProps}
>
<ResponsiveContainer width="100%" height="100%">
<LineChart
data={chartData}
margin={{ top: 10, right: 14, bottom: 16, left: 4 }}
onClick={chartDays.onChartClick}
>
<CartesianGrid
strokeDasharray="3 3"
stroke="var(--border)"
opacity={0.5}
/>
<XAxis
dataKey="t"
type="number"
scale="time"
domain={["dataMin", "dataMax"]}
ticks={ticks}
tickFormatter={(v) => fmt.dateShortSmart(new Date(v as number))}
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
tickLine={false}
axisLine={false}
/>
<YAxis
domain={[0, "auto"]}
width={34}
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
tickLine={false}
axisLine={false}
tickFormatter={(v) => fmt.number(v as number)}
/>
{openPoint ? (
<ReferenceLine x={openPoint.t} {...OPEN_DAY_LINE} />
) : null}
<Tooltip
{...chartDays.tooltipProps}
cursor={{
stroke: "var(--muted-foreground)",
strokeOpacity: 0.3,
strokeDasharray: "3 3",
}}
content={(props) => {
const payload = props.payload as unknown as
| ReadonlyArray<{ payload?: { t: number; dose: number } }>
| undefined;
const point = payload?.[0]?.payload;
if (!props.active || !point) {
return <RichChartTooltip active={false} rows={[]} />;
}
const rows: RichTooltipRow[] = [
{
name: t("medications.doseStrength.tooltipLabel"),
value: `${fmt.number(point.dose)} ${unit}`,
color: CURVE_COLOR,
},
];
return (
<RichChartTooltip
active
label={fmt.dateShortSmart(new Date(point.t))}
rows={rows}
action={chartDays.tooltipAction(chartData.indexOf(point))}
/>
);
}}
/>
<Line
type="stepAfter"
dataKey="dose"
stroke={CURVE_COLOR}
strokeWidth={2}
dot={{ r: 3, fill: CURVE_COLOR, strokeWidth: 0 }}
activeDot={{ r: 4 }}
isAnimationActive={animationsEnabled}
animationDuration={animationsEnabled ? 600 : 0}
animationEasing="ease-out"
/>
</LineChart>
</ResponsiveContainer>
</div>
<ChartDayFooter
links={chartDays}
points={chartData.map((p) => ({ timestamp: p.t }))}
axis="time"
// The margin (4) + the y axis (34) on the left, the margin (14) on
// the right.
insetLeft={4 + 34}
insetRight={14}
/>
{chartDays.active ? (
<ChartDataTable
points={chartData.slice(0, changeCount).map((p, index) => ({
date: pointDays[index]!,
timestamp: dayAnchor(pointDays[index]!),
dose: p.dose,
}))}
columns={[
{
key: "dose",
label: t("medications.doseStrength.tooltipLabel"),
},
]}
unit={unit}
formatValue={(value) => fmt.number(value)}
formatDate={(date) => fmt.dateShortSmartCalendar(date)}
bucket="day"
metricLabel={t("medications.doseStrength.title")}
dayLinks
/>
) : null}
</>
);
return (
@@ -25,6 +25,7 @@ import {
CartesianGrid,
ComposedChart,
Line,
ReferenceLine,
ResponsiveContainer,
Tooltip,
XAxis,
@@ -34,7 +35,17 @@ import {
import { RichChartTooltip, type RichTooltipRow } from "../charts/chart-tooltip";
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 {
ChartDayFooter,
OPEN_DAY_LINE,
useChartDayLinks,
} from "../day/chart-day-links";
import { dateKeyOfInstant } from "../day/day-url";
import { INSTRUMENTS } from "@/lib/mental-health/instruments";
import type { AssessmentRow, InstrumentId } from "./types";
@@ -69,6 +80,26 @@ export function AssessmentHistoryChart({
}));
}, [rows]);
// v1.42 — each result opens the day it was taken on, through the doors
// every day-linked chart has. The dated list under the chart is the
// keyboard way to the same days.
const timeZone = useDisplayTimezone();
const chartDays = useChartDayLinks({
enabled: true,
days: points.map((point) => dateKeyOfInstant(point.timestamp, timeZone)),
focusFor: (index) => {
const point = points[index];
return point
? {
label: t(`mentalHealth.instrument.${def.i18nKey}`),
value: String(point.value),
}
: null;
},
});
const openPoint =
chartDays.openIndex !== undefined ? points[chartDays.openIndex] : undefined;
const animate = !prefersReducedMotion();
const primary = "var(--primary)";
const gradientId = `mh-trend-${instrument}`;
@@ -82,86 +113,108 @@ export function AssessmentHistoryChart({
}
return (
<ResponsiveContainer width="100%" height={CHART_HEIGHT_PX}>
<ComposedChart
data={points}
margin={{ top: 10, right: 8, bottom: 8, left: 8 }}
<div>
<div
data-slot="chart-plot"
{...chartDays.plotProps}
className={chartDays.plotClassName}
>
<defs>
<linearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor={primary} stopOpacity={0.18} />
<stop offset="95%" stopColor={primary} stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid
strokeDasharray="3 3"
stroke="var(--border)"
vertical={false}
/>
<XAxis
dataKey="timestamp"
type="number"
scale="time"
domain={["dataMin", "dataMax"]}
tickFormatter={(ts: number) => fmt.dateShortSmart(new Date(ts))}
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
stroke="var(--border)"
minTickGap={32}
/>
<YAxis
domain={[0, def.maxScore]}
allowDecimals={false}
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
stroke="var(--border)"
width={32}
/>
<Tooltip
content={(props) => {
const active = props.active ?? false;
const payload = props.payload as
ReadonlyArray<{ payload?: ChartPoint }> | undefined;
const point = payload?.[0]?.payload;
if (!active || !point) {
return <RichChartTooltip active={false} rows={[]} />;
}
const rowsOut: RichTooltipRow[] = [
{
name: t("mentalHealth.history.totalLabel"),
value: String(point.value),
color: primary,
},
{
name: t("mentalHealth.history.bandLabel"),
value: t(`mentalHealth.band.${instrument}.${point.band}`),
color: "var(--muted-foreground)",
},
];
return (
<RichChartTooltip
active
label={fmt.dateShortSmart(new Date(point.timestamp))}
rows={rowsOut}
/>
);
}}
/>
<Area
type="monotone"
dataKey="value"
stroke="none"
fill={`url(#${gradientId})`}
isAnimationActive={animate}
/>
<Line
type="monotone"
dataKey="value"
stroke={primary}
strokeWidth={2}
dot={{ r: 3, fill: primary }}
activeDot={{ r: 5 }}
isAnimationActive={animate}
/>
</ComposedChart>
</ResponsiveContainer>
<ResponsiveContainer width="100%" height={CHART_HEIGHT_PX}>
<ComposedChart
data={points}
margin={{ top: 10, right: 8, bottom: 8, left: 8 }}
onClick={chartDays.onChartClick}
>
<defs>
<linearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor={primary} stopOpacity={0.18} />
<stop offset="95%" stopColor={primary} stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid
strokeDasharray="3 3"
stroke="var(--border)"
vertical={false}
/>
<XAxis
dataKey="timestamp"
type="number"
scale="time"
domain={["dataMin", "dataMax"]}
tickFormatter={(ts: number) => fmt.dateShortSmart(new Date(ts))}
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
stroke="var(--border)"
minTickGap={32}
/>
<YAxis
domain={[0, def.maxScore]}
allowDecimals={false}
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
stroke="var(--border)"
width={32}
/>
{openPoint ? (
<ReferenceLine x={openPoint.timestamp} {...OPEN_DAY_LINE} />
) : null}
<Tooltip
{...chartDays.tooltipProps}
content={(props) => {
const active = props.active ?? false;
const payload = props.payload as
ReadonlyArray<{ payload?: ChartPoint }> | undefined;
const point = payload?.[0]?.payload;
if (!active || !point) {
return <RichChartTooltip active={false} rows={[]} />;
}
const rowsOut: RichTooltipRow[] = [
{
name: t("mentalHealth.history.totalLabel"),
value: String(point.value),
color: primary,
},
{
name: t("mentalHealth.history.bandLabel"),
value: t(`mentalHealth.band.${instrument}.${point.band}`),
color: "var(--muted-foreground)",
},
];
return (
<RichChartTooltip
active
label={fmt.dateShortSmart(new Date(point.timestamp))}
rows={rowsOut}
action={chartDays.tooltipAction(points.indexOf(point))}
/>
);
}}
/>
<Area
type="monotone"
dataKey="value"
stroke="none"
fill={`url(#${gradientId})`}
isAnimationActive={animate}
/>
<Line
type="monotone"
dataKey="value"
stroke={primary}
strokeWidth={2}
dot={{ r: 3, fill: primary }}
activeDot={{ r: 5 }}
isAnimationActive={animate}
/>
</ComposedChart>
</ResponsiveContainer>
</div>
<ChartDayFooter
links={chartDays}
points={points}
axis="time"
// The margin (8) + the y axis (32) on the left, the margin on the right.
insetLeft={8 + 32}
insetRight={8}
/>
</div>
);
}
@@ -36,6 +36,8 @@ import { crisisResourcesForLocale } from "@/lib/mental-health/crisis-resources";
import { Activity, ChevronDown } from "lucide-react";
import { cn } from "@/lib/utils";
import { DayLinkAt } from "@/components/day/day-link";
import { AssessmentItemBreakdown } from "./assessment-item-breakdown";
import { CrisisCard } from "./crisis-card";
import type { AssessmentRow, CrisisSet, InstrumentId } from "./types";
@@ -132,9 +134,11 @@ export function AssessmentHistory({
return (
<li key={row.id} className="flex flex-col gap-1.5">
<div className="flex items-center justify-between gap-3 text-sm">
<span className="text-muted-foreground">
{/* v1.42 — the date opens the day around the result: the
keyboard way to the days the chart above opens. */}
<DayLinkAt at={row.takenAt}>
{formatDate(row.takenAt)}
</span>
</DayLinkAt>
<div className="flex items-center gap-2">
{flagged && (
<Button