From c41ec02e925b923020ba0fdf7a0eb29e23ac4d95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 8 Oct 2026 15:30:12 +0200 Subject: [PATCH] feat: prepare the next visit from the server's notable window The block under a planned visit read the day index and up to twelve days to find what changed, so a dose change on a quiet day could fall out of the picked days. It now reads GET /api/day/notable for the stretch from the last visit to today: every change the server names, every notable reading. Add a guard that walks every chart, chart mount and calendar heatmap and fails when one drawn in days lacks the day doors and is not on the reasoned NOT_HERE list. Give the dose-strength curve, the efficacy chart, the mood dimensions, the intraday pulse header and the environment note the same doors. --- messages/de.json | 4 + messages/en.json | 4 + messages/es.json | 4 + messages/fr.json | 4 + messages/it.json | 4 + messages/ko.json | 4 + messages/pl.json | 4 + .../compliance-heatmap-a11y.test.tsx | 35 +- .../__tests__/mood-heatmap-a11y.test.tsx | 33 +- src/components/charts/chart-data-table.tsx | 7 +- src/components/charts/compliance-heatmap.tsx | 14 +- src/components/charts/health-chart.tsx | 1 - .../cycle/__tests__/bbt-chart.test.tsx | 7 +- src/components/dashboard/environment-chip.tsx | 14 +- .../day/__tests__/chart-day.test.ts | 89 +++- .../__tests__/day-door-coverage-guard.test.ts | 270 ++++++++++++ src/components/day/chart-day.tsx | 15 +- src/components/day/since-last-visit.tsx | 160 +++---- .../sleep-stage-stacked-bar.test.tsx | 7 +- .../insights/intraday-pulse-chart.tsx | 9 +- .../insights/mood/mood-dimension-trends.tsx | 108 ++++- .../detail/efficacy/efficacy-chart.tsx | 392 +++++++++++------- .../medications/dose-strength-curve.tsx | 241 ++++++++--- src/lib/query-keys/day.ts | 3 + 24 files changed, 1048 insertions(+), 385 deletions(-) create mode 100644 src/components/day/__tests__/day-door-coverage-guard.test.ts diff --git a/messages/de.json b/messages/de.json index 625afb001..b9b9399de 100644 --- a/messages/de.json +++ b/messages/de.json @@ -12202,6 +12202,10 @@ "inTimelineShort": "Zeitleiste", "askCoachPrompt": "Was war am {date}?", "chartHintTouch": "Antippen zeigt den Wert.", + "chartHintBar": "Klick auf einen Balken öffnet den Tag.", + "chartHintTouchBar": "Antippen zeigt die Werte.", + "chartHintCell": "Klick auf einen Tag öffnet ihn.", + "chartHintTouchCell": "Antippen zeigt den Wert des Tages.", "coachChips": "Tage in dieser Antwort", "description": "Alles, was deine Einträge für diesen Tag enthalten.", "openDocument": "Dokument öffnen", diff --git a/messages/en.json b/messages/en.json index c8a52c09a..7efb7a78b 100644 --- a/messages/en.json +++ b/messages/en.json @@ -12202,6 +12202,10 @@ "inTimelineShort": "Timeline", "askCoachPrompt": "What happened on {date}?", "chartHintTouch": "Tap a point to see its value.", + "chartHintBar": "Click a bar to open its day.", + "chartHintTouchBar": "Tap a bar to see its values.", + "chartHintCell": "Click a day to open it.", + "chartHintTouchCell": "Tap a day to see its value.", "coachChips": "Days in this answer", "description": "Everything your records hold for this day.", "openDocument": "Open document", diff --git a/messages/es.json b/messages/es.json index 238bb72f4..e38b9e1a8 100644 --- a/messages/es.json +++ b/messages/es.json @@ -12202,6 +12202,10 @@ "inTimelineShort": "Cronología", "askCoachPrompt": "¿Qué pasó el {date}?", "chartHintTouch": "Toca un punto para ver su valor.", + "chartHintBar": "Haz clic en una barra para abrir su día.", + "chartHintTouchBar": "Toca una barra para ver sus valores.", + "chartHintCell": "Haz clic en un día para abrirlo.", + "chartHintTouchCell": "Toca un día para ver su valor.", "coachChips": "Días en esta respuesta", "description": "Todo lo que tus registros contienen para este día.", "openDocument": "Abrir documento", diff --git a/messages/fr.json b/messages/fr.json index 0fd018599..9070b5fdc 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -12202,6 +12202,10 @@ "inTimelineShort": "Chronologie", "askCoachPrompt": "Que s’est-il passé le {date} ?", "chartHintTouch": "Touchez un point pour voir sa valeur.", + "chartHintBar": "Cliquez sur une barre pour ouvrir son jour.", + "chartHintTouchBar": "Touchez une barre pour voir ses valeurs.", + "chartHintCell": "Cliquez sur un jour pour l’ouvrir.", + "chartHintTouchCell": "Touchez un jour pour voir sa valeur.", "coachChips": "Jours dans cette réponse", "description": "Tout ce que vos entrées contiennent pour ce jour.", "openDocument": "Ouvrir le document", diff --git a/messages/it.json b/messages/it.json index 582cd2e10..7da2126a2 100644 --- a/messages/it.json +++ b/messages/it.json @@ -12202,6 +12202,10 @@ "inTimelineShort": "Cronologia", "askCoachPrompt": "Cosa è successo il {date}?", "chartHintTouch": "Tocca un punto per vederne il valore.", + "chartHintBar": "Fai clic su una barra per aprire il suo giorno.", + "chartHintTouchBar": "Tocca una barra per vederne i valori.", + "chartHintCell": "Fai clic su un giorno per aprirlo.", + "chartHintTouchCell": "Tocca un giorno per vederne il valore.", "coachChips": "Giorni in questa risposta", "description": "Tutto ciò che le tue voci contengono per questo giorno.", "openDocument": "Apri documento", diff --git a/messages/ko.json b/messages/ko.json index 3fc16b737..ccd0eb935 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -12202,6 +12202,10 @@ "inTimelineShort": "타임라인", "askCoachPrompt": "{date}에 무슨 일이 있었나요?", "chartHintTouch": "점을 탭하면 값이 보여요.", + "chartHintBar": "막대를 클릭하면 그날이 열려요.", + "chartHintTouchBar": "막대를 탭하면 값이 보여요.", + "chartHintCell": "날짜 칸을 클릭하면 그날이 열려요.", + "chartHintTouchCell": "날짜 칸을 탭하면 값이 보여요.", "coachChips": "이 답변에 쓰인 날", "description": "이 날에 대한 모든 기록이에요.", "openDocument": "문서 열기", diff --git a/messages/pl.json b/messages/pl.json index f9487fb8f..032c99b02 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -12202,6 +12202,10 @@ "inTimelineShort": "Oś czasu", "askCoachPrompt": "Co się działo {date}?", "chartHintTouch": "Dotknij punktu, aby zobaczyć wartość.", + "chartHintBar": "Kliknij słupek, aby otworzyć jego dzień.", + "chartHintTouchBar": "Dotknij słupka, aby zobaczyć wartości.", + "chartHintCell": "Kliknij dzień, aby go otworzyć.", + "chartHintTouchCell": "Dotknij dnia, aby zobaczyć jego wartość.", "coachChips": "Dni w tej odpowiedzi", "description": "Wszystko, co Twoje wpisy zawierają dla tego dnia.", "openDocument": "Otwórz dokument", diff --git a/src/components/charts/__tests__/compliance-heatmap-a11y.test.tsx b/src/components/charts/__tests__/compliance-heatmap-a11y.test.tsx index 28ea8a862..bf5459336 100644 --- a/src/components/charts/__tests__/compliance-heatmap-a11y.test.tsx +++ b/src/components/charts/__tests__/compliance-heatmap-a11y.test.tsx @@ -7,11 +7,13 @@ import { ComplianceHeatmap } from "../compliance-heatmap"; /** * 2026-07-17 a11y audit (M2) — medication-compliance heatmap day cells are * pointer-only SVG ``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(" — 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( - /]*data-slot="compliance-heatmap-day-list"[^>]*>([\s\S]*?)<\/ul>/, + const table = html.match( + /]*data-slot="chart-data-table"[^>]*>([\s\S]*?)<\/table>/, ); - expect(listMatch).not.toBeNull(); - const items = listMatch![1].match(/]*>/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}"`); }); }); diff --git a/src/components/charts/__tests__/mood-heatmap-a11y.test.tsx b/src/components/charts/__tests__/mood-heatmap-a11y.test.tsx index 938254e8c..2ae560edd 100644 --- a/src/components/charts/__tests__/mood-heatmap-a11y.test.tsx +++ b/src/components/charts/__tests__/mood-heatmap-a11y.test.tsx @@ -7,11 +7,12 @@ import { MoodHeatmap } from "../mood-heatmap"; /** * 2026-07-17 a11y audit (M2) — mood heatmap day cells are pointer-only SVG * ``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(" — 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( - /]*data-slot="mood-heatmap-day-list"[^>]*>([\s\S]*?)<\/ul>/, + const table = html.match( + /]*data-slot="chart-data-table"[^>]*>([\s\S]*?)<\/table>/, ); - expect(listMatch).not.toBeNull(); - const items = listMatch![1].match(/]*>/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"/); }); }); diff --git a/src/components/charts/chart-data-table.tsx b/src/components/charts/chart-data-table.tsx index 98e1aee0d..57cf248a5 100644 --- a/src/components/charts/chart-data-table.tsx +++ b/src/components/charts/chart-data-table.tsx @@ -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 { @@ -229,7 +234,7 @@ export function ChartDataTable({ ) : ( - formatValue(numeric) + (column.format ?? formatValue)(numeric) )} ); diff --git a/src/components/charts/compliance-heatmap.tsx b/src/components/charts/compliance-heatmap.tsx index b6c30f1c1..67bad9159 100644 --- a/src/components/charts/compliance-heatmap.tsx +++ b/src/components/charts/compliance-heatmap.tsx @@ -375,6 +375,8 @@ export function ComplianceHeatmap({ 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( @@ -383,8 +385,16 @@ export function ComplianceHeatmap({ ) : undefined, }))} - columns={[{ key: "rate", label: t("medications.compliance") }]} - formatValue={(value) => `${value} %`} + 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")} diff --git a/src/components/charts/health-chart.tsx b/src/components/charts/health-chart.tsx index 2d74f3c89..3f539c146 100644 --- a/src/components/charts/health-chart.tsx +++ b/src/components/charts/health-chart.tsx @@ -1760,7 +1760,6 @@ export function HealthChart({ : [], focusFor: (index) => dayFocusFor(dayLinkSource?.[index]), }); - const coarsePointer = chartDays.coarse; const openDayIndex = chartDays.openIndex; const openDayPoint = openDayIndex !== undefined ? chartData?.[openDayIndex] : undefined; diff --git a/src/components/cycle/__tests__/bbt-chart.test.tsx b/src/components/cycle/__tests__/bbt-chart.test.tsx index 52c9fc140..baf1661b9 100644 --- a/src/components/cycle/__tests__/bbt-chart.test.tsx +++ b/src/components/cycle/__tests__/bbt-chart.test.tsx @@ -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( - {node}, + + {node} + , ); } diff --git a/src/components/dashboard/environment-chip.tsx b/src/components/dashboard/environment-chip.tsx index 81323ed0c..61383b8f5 100644 --- a/src/components/dashboard/environment-chip.tsx +++ b/src/components/dashboard/environment-chip.tsx @@ -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() { > - {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 }), + + {date} + , + )} {flags.map((flag) => ( diff --git a/src/components/day/__tests__/chart-day.test.ts b/src/components/day/__tests__/chart-day.test.ts index b19380d7d..127921512 100644 --- a/src/components/day/__tests__/chart-day.test.ts +++ b/src/components/day/__tests__/chart-day.test.ts @@ -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"]); }); }); diff --git a/src/components/day/__tests__/day-door-coverage-guard.test.ts b/src/components/day/__tests__/day-door-coverage-guard.test.ts new file mode 100644 index 000000000..4dd79b891 --- /dev/null +++ b/src/components/day/__tests__/day-door-coverage-guard.test.ts @@ -0,0 +1,270 @@ +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 (click, the touch tooltip's button, + * 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 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 + * `` keyed on a date-shaped field, or `scale="time"`) calls + * `useChartDayLinks` and renders its parts (`ChartDayFooter`, + * `OPEN_DAY_LINE`, the tooltip's props and 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", + "NutrientDailyBarChart", + "NutrientDailyBarChartDynamic", +] as const; + +/** `` 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 = { + "src/app/page-client.tsx": + "The dashboard is today (decision D12): its charts are a summary of the record, and past days open as a layer over it from a date, never from the dashboard's own charts.", + "src/components/charts/medication-compliance-chart.tsx": + "Mounted on the dashboard only (see above); the adherence days open from the intake calendar on the medication insights page.", + "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 `` 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 `` 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 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. + expect(doors.mounts).toBeGreaterThanOrEqual(11); + // HealthChart, the mood line and the nutrient bars themselves, sleep + // stages, mood dimensions, lab marker, custom metric, questionnaire, + // basal temperature, dose strength, efficacy: eleven. + expect( + doors.chartFiles.length, + doors.chartFiles.join("\n"), + ).toBeGreaterThanOrEqual(11); + // 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( + ` s > 1} dayLinks />`, + "HealthChart", + ); + expect(tag).toContain("dayLinks"); + expect(mountOpensDays("")).toBe(false); + expect(isDateAxis('')).toBe(true); + expect(isDateAxis('')).toBe(false); + }); +}); diff --git a/src/components/day/chart-day.tsx b/src/components/day/chart-day.tsx index 34b5ec77c..8826fcb23 100644 --- a/src/components/day/chart-day.tsx +++ b/src/components/day/chart-day.tsx @@ -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"; @@ -59,19 +59,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 | 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(); diff --git a/src/components/day/since-last-visit.tsx b/src/components/day/since-last-visit.tsx index 624fc1145..9a7768285 100644 --- a/src/components/day/since-last-visit.tsx +++ b/src/components/day/since-last-visit.tsx @@ -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 = new Set([ - "doseChange", - "medicationStart", - "medicationEnd", - "pauseStart", - "courseStart", - "illnessOnset", - "allergyOnset", - "labResult", - "procedure", - "vaccination", -]); - const EVENT_ICON: Partial> = { doseChange: Pill, medicationStart: Pill, @@ -92,12 +67,10 @@ const EVENT_ICON: Partial> = { 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; - 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, + 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(`/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(`/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. diff --git a/src/components/insights/__tests__/sleep-stage-stacked-bar.test.tsx b/src/components/insights/__tests__/sleep-stage-stacked-bar.test.tsx index 3ff6b6baa..0abdb5b3c 100644 --- a/src/components/insights/__tests__/sleep-stage-stacked-bar.test.tsx +++ b/src/components/insights/__tests__/sleep-stage-stacked-bar.test.tsx @@ -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( - {node}, + + {node} + , ); } diff --git a/src/components/insights/intraday-pulse-chart.tsx b/src/components/insights/intraday-pulse-chart.tsx index cb50fcd43..42b7c0523 100644 --- a/src/components/insights/intraday-pulse-chart.tsx +++ b/src/components/insights/intraday-pulse-chart.tsx @@ -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({ >