From 2638174b6611bf490f752153e5292f81df75f722 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 9 Oct 2026 12:53:56 +0200 Subject: [PATCH 1/8] fix(timeline): word every code an entry carries, and read a pause as one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The timeline and the day view showed what the server sends as it was sent: a life event's category as FAMILY, a visit's kind as PREVENTIVE, a document's kind as DISCHARGE_LETTER, a trip and a cycle under their sentinel labels, a lab day as a bare "5", a mood as SUPER_GUT, a screener as PHQ9 with its band code. A pause read as "Ramipril" at its start and "Ramipril ended" at its end. Every surface that shows a timeline item (lane labels, hover titles, the screen-reader table, the selection bar, the phone chronicle) now reads it through one wording, item-words.ts: a code goes through the bundle's existing labels, a lab day reads "5 lab values", and a code the bundle does not know is left out rather than shown. The day view's title and second line do the same per event kind (mood, screener and band, sport, severity, visit kind or planned, dose number, intensity, functional impact, document kind), and a medication that starts, ends, pauses or resumes says so, in the day view and in the visit preparation alike. A pause reads "Ramipril paused" at its start and "Ramipril resumed · after 11 days" at its end, on the lanes, in the selection bar and in the chronicle. A guard renders a record with every item kind, every category and every kind code on each surface, in two languages, and fails on any upper-case code or sentinel in the visible text. The wire schema now says which fields carry codes. --- docs/api/openapi.yaml | 8 + messages/de.json | 15 +- messages/en.json | 15 +- messages/es.json | 15 +- messages/fr.json | 15 +- messages/it.json | 15 +- messages/ko.json | 15 +- messages/pl.json | 15 +- .../day/__tests__/chart-day.test.ts | 1 + .../day/__tests__/day-title.test.ts | 21 +- src/components/day/day-sections.tsx | 139 +++-- src/components/day/since-last-visit.tsx | 7 +- .../__tests__/chronicle-model.test.ts | 6 +- .../__tests__/no-raw-code-guard.test.tsx | 502 ++++++++++++++++++ .../__tests__/timeline-components.test.tsx | 51 +- .../timeline/__tests__/timeline-fixture.ts | 17 +- .../__tests__/timeline-geometry.test.ts | 28 +- src/components/timeline/chronicle-model.ts | 6 +- src/components/timeline/item-words.ts | 135 +++++ src/components/timeline/selection-bar.tsx | 5 +- src/components/timeline/timeline-chart.tsx | 30 +- .../timeline/timeline-chronicle.tsx | 15 +- src/components/timeline/timeline-geometry.ts | 48 +- src/lib/day/wire-schemas.ts | 26 +- 24 files changed, 1055 insertions(+), 95 deletions(-) create mode 100644 src/components/timeline/__tests__/no-raw-code-guard.test.tsx create mode 100644 src/components/timeline/item-words.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 77fa87cf8..bc5ec628f 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -81439,10 +81439,14 @@ components: type: string title: type: string + description: "The record's own name, or a code the client words: a mood, a screener's instrument, a workout's sport, + `cycle` for a cycle day." meta: anyOf: - type: string - type: "null" + description: "The second line: text (a dose, a lab value, a duration) or a code or number the client words (a category, + a kind, a severity, a screener's score and band, an intensity, a dose number)." note: anyOf: - type: string @@ -81672,10 +81676,14 @@ components: description: False when the start is a stand-in (a medication without a start date starts at its first intake). label: type: string + description: The item's name. A trip is labelled `travel` and a cycle `cycle`; the client words both. sub: anyOf: - type: string - type: "null" + description: "The second line, by kind: a code for a life event (its category), a visit or procedure (its encounter + kind) and a document (its kind); the analyte count for a lab day; the dose as text for a medication or a + dose change. The client words every code." href: anyOf: - type: string diff --git a/messages/de.json b/messages/de.json index 018ff5319..6ebf927d3 100644 --- a/messages/de.json +++ b/messages/de.json @@ -12284,7 +12284,15 @@ "note": "Zusammengestellt aus deinen Einträgen, ohne Bewertung. Jedes Datum öffnet den ganzen Tag.", "report": "Arztbericht {date} bis heute" }, - "travel": "Unterwegs" + "travel": "Unterwegs", + "event": { + "started": "{label} begonnen", + "ended": "{label} beendet", + "paused": "{label} pausiert", + "resumed": "{label} wieder aufgenommen", + "illnessBegan": "{label} begann", + "illnessResolved": "{label} abgeklungen" + } }, "timeline": { "title": "Zeitleiste", @@ -12487,6 +12495,11 @@ "label": "Gewählter Zeitraum", "open": "Zeitraum wählen", "clear": "Alles zeigen" + }, + "item": { + "labValuesOne": "1 Laborwert", + "labValuesFew": "{count} Laborwerte", + "labValuesOther": "{count} Laborwerte" } }, "lifeEvents": { diff --git a/messages/en.json b/messages/en.json index b965c2afe..41d4ee148 100644 --- a/messages/en.json +++ b/messages/en.json @@ -12284,7 +12284,15 @@ "note": "Compiled from your entries, without judgement. Every date opens the whole day.", "report": "Doctor report from {date} to today" }, - "travel": "Away" + "travel": "Away", + "event": { + "started": "{label} started", + "ended": "{label} ended", + "paused": "{label} paused", + "resumed": "{label} resumed", + "illnessBegan": "{label} began", + "illnessResolved": "{label} resolved" + } }, "timeline": { "title": "Timeline", @@ -12487,6 +12495,11 @@ "label": "Chosen range", "open": "Choose a range", "clear": "Show everything" + }, + "item": { + "labValuesOne": "1 lab value", + "labValuesFew": "{count} lab values", + "labValuesOther": "{count} lab values" } }, "lifeEvents": { diff --git a/messages/es.json b/messages/es.json index aa5917aa9..ad8dd3aaa 100644 --- a/messages/es.json +++ b/messages/es.json @@ -12284,7 +12284,15 @@ "note": "Reunido a partir de tus entradas, sin valoración. Cada fecha abre el día completo.", "report": "Informe médico del {date} hasta hoy" }, - "travel": "De viaje" + "travel": "De viaje", + "event": { + "started": "Inicio: {label}", + "ended": "Fin: {label}", + "paused": "Pausa: {label}", + "resumed": "Reanudación: {label}", + "illnessBegan": "Inicio: {label}", + "illnessResolved": "Recuperación: {label}" + } }, "timeline": { "title": "Cronología", @@ -12487,6 +12495,11 @@ "label": "Periodo elegido", "open": "Elegir un periodo", "clear": "Mostrar todo" + }, + "item": { + "labValuesOne": "1 valor de laboratorio", + "labValuesFew": "{count} valores de laboratorio", + "labValuesOther": "{count} valores de laboratorio" } }, "lifeEvents": { diff --git a/messages/fr.json b/messages/fr.json index 50a5b0c48..bdd6f4e27 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -12284,7 +12284,15 @@ "note": "Établi à partir de vos entrées, sans évaluation. Chaque date ouvre la journée entière.", "report": "Rapport médical du {date} à aujourd’hui" }, - "travel": "En déplacement" + "travel": "En déplacement", + "event": { + "started": "Début : {label}", + "ended": "Fin : {label}", + "paused": "Pause : {label}", + "resumed": "Reprise : {label}", + "illnessBegan": "Début : {label}", + "illnessResolved": "Guérison : {label}" + } }, "timeline": { "title": "Chronologie", @@ -12487,6 +12495,11 @@ "label": "Période choisie", "open": "Choisir une période", "clear": "Tout afficher" + }, + "item": { + "labValuesOne": "1 valeur de laboratoire", + "labValuesFew": "{count} valeurs de laboratoire", + "labValuesOther": "{count} valeurs de laboratoire" } }, "lifeEvents": { diff --git a/messages/it.json b/messages/it.json index 4db7ce4b9..84ee7963b 100644 --- a/messages/it.json +++ b/messages/it.json @@ -12284,7 +12284,15 @@ "note": "Raccolto dalle tue voci, senza valutazioni. Ogni data apre l’intera giornata.", "report": "Referto medico dal {date} a oggi" }, - "travel": "In viaggio" + "travel": "In viaggio", + "event": { + "started": "Inizio: {label}", + "ended": "Fine: {label}", + "paused": "Pausa: {label}", + "resumed": "Ripresa: {label}", + "illnessBegan": "Inizio: {label}", + "illnessResolved": "Guarigione: {label}" + } }, "timeline": { "title": "Cronologia", @@ -12487,6 +12495,11 @@ "label": "Periodo scelto", "open": "Scegli un periodo", "clear": "Mostra tutto" + }, + "item": { + "labValuesOne": "1 valore di laboratorio", + "labValuesFew": "{count} valori di laboratorio", + "labValuesOther": "{count} valori di laboratorio" } }, "lifeEvents": { diff --git a/messages/ko.json b/messages/ko.json index 95e1b73b4..ef5e91477 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -12284,7 +12284,15 @@ "note": "평가 없이 기록에서 모았어요. 날짜를 누르면 그날 전체가 열려요.", "report": "{date}부터 오늘까지 진료 보고서" }, - "travel": "여행 중" + "travel": "여행 중", + "event": { + "started": "{label} 시작", + "ended": "{label} 끝남", + "paused": "{label} 일시 중지", + "resumed": "{label} 다시 시작", + "illnessBegan": "{label} 시작", + "illnessResolved": "{label} 나음" + } }, "timeline": { "title": "타임라인", @@ -12487,6 +12495,11 @@ "label": "선택한 기간", "open": "기간 선택", "clear": "전체 보기" + }, + "item": { + "labValuesOne": "검사 수치 {count}개", + "labValuesFew": "검사 수치 {count}개", + "labValuesOther": "검사 수치 {count}개" } }, "lifeEvents": { diff --git a/messages/pl.json b/messages/pl.json index 3cfb2d900..b99bf0d90 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -12284,7 +12284,15 @@ "note": "Zestawione z Twoich wpisów, bez oceny. Każda data otwiera cały dzień.", "report": "Raport lekarski od {date} do dziś" }, - "travel": "W podróży" + "travel": "W podróży", + "event": { + "started": "Początek: {label}", + "ended": "Koniec: {label}", + "paused": "Wstrzymano: {label}", + "resumed": "Wznowiono: {label}", + "illnessBegan": "Początek: {label}", + "illnessResolved": "Wyzdrowienie: {label}" + } }, "timeline": { "title": "Oś czasu", @@ -12487,6 +12495,11 @@ "label": "Wybrany zakres", "open": "Wybierz zakres", "clear": "Pokaż wszystko" + }, + "item": { + "labValuesOne": "1 wynik badania", + "labValuesFew": "{count} wyniki badań", + "labValuesOther": "{count} wyników badań" } }, "lifeEvents": { diff --git a/src/components/day/__tests__/chart-day.test.ts b/src/components/day/__tests__/chart-day.test.ts index 35d715fce..f29ae03cf 100644 --- a/src/components/day/__tests__/chart-day.test.ts +++ b/src/components/day/__tests__/chart-day.test.ts @@ -80,6 +80,7 @@ describe("Coach day chips", () => { describe("visit preparation", () => { const spell = { + changeTitle: (_kind: string, title: string) => title, notableTitle: () => "Blood pressure", notableText: () => "Highest daily value since March", countMeta: (count: number) => (count > 1 ? `${count} entries` : null), diff --git a/src/components/day/__tests__/day-title.test.ts b/src/components/day/__tests__/day-title.test.ts index ee3ecb0a8..eefa7ded2 100644 --- a/src/components/day/__tests__/day-title.test.ts +++ b/src/components/day/__tests__/day-title.test.ts @@ -7,7 +7,8 @@ import { describe, expect, it } from "vitest"; import { dayTitle } from "../day-sections"; -const t = (key: string) => `t:${key}`; +const t = (key: string, params?: Record) => + params ? `t:${key}(${Object.values(params).join(",")})` : `t:${key}`; describe("dayTitle", () => { it("words the cycle and travel sentinels", () => { @@ -20,4 +21,22 @@ describe("dayTitle", () => { expect(dayTitle("medication", "cycle", t)).toBe("cycle"); expect(dayTitle("document", "travel", t)).toBe("travel"); }); + + it("says what happened to a medication, not only its name", () => { + expect(dayTitle("pauseStart", "Ramipril", t)).toBe( + "t:day.event.paused(Ramipril)", + ); + expect(dayTitle("pauseEnd", "Ramipril", t)).toBe( + "t:day.event.resumed(Ramipril)", + ); + expect(dayTitle("medicationPause", "Ramipril", t)).toBe( + "t:day.event.paused(Ramipril)", + ); + expect(dayTitle("medicationStart", "Ramipril", t)).toBe( + "t:day.event.started(Ramipril)", + ); + expect(dayTitle("medicationEnd", "Ramipril", t)).toBe( + "t:day.event.ended(Ramipril)", + ); + }); }); diff --git a/src/components/day/day-sections.tsx b/src/components/day/day-sections.tsx index a53e2edd4..be3c18f70 100644 --- a/src/components/day/day-sections.tsx +++ b/src/components/day/day-sections.tsx @@ -24,7 +24,6 @@ import { } from "lucide-react"; import { - LIFE_EVENT_CATEGORIES, type DayEvent, type DayEventKind, type DayNotable, @@ -32,7 +31,9 @@ import { type DayRunningKind, type DayValue, } from "@/lib/day/contract"; +import { visitKindWords } from "@/components/timeline/item-words"; import { useFormatters, useTranslations } from "@/lib/i18n/context"; +import { MOOD_LABEL_KEYS } from "@/lib/mood/labels"; import { cn } from "@/lib/utils"; import type { DayFocus } from "./day-layer-controller"; @@ -161,43 +162,114 @@ const RUNNING_COLOR: Record = { lifeEvent: "var(--chart-2)", }; +type Translate = ( + key: string, + params?: Record, +) => string; + +/** The bundle's wording of `key`, or null when the bundle has none. */ +function known(t: Translate, key: string): string | null { + const value = t(key); + return value === key ? null : value; +} + /** - * The server names a few records by a sentinel rather than by text it would - * have to translate: a cycle is titled "cycle", a trip "travel", a cycle - * day carries its flow level. They are worded here, in the reader's - * language; every other title is the record's own text. The record's kind - * decides, not the title: a medication or a document the person named - * "cycle" keeps its own name. + * The server sends what the record holds and leaves the words to the reader's + * language: a cycle is titled "cycle" and a trip "travel", a mood entry + * carries its mood code, a screener its instrument, a workout its sport, and + * a start, an end, a pause or a resumption the bare name of what started, + * ended, paused or resumed. They are worded here. The record's kind decides, + * not the title: a medication or a document the person named "cycle" keeps + * its own name. */ -export function dayTitle( - kind: string, - title: string, - t: (key: string) => string, -): string { - if (kind === "cyclePhase" || kind === "cycleDayLog") return t("nav.cycle"); - if (kind === "travel") return t("day.travel"); - return title; +export function dayTitle(kind: string, title: string, t: Translate): string { + switch (kind) { + case "cyclePhase": + case "cycleDayLog": + return t("nav.cycle"); + case "travel": + return t("day.travel"); + case "medicationStart": + case "courseStart": + return t("day.event.started", { label: title }); + case "medicationEnd": + case "courseEnd": + return t("day.event.ended", { label: title }); + case "pauseStart": + case "medicationPause": + return t("day.event.paused", { label: title }); + case "pauseEnd": + return t("day.event.resumed", { label: title }); + case "illnessOnset": + return t("day.event.illnessBegan", { label: title }); + case "illnessResolved": + return t("day.event.illnessResolved", { label: title }); + case "mood": { + const key = MOOD_LABEL_KEYS[title]; + return (key ? known(t, key) : null) ?? t("nav.mood"); + } + case "assessment": + return ( + known(t, `mentalHealth.instrument.${title.toLowerCase()}`) ?? title + ); + case "workout": + return known(t, `insights.workouts.sport.${title}`) ?? title; + default: + return title; + } } const FLOW_LEVELS = new Set(["NONE", "SPOTTING", "LIGHT", "MEDIUM", "HEAVY"]); -const LIFE_EVENT_CATEGORY_SET: ReadonlySet = new Set( - LIFE_EVENT_CATEGORIES, -); - -function dayMeta( +/** + * A row's second line. Where it is a code (a category, a kind, a severity, a + * band) or a bare number (an intensity, a dose number, a functional impact), + * it is worded; a code the bundle does not know is left out rather than + * shown raw. A dose, a duration or a lab value is the record's own text. + */ +export function dayMeta( kind: string, meta: string, - t: (key: string) => string, -): string { - if (kind === "cycleDayLog" && FLOW_LEVELS.has(meta)) { - return `${t("cycle.flow.label")}: ${t(`cycle.flow.${meta}`)}`; + t: Translate, + title = "", +): string | null { + switch (kind) { + case "cycleDayLog": + return FLOW_LEVELS.has(meta) + ? `${t("cycle.flow.label")}: ${t(`cycle.flow.${meta}`)}` + : null; + case "lifeEvent": + return known(t, `lifeEvents.category.${meta}`); + // The mood is the title; its score says the same thing again. + case "mood": + return null; + case "assessment": { + const [score, band] = meta.split(" "); + const bandText = band + ? known(t, `mentalHealth.band.${title}.${band}`) + : null; + return [score, bandText].filter(Boolean).join(" · ") || null; + } + case "symptom": + return t("symptoms.intensityPill", { value: meta }); + case "allergyOnset": + return known(t, `records.allergies.severity.${meta}`); + case "visit": + case "procedure": + return meta === "PLANNED" + ? t("encounters.status.planned") + : visitKindWords(t, meta); + case "vaccination": + return t("vaccinations.series.doseN", { position: meta }); + case "illnessDayLog": { + const impact = known(t, `illness.impact.${meta}`); + return impact ? t("illness.timeline.impact", { impact }) : null; + } + case "document": + return known(t, `documents.kind.${meta}`); + default: + return meta; } - // A life event's second line is its category, a closed code. - if (kind === "lifeEvent" && LIFE_EVENT_CATEGORY_SET.has(meta)) { - return t(`lifeEvents.category.${meta}`); - } - return meta; } export function DayRunning({ items }: { items: readonly DayRunningItem[] }) { @@ -452,6 +524,9 @@ export function DayEvents({ events }: { events: readonly DayEvent[] }) {
    {events.map((event) => { const Icon = EVENT_ICON[event.kind]; + const meta = event.meta + ? dayMeta(event.kind, event.meta, t, event.title) + : null; return (
  • )} - {event.meta ? ( -

    - {dayMeta(event.kind, event.meta, t)} -

    + {meta ? ( +

    {meta}

    ) : null} {event.note ? ( // The person's own words: content, never muted. diff --git a/src/components/day/since-last-visit.tsx b/src/components/day/since-last-visit.tsx index a8d24cca5..51253ea91 100644 --- a/src/components/day/since-last-visit.tsx +++ b/src/components/day/since-last-visit.tsx @@ -34,7 +34,7 @@ import { import { getUnitForType } from "@/lib/measurements/unit-map"; import { queryKeys } from "@/lib/query-keys"; -import { DAY_SECTION_LABEL, useNotableText } from "./day-sections"; +import { DAY_SECTION_LABEL, dayTitle, useNotableText } from "./day-sections"; import { DayLink } from "./day-link"; import { dateKeyOfInstant } from "./day-url"; import { tileKeyOf } from "./day-values-model"; @@ -86,6 +86,8 @@ export interface PrepRow { export function preparationRows( window: Pick, spell: { + /** A change's title in the reader's words (`dayTitle`). */ + changeTitle: (kind: string, title: string) => string; notableTitle: (notable: DayNotable) => string; notableText: (notable: DayNotable) => string | null; countMeta: (count: number) => string | null; @@ -97,7 +99,7 @@ export function preparationRows( key: `${change.kind}-${change.id}-${change.date}`, date: change.date, Icon: EVENT_ICON[change.kind] ?? FileText, - title: change.title, + title: spell.changeTitle(change.kind, change.title), meta: spell.countMeta(change.count), }); } @@ -171,6 +173,7 @@ export function SinceLastVisit({ const rows = window.data ? preparationRows(window.data, { + changeTitle: (kind, title) => dayTitle(kind, title, t), notableTitle, notableText, countMeta: (count) => diff --git a/src/components/timeline/__tests__/chronicle-model.test.ts b/src/components/timeline/__tests__/chronicle-model.test.ts index f0da55414..37633251b 100644 --- a/src/components/timeline/__tests__/chronicle-model.test.ts +++ b/src/components/timeline/__tests__/chronicle-model.test.ts @@ -148,7 +148,7 @@ describe("buildChronicle", () => { describe("standingChips", () => { it("collects open things once and folds several allergies into one chip", () => { - const chips = standingChips(fullTimeline()); + const chips = standingChips(fullTimeline(), (i) => i.label); expect(chips.map((c) => c.id)).toEqual([ "ill-chronic", "med-1", @@ -162,6 +162,8 @@ describe("standingChips", () => { tl.standing = [ { lane: "illness", id: "pre", label: "Asthma", since: null, href: null }, ]; - expect(standingChips(tl).map((c) => c.label)).toContain("Asthma"); + expect(standingChips(tl, (i) => i.label).map((c) => c.label)).toContain( + "Asthma", + ); }); }); diff --git a/src/components/timeline/__tests__/no-raw-code-guard.test.tsx b/src/components/timeline/__tests__/no-raw-code-guard.test.tsx new file mode 100644 index 000000000..044296ea7 --- /dev/null +++ b/src/components/timeline/__tests__/no-raw-code-guard.test.tsx @@ -0,0 +1,502 @@ +/** + * v1.42 (#613) — no code reaches the screen. The timeline and the day view + * receive what the record holds: a life event's category, a visit's kind, a + * document's kind, an allergy's severity, a mood, a screener's instrument and + * band as their codes, a lab day's analyte count as a bare number, and a + * sentinel for a trip or a cycle. Every one of them is worded in the reader's + * language before it is shown. + * + * This renders a record with every item kind, every category and every kind + * code on every surface that shows it (the chronicle, the selection bar, the + * screen-reader table, the lane labels and their hover titles, the day's + * running items and its events) and asserts that the visible text holds no + * upper-case code and no sentinel. A new kind or a new code that is not + * worded fails here instead of on a screenshot. + */ +import { describe, expect, it } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +import { DayEvents, DayRunning } from "@/components/day/day-sections"; +import { ENCOUNTER_KINDS } from "@/components/encounters/encounter-labels"; +import { + DAY_EVENT_KINDS, + DAY_RUNNING_KINDS, + LIFE_EVENT_CATEGORIES, + TIMELINE_ITEM_KINDS, + type DayEvent, + type DayRunningItem, + type TimelineItem, + type TimelineResponse, +} from "@/lib/day/contract"; +import { I18nProvider } from "@/lib/i18n/context"; +import { MOOD_ENUM_VALUES } from "@/lib/mood/labels"; +import { INBOUND_DOCUMENT_KINDS } from "@/lib/validations/inbound-documents"; + +import { SelectionBar } from "../selection-bar"; +import type { SeriesValueFormat } from "../series-format"; +import { PointMark, SpanMark, TimelineChart } from "../timeline-chart"; +import { TimelineChronicle } from "../timeline-chronicle"; +import { layoutTimeline } from "../timeline-geometry"; +import { useSeriesValueFormat } from "../use-series-value-format"; +import { TODAY, item, wordsIn } from "./timeline-fixture"; + +type Locale = "de" | "en"; + +function render(node: React.ReactElement, locale: Locale) { + return renderToStaticMarkup( + + {node} + , + ); +} + +/** + * The text a reader sees: every character outside a tag, with a space where + * a tag stood, so two runs ("Ereignis 0" and "FAMILY") never fuse into one + * word the code pattern would step over. + */ +function textOf(html: string): string { + let out = ""; + let inTag = false; + for (const ch of html) { + if (ch === "<") inTag = true; + else if (ch === ">") { + inTag = false; + out += " "; + } else if (!inTag) out += ch; + } + return out; +} + +/** + * A code as the server sends it: four or more capitals, digits or + * underscores in one word (`FAMILY`, `SUPER_GUT`, `PHQ9`), or one of the + * lower-case sentinels. + */ +const RAW_CODE = /\b[A-Z][A-Z0-9_]{3,}\b|\btravel\b|\bcycle\b/g; + +function rawCodes(text: string): string[] { + return [...new Set(text.match(RAW_CODE) ?? [])]; +} + +function WithFormat({ + build, +}: { + build: (format: SeriesValueFormat) => React.ReactNode; +}) { + return <>{build(useSeriesValueFormat())}; +} + +/** Every day of September 2026, one per item, so one month holds them all. */ +let nextDay = 1; +const day = () => `2026-09-${String(nextDay++).padStart(2, "0")}`; + +/** Every timeline item kind, with every code its second line can carry. */ +function everyKind(): TimelineResponse { + nextDay = 1; + const life: TimelineItem[] = [ + ...LIFE_EVENT_CATEGORIES.map((category, i) => + item({ + id: `le-${i}`, + kind: "lifeEvent", + start: day(), + label: `Ereignis ${i}`, + sub: category, + }), + ), + item({ + id: "trip", + kind: "travel", + start: "2026-09-02", + end: "2026-09-09", + label: "travel", + }), + ]; + const visits: TimelineItem[] = ENCOUNTER_KINDS.map((kind, i) => + item({ + id: `visit-${i}`, + kind: kind === "PROCEDURE" ? "procedure" : "visit", + start: day(), + // Every other visit has neither a reason nor a practitioner. + label: i % 2 === 0 ? "" : `Termin ${i}`, + sub: kind, + }), + ); + const documents: TimelineItem[] = INBOUND_DOCUMENT_KINDS.map((kind, i) => + item({ + id: `doc-${i}`, + kind: "document", + start: day(), + label: `Brief ${i}`, + sub: kind, + }), + ); + const timeline: TimelineResponse = { + zoom: "quarter", + range: { from: "2026-08-01", to: "2026-10-31", dataFrom: "2026-08-01" }, + bucket: "month", + lanes: [ + { key: "life", items: life }, + { + key: "illness", + items: [ + item({ + id: "chronic", + kind: "chronic", + start: "2026-09-01", + open: true, + label: "Bluthochdruck", + }), + item({ + id: "episode", + kind: "episode", + start: "2026-09-03", + end: "2026-09-12", + label: "Erkältung", + }), + ], + }, + { + key: "allergies", + items: [ + item({ + id: "allergy", + kind: "allergy", + start: "2026-09-04", + open: true, + label: "Penicillin", + }), + ], + }, + { + key: "medications", + items: [ + item({ + id: "med", + kind: "medication", + start: "2026-09-01", + open: true, + label: "Ramipril", + sub: "5 mg", + }), + item({ + id: "course", + kind: "course", + start: "2026-09-05", + end: "2026-09-25", + label: "Vitamin D", + }), + item({ + id: "dose", + kind: "doseChange", + start: "2026-09-10", + label: "Ramipril", + sub: "10 mg", + }), + item({ + id: "pause", + kind: "pause", + start: "2026-09-14", + end: "2026-09-24", + label: "Ramipril", + }), + ], + }, + { + key: "vaccinations", + items: [ + item({ + id: "vacc", + kind: "vaccination", + start: day(), + label: "Tetanus", + }), + ], + }, + { key: "visits", items: visits }, + { + key: "labs", + items: [ + item({ + id: "lab-day", + kind: "labDay", + start: day(), + label: "Kreatinin, Kalium, Natrium", + sub: "5", + }), + ], + }, + { key: "documents", items: documents }, + { + key: "cycle", + items: [ + item({ + id: "cyc", + kind: "cycle", + start: "2026-09-06", + end: "2026-09-10", + label: "cycle", + }), + ], + }, + ], + standing: [], + series: [], + notable: [], + }; + // The fixture itself must reach every kind, or the guard proves less. + const kinds = new Set( + timeline.lanes.flatMap((l) => l.items.map((i) => i.kind)), + ); + expect([...kinds].sort()).toEqual([...TIMELINE_ITEM_KINDS].sort()); + return timeline; +} + +const SELECTED = "2026-09-15"; +const noop = () => undefined; + +describe.each(["de", "en"])("the timeline in %s", (locale) => { + it("words every code in the chronicle", () => { + const text = textOf( + render( + ( + "var(--chart-1)"} + seriesFormat={format} + onOpenDay={noop} + onEditLifeEvent={null} + /> + )} + />, + locale, + ), + ); + expect(rawCodes(text)).toEqual([]); + }); + + it("words every code in the selection bar", () => { + const text = textOf( + render( + ( + key} + seriesColor={() => "var(--chart-1)"} + seriesFormat={format} + onOpenDay={noop} + /> + )} + />, + locale, + ), + ); + expect(text).toContain(locale === "de" ? "5 Laborwerte" : "5 lab values"); + expect(rawCodes(text)).toEqual([]); + }); + + it("words every code in the screen-reader table", () => { + const html = render( + ( + key} + seriesColor={() => "var(--chart-1)"} + seriesFormat={format} + onSelect={noop} + onOpenDay={noop} + /> + )} + />, + locale, + ); + const table = html.slice(html.indexOf('data-slot="timeline-table"')); + expect(rawCodes(textOf(table))).toEqual([]); + }); + + it("words every code in the lane labels and their hover titles", () => { + const layout = layoutTimeline({ + width: 4000, + window: { from: "2026-08-01", to: "2026-10-31" }, + lanes: everyKind().lanes, + series: [], + bucket: "month", + words: wordsIn(locale), + startMissing: "", + today: TODAY, + }); + const labels = layout.lanes.flatMap((l) => l.labels.map((x) => x.text)); + expect(labels.length).toBeGreaterThan(10); + expect(rawCodes(labels.join("\n"))).toEqual([]); + + const titles = textOf( + render( + + {layout.lanes.flatMap((lane) => [ + ...lane.spans.map((span) => ( + key} + /> + )), + ...lane.points.map((point) => ( + + )), + ])} + , + locale, + ), + ); + // The stub `t` above leaves the range words as keys; only the item + // words are under test here. + expect(rawCodes(titles.replace(/timeline\.selection\.\w+/g, ""))).toEqual( + [], + ); + }); +}); + +/* ─── The day view ──────────────────────────────────────────────────────── */ + +function event( + kind: DayEvent["kind"], + id: string, + title: string, + meta: string | null, +): DayEvent { + return { + at: null, + kind, + section: "medications", + id, + title, + meta, + note: null, + docs: [], + href: null, + }; +} + +/** Every event kind, with every code its title or second line can carry. */ +function everyEvent(): DayEvent[] { + const out: DayEvent[] = [ + event("intake", "i", "Ramipril", "5 mg"), + event("doseChange", "dc", "Ramipril", "10 mg"), + event("medicationStart", "ms", "Ramipril", "5 mg"), + event("medicationEnd", "me", "Ramipril", "5 mg"), + event("pauseStart", "ps", "Ramipril", null), + event("pauseEnd", "pe", "Ramipril", null), + event("courseStart", "cs", "Vitamin D", null), + event("courseEnd", "ce", "Vitamin D", null), + event("illnessOnset", "io", "Erkältung", null), + event("illnessResolved", "ir", "Erkältung", null), + event("illnessDayLog", "idl", "Erkältung", "2"), + event("symptom", "sy", "Kopfschmerz", "7"), + event("allergyOnset", "ao-1", "Penicillin", "SEVERE"), + event("allergyOnset", "ao-2", "Pollen", "MILD"), + event("labResult", "lr", "Kreatinin", "1.1 mg/dL"), + event("visit", "planned", "Kontrolle", "PLANNED"), + event("vaccination", "va", "Tetanus", "2"), + event("checkup", "ch", "Hautkrebs-Screening", null), + event("assessment", "as-1", "PHQ9", "12 moderate"), + event("assessment", "as-2", "GAD7", "3 minimal"), + event("assessment", "as-3", "WHO5", "60 good"), + event("assessment", "as-4", "SCI", "20 aboveThreshold"), + event("workout", "wo", "running", "30 min"), + event("cycleDayLog", "cdl", "cycle", "HEAVY"), + ...ENCOUNTER_KINDS.map((kind, i) => + event( + kind === "PROCEDURE" ? "procedure" : "visit", + `visit-${i}`, + "Hausärztin", + kind, + ), + ), + ...INBOUND_DOCUMENT_KINDS.map((kind, i) => + event("document", `doc-${i}`, `Brief ${i}`, kind), + ), + ...MOOD_ENUM_VALUES.map((mood, i) => + event("mood", `mood-${i}`, mood, String(i + 1)), + ), + ...LIFE_EVENT_CATEGORIES.map((category, i) => + event("lifeEvent", `le-${i}`, `Ereignis ${i}`, category), + ), + ]; + expect([...new Set(out.map((e) => e.kind))].sort()).toEqual( + [...DAY_EVENT_KINDS].sort(), + ); + return out; +} + +function running( + kind: DayRunningItem["kind"], + title: string, + sub: string | null, +): DayRunningItem { + return { + kind, + section: "medications", + id: `${kind}-${title}`, + title, + sub, + since: "2026-09-01", + until: null, + dayIndex: 3, + dayCount: null, + href: null, + }; +} + +function everyRunning(): DayRunningItem[] { + const out: DayRunningItem[] = [ + running("medication", "Ramipril", "5 mg"), + running("medicationCourse", "Vitamin D", null), + running("medicationPause", "Ramipril", null), + running("illness", "Erkältung", null), + running("restMode", "Erholung", null), + running("cyclePhase", "cycle", null), + running("travel", "travel", null), + ...LIFE_EVENT_CATEGORIES.map((category, i) => + running("lifeEvent", `Ereignis ${i}`, category), + ), + ]; + expect([...new Set(out.map((r) => r.kind))].sort()).toEqual( + [...DAY_RUNNING_KINDS].sort(), + ); + return out; +} + +describe.each(["de", "en"])("the day view in %s", (locale) => { + it("words every code under What happened", () => { + const text = textOf(render(, locale)); + expect(rawCodes(text)).toEqual([]); + expect(text).toContain(locale === "de" ? "Super gut" : "Amazing"); + }); + + it("words every code under Running that day", () => { + const text = textOf(render(, locale)); + expect(rawCodes(text)).toEqual([]); + }); +}); diff --git a/src/components/timeline/__tests__/timeline-components.test.tsx b/src/components/timeline/__tests__/timeline-components.test.tsx index 5a1ffdfb8..43cdcfab9 100644 --- a/src/components/timeline/__tests__/timeline-components.test.tsx +++ b/src/components/timeline/__tests__/timeline-components.test.tsx @@ -23,7 +23,7 @@ import { MeanPartsLine, SeriesLines, TimelineChart } from "../timeline-chart"; import { TimelineChronicle } from "../timeline-chronicle"; import { layoutTimeline } from "../timeline-geometry"; import { useSeriesValueFormat } from "../use-series-value-format"; -import { TODAY, fullTimeline, readiness } from "./timeline-fixture"; +import { TODAY, fullTimeline, readiness, wordsIn } from "./timeline-fixture"; function render(node: React.ReactElement, locale: "de" | "en" = "de") { // A fresh client with no account answer: the unit preference resolves to @@ -54,8 +54,21 @@ function I18nProbe({ return <>{run(t)}; } -/** The text a reader sees, without the markup between the runs. */ -const text = (html: string) => html.replace(/<[^>]+>/g, ""); +/** + * The text a reader sees, without the markup between the runs. A walk over + * the characters rather than a pattern, so a tag split across another cannot + * survive it. + */ +function text(html: string): string { + let out = ""; + let inTag = false; + for (const ch of html) { + if (ch === "<") inTag = true; + else if (ch === ">") inTag = false; + else if (!inTag) out += ch; + } + return out; +} const COLORS = assignSeriesColors([ "BLOOD_PRESSURE_SYS", @@ -153,6 +166,32 @@ describe("chronicle", () => { expect(html).toMatch(/data-date="2026-01-03">]*bg-muted/); }); + it("reads a pause as paused and resumed, never as an end", () => { + const html = text( + render( + ( + undefined} + onEditLifeEvent={null} + /> + )} + />, + "en", + ), + ); + expect(html).toContain("Ramipril paused"); + expect(html).toContain("Ramipril resumed"); + expect(html).toContain("after 11 days"); + expect(html).not.toContain("Ramipril ended"); + }); + it("shows the month's means beside the month", () => { const html = render( { expect(html).toContain('data-date="2026-01-03"'); }); + it("names a pause as a pause", () => { + const html = text(bar("2021-06-05")); + expect(html).toContain("Ramipril pausiert · 1. Juni bis 11. Juni"); + }); + it("names the bucket's means with the readings behind each", () => { expect(text(bar("2026-01-03"))).toContain( "Ø Blutdruck 129/82 mmHg (Schnitt aus 4 Messungen) · Gewicht 82,6 kg (Schnitt aus 3 Messungen)", @@ -364,6 +408,7 @@ describe("value line colours", () => { lanes: [], series: fullTimeline().series, bucket: "month", + words: wordsIn(), startMissing: "", today: TODAY, }); diff --git a/src/components/timeline/__tests__/timeline-fixture.ts b/src/components/timeline/__tests__/timeline-fixture.ts index 11e3cf2b1..3a346ef28 100644 --- a/src/components/timeline/__tests__/timeline-fixture.ts +++ b/src/components/timeline/__tests__/timeline-fixture.ts @@ -10,6 +10,19 @@ import type { TimelineReadinessResponse, TimelineResponse, } from "@/lib/day/contract"; +import type { Locale } from "@/lib/i18n/config"; +import { pluralKey } from "@/lib/i18n/plural"; +import { getServerTranslator } from "@/lib/i18n/server-translator"; + +import { makeItemWords, type ItemWordsFn } from "../item-words"; + +/** An item's words from the real bundle, outside a React tree. */ +export function wordsIn(locale: Locale = "de"): ItemWordsFn { + const { t } = getServerTranslator(locale); + return makeItemWords(t, (base, count, params) => + t(pluralKey(base, count, locale), { count, ...params }), + ); +} export function item( over: Partial & Pick, @@ -174,8 +187,8 @@ export function fullTimeline(): TimelineResponse { id: "pause-1", kind: "pause", start: "2021-06-01", - end: "2021-06-20", - label: "Pause", + end: "2021-06-11", + label: "Ramipril", }), ], }, diff --git a/src/components/timeline/__tests__/timeline-geometry.test.ts b/src/components/timeline/__tests__/timeline-geometry.test.ts index d011716ae..017872cd6 100644 --- a/src/components/timeline/__tests__/timeline-geometry.test.ts +++ b/src/components/timeline/__tests__/timeline-geometry.test.ts @@ -12,7 +12,6 @@ import { estimateTextWidth, fitText, gridTicks, - itemLabel, itemsInPeriod, latestDataDate, layoutLane, @@ -25,8 +24,11 @@ import { type LaneLayout, type TimelineLayout, } from "../timeline-geometry"; +import { itemLine } from "../item-words"; import { dayNumber } from "../timeline-dates"; -import { TODAY, fullTimeline, item } from "./timeline-fixture"; +import { TODAY, fullTimeline, item, wordsIn } from "./timeline-fixture"; + +const WORDS = wordsIn("de"); const FMT = { year: (k: string) => k.slice(0, 4), @@ -42,6 +44,7 @@ function layoutAt(width: number): TimelineLayout { lanes: tl.lanes, series: tl.series, bucket: tl.bucket, + words: WORDS, startMissing: "Startdatum fehlt", today: TODAY, }); @@ -183,7 +186,7 @@ describe("gridTicks", () => { describe("layoutLane", () => { const window = { from: "2019-01-01", to: "2026-12-31" }; const scale = createScale(window, 164, 1200); - const opts = { startMissing: "Startdatum fehlt", today: TODAY }; + const opts = { words: WORDS, startMissing: "Startdatum fehlt", today: TODAY }; it("puts overlapping periods on separate rows and lets later ones reuse a free row", () => { const lane = fullTimeline().lanes.find((l) => l.key === "illness")!; @@ -207,6 +210,23 @@ describe("layoutLane", () => { expect(pause.row).toBe(host.row); }); + it("labels a pause as a pause, above the medication it interrupts", () => { + const lane = fullTimeline().lanes.find((l) => l.key === "medications")!; + const wide = createScale( + { from: "2021-01-01", to: "2021-12-31" }, + 164, + 1200, + ); + const layout = layoutLane( + { lane, top: 0 }, + { from: "2021-01-01", to: "2021-12-31" }, + wide, + opts, + ); + const label = layout.labels.find((l) => l.itemId === "pause-1"); + expect(label?.text).toBe("Ramipril pausiert"); + }); + it("starts an open period at its start and runs it to today", () => { const lane = fullTimeline().lanes.find((l) => l.key === "allergies")!; const layout = layoutLane({ lane, top: 0 }, window, scale, opts); @@ -226,7 +246,7 @@ describe("layoutLane", () => { label: "Ramipril", sub: "5 mg", }); - expect(itemLabel(unknown, "Startdatum fehlt")).toBe( + expect(itemLine(unknown, WORDS, "Startdatum fehlt")).toBe( "Ramipril · 5 mg · Startdatum fehlt", ); const layout = layoutLane( diff --git a/src/components/timeline/chronicle-model.ts b/src/components/timeline/chronicle-model.ts index 4c50857ba..4baef2406 100644 --- a/src/components/timeline/chronicle-model.ts +++ b/src/components/timeline/chronicle-model.ts @@ -99,10 +99,12 @@ function groupsBetween( /** * The Ongoing block: what has no start (`standing`) and every period still - * open. Allergies fold into one chip when there is more than one. + * open, each period named by `label` (`item-words.ts`, so a running pause + * reads as paused). Allergies fold into one chip when there is more than one. */ export function standingChips( timeline: Pick, + label: (item: TimelineItem) => string, ): StandingChip[] { const chips: StandingChip[] = []; const allergies: StandingChip[] = []; @@ -116,7 +118,7 @@ export function standingChips( const lane = timeline.lanes.find((l) => l.key === key); for (const item of lane?.items ?? []) { if (item.open) - push({ lane: key, id: item.id, label: item.label, count: 1 }); + push({ lane: key, id: item.id, label: label(item), count: 1 }); } } const ordered = chips.sort( diff --git a/src/components/timeline/item-words.ts b/src/components/timeline/item-words.ts new file mode 100644 index 000000000..55decc74d --- /dev/null +++ b/src/components/timeline/item-words.ts @@ -0,0 +1,135 @@ +/** + * The words of one timeline item, in the reader's language (v1.42, #613). + * + * The server sends what the record holds: a life event's category, a visit's + * kind and a document's kind as their codes, a lab day's analyte count as a + * number, and a sentinel label for a trip or a cycle that has no name of its + * own. Every surface that shows an item (the lanes, their hover titles, the + * screen-reader table, the selection bar, the phone chronicle) reads it + * through here, so a code is worded once and never reaches the screen raw. + * + * A code the bundle cannot word is left out rather than shown: an unknown + * category reads as no category, not as `SOMETHING_NEW`. + */ +import { useMemo } from "react"; + +import { encounterKindText } from "@/components/encounters/encounter-labels"; +import type { TimelineItem } from "@/lib/day/contract"; +import { useTranslations } from "@/lib/i18n/context"; + +type Translate = ( + key: string, + params?: Record, +) => string; +type TranslateCount = ( + baseKey: string, + count: number, + params?: Record, +) => string; + +export interface ItemWords { + /** What the item is called. */ + label: string; + /** Its second line (a category, a dose, a count), or null when none. */ + sub: string | null; +} + +export type ItemWordsFn = (item: TimelineItem) => ItemWords; + +/** The bundle's wording of `key`, or null when the bundle has none. */ +function known(t: Translate, key: string): string | null { + const value = t(key); + return value === key ? null : value; +} + +const ENCOUNTER_KIND_CODES: ReadonlySet = new Set([ + "ROUTINE", + "ACUTE", + "SPECIALIST", + "PREVENTIVE", + "EMERGENCY", + "HOSPITAL", + "THERAPY", + "OTHER", + "PROCEDURE", +]); + +/** A visit's kind code, worded; null for a code the bundle does not know. */ +export function visitKindWords(t: Translate, code: string): string | null { + return ENCOUNTER_KIND_CODES.has(code) + ? encounterKindText(t, code as Parameters[1]) + : null; +} + +export function makeItemWords( + t: Translate, + tCount: TranslateCount, +): ItemWordsFn { + return (item) => { + switch (item.kind) { + case "lifeEvent": + return { + label: item.label, + sub: item.sub ? known(t, `lifeEvents.category.${item.sub}`) : null, + }; + case "travel": + return { label: t("day.travel"), sub: null }; + case "cycle": + return { label: t("nav.cycle"), sub: null }; + case "pause": + return { + label: t("day.event.paused", { label: item.label }), + sub: null, + }; + case "visit": + case "procedure": { + const kind = item.sub ? visitKindWords(t, item.sub) : null; + // A visit without a reason or a practitioner is named by its kind. + return item.label + ? { label: item.label, sub: kind } + : { label: kind ?? "", sub: null }; + } + case "labDay": { + const count = Number(item.sub); + return { + label: item.label, + sub: + Number.isInteger(count) && count > 0 + ? tCount("timeline.item.labValues", count) + : null, + }; + } + case "document": + return { + label: item.label, + sub: item.sub ? known(t, `documents.kind.${item.sub}`) : null, + }; + default: + // A dose, a dose change, a name: the record's own text. + return { label: item.label, sub: item.sub }; + } + }; +} + +/** + * The line an item reads as: its label, its second line (a dose change's + * dose is drawn as its own mark, so it is left out here) and the + * unknown-start note. + */ +export function itemLine( + item: TimelineItem, + words: ItemWordsFn, + startMissing: string, +): string { + const { label, sub } = words(item); + const parts = [label]; + if (sub && item.kind !== "doseChange") parts.push(sub); + if (!item.startKnown) parts.push(startMissing); + return parts.filter(Boolean).join(" · "); +} + +/** {@link makeItemWords} with the bundle in hand. */ +export function useItemWords(): ItemWordsFn { + const { t, tCount } = useTranslations(); + return useMemo(() => makeItemWords(t, tCount), [t, tCount]); +} diff --git a/src/components/timeline/selection-bar.tsx b/src/components/timeline/selection-bar.tsx index de5bbeaf0..64ab30eeb 100644 --- a/src/components/timeline/selection-bar.tsx +++ b/src/components/timeline/selection-bar.tsx @@ -30,6 +30,7 @@ import { formatMonthLong, formatMonthYear, } from "./timeline-dates"; +import { useItemWords } from "./item-words"; import { itemsInPeriod } from "./timeline-geometry"; import { MeanPartsLine, laneDotStyle } from "./timeline-chart"; import { @@ -67,6 +68,7 @@ export function SelectionBar({ onEditLifeEvent, }: SelectionBarProps) { const { t, tCount, locale } = useTranslations(); + const words = useItemWords(); const intl = resolveIntlLocale(locale); const bucket = timeline.bucket; const periodFrom = bucketStart(selected, bucket); @@ -138,7 +140,8 @@ export function SelectionBar({ : item.precision === "DAY" ? formatDayMonth(item.start, intl) : formatAtPrecision(item.start, item.precision, intl); - const text = `${item.label}${item.sub ? ` ${item.sub}` : ""} · ${when}`; + const { label, sub } = words(item); + const text = [label, sub, when].filter(Boolean).join(" · "); const editable = item.kind === "lifeEvent" && onEditLifeEvent; if (!canOpenDay && !editable) { return ( diff --git a/src/components/timeline/timeline-chart.tsx b/src/components/timeline/timeline-chart.tsx index ab863d9d1..5d9045f36 100644 --- a/src/components/timeline/timeline-chart.tsx +++ b/src/components/timeline/timeline-chart.tsx @@ -51,6 +51,7 @@ import type { import { resolveIntlLocale } from "@/lib/format-locale"; import { useTranslations } from "@/lib/i18n/context"; +import { useItemWords, type ItemWordsFn } from "./item-words"; import { bucketAfter, bucketStart, @@ -147,6 +148,7 @@ export function TimelineChart({ onOpenDay, }: TimelineChartProps) { const { t, locale } = useTranslations(); + const words = useItemWords(); const fmt = seriesFormat; const intl = resolveIntlLocale(locale); const { ref, width } = useElementWidth(); @@ -164,11 +166,12 @@ export function TimelineChart({ lanes, series: timeline.series, bucket: timeline.bucket, + words, startMissing: t("timeline.startMissing"), today, }) : null, - [width, window, lanes, timeline.series, timeline.bucket, t, today], + [width, window, lanes, timeline.series, timeline.bucket, words, t, today], ); const floor = timeline.range.dataFrom ?? window.from; @@ -676,17 +679,19 @@ function spanTitle( span: PlacedSpan, intl: string, t: ReturnType["t"], + words: ItemWordsFn, ): string { const { item } = span; + const { label, sub } = words(item); const from = formatAtPrecision(item.start, item.precision, intl); const to = item.open ? t("timeline.selection.ongoing") : formatAtPrecision(item.end ?? item.start, item.precision, intl); - const name = item.sub ? `${item.label} · ${item.sub}` : item.label; + const name = sub ? `${label} · ${sub}` : label; return `${name}: ${t("timeline.selection.range", { from, to })}`; } -function SpanMark({ +export function SpanMark({ span, color, x0, @@ -700,7 +705,8 @@ function SpanMark({ t: ReturnType["t"]; }) { const { item, xStart, xEnd, y } = span; - const title = spanTitle(span, intl, t); + const words = useItemWords(); + const title = spanTitle(span, intl, t, words); if (span.pause) { return ( @@ -763,7 +769,7 @@ function SpanMark({ ); } -function PointMark({ +export function PointMark({ point, color, intl, @@ -773,7 +779,8 @@ function PointMark({ intl: string; }) { const { x, y, item, shape } = point; - const title = `${item.label}${item.sub ? ` · ${item.sub}` : ""}: ${formatAtPrecision(item.start, item.precision, intl)}`; + const { label, sub } = useItemWords()(item); + const title = `${label}${sub ? ` · ${sub}` : ""}: ${formatAtPrecision(item.start, item.precision, intl)}`; let glyph: React.ReactNode; switch (shape) { case "diamond": @@ -873,6 +880,7 @@ function TimelineTable({ intl: string; }) { const { t } = useTranslations(); + const words = useItemWords(); const rows = timeline.lanes .filter((lane) => !hiddenLanes.has(lane.key)) .flatMap((lane) => lane.items.map((item) => ({ lane: lane.key, item }))) @@ -893,9 +901,13 @@ function TimelineTable({ {t(`timeline.lanes.${lane}`)} - {item.label} - {item.sub ? ` · ${item.sub}` : ""} - {!item.startKnown ? ` · ${t("timeline.startMissing")}` : ""} + {[ + words(item).label, + words(item).sub, + item.startKnown ? null : t("timeline.startMissing"), + ] + .filter(Boolean) + .join(" · ")} {formatAtPrecision(item.start, item.precision, intl)} diff --git a/src/components/timeline/timeline-chronicle.tsx b/src/components/timeline/timeline-chronicle.tsx index 9e453240b..6ada2fc9c 100644 --- a/src/components/timeline/timeline-chronicle.tsx +++ b/src/components/timeline/timeline-chronicle.tsx @@ -41,6 +41,7 @@ import { type ChronicleGrouping, type ChronicleRails, } from "./chronicle-model"; +import { useItemWords } from "./item-words"; import { formatAtPrecision, formatDayMonth, @@ -146,9 +147,10 @@ export function TimelineChronicle({ onEditLifeEvent: ((id: string) => void) | null; }) { const { t, tCount, locale } = useTranslations(); + const words = useItemWords(); const intl = resolveIntlLocale(locale); const [standingOpen, setStandingOpen] = useState(true); - const chips = standingChips(timeline); + const chips = standingChips(timeline, (item) => words(item).label); const rows = buildChronicle(timeline, today, grouping); // The means ride on the month headers, each bucket on the newest month it // overlaps; the year view lists none. @@ -185,18 +187,23 @@ export function TimelineChronicle({ }; } const { item, role } = entry; + const { label, sub } = words(item); if (role === "end") { + // A pause that ends is the medication taken up again, not ended. return { - title: t("timeline.chronicle.ended", { label: item.label }), + title: + item.kind === "pause" + ? t("day.event.resumed", { label: item.label }) + : t("timeline.chronicle.ended", { label }), meta: entry.days ? tCount("timeline.chronicle.afterDays", entry.days) : null, }; } - const meta = [item.sub, item.startKnown ? null : t("timeline.startMissing")] + const meta = [sub, item.startKnown ? null : t("timeline.startMissing")] .filter(Boolean) .join(" · "); - return { title: item.label, meta: meta || null }; + return { title: label, meta: meta || null }; } return ( diff --git a/src/components/timeline/timeline-geometry.ts b/src/components/timeline/timeline-geometry.ts index b3407c175..c220aaa2e 100644 --- a/src/components/timeline/timeline-geometry.ts +++ b/src/components/timeline/timeline-geometry.ts @@ -26,6 +26,7 @@ import type { TimelineZoom, } from "@/lib/day/contract"; +import { itemLine, type ItemWordsFn } from "./item-words"; import { addMonths, bucketAfter, @@ -455,17 +456,6 @@ function inWindow(item: TimelineItem, window: TimeWindow): boolean { return item.start >= window.from; } -/** - * The visible text of an item: its label, the dose or second line when it - * has one, and the unknown-start note. - */ -export function itemLabel(item: TimelineItem, startMissing: string): string { - const parts = [item.label]; - if (item.sub && item.kind !== "doseChange") parts.push(item.sub); - if (!item.startKnown) parts.push(startMissing); - return parts.filter(Boolean).join(" · "); -} - interface LaneInput { lane: TimelineLane; top: number; @@ -480,7 +470,12 @@ export function layoutLane( { lane, top }: LaneInput, window: TimeWindow, scale: Scale, - options: { startMissing: string; today: string }, + options: { + /** How an item reads (`item-words.ts`): codes worded, never raw. */ + words: ItemWordsFn; + startMissing: string; + today: string; + }, ): LaneLayout { const visible = lane.items.filter((item) => inWindow(item, window)); const pointItems = visible.filter( @@ -580,7 +575,13 @@ export function layoutLane( const rows = Math.max(1, pointRows + rowEnds.length); const height = LANE_PAD_TOP + (rows - 1) * ROW_HEIGHT + LANE_PAD_BOTTOM; - const labels = placeLabels(spans, points, scale, options.startMissing); + const labels = placeLabels( + spans, + points, + scale, + options.words, + options.startMissing, + ); return { key: lane.key, top, height, rows, spans, points, labels }; } @@ -595,6 +596,7 @@ function placeLabels( spans: PlacedSpan[], points: PlacedPoint[], scale: Scale, + words: ItemWordsFn, startMissing: string, ): PlacedLabel[] { const occ = new LineOccupancy(); @@ -662,7 +664,9 @@ function placeLabels( return true; }; - for (const s of openSpans) aboveLabel(s, itemLabel(s.item, startMissing)); + for (const s of openSpans) { + aboveLabel(s, itemLine(s.item, words, startMissing)); + } // A point's name starts just clear of its own glyph. const besideGlyph = (p: PlacedPoint) => p.x + GLYPH_HALF_WIDTH[p.shape] + BESIDE_MARGIN + 1; @@ -671,11 +675,10 @@ function placeLabels( for (const kind of ["lifeEvent", "procedure"] as const) { const strong = kind === "lifeEvent"; for (const p of points.filter((p) => p.item.kind === kind)) { - if ( - rightLabel(p.item.id, p.row, besideGlyph(p), p.y, p.item.label, strong) - ) + const { label } = words(p.item); + if (rightLabel(p.item.id, p.row, besideGlyph(p), p.y, label, strong)) continue; - leftLabel(p.item.id, p.row, leftOfGlyph(p), p.y, p.item.label, strong); + leftLabel(p.item.id, p.row, leftOfGlyph(p), p.y, label, strong); } } // A period's name goes right of its bar, else above it, else left of it. @@ -683,7 +686,7 @@ function placeLabels( // shown once: the repeats read as the same thing again. const shownOnRow = new Set(); for (const s of closedSpans.sort((a, b) => a.xStart - b.xStart)) { - const text = itemLabel(s.item, startMissing); + const text = itemLine(s.item, words, startMissing); const rowKey = `${s.row}:${text}`; if (shownOnRow.has(rowKey)) continue; const strong = s.item.kind === "lifeEvent"; @@ -693,9 +696,10 @@ function placeLabels( leftLabel(s.item.id, s.row, s.xStart - 6, s.y, text, strong); if (placed) shownOnRow.add(rowKey); } - for (const s of pausesWithLabel) aboveLabel(s, s.item.label); + for (const s of pausesWithLabel) aboveLabel(s, words(s.item).label); for (const p of points.filter((p) => p.shape === "doseMark")) { - const text = p.item.sub ?? p.item.label; + const dose = words(p.item); + const text = dose.sub ?? dose.label; const a = p.x + 2; const b = a + estimateTextWidth(text); if (fitsCard(a, b) && occ.tryAdd(line(p.row, "above"), a, b)) { @@ -864,6 +868,7 @@ export function layoutTimeline(input: { lanes: readonly TimelineLane[]; series: readonly TimelineSeries[]; bucket: TimelineBucket; + words: ItemWordsFn; startMissing: string; today: string; }): TimelineLayout { @@ -875,6 +880,7 @@ export function layoutTimeline(input: { const lane = input.lanes.find((l) => l.key === key); if (!lane) continue; const layout = layoutLane({ lane, top }, input.window, scale, { + words: input.words, startMissing: input.startMissing, today: input.today, }); diff --git a/src/lib/day/wire-schemas.ts b/src/lib/day/wire-schemas.ts index a3dbd24eb..e6468e328 100644 --- a/src/lib/day/wire-schemas.ts +++ b/src/lib/day/wire-schemas.ts @@ -122,8 +122,17 @@ const dayEvent = z kind: z.enum(DAY_EVENT_KINDS), section: daySectionKey, id: z.string(), - title: z.string(), - meta: z.string().nullable(), + title: z + .string() + .describe( + "The record's own name, or a code the client words: a mood, a screener's instrument, a workout's sport, `cycle` for a cycle day.", + ), + meta: z + .string() + .nullable() + .describe( + "The second line: text (a dose, a lab value, a duration) or a code or number the client words (a category, a kind, a severity, a screener's score and band, an intensity, a dose number).", + ), note: z .string() .nullable() @@ -318,8 +327,17 @@ const timelineItem = z .describe( "False when the start is a stand-in (a medication without a start date starts at its first intake).", ), - label: z.string(), - sub: z.string().nullable(), + label: z + .string() + .describe( + "The item's name. A trip is labelled `travel` and a cycle `cycle`; the client words both.", + ), + sub: z + .string() + .nullable() + .describe( + "The second line, by kind: a code for a life event (its category), a visit or procedure (its encounter kind) and a document (its kind); the analyte count for a lab day; the dose as text for a medication or a dose change. The client words every code.", + ), href: z.string().nullable(), }) .meta({ id: "TimelineItem" }); From 4c06ecafe31abe53286e06e42e2fe1f20edf74c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 9 Oct 2026 12:56:32 +0200 Subject: [PATCH 2/8] fix(dashboard): show the blood pressure axis in mmHg, not Hg The dashboard's blood pressure chart passed `yAxisUnit="Hg"` beside `unit="mmHg"`, a shortening carried since the first release, so every tick read "154 Hg". The override is gone; "mmHg" is four characters and fits the axis gutter like "bpm" and "mg/dL" do. A unit with a space of its own ("/ 5") is now joined with no-break spaces as well, so Recharts cannot wrap it either. A guard fails on any `yAxisUnit` that is a cut-down form of the `unit` beside it. --- src/app/page-client.tsx | 1 - src/lib/charts/__tests__/axis-unit.test.ts | 58 ++++++++++++++++++++++ src/lib/charts/axis-unit.ts | 3 +- 3 files changed, 60 insertions(+), 2 deletions(-) diff --git a/src/app/page-client.tsx b/src/app/page-client.tsx index 8afce7e8c..63c6ccf7f 100644 --- a/src/app/page-client.tsx +++ b/src/app/page-client.tsx @@ -1780,7 +1780,6 @@ export default function DashboardPageClient({ title={t("dashboard.bloodPressure")} colors={["var(--chart-3)", "var(--chart-4)"]} unit="mmHg" - yAxisUnit="Hg" targetZones={bpTargetZones} compareBaseline={compareBaseline} userTimezone={user?.timezone} diff --git a/src/lib/charts/__tests__/axis-unit.test.ts b/src/lib/charts/__tests__/axis-unit.test.ts index 121a0cce3..80c095c0c 100644 --- a/src/lib/charts/__tests__/axis-unit.test.ts +++ b/src/lib/charts/__tests__/axis-unit.test.ts @@ -39,6 +39,11 @@ describe("axis unit suffix", () => { expect(axisUnitSuffix(null)).toBeUndefined(); }); + it("keeps a unit with a space of its own on one line", () => { + expect(axisUnitSuffix("/ 5")).toBe("\u00a0/\u00a05"); + expect(axisUnitSuffix("mmHg")).toBe("\u00a0mmHg"); + }); + it("finds the axis units it is meant to police", () => { // A matcher that finds nothing would pass the check below by default. expect(axisUnitProps().length).toBeGreaterThanOrEqual(6); @@ -55,6 +60,59 @@ describe("axis unit suffix", () => { }); }); +/** + * Every tag in the app that names both a `unit` and a `yAxisUnit` literal. + * The dashboard's blood pressure chart carried `yAxisUnit="Hg"` beside + * `unit="mmHg"` from the first release on, so its axis read "154 Hg". + */ +function axisUnitOverrides(): Array<{ + file: string; + unit: string; + axis: string; +}> { + const out: Array<{ file: string; unit: string; axis: string }> = []; + for (const root of ["app", "components"]) { + const dir = join(process.cwd(), "src", root); + for (const rel of walkSourceFiles(dir, { + floor: 100, + extensions: [".tsx"], + })) { + if (rel.includes("__tests__")) continue; + const src = stripComments(readFileSync(join(dir, rel), "utf8")); + // The tag around each override: back to the previous element's end, + // forward to the next one's. A prop holding JSX (`icon={}`) can + // sit inside, so the tag is not cut at the first `<`. + for (const m of src.matchAll(/\byAxisUnit="([^"]*)"/g)) { + const before = src.slice(0, m.index); + const from = Math.max(0, before.lastIndexOf("/>"), m.index - 1500); + const afterEnd = src.indexOf("/>", m.index); + const tag = src.slice(from, afterEnd === -1 ? undefined : afterEnd); + const unit = /\bunit="([^"]*)"/.exec(tag); + if (unit) + out.push({ file: `${root}/${rel}`, unit: unit[1], axis: m[1] }); + } + } + } + return out; +} + +describe("axis unit overrides", () => { + it("finds the overrides it is meant to police", () => { + expect(axisUnitOverrides().length).toBeGreaterThanOrEqual(5); + }); + + it("never cuts a unit short on the axis", () => { + const cut = axisUnitOverrides() + .filter( + ({ unit, axis }) => axis !== "" && axis !== unit && unit.includes(axis), + ) + .map( + ({ file, unit, axis }) => `${file}: unit="${unit}" yAxisUnit="${axis}"`, + ); + expect(cut).toEqual([]); + }); +}); + describe("axisTickUnitSuffix", () => { it("keeps a short unit on the ticks", () => { expect(axisTickUnitSuffix("bpm")).toBe(axisUnitSuffix("bpm")); diff --git a/src/lib/charts/axis-unit.ts b/src/lib/charts/axis-unit.ts index abdd2ac50..62656b49b 100644 --- a/src/lib/charts/axis-unit.ts +++ b/src/lib/charts/axis-unit.ts @@ -11,7 +11,8 @@ export function axisUnitSuffix( unit: string | null | undefined, ): string | undefined { - return unit ? ` ${unit}` : undefined; + // A space inside the unit ("/ 5") would break the label the same way. + return unit ? ` ${unit.replace(/ /g, "\u00a0")}` : undefined; } /** From cd784150843a57b9f29c8f69168e759c7ec2f12e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 9 Oct 2026 13:04:27 +0200 Subject: [PATCH 3/8] test(documents): check the archive grid under the flowing default The vault runs its months side by side by default now, so a month is marked above its first document and no longer has a heading row. The grid check reads the arrangement and the month marker instead of an h2. --- e2e/documents.spec.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/e2e/documents.spec.ts b/e2e/documents.spec.ts index 4cd77f256..18aa9dbca 100644 --- a/e2e/documents.spec.ts +++ b/e2e/documents.spec.ts @@ -165,9 +165,13 @@ test.describe("document vault", () => { const firstRow = page.locator('[data-slot="document-month-row"]').first(); await expect(firstRow).toBeVisible({ timeout: 15_000 }); - // Month headings stay the grid's section labels. + // The months run side by side by default: a month is marked above + // its first document instead of taking a heading row of its own. await expect( - page.locator('[data-slot="document-timeline"] h2').first(), + page.locator('[data-slot="document-timeline"]'), + ).toHaveAttribute("data-arrangement", "flow"); + await expect( + page.locator('[data-slot="document-flow-month"]').first(), ).toBeVisible(); // Tiles with the preview on top, and at most `columns` side by side: From 3f47224825567c31ea88da214467ba3ba7d1f30a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 9 Oct 2026 13:13:26 +0200 Subject: [PATCH 4/8] fix(i18n): key the catalog's cache by its content, not the release The service worker serves the locale catalog's boot script cache-first, and its URL carried the package version. Every build of one release therefore shared one URL: an instance updated within a release (a beta, a redeploy) kept the catalog of its first build, and every message added since read as its raw key ("timeline.zoom.range", "timeline.selection.week") or fell back to English. next.config.ts now hashes the message catalogs at build time and the layout keys the script by that hash, so a changed catalog is a new URL. --- next.config.ts | 24 ++++++++++++++ public/sw.js | 5 +-- .../compose-env-whitelist-guard.test.ts | 2 ++ src/__tests__/i18n-catalog-cache-key.test.ts | 31 +++++++++++++++++++ src/app/layout.tsx | 4 ++- 5 files changed, 63 insertions(+), 3 deletions(-) create mode 100644 src/__tests__/i18n-catalog-cache-key.test.ts diff --git a/next.config.ts b/next.config.ts index 2edfcfba3..672f10a91 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,8 +1,31 @@ +import { createHash } from "node:crypto"; +import { readdirSync, readFileSync } from "node:fs"; +import { join } from "node:path"; + import type { NextConfig } from "next"; import bundleAnalyzer from "@next/bundle-analyzer"; import { version as PKG_VERSION } from "./package.json"; +/** + * The message catalogs' content hash, the cache key of the boot script + * (`/i18n/.js?v=…`, `src/app/layout.tsx`). The service worker serves + * that URL cache-first, so a key that does not move with the catalog keeps + * an old catalog alive: keyed by the package version, every build of one + * release (a beta, a redeploy) shared one URL, and a key added since the + * first of them read as its raw name. + */ +function catalogVersion(): string { + const dir = join(process.cwd(), "messages"); + const hash = createHash("sha256"); + for (const file of readdirSync(dir) + .filter((f) => f.endsWith(".json")) + .sort()) { + hash.update(file).update(readFileSync(join(dir, file))); + } + return hash.digest("hex").slice(0, 16); +} + const nextConfig: NextConfig = { output: "standalone", // `next build` type-checks the whole tsconfig program since 16.3, which @@ -20,6 +43,7 @@ const nextConfig: NextConfig = { // had to discover "pull-to-refresh" themselves after every release. env: { NEXT_PUBLIC_APP_VERSION: PKG_VERSION, + NEXT_PUBLIC_I18N_CATALOG_VERSION: catalogVersion(), }, // v1.4.33 IW2 — strip `console.*` calls from the production bundle // (keep `console.error` + `console.warn` so the GlitchTip reporter diff --git a/public/sw.js b/public/sw.js index ce689494f..603d4a6af 100644 --- a/public/sw.js +++ b/public/sw.js @@ -230,8 +230,9 @@ self.addEventListener("fetch", (event) => { return; } - // Locale-catalog boot script — cache-first. The URL is versioned - // (`/i18n/?v=`) and served immutable, so cache-first is + // Locale-catalog boot script — cache-first. The URL is versioned by the + // catalogs' content hash (`/i18n/?v=`, `next.config.ts`), + // so a changed catalog is a new URL and cache-first is // correct and keeps the catalog available on an offline relaunch (the // cached shell HTML references the same versioned URL it was rendered // with). Carries no user data — same public strings as the repository's diff --git a/src/__tests__/compose-env-whitelist-guard.test.ts b/src/__tests__/compose-env-whitelist-guard.test.ts index 79f28af68..027cb5262 100644 --- a/src/__tests__/compose-env-whitelist-guard.test.ts +++ b/src/__tests__/compose-env-whitelist-guard.test.ts @@ -102,6 +102,8 @@ const NOT_OPERATOR_CONFIGURABLE: Readonly> = { // --- Baked at build time by the Dockerfile's ARG/ENV pairs. --- NEXT_PUBLIC_APP_VERSION: "Dockerfile build arg; a runtime value cannot change what is compiled in", + NEXT_PUBLIC_I18N_CATALOG_VERSION: + "computed by next.config.ts from the message catalogs at build time", NEXT_PUBLIC_APP_BUILD_SHA: "Dockerfile build arg, stamped from the release commit", NEXT_PUBLIC_APP_BUILT_AT: "Dockerfile build arg, stamped at image build", diff --git a/src/__tests__/i18n-catalog-cache-key.test.ts b/src/__tests__/i18n-catalog-cache-key.test.ts new file mode 100644 index 000000000..6888e79f6 --- /dev/null +++ b/src/__tests__/i18n-catalog-cache-key.test.ts @@ -0,0 +1,31 @@ +/** + * The locale catalog's boot script is cached by its URL: the service worker + * serves `/i18n/.js?v=…` cache-first. Keyed by the package version, + * every build of one release shared one URL, so a beta updated within a + * release kept its first catalog and every key added since read as its raw + * name ("timeline.zoom.range"). The key is the catalogs' content hash now. + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +const read = (rel: string) => readFileSync(join(process.cwd(), rel), "utf8"); + +describe("the locale catalog's cache key", () => { + it("moves with the catalogs, not with the release", () => { + const layout = read("src/app/layout.tsx"); + const src = + /src=\{`\/i18n\/\$\{initialLocale\}\.js\?v=\$\{([^}]*)\}`\}/.exec(layout); + expect(src?.[1]).toContain("NEXT_PUBLIC_I18N_CATALOG_VERSION"); + expect(src?.[1]).not.toContain("NEXT_PUBLIC_APP_VERSION"); + }); + + it("is computed from the message catalogs at build time", () => { + const config = read("next.config.ts"); + expect(config).toMatch( + /NEXT_PUBLIC_I18N_CATALOG_VERSION:\s*catalogVersion\(\)/, + ); + expect(config).toMatch(/join\(process\.cwd\(\), "messages"\)/); + }); +}); diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 57e112094..e935efd77 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -131,7 +131,9 @@ export default async function RootLayout({ id="healthlog-i18n-boot" strategy="beforeInteractive" nonce={nonce} - src={`/i18n/${initialLocale}.js?v=${process.env.NEXT_PUBLIC_APP_VERSION || "dev"}`} + // Keyed by the catalogs' content (`next.config.ts`), not the + // release: the service worker serves this URL cache-first. + src={`/i18n/${initialLocale}.js?v=${process.env.NEXT_PUBLIC_I18N_CATALOG_VERSION || "dev"}`} /> From 78aa80b2eadaf20eab097508639e41eb8827df7a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 9 Oct 2026 13:13:33 +0200 Subject: [PATCH 5/8] refactor(timeline): word codes through typed key maps, never template keys A key built from a template, t(`timeline.zoom.${zoom}`), is invisible to the call-site coverage guard, so a code without its message would ship as the raw key and the gate stays green. The timeline and the day view now word every code family (lanes, zooms, legends, readiness rows, life event categories, precisions and errors, document kinds, notables, cycle flow, allergy severity, screener instruments and bands, sports, functional impact) through maps typed over the code's own union, so a new code does not compile without its key. Three checks hold it: one resolves every map value in all seven bundles and pins the band and sport maps to their sources, one forbids template keys in both trees (the one existence-checked readiness lookup is named), and one renders the whole timeline page at every zoom and every bucket in two languages and fails on any message key in the visible text. --- src/components/day/day-sections.tsx | 76 +++++---- src/components/day/label-keys.ts | 99 +++++++++++ .../timeline/__tests__/label-keys.test.ts | 78 +++++++++ .../__tests__/no-template-key-guard.test.ts | 64 +++++++ .../__tests__/timeline-view-keys.test.tsx | 122 +++++++++++++ src/components/timeline/item-words.ts | 22 ++- src/components/timeline/label-keys.ts | 160 ++++++++++++++++++ src/components/timeline/life-event-draft.ts | 11 +- src/components/timeline/life-event-form.tsx | 11 +- .../timeline/readiness-inventory.tsx | 19 +-- src/components/timeline/timeline-chart.tsx | 5 +- .../timeline/timeline-chronicle.tsx | 3 +- src/components/timeline/timeline-menus.tsx | 4 +- src/components/timeline/timeline-view.tsx | 18 +- 14 files changed, 627 insertions(+), 65 deletions(-) create mode 100644 src/components/day/label-keys.ts create mode 100644 src/components/timeline/__tests__/label-keys.test.ts create mode 100644 src/components/timeline/__tests__/no-template-key-guard.test.ts create mode 100644 src/components/timeline/__tests__/timeline-view-keys.test.tsx create mode 100644 src/components/timeline/label-keys.ts diff --git a/src/components/day/day-sections.tsx b/src/components/day/day-sections.tsx index be3c18f70..22419defa 100644 --- a/src/components/day/day-sections.tsx +++ b/src/components/day/day-sections.tsx @@ -32,6 +32,11 @@ import { type DayValue, } from "@/lib/day/contract"; import { visitKindWords } from "@/components/timeline/item-words"; +import { + DOCUMENT_KIND_KEY, + LIFE_EVENT_CATEGORY_KEY, + keyOf, +} from "@/components/timeline/label-keys"; import { useFormatters, useTranslations } from "@/lib/i18n/context"; import { MOOD_LABEL_KEYS } from "@/lib/mood/labels"; import { cn } from "@/lib/utils"; @@ -43,6 +48,15 @@ import { tileKeyOf, type DayValueTile, } from "./day-values-model"; +import { + ALLERGY_SEVERITY_KEY, + ASSESSMENT_BAND_KEY, + ASSESSMENT_INSTRUMENT_KEY, + CYCLE_FLOW_KEY, + DAY_NOTABLE_KEY, + ILLNESS_IMPACT_KEY, + WORKOUT_SPORT_KEY, +} from "./label-keys"; import { useDayValueFormat } from "./use-day-value-format"; /** @@ -113,7 +127,7 @@ export function useNotableText() { timeZone: "UTC", }).format(new Date(`${since}T12:00:00.000Z`)); } - return t(`day.notable.${notable.kind}`, params); + return t(DAY_NOTABLE_KEY[notable.kind], params); }; } @@ -167,12 +181,6 @@ type Translate = ( params?: Record, ) => string; -/** The bundle's wording of `key`, or null when the bundle has none. */ -function known(t: Translate, key: string): string | null { - const value = t(key); - return value === key ? null : value; -} - /** * The server sends what the record holds and leaves the words to the reader's * language: a cycle is titled "cycle" and a trip "travel", a mood entry @@ -206,21 +214,21 @@ export function dayTitle(kind: string, title: string, t: Translate): string { return t("day.event.illnessResolved", { label: title }); case "mood": { const key = MOOD_LABEL_KEYS[title]; - return (key ? known(t, key) : null) ?? t("nav.mood"); + return key ? t(key) : t("nav.mood"); + } + case "assessment": { + const key = keyOf(ASSESSMENT_INSTRUMENT_KEY, title); + return key ? t(key) : title; + } + case "workout": { + const key = keyOf(WORKOUT_SPORT_KEY, title); + return key ? t(key) : title; } - case "assessment": - return ( - known(t, `mentalHealth.instrument.${title.toLowerCase()}`) ?? title - ); - case "workout": - return known(t, `insights.workouts.sport.${title}`) ?? title; default: return title; } } -const FLOW_LEVELS = new Set(["NONE", "SPOTTING", "LIGHT", "MEDIUM", "HEAVY"]); - /** * A row's second line. Where it is a code (a category, a kind, a severity, a * band) or a bare number (an intensity, a dose number, a functional impact), @@ -234,26 +242,32 @@ export function dayMeta( title = "", ): string | null { switch (kind) { - case "cycleDayLog": - return FLOW_LEVELS.has(meta) - ? `${t("cycle.flow.label")}: ${t(`cycle.flow.${meta}`)}` - : null; - case "lifeEvent": - return known(t, `lifeEvents.category.${meta}`); + case "cycleDayLog": { + const key = keyOf(CYCLE_FLOW_KEY, meta); + return key ? `${t("cycle.flow.label")}: ${t(key)}` : null; + } + case "lifeEvent": { + const key = keyOf(LIFE_EVENT_CATEGORY_KEY, meta); + return key ? t(key) : null; + } // The mood is the title; its score says the same thing again. case "mood": return null; case "assessment": { const [score, band] = meta.split(" "); - const bandText = band - ? known(t, `mentalHealth.band.${title}.${band}`) + const bands = keyOf(ASSESSMENT_INSTRUMENT_KEY, title) + ? ASSESSMENT_BAND_KEY[title as keyof typeof ASSESSMENT_BAND_KEY] : null; + const bandKey = bands && band ? keyOf(bands, band) : undefined; + const bandText = bandKey ? t(bandKey) : null; return [score, bandText].filter(Boolean).join(" · ") || null; } case "symptom": return t("symptoms.intensityPill", { value: meta }); - case "allergyOnset": - return known(t, `records.allergies.severity.${meta}`); + case "allergyOnset": { + const key = keyOf(ALLERGY_SEVERITY_KEY, meta); + return key ? t(key) : null; + } case "visit": case "procedure": return meta === "PLANNED" @@ -262,11 +276,13 @@ export function dayMeta( case "vaccination": return t("vaccinations.series.doseN", { position: meta }); case "illnessDayLog": { - const impact = known(t, `illness.impact.${meta}`); - return impact ? t("illness.timeline.impact", { impact }) : null; + const key = keyOf(ILLNESS_IMPACT_KEY, meta); + return key ? t("illness.timeline.impact", { impact: t(key) }) : null; + } + case "document": { + const key = keyOf(DOCUMENT_KIND_KEY, meta); + return key ? t(key) : null; } - case "document": - return known(t, `documents.kind.${meta}`); default: return meta; } diff --git a/src/components/day/label-keys.ts b/src/components/day/label-keys.ts new file mode 100644 index 000000000..237fc520c --- /dev/null +++ b/src/components/day/label-keys.ts @@ -0,0 +1,99 @@ +/** + * The message key of every code the day view words (v1.42, #613). + * + * Maps rather than template keys, for the reason in + * `components/timeline/label-keys.ts`: each is typed over the code's own + * union, and `label-keys.test.ts` resolves every value against the bundle. + */ +import type { AllergySeverity, FlowLevel } from "@/generated/prisma/client"; +import type { DayNotableKind } from "@/lib/day/contract"; +import type { InstrumentId } from "@/lib/mental-health/instruments"; +import type { WorkoutSportType } from "@/lib/validations/workout"; + +export const DAY_NOTABLE_KEY: Readonly> = { + extremeHigh: "day.notable.extremeHigh", + extremeLow: "day.notable.extremeLow", + firstValue: "day.notable.firstValue", + gap: "day.notable.gap", +}; + +export const CYCLE_FLOW_KEY: Readonly> = { + NONE: "cycle.flow.NONE", + SPOTTING: "cycle.flow.SPOTTING", + LIGHT: "cycle.flow.LIGHT", + MEDIUM: "cycle.flow.MEDIUM", + HEAVY: "cycle.flow.HEAVY", +}; + +export const ALLERGY_SEVERITY_KEY: Readonly> = { + MILD: "records.allergies.severity.MILD", + MODERATE: "records.allergies.severity.MODERATE", + SEVERE: "records.allergies.severity.SEVERE", +}; + +export const ASSESSMENT_INSTRUMENT_KEY: Readonly> = + { + PHQ9: "mentalHealth.instrument.phq9", + GAD7: "mentalHealth.instrument.gad7", + WHO5: "mentalHealth.instrument.who5", + SCI: "mentalHealth.instrument.sci", + }; + +/** Each screener's bands (`instruments.ts`), by the band key it stores. */ +export const ASSESSMENT_BAND_KEY: Readonly< + Record>> +> = { + PHQ9: { + minimal: "mentalHealth.band.PHQ9.minimal", + mild: "mentalHealth.band.PHQ9.mild", + moderate: "mentalHealth.band.PHQ9.moderate", + modSevere: "mentalHealth.band.PHQ9.modSevere", + severe: "mentalHealth.band.PHQ9.severe", + }, + GAD7: { + minimal: "mentalHealth.band.GAD7.minimal", + mild: "mentalHealth.band.GAD7.mild", + moderate: "mentalHealth.band.GAD7.moderate", + severe: "mentalHealth.band.GAD7.severe", + }, + WHO5: { + low: "mentalHealth.band.WHO5.low", + good: "mentalHealth.band.WHO5.good", + }, + SCI: { + belowThreshold: "mentalHealth.band.SCI.belowThreshold", + aboveThreshold: "mentalHealth.band.SCI.aboveThreshold", + }, +}; + +export const WORKOUT_SPORT_KEY: Readonly> = { + walking: "insights.workouts.sport.walking", + running: "insights.workouts.sport.running", + cycling: "insights.workouts.sport.cycling", + hiking: "insights.workouts.sport.hiking", + swimming: "insights.workouts.sport.swimming", + rowing: "insights.workouts.sport.rowing", + elliptical: "insights.workouts.sport.elliptical", + stairClimber: "insights.workouts.sport.stairClimber", + yoga: "insights.workouts.sport.yoga", + mindAndBody: "insights.workouts.sport.mindAndBody", + strength: "insights.workouts.sport.strength", + hiit: "insights.workouts.sport.hiit", + dance: "insights.workouts.sport.dance", + golf: "insights.workouts.sport.golf", + badminton: "insights.workouts.sport.badminton", + tennis: "insights.workouts.sport.tennis", + basketball: "insights.workouts.sport.basketball", + soccer: "insights.workouts.sport.soccer", + crossTraining: "insights.workouts.sport.crossTraining", + mixedCardio: "insights.workouts.sport.mixedCardio", + other: "insights.workouts.sport.other", +}; + +/** An illness day's functional impact, 0 (fully functional) to 3. */ +export const ILLNESS_IMPACT_KEY: Readonly> = { + "0": "illness.impact.0", + "1": "illness.impact.1", + "2": "illness.impact.2", + "3": "illness.impact.3", +}; diff --git a/src/components/timeline/__tests__/label-keys.test.ts b/src/components/timeline/__tests__/label-keys.test.ts new file mode 100644 index 000000000..651bcad93 --- /dev/null +++ b/src/components/timeline/__tests__/label-keys.test.ts @@ -0,0 +1,78 @@ +/** + * v1.42 (#613) — every key the timeline and the day view word a code with + * exists. The maps in `label-keys.ts` replaced template keys the call-site + * coverage guard could not read; this walks their values instead, so a map + * entry without its message fails here rather than on a screen. + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +import * as dayKeys from "@/components/day/label-keys"; +import { INSTRUMENTS, INSTRUMENT_ORDER } from "@/lib/mental-health/instruments"; +import { workoutSportTypeEnum } from "@/lib/validations/workout"; + +import * as timelineKeys from "../label-keys"; + +const LOCALES = ["de", "en", "es", "fr", "it", "ko", "pl"] as const; + +const bundles = Object.fromEntries( + LOCALES.map((l) => [ + l, + JSON.parse( + readFileSync(join(process.cwd(), "messages", `${l}.json`), "utf8"), + ), + ]), +); + +function leaf(bundle: unknown, key: string): unknown { + return key + .split(".") + .reduce( + (o, part) => + o && typeof o === "object" + ? (o as Record)[part] + : undefined, + bundle, + ); +} + +/** Every string value in a map, maps of maps included, with its path. */ +function values(map: unknown, path: string): Array<[string, string]> { + if (typeof map === "string") return [[path, map]]; + if (!map || typeof map !== "object") return []; + return Object.entries(map).flatMap(([k, v]) => values(v, `${path}.${k}`)); +} + +const MAPS = [ + ...Object.entries(timelineKeys), + ...Object.entries(dayKeys), +].filter(([, v]) => typeof v === "object"); + +describe("label key maps", () => { + it("finds the maps it is meant to check", () => { + expect(MAPS.length).toBeGreaterThanOrEqual(18); + }); + + it.each(LOCALES)("resolves every key in %s", (locale) => { + const missing = MAPS.flatMap(([name, map]) => values(map, name)).filter( + ([, key]) => typeof leaf(bundles[locale], key) !== "string", + ); + expect(missing).toEqual([]); + }); + + it("names every band each screener stores", () => { + for (const id of INSTRUMENT_ORDER) { + expect(Object.keys(dayKeys.ASSESSMENT_BAND_KEY[id]).sort()).toEqual( + INSTRUMENTS[id].bands.map((b) => b.key).sort(), + ); + } + }); + + it("names every sport a workout can carry", () => { + expect(Object.keys(dayKeys.WORKOUT_SPORT_KEY).sort()).toEqual( + [...workoutSportTypeEnum.options].sort(), + ); + }); +}); diff --git a/src/components/timeline/__tests__/no-template-key-guard.test.ts b/src/components/timeline/__tests__/no-template-key-guard.test.ts new file mode 100644 index 000000000..f9661ba6e --- /dev/null +++ b/src/components/timeline/__tests__/no-template-key-guard.test.ts @@ -0,0 +1,64 @@ +/** + * v1.42 (#613) — no template message keys in the timeline or the day view. + * + * `t(\`timeline.zoom.${zoom}\`)` reads fine and is invisible to + * `i18n-call-site-coverage.test.ts`, which only resolves literal keys. A + * value without its key then ships as the key's raw name. These two trees + * word codes through the typed maps in `label-keys.ts` instead, which + * `label-keys.test.ts` resolves value by value; this keeps the template form + * from coming back. + * + * One call stays, by name: `wordKey` words the readiness keys the server + * sends (a detail, a gap), and it asks the bundle whether the key exists + * before it uses it, so a key the bundle lacks is left out, never shown. + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { + stripComments, + walkSourceFiles, +} from "@/__tests__/helpers/source-files"; + +const ROOTS = ["src/components/timeline", "src/components/day"] as const; + +/** `t(` or `tCount(` whose key is a template literal, across line breaks. */ +const TEMPLATE_KEY = /(?> = { + "src/components/timeline/readiness-inventory.tsx": { + count: 1, + why: "wordKey checks the server-sent key exists before it words it", + }, +}; + +function templateCalls(): Map { + const out = new Map(); + for (const root of ROOTS) { + const dir = join(process.cwd(), root); + for (const rel of walkSourceFiles(dir, { floor: 10 })) { + if (rel.includes("__tests__")) continue; + const src = stripComments(readFileSync(join(dir, rel), "utf8")); + const count = [...src.matchAll(TEMPLATE_KEY)].length; + if (count > 0) out.set(`${root}/${rel}`, count); + } + } + return out; +} + +describe("template message keys", () => { + it("finds the one sanctioned call, so the matcher is live", () => { + expect( + templateCalls().get("src/components/timeline/readiness-inventory.tsx"), + ).toBe(1); + }); + + it("are not used in the timeline or the day view", () => { + const offenders = [...templateCalls()] + .filter(([file, count]) => ALLOWED[file]?.count !== count) + .map(([file, count]) => `${file}: ${count}`); + expect(offenders).toEqual([]); + }); +}); diff --git a/src/components/timeline/__tests__/timeline-view-keys.test.tsx b/src/components/timeline/__tests__/timeline-view-keys.test.tsx new file mode 100644 index 000000000..e5e372a19 --- /dev/null +++ b/src/components/timeline/__tests__/timeline-view-keys.test.tsx @@ -0,0 +1,122 @@ +/** + * v1.42 (#613) — the whole timeline page, at every zoom and every bucket, + * shows no message key. Rendered through SSR with the reads stubbed, in two + * languages: the zoom control, the legend, the selection bar's head and its + * "no value" means, the lanes table and the phone chronicle all come out of + * one render, so a key that only one zoom or one bucket reaches is reached + * here too. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +import { + TIMELINE_BUCKETS, + TIMELINE_ZOOMS, + type TimelineBucket, + type TimelineResponse, + type TimelineZoom, +} from "@/lib/day/contract"; +import { I18nProvider } from "@/lib/i18n/context"; + +import { TODAY, fullTimeline, readiness } from "./timeline-fixture"; + +let search = new URLSearchParams(); +let timeline: TimelineResponse = fullTimeline(); + +vi.mock("next/navigation", () => ({ + usePathname: () => "/timeline", + useSearchParams: () => search, + useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), +})); +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ + user: { id: "u1", timezone: "Europe/Berlin", modules: {} }, + }), +})); +vi.mock("@/hooks/use-record-capabilities", () => ({ + useRecordCapabilities: () => ({ inSharedRecord: false }), +})); +vi.mock("../use-timeline", () => ({ + useTimeline: () => ({ + data: timeline, + isPending: false, + isError: false, + refetch: vi.fn(), + }), + useTimelineReadiness: () => ({ + data: readiness(), + isPending: false, + isError: false, + }), + useLifeEvents: () => ({ data: { events: [] } }), + useLifeEventMutations: () => ({ + create: { isPending: false }, + update: { isPending: false }, + remove: { isPending: false }, + }), +})); +vi.mock("../timeline-dates", async (importOriginal) => { + const real = await importOriginal(); + return { ...real, todayKeyIn: () => TODAY }; +}); + +/** The text a reader sees: every character outside a tag, runs kept apart. */ +function textOf(html: string): string { + let out = ""; + let inTag = false; + for (const ch of html) { + if (ch === "<") inTag = true; + else if (ch === ">") { + inTag = false; + out += " "; + } else if (!inTag) out += ch; + } + return out; +} + +/** A message key as it would leak: a namespace, a dot, a name. */ +const RAW_KEY = + /\b(?:timeline|day|lifeEvents|documents|encounters|mentalHealth|insights|records|cycle|illness|charts|measurements)\.[a-zA-Z][\w.]*/g; + +async function renderView( + zoom: TimelineZoom, + bucket: TimelineBucket, + locale: "de" | "en", +) { + search = new URLSearchParams({ zoom }); + if (zoom === "range") { + search.set("from", "2025-10-01"); + search.set("to", "2026-01-31"); + } + timeline = { ...fullTimeline(), zoom, bucket }; + const { TimelineView } = await import("../timeline-view"); + return textOf( + renderToStaticMarkup( + + + + + , + ), + ); +} + +beforeEach(() => { + search = new URLSearchParams(); +}); + +describe("the timeline page shows no message key", () => { + for (const locale of ["de", "en"] as const) { + for (const zoom of TIMELINE_ZOOMS) { + for (const bucket of TIMELINE_BUCKETS) { + it(`${locale}, zoom ${zoom}, bucket ${bucket}`, async () => { + const text = await renderView(zoom, bucket, locale); + // The render reached the page, not a loading or error state. + expect(text.length).toBeGreaterThan(500); + expect([...new Set(text.match(RAW_KEY) ?? [])]).toEqual([]); + }); + } + } + } +}); diff --git a/src/components/timeline/item-words.ts b/src/components/timeline/item-words.ts index 55decc74d..b526dd18c 100644 --- a/src/components/timeline/item-words.ts +++ b/src/components/timeline/item-words.ts @@ -17,6 +17,12 @@ import { encounterKindText } from "@/components/encounters/encounter-labels"; import type { TimelineItem } from "@/lib/day/contract"; import { useTranslations } from "@/lib/i18n/context"; +import { + DOCUMENT_KIND_KEY, + LIFE_EVENT_CATEGORY_KEY, + keyOf, +} from "./label-keys"; + type Translate = ( key: string, params?: Record, @@ -36,10 +42,14 @@ export interface ItemWords { export type ItemWordsFn = (item: TimelineItem) => ItemWords; -/** The bundle's wording of `key`, or null when the bundle has none. */ -function known(t: Translate, key: string): string | null { - const value = t(key); - return value === key ? null : value; +/** A code worded through `map`, or null for none or a code it lacks. */ +function worded( + t: Translate, + map: Readonly>, + code: string | null, +): string | null { + const key = code === null ? undefined : keyOf(map, code); + return key ? t(key) : null; } const ENCOUNTER_KIND_CODES: ReadonlySet = new Set([ @@ -70,7 +80,7 @@ export function makeItemWords( case "lifeEvent": return { label: item.label, - sub: item.sub ? known(t, `lifeEvents.category.${item.sub}`) : null, + sub: worded(t, LIFE_EVENT_CATEGORY_KEY, item.sub), }; case "travel": return { label: t("day.travel"), sub: null }; @@ -102,7 +112,7 @@ export function makeItemWords( case "document": return { label: item.label, - sub: item.sub ? known(t, `documents.kind.${item.sub}`) : null, + sub: worded(t, DOCUMENT_KIND_KEY, item.sub), }; default: // A dose, a dose change, a name: the record's own text. diff --git a/src/components/timeline/label-keys.ts b/src/components/timeline/label-keys.ts new file mode 100644 index 000000000..f4de96e35 --- /dev/null +++ b/src/components/timeline/label-keys.ts @@ -0,0 +1,160 @@ +/** + * The message key of every code the timeline words (v1.42, #613). + * + * A key built from a template (`t(\`timeline.zoom.${zoom}\`)`) is invisible to + * `i18n-call-site-coverage.test.ts`, so a value without its key shipped as + * the raw key and nothing in the gate noticed. Each code family is a map + * here instead: typed over the code's own union, so a new code does not + * compile until it has a key, and walked by `label-keys.test.ts`, which + * resolves every value against the bundle. `no-template-key-guard.test.ts` + * keeps the template form out of the timeline and the day view. + */ +import type { + DayNotableKind, + LifeEventCategory, + LifeEventPrecision, + TimelineBucket, + TimelineLaneKey, + TimelineReadinessKey, + TimelineReadinessStatus, + TimelineReadinessVerdict, + TimelineZoom, +} from "@/lib/day/contract"; +import type { InboundDocumentKindValue } from "@/lib/validations/inbound-documents"; + +import type { LifeEventDraftError } from "./life-event-draft"; + +export const TIMELINE_LANE_LABEL_KEY: Readonly< + Record +> = { + life: "timeline.lanes.life", + illness: "timeline.lanes.illness", + allergies: "timeline.lanes.allergies", + medications: "timeline.lanes.medications", + vaccinations: "timeline.lanes.vaccinations", + visits: "timeline.lanes.visits", + labs: "timeline.lanes.labs", + documents: "timeline.lanes.documents", + cycle: "timeline.lanes.cycle", +}; + +export const TIMELINE_ZOOM_LABEL_KEY: Readonly> = { + all: "timeline.zoom.all", + year: "timeline.zoom.year", + quarter: "timeline.zoom.quarter", + range: "timeline.zoom.range", +}; + +export const TIMELINE_LEGEND_MEAN_KEY: Readonly< + Record +> = { + quarter: "timeline.legendMeanQuarter", + month: "timeline.legendMeanMonth", + week: "timeline.legendMeanWeek", + day: "timeline.legendMeanDay", +}; + +/** The two blood pressure lines read as their own names on the chart. */ +export const TIMELINE_BLOOD_PRESSURE_SERIES_KEY: Readonly< + Record<"BLOOD_PRESSURE_SYS" | "BLOOD_PRESSURE_DIA", string> +> = { + BLOOD_PRESSURE_SYS: "timeline.values.series.BLOOD_PRESSURE_SYS", + BLOOD_PRESSURE_DIA: "timeline.values.series.BLOOD_PRESSURE_DIA", +}; + +export const TIMELINE_CHRONICLE_NOTABLE_KEY: Readonly< + Record +> = { + extremeHigh: "timeline.chronicle.notable.extremeHigh", + extremeLow: "timeline.chronicle.notable.extremeLow", + firstValue: "timeline.chronicle.notable.firstValue", + gap: "timeline.chronicle.notable.gap", +}; + +/** A readiness row's name: the lane's own, or the inventory's for the rest. */ +export const TIMELINE_READINESS_LANE_KEY: Readonly< + Record +> = { + values: "timeline.readiness.lanes.values", + mood: "timeline.readiness.lanes.mood", + environment: "timeline.readiness.lanes.environment", + life: "timeline.readiness.lanes.life", + illness: "timeline.lanes.illness", + allergies: "timeline.lanes.allergies", + medications: "timeline.lanes.medications", + vaccinations: "timeline.lanes.vaccinations", + visits: "timeline.lanes.visits", + labs: "timeline.lanes.labs", + documents: "timeline.lanes.documents", + cycle: "timeline.lanes.cycle", +}; + +export const TIMELINE_READINESS_VERDICT_KEY: Readonly< + Record +> = { + carries: "timeline.readiness.verdict.carries", + thin: "timeline.readiness.verdict.thin", +}; + +export const TIMELINE_READINESS_STATUS_KEY: Readonly< + Record +> = { + carries: "timeline.readiness.status.carries", + thin: "timeline.readiness.status.thin", + empty: "timeline.readiness.status.empty", +}; + +export const LIFE_EVENT_CATEGORY_KEY: Readonly< + Record +> = { + FAMILY: "lifeEvents.category.FAMILY", + HOME: "lifeEvents.category.HOME", + WORK: "lifeEvents.category.WORK", + LOSS: "lifeEvents.category.LOSS", + OTHER: "lifeEvents.category.OTHER", +}; + +export const LIFE_EVENT_PRECISION_KEY: Readonly< + Record +> = { + DAY: "lifeEvents.precision.DAY", + MONTH: "lifeEvents.precision.MONTH", + YEAR: "lifeEvents.precision.YEAR", +}; + +export const LIFE_EVENT_ERROR_KEY: Readonly< + Record +> = { + titleRequired: "lifeEvents.errors.titleRequired", + titleTooLong: "lifeEvents.errors.titleTooLong", + categoryRequired: "lifeEvents.errors.categoryRequired", + dateRequired: "lifeEvents.errors.dateRequired", + endBeforeStart: "lifeEvents.errors.endBeforeStart", + noteTooLong: "lifeEvents.errors.noteTooLong", +}; + +export const DOCUMENT_KIND_KEY: Readonly< + Record +> = { + DOCTOR_REPORT: "documents.kind.DOCTOR_REPORT", + DISCHARGE_LETTER: "documents.kind.DISCHARGE_LETTER", + LAB_RESULT: "documents.kind.LAB_RESULT", + IMAGING: "documents.kind.IMAGING", + PRESCRIPTION: "documents.kind.PRESCRIPTION", + REFERRAL: "documents.kind.REFERRAL", + INSURANCE: "documents.kind.INSURANCE", + VACCINATION: "documents.kind.VACCINATION", + SICK_NOTE: "documents.kind.SICK_NOTE", + OTHER: "documents.kind.OTHER", +}; + +/** + * `map[code]` for a code that arrived as a plain string: the key, or + * undefined for a code the map does not hold. + */ +export function keyOf( + map: Readonly>, + code: string, +): string | undefined { + return Object.hasOwn(map, code) ? map[code as K] : undefined; +} diff --git a/src/components/timeline/life-event-draft.ts b/src/components/timeline/life-event-draft.ts index d2514303e..12954fe9c 100644 --- a/src/components/timeline/life-event-draft.ts +++ b/src/components/timeline/life-event-draft.ts @@ -32,8 +32,17 @@ export interface LifeEventDraft { note: string; } +/** What a draft can be refused for, each worded under `lifeEvents.errors`. */ +export type LifeEventDraftError = + | "titleRequired" + | "titleTooLong" + | "categoryRequired" + | "dateRequired" + | "endBeforeStart" + | "noteTooLong"; + export type LifeEventDraftErrors = Partial< - Record<"title" | "category" | "start" | "end" | "note", string> + Record<"title" | "category" | "start" | "end" | "note", LifeEventDraftError> >; /** Snap a date to the first day of its month or year. */ diff --git a/src/components/timeline/life-event-form.tsx b/src/components/timeline/life-event-form.tsx index 203ce7000..e329b5930 100644 --- a/src/components/timeline/life-event-form.tsx +++ b/src/components/timeline/life-event-form.tsx @@ -47,6 +47,11 @@ import { type LifeEventDraft, type LifeEventDraftErrors, } from "./life-event-draft"; +import { + LIFE_EVENT_CATEGORY_KEY, + LIFE_EVENT_ERROR_KEY, + LIFE_EVENT_PRECISION_KEY, +} from "./label-keys"; import { Segmented } from "./segmented"; import { formatMonthLong, todayKeyIn } from "./timeline-dates"; import { useLifeEventMutations } from "./use-timeline"; @@ -105,7 +110,7 @@ function CategoryChips({ : "border-border text-foreground hover:bg-accent", )} > - {t(`lifeEvents.category.${category}`)} + {t(LIFE_EVENT_CATEGORY_KEY[category])} ); })} @@ -227,7 +232,7 @@ export function LifeEventForm({ value: LifeEventDraft[K], ) => setDraft((d) => ({ ...d, [key]: value })); const fieldError = (field: keyof LifeEventDraftErrors) => - errors[field] ? t(`lifeEvents.errors.${errors[field]}`) : undefined; + errors[field] ? t(LIFE_EVENT_ERROR_KEY[errors[field]]) : undefined; async function submit(e: React.FormEvent) { e.preventDefault(); @@ -381,7 +386,7 @@ export function LifeEventForm({ ({ value: p, - label: t(`lifeEvents.precision.${p}`), + label: t(LIFE_EVENT_PRECISION_KEY[p]), }))} value={draft.precision} onChange={(p) => setDraft((d) => withPrecision(d, p))} diff --git a/src/components/timeline/readiness-inventory.tsx b/src/components/timeline/readiness-inventory.tsx index b9486285f..68c27df50 100644 --- a/src/components/timeline/readiness-inventory.tsx +++ b/src/components/timeline/readiness-inventory.tsx @@ -27,6 +27,11 @@ import { useTranslations } from "@/lib/i18n/context"; import { isCalendarDateKey } from "@/lib/tz/date-only"; import { cn } from "@/lib/utils"; +import { + TIMELINE_READINESS_LANE_KEY, + TIMELINE_READINESS_STATUS_KEY, + TIMELINE_READINESS_VERDICT_KEY, +} from "./label-keys"; import { formatMonthYear } from "./timeline-dates"; import { orderedReadinessLanes, readinessTally } from "./readiness-model"; @@ -68,15 +73,7 @@ export function wordKey( } export function laneLabel(t: Translate, key: TimelineReadinessLane["key"]) { - if ( - key === "values" || - key === "mood" || - key === "environment" || - key === "life" - ) { - return t(`timeline.readiness.lanes.${key}`); - } - return t(`timeline.lanes.${key}`); + return t(TIMELINE_READINESS_LANE_KEY[key]); } const STATUS_ICON: Record = { @@ -119,7 +116,7 @@ export function VerdictMeter({

    - {t(`timeline.readiness.verdict.${readiness.verdict}`)} + {t(TIMELINE_READINESS_VERDICT_KEY[readiness.verdict])} {readiness.verdict === "carries" && ( <> @@ -178,7 +175,7 @@ export function ReadinessInventory({ >

    diff --git a/src/components/timeline/timeline-chart.tsx b/src/components/timeline/timeline-chart.tsx index 5d9045f36..2004eb12d 100644 --- a/src/components/timeline/timeline-chart.tsx +++ b/src/components/timeline/timeline-chart.tsx @@ -52,6 +52,7 @@ import { resolveIntlLocale } from "@/lib/format-locale"; import { useTranslations } from "@/lib/i18n/context"; import { useItemWords, type ItemWordsFn } from "./item-words"; +import { TIMELINE_LANE_LABEL_KEY } from "./label-keys"; import { bucketAfter, bucketStart, @@ -423,7 +424,7 @@ function ChartBody({ fontWeight={500} fill="var(--foreground)" > - {fitText(t(`timeline.lanes.${lane.key}`), scale.x0 - 30, 13)} + {fitText(t(TIMELINE_LANE_LABEL_KEY[lane.key]), scale.x0 - 30, 13)} {lane.spans.map((span) => ( {rows.map(({ lane, item }) => ( - {t(`timeline.lanes.${lane}`)} + {t(TIMELINE_LANE_LABEL_KEY[lane])} {[ words(item).label, diff --git a/src/components/timeline/timeline-chronicle.tsx b/src/components/timeline/timeline-chronicle.tsx index 6ada2fc9c..df4bcc51f 100644 --- a/src/components/timeline/timeline-chronicle.tsx +++ b/src/components/timeline/timeline-chronicle.tsx @@ -42,6 +42,7 @@ import { type ChronicleRails, } from "./chronicle-model"; import { useItemWords } from "./item-words"; +import { TIMELINE_CHRONICLE_NOTABLE_KEY } from "./label-keys"; import { formatAtPrecision, formatDayMonth, @@ -182,7 +183,7 @@ export function TimelineChronicle({ } { if (entry.kind === "notable") { return { - title: t(`timeline.chronicle.notable.${entry.notable}`), + title: t(TIMELINE_CHRONICLE_NOTABLE_KEY[entry.notable]), meta: t("timeline.chronicle.notableMeta"), }; } diff --git a/src/components/timeline/timeline-menus.tsx b/src/components/timeline/timeline-menus.tsx index b94cdd571..bad95fb11 100644 --- a/src/components/timeline/timeline-menus.tsx +++ b/src/components/timeline/timeline-menus.tsx @@ -20,6 +20,8 @@ import { import { TIMELINE_MAX_SERIES, type TimelineLaneKey } from "@/lib/day/contract"; import { useTranslations } from "@/lib/i18n/context"; +import { TIMELINE_LANE_LABEL_KEY } from "./label-keys"; + /** The most value lines the chart runs under its lanes. */ export const MAX_VALUE_SERIES = TIMELINE_MAX_SERIES; @@ -131,7 +133,7 @@ export function LayersMenu({ onSelect={(event) => event.preventDefault()} onCheckedChange={(next) => onToggle(lane, next === true)} > - {t(`timeline.lanes.${lane}`)} + {t(TIMELINE_LANE_LABEL_KEY[lane])} ))} diff --git a/src/components/timeline/timeline-view.tsx b/src/components/timeline/timeline-view.tsx index 99962904f..a3de9ffb2 100644 --- a/src/components/timeline/timeline-view.tsx +++ b/src/components/timeline/timeline-view.tsx @@ -45,6 +45,11 @@ import { MEASUREMENT_TYPE_LABEL_KEYS } from "@/lib/measurements/type-label-keys" import type { ModuleKey } from "@/lib/modules/registry"; import type { ChronicleGrouping } from "./chronicle-model"; +import { + TIMELINE_BLOOD_PRESSURE_SERIES_KEY, + TIMELINE_LEGEND_MEAN_KEY, + TIMELINE_ZOOM_LABEL_KEY, +} from "./label-keys"; import { LifeEventSheet } from "./life-event-sheet"; import { ReadinessCard } from "./readiness-card"; import { @@ -284,7 +289,7 @@ export function TimelineView() { // "Dia") lean on a column header the chart does not have. const seriesLabel = (key: string) => key === "BLOOD_PRESSURE_SYS" || key === "BLOOD_PRESSURE_DIA" - ? t(`timeline.values.series.${key}`) + ? t(TIMELINE_BLOOD_PRESSURE_SERIES_KEY[key]) : key === "MOOD" ? t("timeline.readiness.lanes.mood") : t(MEASUREMENT_TYPE_LABEL_KEYS[key as MeasurementType] ?? key); @@ -309,7 +314,7 @@ export function TimelineView() { const zoomOptions = TIMELINE_ZOOMS.map((z) => ({ value: z, - label: t(`timeline.zoom.${z}`), + label: t(TIMELINE_ZOOM_LABEL_KEY[z]), })); const groupingOptions: Array<{ value: ChronicleGrouping; label: string }> = [ { value: "year", label: t("timeline.chronicle.years") }, @@ -544,13 +549,6 @@ export function TimelineView() { ); } -const LEGEND_MEAN_KEY: Readonly> = { - quarter: "timeline.legendMeanQuarter", - month: "timeline.legendMeanMonth", - week: "timeline.legendMeanWeek", - day: "timeline.legendMeanDay", -}; - function Legend({ bucket, hasSeries, @@ -586,7 +584,7 @@ function Legend({ data-bucket={bucket} > Date: Fri, 9 Oct 2026 13:18:08 +0200 Subject: [PATCH 6/8] test(timeline): state the template-key guard's floor on what it found --- .../timeline/__tests__/no-template-key-guard.test.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/components/timeline/__tests__/no-template-key-guard.test.ts b/src/components/timeline/__tests__/no-template-key-guard.test.ts index f9661ba6e..7c460c90c 100644 --- a/src/components/timeline/__tests__/no-template-key-guard.test.ts +++ b/src/components/timeline/__tests__/no-template-key-guard.test.ts @@ -50,9 +50,11 @@ function templateCalls(): Map { describe("template message keys", () => { it("finds the one sanctioned call, so the matcher is live", () => { - expect( - templateCalls().get("src/components/timeline/readiness-inventory.tsx"), - ).toBe(1); + const calls = templateCalls(); + expect(calls.size).toBeGreaterThanOrEqual(1); + expect(calls.get("src/components/timeline/readiness-inventory.tsx")).toBe( + 1, + ); }); it("are not used in the timeline or the day view", () => { From 59b072750a98032d88a76314de9078d508cefccf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 9 Oct 2026 13:29:29 +0200 Subject: [PATCH 7/8] test(timeline): load the page once before rendering every zoom --- .../timeline/__tests__/timeline-view-keys.test.tsx | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/components/timeline/__tests__/timeline-view-keys.test.tsx b/src/components/timeline/__tests__/timeline-view-keys.test.tsx index e5e372a19..464d108d3 100644 --- a/src/components/timeline/__tests__/timeline-view-keys.test.tsx +++ b/src/components/timeline/__tests__/timeline-view-keys.test.tsx @@ -6,7 +6,7 @@ * one render, so a key that only one zoom or one bucket reaches is reached * here too. */ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { renderToStaticMarkup } from "react-dom/server"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; @@ -90,7 +90,6 @@ async function renderView( search.set("to", "2026-01-31"); } timeline = { ...fullTimeline(), zoom, bucket }; - const { TimelineView } = await import("../timeline-view"); return textOf( renderToStaticMarkup( @@ -102,6 +101,14 @@ async function renderView( ); } +let TimelineView: typeof import("../timeline-view").TimelineView; + +// The page's module graph is large; load it once, outside the per-test +// timeout, so a busy machine does not time out the first render. +beforeAll(async () => { + ({ TimelineView } = await import("../timeline-view")); +}, 60_000); + beforeEach(() => { search = new URLSearchParams(); }); From bde6880c3e0491d79823906b0aeb9cee854cd28d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 9 Oct 2026 14:11:46 +0200 Subject: [PATCH 8/8] fix(timeline): read the sleep line as time asleep per night Sleep is stored one row per stage, and the timeline read it like any other type: the live path took the mean of a day's rows and the long path the rolled-up month of them, so IN_BED, AWAKE and every CORE fragment counted as a night, and a quarter of seven-hour nights read as under two hours. The sleep series now reconstructs nights the way the sleep page and the day view do (CORE + DEEP + REM of the night's winning writer, keyed by its wake day) and averages the bucket's nights, on every window length; the rollups are not read for it. The stage rows are read a year at a time with a day's margin on either side, so no read holds a decade of rows and no night is cut where one read ends. A long window is cached per record like the other long series. --- src/lib/timeline/series.ts | 105 +++++++++++- .../integration/timeline-sleep-nights.test.ts | 162 ++++++++++++++++++ 2 files changed, 263 insertions(+), 4 deletions(-) create mode 100644 tests/integration/timeline-sleep-nights.test.ts diff --git a/src/lib/timeline/series.ts b/src/lib/timeline/series.ts index 942115a0f..dd85a67e7 100644 --- a/src/lib/timeline/series.ts +++ b/src/lib/timeline/series.ts @@ -20,7 +20,13 @@ * * Every value is the mean of the bucket's days, each day weighing one (a * cumulative type's days are its day totals, a pulse-like type's day is the - * mean of its hourly means, `daily-stats.ts`), matching the charts. Each + * mean of its hourly means, `daily-stats.ts`), matching the charts. Sleep is + * the exception to both sources: it is stored one row per stage, so a day + * mean or a rolled-up month would average stage fragments (and count an + * in-bed row beside the stages inside it). Its value is the night's time + * asleep as the sleep page reads it (`reconstructSleepNights`), one per + * night keyed by its wake day, and a bucket's mean is the mean of its + * nights. Each * point carries the readings behind it, so the chart can mark a mean that * rests on one or two. A bucket without a reading is not sent, and a series * without a point is not sent. `MOOD` is the mood score, read from the @@ -28,6 +34,7 @@ */ import type { MeasurementType } from "@/generated/prisma/enums"; +import { reconstructSleepNights } from "@/lib/analytics/sleep-night"; import { caches, cached } from "@/lib/cache/server-cache"; import { readLocalDailyCells, type DayCell } from "@/lib/day/daily-stats"; import type { TimelineBucket, TimelineSeries } from "@/lib/day/contract"; @@ -218,6 +225,93 @@ function readsRollups(from: string, to: string): boolean { return days >= LIVE_WINDOW_MAX_DAYS; } +/** Sleep is read a year of stage rows at a time, so no read holds more. */ +const SLEEP_CHUNK_DAYS = 366; + +/** + * Margin read around each chunk, so a night that crosses the chunk's edge is + * read whole in the chunk its wake day belongs to and not cut in two. + */ +const SLEEP_CHUNK_MARGIN_MS = 24 * 3_600_000; + +/** + * Time asleep per night for the nights that woke in `[from, to]` (local + * day keys), each a cell of one reading. The stage rows are read a year at + * a time and folded into nights before the next year is read, so a decade + * of nights never sits in memory as rows at once. + */ +export async function readNightCells(args: { + userId: string; + from: string; + to: string; + tz: string; + priorityJson: unknown; +}): Promise> { + const out = new Map(); + for ( + let chunkFrom = args.from; + chunkFrom <= args.to; + chunkFrom = shiftDateKey(chunkFrom, SLEEP_CHUNK_DAYS) + ) { + const next = shiftDateKey(chunkFrom, SLEEP_CHUNK_DAYS); + const chunkTo = next > args.to ? args.to : shiftDateKey(next, -1); + const start = startOfLocalDayKey(chunkFrom, args.tz); + const end = startOfLocalDayKey(shiftDateKey(chunkTo, 1), args.tz); + const rows = await prisma.measurement.findMany({ + where: { + userId: args.userId, + type: "SLEEP_DURATION", + deletedAt: null, + measuredAt: { + gte: new Date(start.getTime() - SLEEP_CHUNK_MARGIN_MS), + lt: new Date(end.getTime() + SLEEP_CHUNK_MARGIN_MS), + }, + }, + orderBy: { measuredAt: "asc" }, + select: { + value: true, + measuredAt: true, + sleepStage: true, + source: true, + deviceType: true, + }, + }); + for (const night of reconstructSleepNights( + rows, + args.tz, + args.priorityJson, + )) { + if (night.night < chunkFrom || night.night > chunkTo) continue; + if (night.asleepMinutes <= 0) continue; + out.set(night.night, { value: night.asleepMinutes, count: 1 }); + } + } + return out; +} + +/** The sleep series: the mean time asleep of each bucket's nights. */ +async function sleepPoints(args: { + userId: string; + from: string; + to: string; + tz: string; + bucket: TimelineBucket; + priorityJson: unknown; +}): Promise { + const read = async () => foldDays(await readNightCells(args), args.bucket); + if (!readsRollups(args.from, args.to)) return read(); + // A long window is cached under the record's prefix: every measurement + // write marks it stale with the rest of the analytics bucket. + const key = `${args.userId}|timeline-sleep|${args.bucket}|${args.tz}|${args.from}|${args.to}`; + return cached( + caches.analytics, + key, + read, + undefined, + PRE_FOLD_TTL_MS, + ) as Promise; +} + async function measurementSeries(args: { userId: string; type: MeasurementType; @@ -231,9 +325,12 @@ async function measurementSeries(args: { const start = startOfLocalDayKey(args.from, args.tz); const end = startOfLocalDayKey(shiftDateKey(args.to, 1), args.tz); let points: SeriesPoint[]; - // Weeks and days never read the rollups: their windows are short (a - // `day` range is under six weeks), and the rollups cut at UTC midnight. - if ( + // Sleep reads its nights at every length. Weeks and days never read the + // rollups: their windows are short (a `day` range is under six weeks), and + // the rollups cut at UTC midnight. + if (args.type === "SLEEP_DURATION") { + points = await sleepPoints(args); + } else if ( args.bucket !== "week" && args.bucket !== "day" && readsRollups(args.from, args.to) diff --git a/tests/integration/timeline-sleep-nights.test.ts b/tests/integration/timeline-sleep-nights.test.ts new file mode 100644 index 000000000..d6ee07a80 --- /dev/null +++ b/tests/integration/timeline-sleep-nights.test.ts @@ -0,0 +1,162 @@ +/** + * v1.42 — the timeline's sleep line is the time asleep per night, averaged + * over the bucket's nights. Against Postgres. + * + * Sleep is stored one row per stage. Read like any other type, the timeline + * averaged those rows as if each were a night (IN_BED, AWAKE and every CORE + * fragment alike), so a quarter of seven-hour nights read as a little over + * two and a half hours. A night here is what the sleep page and the day view + * call one: CORE + DEEP + REM of the night's winning writer, IN_BED and + * AWAKE left out, keyed by its wake day. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { getPrismaClient, truncateAllTables } from "./setup"; + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +const TZ = "Europe/Berlin"; +const NOW = new Date("2026-10-09T12:00:00.000Z"); + +let user = ""; +let seq = 0; + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + // A fresh record per test: the long windows are cached per record. + user = `timeline-sleep-${++seq}`; + await getPrismaClient().user.create({ + data: { + id: user, + username: user, + email: `${user}@example.test`, + timezone: TZ, + }, + }); +}); + +/** A Berlin wall-clock instant (summer and winter offsets both handled). */ +function berlin(day: string, hhmm: string): Date { + const guess = new Date(`${day}T${hhmm}:00.000Z`); + const wall = new Intl.DateTimeFormat("en-CA", { + timeZone: TZ, + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + hourCycle: "h23", + }).format(guess); + const [d, t] = wall.split(", "); + const shown = new Date(`${d}T${t}:00.000Z`); + return new Date(guess.getTime() - (shown.getTime() - guess.getTime())); +} + +/** + * One night that wakes on `wake`: in bed from 22:40 the evening before to + * 06:40, asleep 50 + 150 + 90 + 100 + `lastCore` minutes around one AWAKE + * spell, the first segment ending before midnight. Each row's instant is its + * segment's end, as the importers write it. + */ +async function seedNight(wake: string, lastCore: number) { + const { shiftDateKey } = await import("@/lib/tz/format"); + const eve = shiftDateKey(wake, -1); + const rows: Array<[string, number, string, string]> = [ + ["IN_BED", 480, wake, "06:40"], + ["CORE", 50, eve, "23:50"], + ["CORE", 150, wake, "02:20"], + ["DEEP", 90, wake, "03:50"], + ["REM", 100, wake, "05:30"], + ["AWAKE", 20, wake, "05:50"], + ["CORE", lastCore, wake, "06:30"], + ]; + await getPrismaClient().measurement.createMany({ + data: rows.map(([stage, minutes, day, end], i) => ({ + userId: user, + type: "SLEEP_DURATION" as const, + value: minutes, + unit: "minutes", + source: "APPLE_HEALTH" as const, + measuredAt: berlin(day, end), + externalId: `uuid-${wake}-${i}`, + sleepStage: stage as "IN_BED" | "CORE" | "DEEP" | "REM" | "AWAKE", + })), + }); +} + +async function sleepSeries( + from: string, + to: string, + bucket: "month" | "quarter", +) { + const { loadTimelineSeries } = await import("@/lib/timeline/series"); + const [out] = await loadTimelineSeries({ + userId: user, + keys: ["SLEEP_DURATION"], + from, + to, + tz: TZ, + bucket, + priorityJson: null, + now: NOW, + }); + return out?.points ?? []; +} + +describe("the timeline's sleep line", () => { + it("averages the nights of a quarter, each night its time asleep", async () => { + await seedNight("2026-07-03", 40); // 430 min asleep + await seedNight("2026-08-12", 50); // 440 + await seedNight("2026-09-20", 60); // 450 + expect(await sleepSeries("2026-07-01", "2026-09-30", "quarter")).toEqual([ + { t: "2026-07-01", mean: 440, count: 3 }, + ]); + expect(await sleepSeries("2026-07-01", "2026-09-30", "month")).toEqual([ + { t: "2026-07-01", mean: 430, count: 1 }, + { t: "2026-08-01", mean: 440, count: 1 }, + { t: "2026-09-01", mean: 450, count: 1 }, + ]); + }); + + it("reads the nights, not the rolled-up stage rows, over a long window", async () => { + await seedNight("2023-02-03", 40); // 430 + await seedNight("2023-03-04", 60); // 450 + // What the rollup tier holds for those days: the mean of the stage rows. + await getPrismaClient().measurementRollup.createMany({ + data: ["2023-02-01", "2023-03-01"].map((month) => ({ + userId: user, + type: "SLEEP_DURATION" as const, + granularity: "MONTH" as const, + bucketStart: new Date(`${month}T00:00:00.000Z`), + source: "APPLE_HEALTH" as const, + count: 6, + mean: 155, + minValue: 20, + maxValue: 480, + sumValue: 930, + })), + }); + expect(await sleepSeries("2016-10-09", "2026-10-09", "quarter")).toEqual([ + { t: "2023-01-01", mean: 440, count: 2 }, + ]); + }); + + it("keeps a night whole where one year's read ends and the next begins", async () => { + const { readNightCells } = await import("@/lib/timeline/series"); + const { shiftDateKey } = await import("@/lib/tz/format"); + // The second read starts 366 days after the first. + const from = "2024-01-01"; + const boundary = shiftDateKey(from, 366); + await seedNight(boundary, 40); + const nights = await readNightCells({ + userId: user, + from, + to: "2025-12-31", + tz: TZ, + priorityJson: null, + }); + expect([...nights]).toEqual([[boundary, { value: 430, count: 1 }]]); + }); +});