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/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: 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/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"}`} /> 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/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..22419defa 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,14 @@ import { type DayRunningKind, 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"; import type { DayFocus } from "./day-layer-controller"; @@ -42,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"; /** @@ -112,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); }; } @@ -161,43 +176,116 @@ const RUNNING_COLOR: Record = { lifeEvent: "var(--chart-2)", }; +type Translate = ( + key: string, + params?: Record, +) => string; + /** - * 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 ? 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; + } + 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": { + 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 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": { + const key = keyOf(ALLERGY_SEVERITY_KEY, meta); + return key ? t(key) : null; + } + 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 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; + } + 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 +540,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/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/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__/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-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__/no-template-key-guard.test.ts b/src/components/timeline/__tests__/no-template-key-guard.test.ts new file mode 100644 index 000000000..7c460c90c --- /dev/null +++ b/src/components/timeline/__tests__/no-template-key-guard.test.ts @@ -0,0 +1,66 @@ +/** + * 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", () => { + 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", () => { + 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-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/__tests__/timeline-view-keys.test.tsx b/src/components/timeline/__tests__/timeline-view-keys.test.tsx new file mode 100644 index 000000000..464d108d3 --- /dev/null +++ b/src/components/timeline/__tests__/timeline-view-keys.test.tsx @@ -0,0 +1,129 @@ +/** + * 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 { beforeAll, 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 }; + return textOf( + renderToStaticMarkup( + + + + + , + ), + ); +} + +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(); +}); + +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/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..b526dd18c --- /dev/null +++ b/src/components/timeline/item-words.ts @@ -0,0 +1,145 @@ +/** + * 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"; + +import { + DOCUMENT_KIND_KEY, + LIFE_EVENT_CATEGORY_KEY, + keyOf, +} from "./label-keys"; + +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; + +/** 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([ + "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: worded(t, LIFE_EVENT_CATEGORY_KEY, item.sub), + }; + 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: worded(t, DOCUMENT_KIND_KEY, item.sub), + }; + 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/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/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..2004eb12d 100644 --- a/src/components/timeline/timeline-chart.tsx +++ b/src/components/timeline/timeline-chart.tsx @@ -51,6 +51,8 @@ import type { 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, @@ -147,6 +149,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 +167,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; @@ -420,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) => ( ["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 +706,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 +770,7 @@ function SpanMark({ ); } -function PointMark({ +export function PointMark({ point, color, intl, @@ -773,7 +780,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 +881,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 }))) @@ -891,11 +900,15 @@ function TimelineTable({ {rows.map(({ lane, item }) => ( - {t(`timeline.lanes.${lane}`)} + {t(TIMELINE_LANE_LABEL_KEY[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..df4bcc51f 100644 --- a/src/components/timeline/timeline-chronicle.tsx +++ b/src/components/timeline/timeline-chronicle.tsx @@ -41,6 +41,8 @@ import { type ChronicleGrouping, type ChronicleRails, } from "./chronicle-model"; +import { useItemWords } from "./item-words"; +import { TIMELINE_CHRONICLE_NOTABLE_KEY } from "./label-keys"; import { formatAtPrecision, formatDayMonth, @@ -146,9 +148,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. @@ -180,23 +183,28 @@ 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"), }; } 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/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} > { 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; } /** 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" }); 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 }]]); + }); +});