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

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

    + {meta ? ( +

    {meta}

    ) : null} {event.note ? ( // The person's own words: content, never muted. diff --git a/src/components/day/since-last-visit.tsx b/src/components/day/since-last-visit.tsx index a8d24cca5..51253ea91 100644 --- a/src/components/day/since-last-visit.tsx +++ b/src/components/day/since-last-visit.tsx @@ -34,7 +34,7 @@ import { import { getUnitForType } from "@/lib/measurements/unit-map"; import { queryKeys } from "@/lib/query-keys"; -import { DAY_SECTION_LABEL, useNotableText } from "./day-sections"; +import { DAY_SECTION_LABEL, dayTitle, useNotableText } from "./day-sections"; import { DayLink } from "./day-link"; import { dateKeyOfInstant } from "./day-url"; import { tileKeyOf } from "./day-values-model"; @@ -86,6 +86,8 @@ export interface PrepRow { export function preparationRows( window: Pick, spell: { + /** A change's title in the reader's words (`dayTitle`). */ + changeTitle: (kind: string, title: string) => string; notableTitle: (notable: DayNotable) => string; notableText: (notable: DayNotable) => string | null; countMeta: (count: number) => string | null; @@ -97,7 +99,7 @@ export function preparationRows( key: `${change.kind}-${change.id}-${change.date}`, date: change.date, Icon: EVENT_ICON[change.kind] ?? FileText, - title: change.title, + title: spell.changeTitle(change.kind, change.title), meta: spell.countMeta(change.count), }); } @@ -171,6 +173,7 @@ export function SinceLastVisit({ const rows = window.data ? preparationRows(window.data, { + changeTitle: (kind, title) => dayTitle(kind, title, t), notableTitle, notableText, countMeta: (count) => diff --git a/src/components/timeline/__tests__/chronicle-model.test.ts b/src/components/timeline/__tests__/chronicle-model.test.ts index f0da55414..37633251b 100644 --- a/src/components/timeline/__tests__/chronicle-model.test.ts +++ b/src/components/timeline/__tests__/chronicle-model.test.ts @@ -148,7 +148,7 @@ describe("buildChronicle", () => { describe("standingChips", () => { it("collects open things once and folds several allergies into one chip", () => { - const chips = standingChips(fullTimeline()); + const chips = standingChips(fullTimeline(), (i) => i.label); expect(chips.map((c) => c.id)).toEqual([ "ill-chronic", "med-1", @@ -162,6 +162,8 @@ describe("standingChips", () => { tl.standing = [ { lane: "illness", id: "pre", label: "Asthma", since: null, href: null }, ]; - expect(standingChips(tl).map((c) => c.label)).toContain("Asthma"); + expect(standingChips(tl, (i) => i.label).map((c) => c.label)).toContain( + "Asthma", + ); }); }); diff --git a/src/components/timeline/__tests__/no-raw-code-guard.test.tsx b/src/components/timeline/__tests__/no-raw-code-guard.test.tsx new file mode 100644 index 000000000..044296ea7 --- /dev/null +++ b/src/components/timeline/__tests__/no-raw-code-guard.test.tsx @@ -0,0 +1,502 @@ +/** + * v1.42 (#613) — no code reaches the screen. The timeline and the day view + * receive what the record holds: a life event's category, a visit's kind, a + * document's kind, an allergy's severity, a mood, a screener's instrument and + * band as their codes, a lab day's analyte count as a bare number, and a + * sentinel for a trip or a cycle. Every one of them is worded in the reader's + * language before it is shown. + * + * This renders a record with every item kind, every category and every kind + * code on every surface that shows it (the chronicle, the selection bar, the + * screen-reader table, the lane labels and their hover titles, the day's + * running items and its events) and asserts that the visible text holds no + * upper-case code and no sentinel. A new kind or a new code that is not + * worded fails here instead of on a screenshot. + */ +import { describe, expect, it } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +import { DayEvents, DayRunning } from "@/components/day/day-sections"; +import { ENCOUNTER_KINDS } from "@/components/encounters/encounter-labels"; +import { + DAY_EVENT_KINDS, + DAY_RUNNING_KINDS, + LIFE_EVENT_CATEGORIES, + TIMELINE_ITEM_KINDS, + type DayEvent, + type DayRunningItem, + type TimelineItem, + type TimelineResponse, +} from "@/lib/day/contract"; +import { I18nProvider } from "@/lib/i18n/context"; +import { MOOD_ENUM_VALUES } from "@/lib/mood/labels"; +import { INBOUND_DOCUMENT_KINDS } from "@/lib/validations/inbound-documents"; + +import { SelectionBar } from "../selection-bar"; +import type { SeriesValueFormat } from "../series-format"; +import { PointMark, SpanMark, TimelineChart } from "../timeline-chart"; +import { TimelineChronicle } from "../timeline-chronicle"; +import { layoutTimeline } from "../timeline-geometry"; +import { useSeriesValueFormat } from "../use-series-value-format"; +import { TODAY, item, wordsIn } from "./timeline-fixture"; + +type Locale = "de" | "en"; + +function render(node: React.ReactElement, locale: Locale) { + return renderToStaticMarkup( + + {node} + , + ); +} + +/** + * The text a reader sees: every character outside a tag, with a space where + * a tag stood, so two runs ("Ereignis 0" and "FAMILY") never fuse into one + * word the code pattern would step over. + */ +function textOf(html: string): string { + let out = ""; + let inTag = false; + for (const ch of html) { + if (ch === "<") inTag = true; + else if (ch === ">") { + inTag = false; + out += " "; + } else if (!inTag) out += ch; + } + return out; +} + +/** + * A code as the server sends it: four or more capitals, digits or + * underscores in one word (`FAMILY`, `SUPER_GUT`, `PHQ9`), or one of the + * lower-case sentinels. + */ +const RAW_CODE = /\b[A-Z][A-Z0-9_]{3,}\b|\btravel\b|\bcycle\b/g; + +function rawCodes(text: string): string[] { + return [...new Set(text.match(RAW_CODE) ?? [])]; +} + +function WithFormat({ + build, +}: { + build: (format: SeriesValueFormat) => React.ReactNode; +}) { + return <>{build(useSeriesValueFormat())}; +} + +/** Every day of September 2026, one per item, so one month holds them all. */ +let nextDay = 1; +const day = () => `2026-09-${String(nextDay++).padStart(2, "0")}`; + +/** Every timeline item kind, with every code its second line can carry. */ +function everyKind(): TimelineResponse { + nextDay = 1; + const life: TimelineItem[] = [ + ...LIFE_EVENT_CATEGORIES.map((category, i) => + item({ + id: `le-${i}`, + kind: "lifeEvent", + start: day(), + label: `Ereignis ${i}`, + sub: category, + }), + ), + item({ + id: "trip", + kind: "travel", + start: "2026-09-02", + end: "2026-09-09", + label: "travel", + }), + ]; + const visits: TimelineItem[] = ENCOUNTER_KINDS.map((kind, i) => + item({ + id: `visit-${i}`, + kind: kind === "PROCEDURE" ? "procedure" : "visit", + start: day(), + // Every other visit has neither a reason nor a practitioner. + label: i % 2 === 0 ? "" : `Termin ${i}`, + sub: kind, + }), + ); + const documents: TimelineItem[] = INBOUND_DOCUMENT_KINDS.map((kind, i) => + item({ + id: `doc-${i}`, + kind: "document", + start: day(), + label: `Brief ${i}`, + sub: kind, + }), + ); + const timeline: TimelineResponse = { + zoom: "quarter", + range: { from: "2026-08-01", to: "2026-10-31", dataFrom: "2026-08-01" }, + bucket: "month", + lanes: [ + { key: "life", items: life }, + { + key: "illness", + items: [ + item({ + id: "chronic", + kind: "chronic", + start: "2026-09-01", + open: true, + label: "Bluthochdruck", + }), + item({ + id: "episode", + kind: "episode", + start: "2026-09-03", + end: "2026-09-12", + label: "Erkältung", + }), + ], + }, + { + key: "allergies", + items: [ + item({ + id: "allergy", + kind: "allergy", + start: "2026-09-04", + open: true, + label: "Penicillin", + }), + ], + }, + { + key: "medications", + items: [ + item({ + id: "med", + kind: "medication", + start: "2026-09-01", + open: true, + label: "Ramipril", + sub: "5 mg", + }), + item({ + id: "course", + kind: "course", + start: "2026-09-05", + end: "2026-09-25", + label: "Vitamin D", + }), + item({ + id: "dose", + kind: "doseChange", + start: "2026-09-10", + label: "Ramipril", + sub: "10 mg", + }), + item({ + id: "pause", + kind: "pause", + start: "2026-09-14", + end: "2026-09-24", + label: "Ramipril", + }), + ], + }, + { + key: "vaccinations", + items: [ + item({ + id: "vacc", + kind: "vaccination", + start: day(), + label: "Tetanus", + }), + ], + }, + { key: "visits", items: visits }, + { + key: "labs", + items: [ + item({ + id: "lab-day", + kind: "labDay", + start: day(), + label: "Kreatinin, Kalium, Natrium", + sub: "5", + }), + ], + }, + { key: "documents", items: documents }, + { + key: "cycle", + items: [ + item({ + id: "cyc", + kind: "cycle", + start: "2026-09-06", + end: "2026-09-10", + label: "cycle", + }), + ], + }, + ], + standing: [], + series: [], + notable: [], + }; + // The fixture itself must reach every kind, or the guard proves less. + const kinds = new Set( + timeline.lanes.flatMap((l) => l.items.map((i) => i.kind)), + ); + expect([...kinds].sort()).toEqual([...TIMELINE_ITEM_KINDS].sort()); + return timeline; +} + +const SELECTED = "2026-09-15"; +const noop = () => undefined; + +describe.each(["de", "en"])("the timeline in %s", (locale) => { + it("words every code in the chronicle", () => { + const text = textOf( + render( + ( + "var(--chart-1)"} + seriesFormat={format} + onOpenDay={noop} + onEditLifeEvent={null} + /> + )} + />, + locale, + ), + ); + expect(rawCodes(text)).toEqual([]); + }); + + it("words every code in the selection bar", () => { + const text = textOf( + render( + ( + key} + seriesColor={() => "var(--chart-1)"} + seriesFormat={format} + onOpenDay={noop} + /> + )} + />, + locale, + ), + ); + expect(text).toContain(locale === "de" ? "5 Laborwerte" : "5 lab values"); + expect(rawCodes(text)).toEqual([]); + }); + + it("words every code in the screen-reader table", () => { + const html = render( + ( + key} + seriesColor={() => "var(--chart-1)"} + seriesFormat={format} + onSelect={noop} + onOpenDay={noop} + /> + )} + />, + locale, + ); + const table = html.slice(html.indexOf('data-slot="timeline-table"')); + expect(rawCodes(textOf(table))).toEqual([]); + }); + + it("words every code in the lane labels and their hover titles", () => { + const layout = layoutTimeline({ + width: 4000, + window: { from: "2026-08-01", to: "2026-10-31" }, + lanes: everyKind().lanes, + series: [], + bucket: "month", + words: wordsIn(locale), + startMissing: "", + today: TODAY, + }); + const labels = layout.lanes.flatMap((l) => l.labels.map((x) => x.text)); + expect(labels.length).toBeGreaterThan(10); + expect(rawCodes(labels.join("\n"))).toEqual([]); + + const titles = textOf( + render( + + {layout.lanes.flatMap((lane) => [ + ...lane.spans.map((span) => ( + key} + /> + )), + ...lane.points.map((point) => ( + + )), + ])} + , + locale, + ), + ); + // The stub `t` above leaves the range words as keys; only the item + // words are under test here. + expect(rawCodes(titles.replace(/timeline\.selection\.\w+/g, ""))).toEqual( + [], + ); + }); +}); + +/* ─── The day view ──────────────────────────────────────────────────────── */ + +function event( + kind: DayEvent["kind"], + id: string, + title: string, + meta: string | null, +): DayEvent { + return { + at: null, + kind, + section: "medications", + id, + title, + meta, + note: null, + docs: [], + href: null, + }; +} + +/** Every event kind, with every code its title or second line can carry. */ +function everyEvent(): DayEvent[] { + const out: DayEvent[] = [ + event("intake", "i", "Ramipril", "5 mg"), + event("doseChange", "dc", "Ramipril", "10 mg"), + event("medicationStart", "ms", "Ramipril", "5 mg"), + event("medicationEnd", "me", "Ramipril", "5 mg"), + event("pauseStart", "ps", "Ramipril", null), + event("pauseEnd", "pe", "Ramipril", null), + event("courseStart", "cs", "Vitamin D", null), + event("courseEnd", "ce", "Vitamin D", null), + event("illnessOnset", "io", "Erkältung", null), + event("illnessResolved", "ir", "Erkältung", null), + event("illnessDayLog", "idl", "Erkältung", "2"), + event("symptom", "sy", "Kopfschmerz", "7"), + event("allergyOnset", "ao-1", "Penicillin", "SEVERE"), + event("allergyOnset", "ao-2", "Pollen", "MILD"), + event("labResult", "lr", "Kreatinin", "1.1 mg/dL"), + event("visit", "planned", "Kontrolle", "PLANNED"), + event("vaccination", "va", "Tetanus", "2"), + event("checkup", "ch", "Hautkrebs-Screening", null), + event("assessment", "as-1", "PHQ9", "12 moderate"), + event("assessment", "as-2", "GAD7", "3 minimal"), + event("assessment", "as-3", "WHO5", "60 good"), + event("assessment", "as-4", "SCI", "20 aboveThreshold"), + event("workout", "wo", "running", "30 min"), + event("cycleDayLog", "cdl", "cycle", "HEAVY"), + ...ENCOUNTER_KINDS.map((kind, i) => + event( + kind === "PROCEDURE" ? "procedure" : "visit", + `visit-${i}`, + "Hausärztin", + kind, + ), + ), + ...INBOUND_DOCUMENT_KINDS.map((kind, i) => + event("document", `doc-${i}`, `Brief ${i}`, kind), + ), + ...MOOD_ENUM_VALUES.map((mood, i) => + event("mood", `mood-${i}`, mood, String(i + 1)), + ), + ...LIFE_EVENT_CATEGORIES.map((category, i) => + event("lifeEvent", `le-${i}`, `Ereignis ${i}`, category), + ), + ]; + expect([...new Set(out.map((e) => e.kind))].sort()).toEqual( + [...DAY_EVENT_KINDS].sort(), + ); + return out; +} + +function running( + kind: DayRunningItem["kind"], + title: string, + sub: string | null, +): DayRunningItem { + return { + kind, + section: "medications", + id: `${kind}-${title}`, + title, + sub, + since: "2026-09-01", + until: null, + dayIndex: 3, + dayCount: null, + href: null, + }; +} + +function everyRunning(): DayRunningItem[] { + const out: DayRunningItem[] = [ + running("medication", "Ramipril", "5 mg"), + running("medicationCourse", "Vitamin D", null), + running("medicationPause", "Ramipril", null), + running("illness", "Erkältung", null), + running("restMode", "Erholung", null), + running("cyclePhase", "cycle", null), + running("travel", "travel", null), + ...LIFE_EVENT_CATEGORIES.map((category, i) => + running("lifeEvent", `Ereignis ${i}`, category), + ), + ]; + expect([...new Set(out.map((r) => r.kind))].sort()).toEqual( + [...DAY_RUNNING_KINDS].sort(), + ); + return out; +} + +describe.each(["de", "en"])("the day view in %s", (locale) => { + it("words every code under What happened", () => { + const text = textOf(render(, locale)); + expect(rawCodes(text)).toEqual([]); + expect(text).toContain(locale === "de" ? "Super gut" : "Amazing"); + }); + + it("words every code under Running that day", () => { + const text = textOf(render(, locale)); + expect(rawCodes(text)).toEqual([]); + }); +}); diff --git a/src/components/timeline/__tests__/timeline-components.test.tsx b/src/components/timeline/__tests__/timeline-components.test.tsx index 5a1ffdfb8..43cdcfab9 100644 --- a/src/components/timeline/__tests__/timeline-components.test.tsx +++ b/src/components/timeline/__tests__/timeline-components.test.tsx @@ -23,7 +23,7 @@ import { MeanPartsLine, SeriesLines, TimelineChart } from "../timeline-chart"; import { TimelineChronicle } from "../timeline-chronicle"; import { layoutTimeline } from "../timeline-geometry"; import { useSeriesValueFormat } from "../use-series-value-format"; -import { TODAY, fullTimeline, readiness } from "./timeline-fixture"; +import { TODAY, fullTimeline, readiness, wordsIn } from "./timeline-fixture"; function render(node: React.ReactElement, locale: "de" | "en" = "de") { // A fresh client with no account answer: the unit preference resolves to @@ -54,8 +54,21 @@ function I18nProbe({ return <>{run(t)}; } -/** The text a reader sees, without the markup between the runs. */ -const text = (html: string) => html.replace(/<[^>]+>/g, ""); +/** + * The text a reader sees, without the markup between the runs. A walk over + * the characters rather than a pattern, so a tag split across another cannot + * survive it. + */ +function text(html: string): string { + let out = ""; + let inTag = false; + for (const ch of html) { + if (ch === "<") inTag = true; + else if (ch === ">") inTag = false; + else if (!inTag) out += ch; + } + return out; +} const COLORS = assignSeriesColors([ "BLOOD_PRESSURE_SYS", @@ -153,6 +166,32 @@ describe("chronicle", () => { expect(html).toMatch(/data-date="2026-01-03">]*bg-muted/); }); + it("reads a pause as paused and resumed, never as an end", () => { + const html = text( + render( + ( + undefined} + onEditLifeEvent={null} + /> + )} + />, + "en", + ), + ); + expect(html).toContain("Ramipril paused"); + expect(html).toContain("Ramipril resumed"); + expect(html).toContain("after 11 days"); + expect(html).not.toContain("Ramipril ended"); + }); + it("shows the month's means beside the month", () => { const html = render( { expect(html).toContain('data-date="2026-01-03"'); }); + it("names a pause as a pause", () => { + const html = text(bar("2021-06-05")); + expect(html).toContain("Ramipril pausiert · 1. Juni bis 11. Juni"); + }); + it("names the bucket's means with the readings behind each", () => { expect(text(bar("2026-01-03"))).toContain( "Ø Blutdruck 129/82 mmHg (Schnitt aus 4 Messungen) · Gewicht 82,6 kg (Schnitt aus 3 Messungen)", @@ -364,6 +408,7 @@ describe("value line colours", () => { lanes: [], series: fullTimeline().series, bucket: "month", + words: wordsIn(), startMissing: "", today: TODAY, }); diff --git a/src/components/timeline/__tests__/timeline-fixture.ts b/src/components/timeline/__tests__/timeline-fixture.ts index 11e3cf2b1..3a346ef28 100644 --- a/src/components/timeline/__tests__/timeline-fixture.ts +++ b/src/components/timeline/__tests__/timeline-fixture.ts @@ -10,6 +10,19 @@ import type { TimelineReadinessResponse, TimelineResponse, } from "@/lib/day/contract"; +import type { Locale } from "@/lib/i18n/config"; +import { pluralKey } from "@/lib/i18n/plural"; +import { getServerTranslator } from "@/lib/i18n/server-translator"; + +import { makeItemWords, type ItemWordsFn } from "../item-words"; + +/** An item's words from the real bundle, outside a React tree. */ +export function wordsIn(locale: Locale = "de"): ItemWordsFn { + const { t } = getServerTranslator(locale); + return makeItemWords(t, (base, count, params) => + t(pluralKey(base, count, locale), { count, ...params }), + ); +} export function item( over: Partial & Pick, @@ -174,8 +187,8 @@ export function fullTimeline(): TimelineResponse { id: "pause-1", kind: "pause", start: "2021-06-01", - end: "2021-06-20", - label: "Pause", + end: "2021-06-11", + label: "Ramipril", }), ], }, diff --git a/src/components/timeline/__tests__/timeline-geometry.test.ts b/src/components/timeline/__tests__/timeline-geometry.test.ts index d011716ae..017872cd6 100644 --- a/src/components/timeline/__tests__/timeline-geometry.test.ts +++ b/src/components/timeline/__tests__/timeline-geometry.test.ts @@ -12,7 +12,6 @@ import { estimateTextWidth, fitText, gridTicks, - itemLabel, itemsInPeriod, latestDataDate, layoutLane, @@ -25,8 +24,11 @@ import { type LaneLayout, type TimelineLayout, } from "../timeline-geometry"; +import { itemLine } from "../item-words"; import { dayNumber } from "../timeline-dates"; -import { TODAY, fullTimeline, item } from "./timeline-fixture"; +import { TODAY, fullTimeline, item, wordsIn } from "./timeline-fixture"; + +const WORDS = wordsIn("de"); const FMT = { year: (k: string) => k.slice(0, 4), @@ -42,6 +44,7 @@ function layoutAt(width: number): TimelineLayout { lanes: tl.lanes, series: tl.series, bucket: tl.bucket, + words: WORDS, startMissing: "Startdatum fehlt", today: TODAY, }); @@ -183,7 +186,7 @@ describe("gridTicks", () => { describe("layoutLane", () => { const window = { from: "2019-01-01", to: "2026-12-31" }; const scale = createScale(window, 164, 1200); - const opts = { startMissing: "Startdatum fehlt", today: TODAY }; + const opts = { words: WORDS, startMissing: "Startdatum fehlt", today: TODAY }; it("puts overlapping periods on separate rows and lets later ones reuse a free row", () => { const lane = fullTimeline().lanes.find((l) => l.key === "illness")!; @@ -207,6 +210,23 @@ describe("layoutLane", () => { expect(pause.row).toBe(host.row); }); + it("labels a pause as a pause, above the medication it interrupts", () => { + const lane = fullTimeline().lanes.find((l) => l.key === "medications")!; + const wide = createScale( + { from: "2021-01-01", to: "2021-12-31" }, + 164, + 1200, + ); + const layout = layoutLane( + { lane, top: 0 }, + { from: "2021-01-01", to: "2021-12-31" }, + wide, + opts, + ); + const label = layout.labels.find((l) => l.itemId === "pause-1"); + expect(label?.text).toBe("Ramipril pausiert"); + }); + it("starts an open period at its start and runs it to today", () => { const lane = fullTimeline().lanes.find((l) => l.key === "allergies")!; const layout = layoutLane({ lane, top: 0 }, window, scale, opts); @@ -226,7 +246,7 @@ describe("layoutLane", () => { label: "Ramipril", sub: "5 mg", }); - expect(itemLabel(unknown, "Startdatum fehlt")).toBe( + expect(itemLine(unknown, WORDS, "Startdatum fehlt")).toBe( "Ramipril · 5 mg · Startdatum fehlt", ); const layout = layoutLane( diff --git a/src/components/timeline/chronicle-model.ts b/src/components/timeline/chronicle-model.ts index 4c50857ba..4baef2406 100644 --- a/src/components/timeline/chronicle-model.ts +++ b/src/components/timeline/chronicle-model.ts @@ -99,10 +99,12 @@ function groupsBetween( /** * The Ongoing block: what has no start (`standing`) and every period still - * open. Allergies fold into one chip when there is more than one. + * open, each period named by `label` (`item-words.ts`, so a running pause + * reads as paused). Allergies fold into one chip when there is more than one. */ export function standingChips( timeline: Pick, + label: (item: TimelineItem) => string, ): StandingChip[] { const chips: StandingChip[] = []; const allergies: StandingChip[] = []; @@ -116,7 +118,7 @@ export function standingChips( const lane = timeline.lanes.find((l) => l.key === key); for (const item of lane?.items ?? []) { if (item.open) - push({ lane: key, id: item.id, label: item.label, count: 1 }); + push({ lane: key, id: item.id, label: label(item), count: 1 }); } } const ordered = chips.sort( diff --git a/src/components/timeline/item-words.ts b/src/components/timeline/item-words.ts new file mode 100644 index 000000000..55decc74d --- /dev/null +++ b/src/components/timeline/item-words.ts @@ -0,0 +1,135 @@ +/** + * The words of one timeline item, in the reader's language (v1.42, #613). + * + * The server sends what the record holds: a life event's category, a visit's + * kind and a document's kind as their codes, a lab day's analyte count as a + * number, and a sentinel label for a trip or a cycle that has no name of its + * own. Every surface that shows an item (the lanes, their hover titles, the + * screen-reader table, the selection bar, the phone chronicle) reads it + * through here, so a code is worded once and never reaches the screen raw. + * + * A code the bundle cannot word is left out rather than shown: an unknown + * category reads as no category, not as `SOMETHING_NEW`. + */ +import { useMemo } from "react"; + +import { encounterKindText } from "@/components/encounters/encounter-labels"; +import type { TimelineItem } from "@/lib/day/contract"; +import { useTranslations } from "@/lib/i18n/context"; + +type Translate = ( + key: string, + params?: Record, +) => string; +type TranslateCount = ( + baseKey: string, + count: number, + params?: Record, +) => string; + +export interface ItemWords { + /** What the item is called. */ + label: string; + /** Its second line (a category, a dose, a count), or null when none. */ + sub: string | null; +} + +export type ItemWordsFn = (item: TimelineItem) => ItemWords; + +/** The bundle's wording of `key`, or null when the bundle has none. */ +function known(t: Translate, key: string): string | null { + const value = t(key); + return value === key ? null : value; +} + +const ENCOUNTER_KIND_CODES: ReadonlySet = new Set([ + "ROUTINE", + "ACUTE", + "SPECIALIST", + "PREVENTIVE", + "EMERGENCY", + "HOSPITAL", + "THERAPY", + "OTHER", + "PROCEDURE", +]); + +/** A visit's kind code, worded; null for a code the bundle does not know. */ +export function visitKindWords(t: Translate, code: string): string | null { + return ENCOUNTER_KIND_CODES.has(code) + ? encounterKindText(t, code as Parameters[1]) + : null; +} + +export function makeItemWords( + t: Translate, + tCount: TranslateCount, +): ItemWordsFn { + return (item) => { + switch (item.kind) { + case "lifeEvent": + return { + label: item.label, + sub: item.sub ? known(t, `lifeEvents.category.${item.sub}`) : null, + }; + case "travel": + return { label: t("day.travel"), sub: null }; + case "cycle": + return { label: t("nav.cycle"), sub: null }; + case "pause": + return { + label: t("day.event.paused", { label: item.label }), + sub: null, + }; + case "visit": + case "procedure": { + const kind = item.sub ? visitKindWords(t, item.sub) : null; + // A visit without a reason or a practitioner is named by its kind. + return item.label + ? { label: item.label, sub: kind } + : { label: kind ?? "", sub: null }; + } + case "labDay": { + const count = Number(item.sub); + return { + label: item.label, + sub: + Number.isInteger(count) && count > 0 + ? tCount("timeline.item.labValues", count) + : null, + }; + } + case "document": + return { + label: item.label, + sub: item.sub ? known(t, `documents.kind.${item.sub}`) : null, + }; + default: + // A dose, a dose change, a name: the record's own text. + return { label: item.label, sub: item.sub }; + } + }; +} + +/** + * The line an item reads as: its label, its second line (a dose change's + * dose is drawn as its own mark, so it is left out here) and the + * unknown-start note. + */ +export function itemLine( + item: TimelineItem, + words: ItemWordsFn, + startMissing: string, +): string { + const { label, sub } = words(item); + const parts = [label]; + if (sub && item.kind !== "doseChange") parts.push(sub); + if (!item.startKnown) parts.push(startMissing); + return parts.filter(Boolean).join(" · "); +} + +/** {@link makeItemWords} with the bundle in hand. */ +export function useItemWords(): ItemWordsFn { + const { t, tCount } = useTranslations(); + return useMemo(() => makeItemWords(t, tCount), [t, tCount]); +} diff --git a/src/components/timeline/selection-bar.tsx b/src/components/timeline/selection-bar.tsx index de5bbeaf0..64ab30eeb 100644 --- a/src/components/timeline/selection-bar.tsx +++ b/src/components/timeline/selection-bar.tsx @@ -30,6 +30,7 @@ import { formatMonthLong, formatMonthYear, } from "./timeline-dates"; +import { useItemWords } from "./item-words"; import { itemsInPeriod } from "./timeline-geometry"; import { MeanPartsLine, laneDotStyle } from "./timeline-chart"; import { @@ -67,6 +68,7 @@ export function SelectionBar({ onEditLifeEvent, }: SelectionBarProps) { const { t, tCount, locale } = useTranslations(); + const words = useItemWords(); const intl = resolveIntlLocale(locale); const bucket = timeline.bucket; const periodFrom = bucketStart(selected, bucket); @@ -138,7 +140,8 @@ export function SelectionBar({ : item.precision === "DAY" ? formatDayMonth(item.start, intl) : formatAtPrecision(item.start, item.precision, intl); - const text = `${item.label}${item.sub ? ` ${item.sub}` : ""} · ${when}`; + const { label, sub } = words(item); + const text = [label, sub, when].filter(Boolean).join(" · "); const editable = item.kind === "lifeEvent" && onEditLifeEvent; if (!canOpenDay && !editable) { return ( diff --git a/src/components/timeline/timeline-chart.tsx b/src/components/timeline/timeline-chart.tsx index ab863d9d1..5d9045f36 100644 --- a/src/components/timeline/timeline-chart.tsx +++ b/src/components/timeline/timeline-chart.tsx @@ -51,6 +51,7 @@ import type { import { resolveIntlLocale } from "@/lib/format-locale"; import { useTranslations } from "@/lib/i18n/context"; +import { useItemWords, type ItemWordsFn } from "./item-words"; import { bucketAfter, bucketStart, @@ -147,6 +148,7 @@ export function TimelineChart({ onOpenDay, }: TimelineChartProps) { const { t, locale } = useTranslations(); + const words = useItemWords(); const fmt = seriesFormat; const intl = resolveIntlLocale(locale); const { ref, width } = useElementWidth(); @@ -164,11 +166,12 @@ export function TimelineChart({ lanes, series: timeline.series, bucket: timeline.bucket, + words, startMissing: t("timeline.startMissing"), today, }) : null, - [width, window, lanes, timeline.series, timeline.bucket, t, today], + [width, window, lanes, timeline.series, timeline.bucket, words, t, today], ); const floor = timeline.range.dataFrom ?? window.from; @@ -676,17 +679,19 @@ function spanTitle( span: PlacedSpan, intl: string, t: ReturnType["t"], + words: ItemWordsFn, ): string { const { item } = span; + const { label, sub } = words(item); const from = formatAtPrecision(item.start, item.precision, intl); const to = item.open ? t("timeline.selection.ongoing") : formatAtPrecision(item.end ?? item.start, item.precision, intl); - const name = item.sub ? `${item.label} · ${item.sub}` : item.label; + const name = sub ? `${label} · ${sub}` : label; return `${name}: ${t("timeline.selection.range", { from, to })}`; } -function SpanMark({ +export function SpanMark({ span, color, x0, @@ -700,7 +705,8 @@ function SpanMark({ t: ReturnType["t"]; }) { const { item, xStart, xEnd, y } = span; - const title = spanTitle(span, intl, t); + const words = useItemWords(); + const title = spanTitle(span, intl, t, words); if (span.pause) { return ( @@ -763,7 +769,7 @@ function SpanMark({ ); } -function PointMark({ +export function PointMark({ point, color, intl, @@ -773,7 +779,8 @@ function PointMark({ intl: string; }) { const { x, y, item, shape } = point; - const title = `${item.label}${item.sub ? ` · ${item.sub}` : ""}: ${formatAtPrecision(item.start, item.precision, intl)}`; + const { label, sub } = useItemWords()(item); + const title = `${label}${sub ? ` · ${sub}` : ""}: ${formatAtPrecision(item.start, item.precision, intl)}`; let glyph: React.ReactNode; switch (shape) { case "diamond": @@ -873,6 +880,7 @@ function TimelineTable({ intl: string; }) { const { t } = useTranslations(); + const words = useItemWords(); const rows = timeline.lanes .filter((lane) => !hiddenLanes.has(lane.key)) .flatMap((lane) => lane.items.map((item) => ({ lane: lane.key, item }))) @@ -893,9 +901,13 @@ function TimelineTable({ {t(`timeline.lanes.${lane}`)} - {item.label} - {item.sub ? ` · ${item.sub}` : ""} - {!item.startKnown ? ` · ${t("timeline.startMissing")}` : ""} + {[ + words(item).label, + words(item).sub, + item.startKnown ? null : t("timeline.startMissing"), + ] + .filter(Boolean) + .join(" · ")} {formatAtPrecision(item.start, item.precision, intl)} diff --git a/src/components/timeline/timeline-chronicle.tsx b/src/components/timeline/timeline-chronicle.tsx index 9e453240b..6ada2fc9c 100644 --- a/src/components/timeline/timeline-chronicle.tsx +++ b/src/components/timeline/timeline-chronicle.tsx @@ -41,6 +41,7 @@ import { type ChronicleGrouping, type ChronicleRails, } from "./chronicle-model"; +import { useItemWords } from "./item-words"; import { formatAtPrecision, formatDayMonth, @@ -146,9 +147,10 @@ export function TimelineChronicle({ onEditLifeEvent: ((id: string) => void) | null; }) { const { t, tCount, locale } = useTranslations(); + const words = useItemWords(); const intl = resolveIntlLocale(locale); const [standingOpen, setStandingOpen] = useState(true); - const chips = standingChips(timeline); + const chips = standingChips(timeline, (item) => words(item).label); const rows = buildChronicle(timeline, today, grouping); // The means ride on the month headers, each bucket on the newest month it // overlaps; the year view lists none. @@ -185,18 +187,23 @@ export function TimelineChronicle({ }; } const { item, role } = entry; + const { label, sub } = words(item); if (role === "end") { + // A pause that ends is the medication taken up again, not ended. return { - title: t("timeline.chronicle.ended", { label: item.label }), + title: + item.kind === "pause" + ? t("day.event.resumed", { label: item.label }) + : t("timeline.chronicle.ended", { label }), meta: entry.days ? tCount("timeline.chronicle.afterDays", entry.days) : null, }; } - const meta = [item.sub, item.startKnown ? null : t("timeline.startMissing")] + const meta = [sub, item.startKnown ? null : t("timeline.startMissing")] .filter(Boolean) .join(" · "); - return { title: item.label, meta: meta || null }; + return { title: label, meta: meta || null }; } return ( diff --git a/src/components/timeline/timeline-geometry.ts b/src/components/timeline/timeline-geometry.ts index b3407c175..c220aaa2e 100644 --- a/src/components/timeline/timeline-geometry.ts +++ b/src/components/timeline/timeline-geometry.ts @@ -26,6 +26,7 @@ import type { TimelineZoom, } from "@/lib/day/contract"; +import { itemLine, type ItemWordsFn } from "./item-words"; import { addMonths, bucketAfter, @@ -455,17 +456,6 @@ function inWindow(item: TimelineItem, window: TimeWindow): boolean { return item.start >= window.from; } -/** - * The visible text of an item: its label, the dose or second line when it - * has one, and the unknown-start note. - */ -export function itemLabel(item: TimelineItem, startMissing: string): string { - const parts = [item.label]; - if (item.sub && item.kind !== "doseChange") parts.push(item.sub); - if (!item.startKnown) parts.push(startMissing); - return parts.filter(Boolean).join(" · "); -} - interface LaneInput { lane: TimelineLane; top: number; @@ -480,7 +470,12 @@ export function layoutLane( { lane, top }: LaneInput, window: TimeWindow, scale: Scale, - options: { startMissing: string; today: string }, + options: { + /** How an item reads (`item-words.ts`): codes worded, never raw. */ + words: ItemWordsFn; + startMissing: string; + today: string; + }, ): LaneLayout { const visible = lane.items.filter((item) => inWindow(item, window)); const pointItems = visible.filter( @@ -580,7 +575,13 @@ export function layoutLane( const rows = Math.max(1, pointRows + rowEnds.length); const height = LANE_PAD_TOP + (rows - 1) * ROW_HEIGHT + LANE_PAD_BOTTOM; - const labels = placeLabels(spans, points, scale, options.startMissing); + const labels = placeLabels( + spans, + points, + scale, + options.words, + options.startMissing, + ); return { key: lane.key, top, height, rows, spans, points, labels }; } @@ -595,6 +596,7 @@ function placeLabels( spans: PlacedSpan[], points: PlacedPoint[], scale: Scale, + words: ItemWordsFn, startMissing: string, ): PlacedLabel[] { const occ = new LineOccupancy(); @@ -662,7 +664,9 @@ function placeLabels( return true; }; - for (const s of openSpans) aboveLabel(s, itemLabel(s.item, startMissing)); + for (const s of openSpans) { + aboveLabel(s, itemLine(s.item, words, startMissing)); + } // A point's name starts just clear of its own glyph. const besideGlyph = (p: PlacedPoint) => p.x + GLYPH_HALF_WIDTH[p.shape] + BESIDE_MARGIN + 1; @@ -671,11 +675,10 @@ function placeLabels( for (const kind of ["lifeEvent", "procedure"] as const) { const strong = kind === "lifeEvent"; for (const p of points.filter((p) => p.item.kind === kind)) { - if ( - rightLabel(p.item.id, p.row, besideGlyph(p), p.y, p.item.label, strong) - ) + const { label } = words(p.item); + if (rightLabel(p.item.id, p.row, besideGlyph(p), p.y, label, strong)) continue; - leftLabel(p.item.id, p.row, leftOfGlyph(p), p.y, p.item.label, strong); + leftLabel(p.item.id, p.row, leftOfGlyph(p), p.y, label, strong); } } // A period's name goes right of its bar, else above it, else left of it. @@ -683,7 +686,7 @@ function placeLabels( // shown once: the repeats read as the same thing again. const shownOnRow = new Set(); for (const s of closedSpans.sort((a, b) => a.xStart - b.xStart)) { - const text = itemLabel(s.item, startMissing); + const text = itemLine(s.item, words, startMissing); const rowKey = `${s.row}:${text}`; if (shownOnRow.has(rowKey)) continue; const strong = s.item.kind === "lifeEvent"; @@ -693,9 +696,10 @@ function placeLabels( leftLabel(s.item.id, s.row, s.xStart - 6, s.y, text, strong); if (placed) shownOnRow.add(rowKey); } - for (const s of pausesWithLabel) aboveLabel(s, s.item.label); + for (const s of pausesWithLabel) aboveLabel(s, words(s.item).label); for (const p of points.filter((p) => p.shape === "doseMark")) { - const text = p.item.sub ?? p.item.label; + const dose = words(p.item); + const text = dose.sub ?? dose.label; const a = p.x + 2; const b = a + estimateTextWidth(text); if (fitsCard(a, b) && occ.tryAdd(line(p.row, "above"), a, b)) { @@ -864,6 +868,7 @@ export function layoutTimeline(input: { lanes: readonly TimelineLane[]; series: readonly TimelineSeries[]; bucket: TimelineBucket; + words: ItemWordsFn; startMissing: string; today: string; }): TimelineLayout { @@ -875,6 +880,7 @@ export function layoutTimeline(input: { const lane = input.lanes.find((l) => l.key === key); if (!lane) continue; const layout = layoutLane({ lane, top }, input.window, scale, { + words: input.words, startMissing: input.startMissing, today: input.today, }); diff --git a/src/lib/day/wire-schemas.ts b/src/lib/day/wire-schemas.ts index a3dbd24eb..e6468e328 100644 --- a/src/lib/day/wire-schemas.ts +++ b/src/lib/day/wire-schemas.ts @@ -122,8 +122,17 @@ const dayEvent = z kind: z.enum(DAY_EVENT_KINDS), section: daySectionKey, id: z.string(), - title: z.string(), - meta: z.string().nullable(), + title: z + .string() + .describe( + "The record's own name, or a code the client words: a mood, a screener's instrument, a workout's sport, `cycle` for a cycle day.", + ), + meta: z + .string() + .nullable() + .describe( + "The second line: text (a dose, a lab value, a duration) or a code or number the client words (a category, a kind, a severity, a screener's score and band, an intensity, a dose number).", + ), note: z .string() .nullable() @@ -318,8 +327,17 @@ const timelineItem = z .describe( "False when the start is a stand-in (a medication without a start date starts at its first intake).", ), - label: z.string(), - sub: z.string().nullable(), + label: z + .string() + .describe( + "The item's name. A trip is labelled `travel` and a cycle `cycle`; the client words both.", + ), + sub: z + .string() + .nullable() + .describe( + "The second line, by kind: a code for a life event (its category), a visit or procedure (its encounter kind) and a document (its kind); the analyte count for a lab day; the dose as text for a medication or a dose change. The client words every code.", + ), href: z.string().nullable(), }) .meta({ id: "TimelineItem" });