diff --git a/messages/de.json b/messages/de.json index d71e38a98..0b7accb9b 100644 --- a/messages/de.json +++ b/messages/de.json @@ -13655,7 +13655,11 @@ "start": "Beginn", "end": "Ende", "seriesCaption": "Mittelwerte nach Zeitraum", - "period": "Zeitraum" + "period": "Zeitraum", + "labsCaption": "Laborwerte nach Tag", + "marker": "Marker", + "date": "Datum", + "value": "Wert" }, "selection": { "empty": "Keine Einträge in diesem Zeitraum", @@ -13700,7 +13704,22 @@ "labValuesOne": "1 Laborwert", "labValuesFew": "{count} Laborwerte", "labValuesOther": "{count} Laborwerte" - } + }, + "labs": { + "menuTitle": "Laborwerte", + "otherPanel": "Weitere", + "above": "über dem Referenzbereich", + "below": "unter dem Referenzbereich", + "reference": "Referenz {range}", + "report": "Befund: {title}", + "reportUntitled": "Befund öffnen", + "excludedOne": "{name}: {count} Wert in einer anderen Einheit ist nicht eingezeichnet", + "excludedFew": "{name}: {count} Werte in einer anderen Einheit sind nicht eingezeichnet", + "excludedOther": "{name}: {count} Werte in einer anderen Einheit sind nicht eingezeichnet" + }, + "legendLabValue": "Laborwert, wie gemessen", + "legendLabOutside": "Außerhalb des Referenzbereichs", + "legendLabRange": "Referenzbereich" }, "lifeEvents": { "title": "Lebensereignis", diff --git a/messages/en.json b/messages/en.json index 49c2d6fcd..234157ac0 100644 --- a/messages/en.json +++ b/messages/en.json @@ -13655,7 +13655,11 @@ "start": "Start", "end": "End", "seriesCaption": "Value means by period", - "period": "Period" + "period": "Period", + "labsCaption": "Lab values by day", + "marker": "Marker", + "date": "Date", + "value": "Value" }, "selection": { "empty": "No entries in this period", @@ -13700,7 +13704,22 @@ "labValuesOne": "1 lab value", "labValuesFew": "{count} lab values", "labValuesOther": "{count} lab values" - } + }, + "labs": { + "menuTitle": "Lab values", + "otherPanel": "Other", + "above": "above range", + "below": "below range", + "reference": "reference {range}", + "report": "Report: {title}", + "reportUntitled": "Open the report", + "excludedOne": "{name}: {count} value in another unit is not drawn", + "excludedFew": "{name}: {count} values in another unit are not drawn", + "excludedOther": "{name}: {count} values in another unit are not drawn" + }, + "legendLabValue": "Lab value, as measured", + "legendLabOutside": "Outside the reference range", + "legendLabRange": "Reference range" }, "lifeEvents": { "title": "Life event", diff --git a/messages/es.json b/messages/es.json index 664fbb6b2..127b8be61 100644 --- a/messages/es.json +++ b/messages/es.json @@ -13655,7 +13655,11 @@ "start": "Inicio", "end": "Fin", "seriesCaption": "Medias por periodo", - "period": "Periodo" + "period": "Periodo", + "labsCaption": "Valores de laboratorio por día", + "marker": "Marcador", + "date": "Fecha", + "value": "Valor" }, "selection": { "empty": "No hay entradas en este periodo", @@ -13700,7 +13704,22 @@ "labValuesOne": "1 valor de laboratorio", "labValuesFew": "{count} valores de laboratorio", "labValuesOther": "{count} valores de laboratorio" - } + }, + "labs": { + "menuTitle": "Valores de laboratorio", + "otherPanel": "Otros", + "above": "por encima del intervalo", + "below": "por debajo del intervalo", + "reference": "referencia {range}", + "report": "Informe: {title}", + "reportUntitled": "Abrir el informe", + "excludedOne": "{name}: {count} valor en otra unidad no se muestra", + "excludedFew": "{name}: {count} valores en otra unidad no se muestran", + "excludedOther": "{name}: {count} valores en otra unidad no se muestran" + }, + "legendLabValue": "Valor de laboratorio, tal como se midió", + "legendLabOutside": "Fuera del intervalo de referencia", + "legendLabRange": "Intervalo de referencia" }, "lifeEvents": { "title": "Acontecimiento vital", diff --git a/messages/fr.json b/messages/fr.json index c48261e70..7e2595b56 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -13655,7 +13655,11 @@ "start": "Début", "end": "Fin", "seriesCaption": "Moyennes par période", - "period": "Période" + "period": "Période", + "labsCaption": "Valeurs de laboratoire par jour", + "marker": "Marqueur", + "date": "Date", + "value": "Valeur" }, "selection": { "empty": "Aucune entrée sur cette période", @@ -13700,7 +13704,22 @@ "labValuesOne": "1 valeur de laboratoire", "labValuesFew": "{count} valeurs de laboratoire", "labValuesOther": "{count} valeurs de laboratoire" - } + }, + "labs": { + "menuTitle": "Valeurs de laboratoire", + "otherPanel": "Autres", + "above": "au-dessus de l’intervalle", + "below": "en dessous de l’intervalle", + "reference": "référence {range}", + "report": "Compte rendu : {title}", + "reportUntitled": "Ouvrir le compte rendu", + "excludedOne": "{name} : {count} valeur dans une autre unité n’est pas affichée", + "excludedFew": "{name} : {count} valeurs dans une autre unité ne sont pas affichées", + "excludedOther": "{name} : {count} valeurs dans une autre unité ne sont pas affichées" + }, + "legendLabValue": "Valeur de laboratoire, telle que mesurée", + "legendLabOutside": "Hors de l’intervalle de référence", + "legendLabRange": "Intervalle de référence" }, "lifeEvents": { "title": "Événement de vie", diff --git a/messages/it.json b/messages/it.json index f44262fe3..a709e5531 100644 --- a/messages/it.json +++ b/messages/it.json @@ -13655,7 +13655,11 @@ "start": "Inizio", "end": "Fine", "seriesCaption": "Medie per periodo", - "period": "Periodo" + "period": "Periodo", + "labsCaption": "Valori di laboratorio per giorno", + "marker": "Marcatore", + "date": "Data", + "value": "Valore" }, "selection": { "empty": "Nessuna voce in questo periodo", @@ -13700,7 +13704,22 @@ "labValuesOne": "1 valore di laboratorio", "labValuesFew": "{count} valori di laboratorio", "labValuesOther": "{count} valori di laboratorio" - } + }, + "labs": { + "menuTitle": "Valori di laboratorio", + "otherPanel": "Altri", + "above": "sopra l’intervallo", + "below": "sotto l’intervallo", + "reference": "riferimento {range}", + "report": "Referto: {title}", + "reportUntitled": "Apri il referto", + "excludedOne": "{name}: {count} valore in un’altra unità non è riportato", + "excludedFew": "{name}: {count} valori in un’altra unità non sono riportati", + "excludedOther": "{name}: {count} valori in un’altra unità non sono riportati" + }, + "legendLabValue": "Valore di laboratorio, così come misurato", + "legendLabOutside": "Fuori dall’intervallo di riferimento", + "legendLabRange": "Intervallo di riferimento" }, "lifeEvents": { "title": "Evento di vita", diff --git a/messages/ko.json b/messages/ko.json index 01cea67ec..50d52e248 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -13655,7 +13655,11 @@ "start": "시작", "end": "끝", "seriesCaption": "기간별 평균", - "period": "기간" + "period": "기간", + "labsCaption": "날짜별 검사 수치", + "marker": "항목", + "date": "날짜", + "value": "값" }, "selection": { "empty": "이 기간에는 항목이 없습니다", @@ -13700,7 +13704,22 @@ "labValuesOne": "검사 수치 1개", "labValuesFew": "검사 수치 {count}개", "labValuesOther": "검사 수치 {count}개" - } + }, + "labs": { + "menuTitle": "검사 수치", + "otherPanel": "기타", + "above": "범위 초과", + "below": "범위 미만", + "reference": "참고 범위 {range}", + "report": "보고서: {title}", + "reportUntitled": "보고서 열기", + "excludedOne": "{name}: 다른 단위로 기록된 값 {count}개는 표시되지 않습니다", + "excludedFew": "{name}: 다른 단위로 기록된 값 {count}개는 표시되지 않습니다", + "excludedOther": "{name}: 다른 단위로 기록된 값 {count}개는 표시되지 않습니다" + }, + "legendLabValue": "측정된 그대로의 검사 수치", + "legendLabOutside": "참고 범위 밖", + "legendLabRange": "참고 범위" }, "lifeEvents": { "title": "삶의 사건", diff --git a/messages/nl.json b/messages/nl.json index 9fe4b8f08..cfd5cb096 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -13655,7 +13655,11 @@ "start": "Begin", "end": "Einde", "seriesCaption": "Gemiddelde waarden per periode", - "period": "Periode" + "period": "Periode", + "labsCaption": "Labwaarden per dag", + "marker": "Marker", + "date": "Datum", + "value": "Waarde" }, "selection": { "empty": "Geen registraties in deze periode", @@ -13700,7 +13704,22 @@ "labValuesOne": "1 labwaarde", "labValuesFew": "{count} labwaarden", "labValuesOther": "{count} labwaarden" - } + }, + "labs": { + "menuTitle": "Labwaarden", + "otherPanel": "Overig", + "above": "boven het bereik", + "below": "onder het bereik", + "reference": "referentie {range}", + "report": "Verslag: {title}", + "reportUntitled": "Verslag openen", + "excludedOne": "{name}: {count} waarde in een andere eenheid wordt niet getoond", + "excludedFew": "{name}: {count} waarden in een andere eenheid worden niet getoond", + "excludedOther": "{name}: {count} waarden in een andere eenheid worden niet getoond" + }, + "legendLabValue": "Labwaarde, zoals gemeten", + "legendLabOutside": "Buiten het referentiebereik", + "legendLabRange": "Referentiebereik" }, "lifeEvents": { "title": "Levensgebeurtenis", diff --git a/messages/pl.json b/messages/pl.json index 94fc0bcaf..22e19f30f 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -13655,7 +13655,11 @@ "start": "Początek", "end": "Koniec", "seriesCaption": "Średnie według okresu", - "period": "Okres" + "period": "Okres", + "labsCaption": "Wyniki badań według dni", + "marker": "Parametr", + "date": "Data", + "value": "Wartość" }, "selection": { "empty": "Brak wpisów w tym okresie", @@ -13700,7 +13704,22 @@ "labValuesOne": "1 wynik badania", "labValuesFew": "{count} wyniki badań", "labValuesOther": "{count} wyników badań" - } + }, + "labs": { + "menuTitle": "Wyniki badań", + "otherPanel": "Inne", + "above": "powyżej zakresu", + "below": "poniżej zakresu", + "reference": "zakres {range}", + "report": "Dokument: {title}", + "reportUntitled": "Otwórz dokument", + "excludedOne": "{name}: {count} wartość w innej jednostce nie jest pokazana", + "excludedFew": "{name}: {count} wartości w innej jednostce nie są pokazane", + "excludedOther": "{name}: {count} wartości w innej jednostce nie jest pokazanych" + }, + "legendLabValue": "Wynik badania, tak jak zmierzono", + "legendLabOutside": "Poza zakresem referencyjnym", + "legendLabRange": "Zakres referencyjny" }, "lifeEvents": { "title": "Wydarzenie życiowe", diff --git a/messages/pt-BR.json b/messages/pt-BR.json index 0e3fd7d56..508fd0da6 100644 --- a/messages/pt-BR.json +++ b/messages/pt-BR.json @@ -13655,7 +13655,11 @@ "start": "Início", "end": "Fim", "seriesCaption": "Médias de valores por período", - "period": "Período" + "period": "Período", + "labsCaption": "Valores de exames por dia", + "marker": "Marcador", + "date": "Data", + "value": "Valor" }, "selection": { "empty": "Nenhuma entrada neste período", @@ -13700,7 +13704,22 @@ "labValuesOne": "1 valor laboratorial", "labValuesFew": "{count} valores laboratoriais", "labValuesOther": "{count} valores laboratoriais" - } + }, + "labs": { + "menuTitle": "Valores de exames", + "otherPanel": "Outros", + "above": "acima da faixa", + "below": "abaixo da faixa", + "reference": "referência {range}", + "report": "Laudo: {title}", + "reportUntitled": "Abrir o laudo", + "excludedOne": "{name}: {count} valor em outra unidade não aparece no gráfico", + "excludedFew": "{name}: {count} valores em outra unidade não aparecem no gráfico", + "excludedOther": "{name}: {count} valores em outra unidade não aparecem no gráfico" + }, + "legendLabValue": "Valor de exame, como foi medido", + "legendLabOutside": "Fora da faixa de referência", + "legendLabRange": "Faixa de referência" }, "lifeEvents": { "title": "Evento de vida", diff --git a/src/__tests__/baselines/source-file-lines.json b/src/__tests__/baselines/source-file-lines.json index 4f04a28da..61f72e3a8 100644 --- a/src/__tests__/baselines/source-file-lines.json +++ b/src/__tests__/baselines/source-file-lines.json @@ -1,7 +1,7 @@ { "$comment": "Production files over 800 lines and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.", "count": 84, - "total": 94692, + "total": 94504, "entries": { "app/medications/page-client.tsx": 839, "components/charts/mood-chart.tsx": 1166, @@ -18,8 +18,8 @@ "components/medications/wizard/wizard-payload.ts": 1414, "components/mood/mood-list.tsx": 1408, "components/records/life-circumstances-manager.tsx": 1080, - "components/timeline/timeline-chart.tsx": 1174, - "components/timeline/timeline-geometry.ts": 1395, + "components/timeline/timeline-chart.tsx": 1093, + "components/timeline/timeline-geometry.ts": 1288, "lib/ai/anthropic-client.ts": 804, "lib/ai/coach/about-me.ts": 1012, "lib/ai/coach/coach-prose-grounding.ts": 1079, diff --git a/src/__tests__/baselines/source-function-lines.json b/src/__tests__/baselines/source-function-lines.json index 05a8b99d1..1cf1294f5 100644 --- a/src/__tests__/baselines/source-function-lines.json +++ b/src/__tests__/baselines/source-function-lines.json @@ -1,14 +1,14 @@ { "$comment": "Production functions over 200 lines, keyed \"file :: name\", and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.", - "count": 346, - "total": 112219, + "count": 345, + "total": 111940, "entries": { "app/achievements/page.tsx :: AchievementsPage": 206, "app/api/admin/backups/upload/route.ts :: apiHandler(arg)": 286, "app/api/admin/notifications/reminder-check/route.ts :: apiHandler(arg)": 206, "app/api/admin/settings/route.ts :: apiHandler(arg) #2": 211, "app/api/auth/login/route.ts :: apiHandler(arg)": 262, - "app/api/auth/me/route.ts :: apiHandler(arg)": 386, + "app/api/auth/me/route.ts :: apiHandler(arg)": 378, "app/api/auth/oidc/callback/route.ts :: apiHandler(arg)": 258, "app/api/auth/register/route.ts :: apiHandler(arg)": 268, "app/api/coach/about-me/route.ts :: apiHandler(arg) #2": 309, @@ -19,7 +19,7 @@ "app/api/encounters/[id]/route.ts :: apiHandler(arg) #2": 269, "app/api/environment/route.ts :: apiHandler(arg)": 201, "app/api/export/health-record/route.ts :: apiHandler(arg)": 241, - "app/api/ingest/medication/route.ts :: apiHandler(arg)": 262, + "app/api/ingest/medication/route.ts :: apiHandler(arg)": 259, "app/api/insights/cards/route.ts :: apiHandler(arg)": 211, "app/api/integrations/status/route.ts :: apiHandler(arg)": 451, "app/api/labs/[id]/route.ts :: apiHandler(arg) #2": 239, @@ -62,7 +62,7 @@ "components/admin/login-overview-section.tsx :: LoginOverviewSection": 503, "components/admin/pulse-day-mean-card.tsx :: PulseDayMeanCard": 305, "components/admin/reminders-section.tsx :: RemindersSection": 309, - "components/admin/user-management-section.tsx :: UserManagementSection": 626, + "components/admin/user-management-section.tsx :: UserManagementSection": 622, "components/admin/web-push-vapid-section.tsx :: WebPushVapidSection": 217, "components/auth/claim-profile.tsx :: ClaimProfile": 429, "components/auth/mfa-login-step.tsx :: MfaLoginStep": 217, @@ -232,10 +232,9 @@ "components/targets/range-bar.tsx :: RangeBar": 220, "components/targets/target-edit-sheet.tsx :: TargetEditSheetBody": 474, "components/timeline/life-event-form.tsx :: LifeEventForm": 281, - "components/timeline/series-point-tips.tsx :: SeriesPointTips": 212, - "components/timeline/timeline-chart.tsx :: ChartBody": 243, - "components/timeline/timeline-chronicle.tsx :: TimelineChronicle": 274, - "components/timeline/timeline-view.tsx :: TimelineView": 501, + "components/timeline/timeline-chart.tsx :: ChartBody": 236, + "components/timeline/timeline-chronicle.tsx :: TimelineChronicle": 246, + "components/timeline/timeline-view.tsx :: TimelineView": 486, "components/ui/responsive-sheet.tsx :: ResponsiveSheet": 239, "components/vaccinations/catalog-picker.tsx :: CatalogPicker": 221, "components/vaccinations/custom-vaccine-sheet.tsx :: CustomVaccineSheet": 246, @@ -285,7 +284,7 @@ "lib/gamification/achievements-result.ts :: buildAchievementsResult": 586, "lib/google-health/data-points.ts :: forEachDataPointPage": 288, "lib/google-health/data-points.ts :: forEachDataPointPage > walk": 223, - "lib/google-health/sync-core.ts :: upsertGoogleHealthMeasurements": 453, + "lib/google-health/sync-core.ts :: upsertGoogleHealthMeasurements": 452, "lib/illness/correlation.ts :: computeIllnessCorrelation": 233, "lib/import/labs-csv.ts :: importLabsCsv": 309, "lib/import/labs-csv.ts :: importLabsCsv > flush": 231, @@ -348,6 +347,6 @@ "lib/withings/sync-ecg.ts :: syncUserEcg": 202, "lib/withings/sync-sleep.ts :: syncUserSleep": 414, "lib/withings/sync.ts :: syncUserMeasurements": 337, - "proxy.ts :: proxy": 499 + "proxy.ts :: proxy": 498 } } diff --git a/src/components/timeline/__tests__/lab-lines.test.tsx b/src/components/timeline/__tests__/lab-lines.test.tsx new file mode 100644 index 000000000..ffb988407 --- /dev/null +++ b/src/components/timeline/__tests__/lab-lines.test.tsx @@ -0,0 +1,417 @@ +/** + * Lab markers on the timeline (#1299), as laid out and as rendered: a row + * per marker under the value lines with its readings as single points, the + * reference range behind them, a reading outside it drawn as a triangle and + * worded as outside wherever it is written out, the report it came from, + * the readings a unit left out named, and the menu's groups. All data + * invented. + */ +import { describe, expect, it } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +import type { TimelineLabSeries, TimelineResponse } from "@/lib/day/contract"; +import { I18nProvider } from "@/lib/i18n/context"; + +import { + LabExcludedNote, + LabLines, + LabSeriesTable, + labReadingsIn, +} from "../lab-lines"; +import { SelectionBar } from "../selection-bar"; +import type { SeriesValueFormat } from "../series-format"; +import { SERIES_HEIGHT } from "../series-geometry"; +import { layoutTimeline } from "../timeline-geometry"; +import { TimelineChronicle } from "../timeline-chronicle"; +import { TimelineLegend } from "../timeline-legend"; +import { labOptionGroups } from "../timeline-menus"; +import { useSeriesValueFormat } from "../use-series-value-format"; +import { TODAY, fullTimeline, item, wordsIn } from "./timeline-fixture"; +import { primeLocales } from "@/__tests__/helpers/prime-locales"; + +primeLocales("de"); + +const FERRITIN: TimelineLabSeries = { + key: "lab:bm-ferritin", + biomarkerId: "bm-ferritin", + name: "Ferritin", + panel: "Eisen", + unit: "ng/mL", + band: { low: 30, high: 400 }, + points: [ + { + id: "r1", + date: "2024-02-12", + value: 18, + sourceValue: null, + rangeStatus: "below", + referenceLow: 30, + referenceHigh: 400, + report: { + id: "d1", + title: "Befund Februar", + href: "/documents?doc=d1", + }, + }, + { + id: "r2", + date: "2025-06-01", + value: 85, + sourceValue: null, + rangeStatus: "in-range", + referenceLow: 30, + referenceHigh: 400, + report: null, + }, + { + id: "r3", + date: "2026-03-02", + value: 420, + sourceValue: null, + rangeStatus: "above", + referenceLow: 30, + referenceHigh: 400, + report: null, + }, + ], + excluded: 0, +}; + +const HBA1C: TimelineLabSeries = { + key: "lab:bm-hba1c", + biomarkerId: "bm-hba1c", + name: "HbA1c", + panel: null, + unit: "%", + band: null, + points: [ + { + id: "h1", + date: "2026-03-02", + value: 5.4, + sourceValue: null, + rangeStatus: "unknown", + referenceLow: null, + referenceHigh: null, + report: null, + }, + ], + excluded: 2, +}; + +const WINDOW = { from: "2024-01-01", to: "2026-06-30" }; + +function layout(labSeries: readonly TimelineLabSeries[]) { + const timeline = fullTimeline(); + return layoutTimeline({ + width: 900, + window: WINDOW, + lanes: timeline.lanes, + series: timeline.series, + labSeries, + bucket: "quarter", + words: wordsIn("de"), + startMissing: "?", + today: TODAY, + }); +} + +function render(node: React.ReactElement, locale: "de" | "en" = "de") { + return renderToStaticMarkup( + + {node} + , + ); +} + +/** The text a reader sees, 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.replace(/\s+/g, " "); +} + +function WithFormat({ + build, +}: { + build: (format: SeriesValueFormat) => React.ReactNode; +}) { + return <>{build(useSeriesValueFormat())}; +} + +describe("the lab rows' geometry", () => { + it("adds a row per marker under the value lines and leaves the rest as it was", () => { + const without = layout([]); + const withLabs = layout([FERRITIN, HBA1C]); + expect(withLabs.series).toEqual(without.series); + expect(withLabs.lanes).toEqual(without.lanes); + expect(withLabs.height - without.height).toBe(2 * SERIES_HEIGHT); + const [ferritin, hba1c] = withLabs.labSeries; + expect(ferritin.top).toBe( + without.seriesTop + without.series.length * SERIES_HEIGHT + 6, + ); + expect(hba1c.top - ferritin.top).toBe(SERIES_HEIGHT); + }); + + it("places each reading on its own day, inside the plot and inside its row", () => { + const { labSeries, scale } = layout([FERRITIN]); + const [row] = labSeries; + expect(row.points.map((p) => p.id)).toEqual(["r1", "r2", "r3"]); + for (const p of row.points) { + expect(p.x).toBeCloseTo(scale.xMid(p.date), 5); + expect(p.x).toBeGreaterThanOrEqual(scale.x0); + expect(p.x).toBeLessThanOrEqual(scale.x1); + expect(p.y).toBeGreaterThanOrEqual(row.top); + expect(p.y).toBeLessThanOrEqual(row.top + row.height); + } + // Higher values sit higher; the readings are joined in time order. + const [low, mid, high] = row.points; + expect(low.y).toBeGreaterThan(mid.y); + expect(mid.y).toBeGreaterThan(high.y); + expect(row.path.match(/[ML]/g)).toEqual(["M", "L", "L"]); + expect(row.latest?.id).toBe("r3"); + }); + + it("holds the reference range in the scale and paints it behind the points", () => { + const [row] = layout([FERRITIN]).labSeries; + expect(row.band).not.toBeNull(); + const { top, bottom } = row.band!; + expect(top).not.toBeNull(); + expect(bottom).not.toBeNull(); + // The in-range reading lies inside the band, the others outside it. + const [low, mid, high] = row.points; + expect(mid.y).toBeGreaterThan(top!); + expect(mid.y).toBeLessThan(bottom!); + expect(low.y).toBeGreaterThan(bottom!); + expect(high.y).toBeLessThan(top!); + }); + + it("draws one reading as a point with no line, and an empty window as no point", () => { + const [single] = layout([HBA1C]).labSeries; + expect(single.points).toHaveLength(1); + expect(single.path).toBe(""); + expect(single.band).toBeNull(); + expect(single.points[0].y).toBeCloseTo(single.top + single.height / 2, 5); + const [empty] = layout([{ ...FERRITIN, points: [] }]).labSeries; + expect(empty.points).toEqual([]); + expect(empty.latest).toBeNull(); + }); +}); + +describe("the lab rows as drawn", () => { + function drawn(rows: readonly TimelineLabSeries[]) { + const l = layout(rows); + return render( + + (key === FERRITIN.key ? "Ferritin" : "HbA1c")} + seriesColor={() => "var(--chart-3)"} + valueText={(value, _source, unit) => `${value} ${unit}`} + /> + , + ); + } + + it("marks a reading outside its range by shape, the way it left", () => { + const html = drawn([FERRITIN]); + expect(html).toContain('data-status="below"'); + expect(html).toContain('data-status="above"'); + // Outside the range a triangle (a path), inside it a dot. + expect(html).toMatch(/]*data-status="below"/); + expect(html).toMatch(/]*data-status="above"/); + expect(html).toMatch(/]*data-status="in-range"/); + // The band and its dashed edges, in the row's own colour. + expect(html).toContain('data-slot="timeline-lab-band-fill"'); + expect(html.match(/data-slot="timeline-lab-band-edge"/g)).toHaveLength(2); + expect(html).toContain('data-color="var(--chart-3)"'); + // The newest reading beside the name; the selected bucket's ringed. + expect(textOf(html)).toContain("420 ng/mL"); + expect(html.match(/data-slot="timeline-series-selected"/g)).toHaveLength(1); + }); + + it("says so when the window holds no reading", () => { + const html = drawn([{ ...FERRITIN, points: [] }]); + expect(textOf(html)).toContain("Keine Werte in diesem Zeitraum"); + expect(html).not.toContain('data-slot="timeline-lab-mark"'); + }); +}); + +describe("the lab readings written out", () => { + function bar(selected: string, locale: "de" | "en" = "en") { + const timeline: TimelineResponse = { + ...fullTimeline(), + bucket: "quarter", + labSeries: [FERRITIN, HBA1C], + }; + return render( + ( + + key === FERRITIN.key + ? "Ferritin" + : key === HBA1C.key + ? "HbA1c" + : key + } + seriesColor={() => "var(--chart-3)"} + seriesFormat={format} + onOpenDay={() => undefined} + /> + )} + />, + locale, + ); + } + + it("lists the bucket's readings with their day, a word outside the range and the report", () => { + const html = bar("2024-02-20"); + const text = textOf(html); + expect(text).toContain("Ferritin 18 ng/mL, below range, Feb 12"); + expect(html).toMatch( + /]*href="\/documents\?doc=d1"[^>]*>Report: Befund Februar<\/a>/, + ); + const both = textOf(bar("2026-03-10")); + expect(both).toContain("Ferritin 420 ng/mL, above range, Mar 2"); + // In range and without a range: the value alone. + expect(both).toContain("HbA1c 5.4 %, Mar 2"); + expect(both).not.toContain("HbA1c 5.4 %, above"); + }); + + it("words the verdict in the reader's language", () => { + expect(textOf(bar("2024-02-20", "de"))).toContain( + "Ferritin 18 ng/mL, unter dem Referenzbereich", + ); + }); + + it("finds the readings of a stretch on their own days", () => { + expect( + labReadingsIn([FERRITIN, HBA1C], "2026-03-02", "2026-03-02").map( + (r) => r.point.id, + ), + ).toEqual(["r3", "h1"]); + expect(labReadingsIn([FERRITIN], "2023-01-01", "2023-12-31")).toEqual([]); + }); + + it("rides on the phone's lab-day row, with no link inside the row's button", () => { + const timeline: TimelineResponse = { + ...fullTimeline(), + bucket: "quarter", + lanes: [ + { + key: "labs", + items: [ + item({ + id: "lab-day:2024-02-12", + kind: "labDay", + start: "2024-02-12", + label: "Ferritin", + sub: "1", + href: "/labs", + }), + ], + }, + ], + standing: [], + labSeries: [FERRITIN], + }; + const html = render( + ( + "var(--chart-3)"} + seriesFormat={format} + onOpenDay={() => undefined} + onEditLifeEvent={null} + /> + )} + />, + "en", + ); + const row = html.slice(html.indexOf('data-slot="timeline-chronicle-labs"')); + expect(textOf(row)).toContain("Ferritin 18 ng/mL, below range"); + expect(html).not.toContain('data-slot="timeline-lab-report"'); + }); + + it("names the readings a unit left out, and lists every reading for a screen reader", () => { + const note = textOf( + render( + (key === HBA1C.key ? "HbA1c" : "Ferritin")} + />, + "en", + ), + ); + expect(note).toContain("HbA1c: 2 values in another unit are not drawn"); + expect(note).not.toContain("Ferritin"); + + const table = render( + "Ferritin"} + valueText={(value, _source, unit) => `${value} ${unit}`} + intl="en-GB" + />, + "en", + ); + const rows = table.match(//g) ?? []; + expect(rows).toHaveLength(4); + expect(textOf(table)).toContain( + "18 ng/mL, below range, reference 30–400 ng/mL, Report: Befund Februar", + ); + }); + + it("explains its marks in the legend only when a lab line is drawn", () => { + const without = render(); + expect(without).not.toContain("timeline-legend-lab"); + const withLabs = textOf( + render( + , + "en", + ), + ); + expect(withLabs).toContain("Lab value, as measured"); + expect(withLabs).toContain("Outside the reference range"); + expect(withLabs).toContain("Reference range"); + }); +}); + +describe("the lab markers in the menu", () => { + it("groups by the labs list's group, named groups first, in the reader's order", () => { + const groups = labOptionGroups( + [ + { key: "lab:c", name: "Zink", panel: null }, + { key: "lab:b", name: "Transferrin", panel: "Eisen" }, + { key: "lab:a", name: "Ferritin", panel: "Eisen" }, + { key: "lab:d", name: "Ärzte-Wert", panel: "Blutbild" }, + { key: "lab:a", name: "Ferritin", panel: "Eisen" }, + ], + "de-DE", + ); + expect(groups).toEqual([ + { panel: "Blutbild", keys: ["lab:d"] }, + { panel: "Eisen", keys: ["lab:a", "lab:b"] }, + { panel: null, keys: ["lab:c"] }, + ]); + }); +}); diff --git a/src/components/timeline/__tests__/no-raw-code-guard.test.tsx b/src/components/timeline/__tests__/no-raw-code-guard.test.tsx index 8934caa50..8c6e441fb 100644 --- a/src/components/timeline/__tests__/no-raw-code-guard.test.tsx +++ b/src/components/timeline/__tests__/no-raw-code-guard.test.tsx @@ -105,6 +105,8 @@ 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; + // Taken in its place among the days, so the other items keep theirs. + let labDay = ""; const life: TimelineItem[] = [ ...LIFE_EVENT_CATEGORIES.map((category, i) => item({ @@ -235,7 +237,7 @@ function everyKind(): TimelineResponse { item({ id: "lab-day", kind: "labDay", - start: day(), + start: (labDay = day()), label: "Kreatinin, Kalium, Natrium", sub: "5", }), @@ -258,7 +260,31 @@ function everyKind(): TimelineResponse { standing: [], series: [], availableSeries: [], - labSeries: [], + // A lab line (#1299) with a reading of every range verdict on the lab + // day: each is worded, none reaches the screen as its code. + labSeries: [ + { + key: "lab:bm-k", + biomarkerId: "bm-k", + name: "Kalium", + panel: null, + unit: "mmol/L", + band: { low: 3.5, high: 5.1 }, + points: (["below", "in-range", "above", "unknown"] as const).map( + (rangeStatus, i) => ({ + id: `k${i}`, + date: labDay, + value: 3 + i, + sourceValue: null, + rangeStatus, + referenceLow: 3.5, + referenceHigh: 5.1, + report: null, + }), + ), + excluded: 1, + }, + ], availableLabs: [], notable: [], }; @@ -318,6 +344,10 @@ describe.each(["de", "en"])("the timeline in %s", (locale) => { ), ); expect(text).toContain(locale === "de" ? "5 Laborwerte" : "5 lab values"); + expect(text).toContain( + locale === "de" ? "über dem Referenzbereich" : "above range", + ); + expect(text).not.toMatch(/in-range|unknown/); expect(rawCodes(text)).toEqual([]); expect(text).not.toContain(MIDDLE_DOT); }); diff --git a/src/components/timeline/__tests__/timeline-geometry.test.ts b/src/components/timeline/__tests__/timeline-geometry.test.ts index e3eee440f..6d241dfca 100644 --- a/src/components/timeline/__tests__/timeline-geometry.test.ts +++ b/src/components/timeline/__tests__/timeline-geometry.test.ts @@ -19,7 +19,6 @@ import { itemsInPeriod, latestDataDate, layoutLane, - layoutSeries, layoutTimeline, stepSelection, todayLabelFits, @@ -28,6 +27,7 @@ import { type LaneLayout, type TimelineLayout, } from "../timeline-geometry"; +import { layoutSeries } from "../series-geometry"; import { itemLine } from "../item-words"; import { dayNumber } from "../timeline-dates"; import { TODAY, fullTimeline, item, wordsIn } from "./timeline-fixture"; diff --git a/src/components/timeline/__tests__/timeline-labs.browser.test.tsx b/src/components/timeline/__tests__/timeline-labs.browser.test.tsx new file mode 100644 index 000000000..1be1f5a7f --- /dev/null +++ b/src/components/timeline/__tests__/timeline-labs.browser.test.tsx @@ -0,0 +1,297 @@ +/** + * Lab markers on the timeline page (#1299), in a real browser: the page as + * mounted, its reads answered by a stubbed network. + * + * - A stored choice that names a lab marker reaches the request, and the + * marker is drawn as its readings under the value lines. + * - The values menu offers the record's markers under their groups; + * choosing one adds it to the stored list and to the next request, + * unchoosing removes it from both. + * - With the labs module off, no marker is offered or requested. + * - A reading's point reads on its own by keyboard: day, value, the word + * for a reading outside its range, the range and the report. + * - At 390 px the page does not scroll sideways, the values menu is an + * icon button with a name, and the lab day's row lists the readings. + * + * All data invented. + */ +import { describe, expect, it } from "vitest"; +import { page, userEvent } from "vitest/browser"; + +import type { TimelineResponse } from "@/lib/day/contract"; + +import { + stubFetch, + type FetchStub, +} from "../../../../tests/browser/fetch-stub"; +import { renderWithApp } from "../../../../tests/browser/render"; +import { setSearch } from "../../../../tests/browser/url"; +import { TimelineView } from "../timeline-view"; + +const VALUES_KEY = "healthlog.timeline.values"; + +function timeline(values: readonly string[]): TimelineResponse { + const ferritin = values.includes("lab:bm-ferritin"); + return { + zoom: "all", + range: { from: "2024-01-01", to: "2026-06-30", dataFrom: "2024-01-01" }, + bucket: "quarter", + lanes: [ + { + key: "labs", + items: [ + { + id: "lab-day:2024-02-12", + kind: "labDay", + start: "2024-02-12", + end: null, + open: false, + precision: "DAY", + startKnown: true, + label: "Ferritin", + sub: "1", + href: "/labs", + group: null, + }, + { + id: "lab-day:2026-03-02", + kind: "labDay", + start: "2026-03-02", + end: null, + open: false, + precision: "DAY", + startKnown: true, + label: "Ferritin, HbA1c", + sub: "2", + href: "/labs", + group: null, + }, + ], + }, + ], + standing: [], + series: values.includes("WEIGHT") + ? [ + { + key: "WEIGHT", + unit: "kg", + points: [ + { t: "2025-01-01", mean: 82.4, count: 6 }, + { t: "2025-04-01", mean: 81.9, count: 5 }, + ], + }, + ] + : [], + availableSeries: ["WEIGHT"], + labSeries: ferritin + ? [ + { + key: "lab:bm-ferritin", + biomarkerId: "bm-ferritin", + name: "Ferritin", + panel: "Iron", + unit: "ng/mL", + band: { low: 30, high: 400 }, + points: [ + { + id: "r1", + date: "2024-02-12", + value: 18, + sourceValue: null, + rangeStatus: "below", + referenceLow: 30, + referenceHigh: 400, + report: { + id: "d1", + title: "February report", + href: "/documents?doc=d1", + }, + }, + { + id: "r2", + date: "2026-03-02", + value: 120, + sourceValue: null, + rangeStatus: "in-range", + referenceLow: 30, + referenceHigh: 400, + report: null, + }, + ], + excluded: 0, + }, + ] + : [], + availableLabs: [ + { + key: "lab:bm-hba1c", + biomarkerId: "bm-hba1c", + name: "HbA1c", + panel: "Glucose", + unit: "%", + }, + { + key: "lab:bm-ferritin", + biomarkerId: "bm-ferritin", + name: "Ferritin", + panel: "Iron", + unit: "ng/mL", + }, + ], + notable: [], + }; +} + +/** The `values` a request named, as a list. */ +function valuesOf(path: string): string[] { + const raw = new URL(path, window.location.origin).searchParams.get("values"); + return raw ? raw.split(",") : []; +} + +function stubTimeline(): FetchStub { + return stubFetch() + .on("GET", "/api/timeline", (request) => ({ + data: timeline(valuesOf(request.path)), + })) + .on("GET", "/api/timeline/readiness", { + data: { verdict: "carries", since: "2024-01-01", lanes: [] }, + }) + .on("GET", "/api/life-events", { data: { events: [] } }); +} + +async function mount( + stored: string[] | null, + modules: Record = { timeline: true, labs: true }, +) { + if (stored) window.localStorage.setItem(VALUES_KEY, JSON.stringify(stored)); + setSearch("?zoom=all"); + const net = stubTimeline(); + const { screen } = await renderWithApp(, { + user: { timezone: "Europe/Berlin", modules }, + }); + return { net, screen }; +} + +function lastTimelineValues(net: FetchStub): string[] { + const calls = net.calls("GET", "/api/timeline"); + return valuesOf(calls[calls.length - 1]?.path ?? ""); +} + +describe("lab lines on the timeline page", () => { + it("asks for a stored lab marker and draws it as its readings", async () => { + await page.viewport(1280, 900); + const { net } = await mount(["WEIGHT", "lab:bm-ferritin"]); + await expect + .poll( + () => + document.querySelectorAll( + '[data-lab-series="lab:bm-ferritin"] [data-slot="timeline-lab-mark"]', + ).length, + ) + .toBe(2); + expect(lastTimelineValues(net)).toEqual(["WEIGHT", "lab:bm-ferritin"]); + const below = document.querySelector( + '[data-lab-series="lab:bm-ferritin"] [data-status="below"]', + ); + expect(below?.tagName.toLowerCase()).toBe("path"); + // The value line keeps its own row above it. + expect(document.querySelector('[data-series="WEIGHT"]')).not.toBeNull(); + // The name leads to the marker's page. + const link = document.querySelector( + '[data-slot="timeline-series-link"][data-series="lab:bm-ferritin"]', + ); + expect(link?.getAttribute("href")).toBe("/labs/bm-ferritin"); + }); + + it("offers the record's markers under their groups, and a choice persists both ways", async () => { + await page.viewport(1280, 900); + const { net, screen } = await mount(["WEIGHT"]); + const trigger = screen.getByRole("button", { name: /Values \(1\)/ }); + await expect.element(trigger).toBeVisible(); + await trigger.click(); + await expect.element(screen.getByText("Lab values")).toBeVisible(); + await expect.element(screen.getByText("Glucose")).toBeVisible(); + await expect.element(screen.getByText("Iron")).toBeVisible(); + const ferritin = screen.getByRole("menuitemcheckbox", { name: "Ferritin" }); + await expect.element(ferritin).toHaveAttribute("aria-checked", "false"); + await ferritin.click(); + await expect.element(ferritin).toHaveAttribute("aria-checked", "true"); + expect(JSON.parse(window.localStorage.getItem(VALUES_KEY)!)).toEqual([ + "WEIGHT", + "lab:bm-ferritin", + ]); + await expect + .poll(() => lastTimelineValues(net)) + .toEqual(["WEIGHT", "lab:bm-ferritin"]); + + await ferritin.click(); + await expect.element(ferritin).toHaveAttribute("aria-checked", "false"); + expect(JSON.parse(window.localStorage.getItem(VALUES_KEY)!)).toEqual([ + "WEIGHT", + ]); + await expect.poll(() => lastTimelineValues(net)).toEqual(["WEIGHT"]); + }); + + it("neither offers nor asks for a marker with the labs module off", async () => { + await page.viewport(1280, 900); + const { net, screen } = await mount(["WEIGHT", "lab:bm-ferritin"], { + timeline: true, + labs: false, + }); + const trigger = screen.getByRole("button", { name: /Values \(1\)/ }); + await expect.element(trigger).toBeVisible(); + expect(lastTimelineValues(net)).toEqual(["WEIGHT"]); + await trigger.click(); + await expect + .element(screen.getByRole("menuitemcheckbox", { name: "Weight" })) + .toBeVisible(); + expect(document.querySelector('[data-slot="timeline-values-labs"]')).toBe( + null, + ); + }); + + it("reads a reading's point on its own by keyboard", async () => { + await page.viewport(1280, 900); + await mount(["lab:bm-ferritin"]); + const target = () => + document.querySelector( + '[data-slot="timeline-point-target"][data-series="lab:bm-ferritin"][data-t="r1"]', + ); + await expect.poll(() => target() !== null).toBe(true); + expect(target()!.getAttribute("aria-label")).toBe( + "Ferritin, Feb 12, 2024: 18 ng/mL, below range, reference 30–400 ng/mL, Report: February report", + ); + // One tab stop per line, on its newest reading; Left walks back. + target()! + .parentElement?.querySelector('[data-t="r2"]') + ?.focus(); + await userEvent.keyboard("{ArrowLeft}"); + expect(document.activeElement).toBe(target()); + const tip = document.querySelector('[data-slot="timeline-point-tip"]'); + expect(tip?.textContent).toContain("18 ng/mL"); + expect(tip?.textContent).toContain("below range"); + expect(tip?.textContent).toContain("February report"); + }); + + it("fits a phone: no sideways scroll, a named values button, the readings on the lab day", async () => { + await page.viewport(390, 844); + const { screen } = await mount(["lab:bm-ferritin"]); + await expect + .element(screen.getByRole("button", { name: "Values (1)" })) + .toBeVisible(); + // Newest first, each lab day with its own readings. + await expect + .poll(() => + [ + ...document.querySelectorAll('[data-slot="timeline-chronicle-labs"]'), + ].map((el) => el.textContent), + ) + .toEqual(["Ferritin 120 ng/mL", "Ferritin 18 ng/mL, below range"]); + const root = document.documentElement; + expect(root.scrollWidth).toBeLessThanOrEqual(root.clientWidth); + const mobile = document.querySelector( + '[data-slot="timeline-mobile"]', + )!; + expect(mobile.scrollWidth).toBeLessThanOrEqual(mobile.clientWidth); + }); +}); diff --git a/src/components/timeline/__tests__/timeline-rework.test.tsx b/src/components/timeline/__tests__/timeline-rework.test.tsx index 80ac9f356..59222150b 100644 --- a/src/components/timeline/__tests__/timeline-rework.test.tsx +++ b/src/components/timeline/__tests__/timeline-rework.test.tsx @@ -24,13 +24,10 @@ import { SERIES_PALETTE, assignSeriesColors } from "../series-colors"; import type { SeriesValueFormat } from "../series-format"; import { SeriesLines } from "../timeline-chart"; import { TimelineChronicle } from "../timeline-chronicle"; -import { - SERIES_GAP, - SERIES_HEIGHT, - layoutTimeline, -} from "../timeline-geometry"; +import { SERIES_GAP, layoutTimeline } from "../timeline-geometry"; +import { SERIES_HEIGHT } from "../series-geometry"; import { ValueSeriesMenu } from "../timeline-menus"; -import { TimelineLegend } from "../timeline-view"; +import { TimelineLegend } from "../timeline-legend"; import { useSeriesValueFormat } from "../use-series-value-format"; import { item, wordsIn } from "./timeline-fixture"; diff --git a/src/components/timeline/lab-lines.tsx b/src/components/timeline/lab-lines.tsx new file mode 100644 index 000000000..619f627bc --- /dev/null +++ b/src/components/timeline/lab-lines.tsx @@ -0,0 +1,540 @@ +"use client"; + +/** + * Lab markers on the timeline (#1299): their rows under the value lines, + * their readings in the selection bar and the phone chronicle, and their + * table for a screen reader. + * + * A lab line is its readings, one point each on the day of the draw, never a + * mean. Each row has its own scale, which holds the reference range as well + * as the points, and the range is painted behind them as the labs page + * paints it: a faint band with dashed edges, in the row's own colour. A + * reading outside its range is a triangle pointing the way it left (up above + * the range, down below it) and says so in words wherever it is written out, + * so the colour is never the only sign. The numbers read through the labs + * page's own formatter, so a reading keeps every digit it was stored with. + */ +import { useCallback } from "react"; +import Link from "next/link"; + +import { useLabNumber } from "@/components/labs/use-lab-format"; +import { InlineAction } from "@/components/ui/inline-action"; +import type { + TimelineBucket, + TimelineLabPoint, + TimelineLabSeries, +} from "@/lib/day/contract"; +import { resolveIntlLocale } from "@/lib/format-locale"; +import { formatReferenceRange } from "@/lib/labs/reference-range"; +import { useTranslations } from "@/lib/i18n/context"; + +import type { ChronicleEntry } from "./chronicle-model"; +import { TIMELINE_LAB_RANGE_KEY } from "./label-keys"; +import { + bucketAfter, + dayKey, + dayNumber, + formatDayMonth, + formatDayMonthYear, +} from "./timeline-dates"; +import { seriesNameLines, type TimelineLayout } from "./timeline-geometry"; + +/** "18 ng/mL", every digit the reading holds, in the reader's locale. */ +export type LabValueText = ( + value: number, + sourceValue: number | null, + unit: string, +) => string; + +export function useLabValueText(): LabValueText { + const labNumber = useLabNumber(); + return useCallback( + (value, sourceValue, unit) => + `${labNumber(value, sourceValue)} ${unit}`.trim(), + [labNumber], + ); +} + +/** "below range" / "above range", or null for a reading in range. */ +export function useLabVerdict(): ( + status: TimelineLabPoint["rangeStatus"], +) => string | null { + const { t } = useTranslations(); + return useCallback( + (status) => + status === "below" || status === "above" + ? t(TIMELINE_LAB_RANGE_KEY[status]) + : null, + [t], + ); +} + +/** "Reference 30–400 ng/mL", or null without a range. */ +export function useLabReference(): ( + point: Pick, + unit: string, +) => string | null { + const { t } = useTranslations(); + const labNumber = useLabNumber(); + return useCallback( + (point, unit) => { + const range = formatReferenceRange( + point.referenceLow, + point.referenceHigh, + (v) => labNumber(v), + ); + return range + ? t("timeline.labs.reference", { range: `${range} ${unit}`.trim() }) + : null; + }, + [t, labNumber], + ); +} + +/** The readings of every lab line whose day lies in `[from, to]`. */ +export function labReadingsIn( + labSeries: readonly TimelineLabSeries[], + from: string, + to: string, +): Array<{ key: string; unit: string; point: TimelineLabPoint }> { + return labSeries.flatMap((s) => + s.points + .filter((p) => p.date >= from && p.date <= to) + .map((point) => ({ key: s.key, unit: s.unit, point })), + ); +} + +/* ─── The rows on the chart ───────────────────────────────────────────────── */ + +/** Where a row's name and latest value start, as on the value lines. */ +const TEXT_X = 16; + +/** + * A reading's mark: a dot in range (or without one), a triangle pointing up + * above it and down below it. Every mark takes the row's `currentColor`. + */ +export function LabPointGlyph({ + x, + y, + status, +}: { + x: number; + y: number; + status: TimelineLabPoint["rangeStatus"]; +}) { + if (status === "above" || status === "below") { + const d = + status === "above" + ? `M${x} ${y - 4.5}L${x + 4.5} ${y + 3.5}L${x - 4.5} ${y + 3.5}Z` + : `M${x} ${y + 4.5}L${x + 4.5} ${y - 3.5}L${x - 4.5} ${y - 3.5}Z`; + return ( + + ); + } + return ( + + ); +} + +export function LabLines({ + layout, + selectedBucket, + bucket, + seriesLabel, + seriesColor, + valueText, +}: { + layout: Pick< + TimelineLayout, + "labSeries" | "series" | "seriesTop" | "scale" | "width" + >; + /** The first day of the selected bucket: its readings are ringed. */ + selectedBucket: string | null; + bucket: TimelineBucket; + seriesLabel: (key: string) => string; + seriesColor: (key: string) => string; + valueText: LabValueText; +}) { + const { t } = useTranslations(); + const { scale, width } = layout; + const rows = layout.labSeries; + const selectedTo = selectedBucket + ? dayKey(dayNumber(bucketAfter(selectedBucket, bucket)) - 1) + : null; + const nameLines = (key: string) => + seriesNameLines(seriesLabel(key), scale.x0 - 12 - TEXT_X, 13); + return ( + <> + {/* The hairline above the rows, when no value line drew it already. */} + {layout.series.length === 0 && rows.length > 0 && ( + + )} + {rows.map((row) => { + const color = seriesColor(row.key); + const lines = nameLines(row.key); + return ( + + {row.band && ( + + {row.band.top !== null && row.band.bottom !== null && ( + + )} + {[row.band.top, row.band.bottom].map((edge, i) => + edge === null ? null : ( + + ), + )} + + )} + + + {lines.map((line, i) => ( + + {line} + + ))} + + + {row.latest === null + ? t("timeline.values.noneInWindow") + : valueText(row.latest.value, row.latest.sourceValue, row.unit)} + + {row.path && ( + + )} + {row.points.map((point) => ( + + + + ))} + {selectedBucket !== null && + selectedTo !== null && + row.points + .filter( + (point) => + point.date >= selectedBucket && point.date <= selectedTo, + ) + .map((point) => ( + + ))} + + ); + })} + + ); +} + +/* ─── Written out ─────────────────────────────────────────────────────────── */ + +/** + * Lab readings as a line of their own under the means: "● Ferritin 18 ng/mL, + * below range, 12 Feb." with the report it came from as a link. Each reading + * is one unit that wraps only between units, like a means line. + */ +export function LabReadingsLine({ + readings, + label, + seriesColor, + valueText, + showDate, + links = true, + intl, +}: { + readings: ReturnType; + label: (key: string) => string; + seriesColor: (key: string) => string; + valueText: LabValueText; + /** The day beside each reading: where a line covers more than one day. */ + showDate: boolean; + /** + * The report as a link. Off inside a row that is itself a button (a link + * cannot sit inside one); the day the row opens names the report. + */ + links?: boolean; + intl: string; +}) { + const { t } = useTranslations(); + const verdict = useLabVerdict(); + return readings.map(({ key, unit, point }, i) => { + const words = [ + `${label(key)} ${valueText(point.value, point.sourceValue, unit)}`, + verdict(point.rangeStatus), + showDate ? formatDayMonth(point.date, intl) : null, + ] + .filter(Boolean) + .join(", "); + return ( + + {i > 0 ? , : null} + + ); + }); +} + +/** + * A lab day's row in the phone chronicle lists the chosen lab lines' + * readings of that day under its names. The row is a button that opens the + * day, so the report is named there, not linked here. + */ +export function ChronicleLabReadings({ + entry, + labSeries, + seriesColor, + className, +}: { + entry: ChronicleEntry; + labSeries: readonly TimelineLabSeries[]; + seriesColor: (key: string) => string; + /** The row's means-line classes, so both read alike. */ + className: string; +}) { + const { locale } = useTranslations(); + const valueText = useLabValueText(); + if ( + entry.kind !== "item" || + entry.item.kind !== "labDay" || + entry.role === "end" + ) { + return null; + } + const readings = labReadingsIn(labSeries, entry.date, entry.date); + if (readings.length === 0) return null; + return ( + + labSeries.find((s) => s.key === key)?.name ?? key} + seriesColor={seriesColor} + valueText={valueText} + showDate={false} + links={false} + intl={resolveIntlLocale(locale)} + /> + + ); +} + +/** + * A lab line with readings left out because their unit does not convert into + * the marker's says so, once, under the chart (and over the phone list): + * nothing is dropped without a word. + */ +export function LabExcludedNote({ + labSeries, + label, + block = false, +}: { + labSeries: readonly TimelineLabSeries[]; + label: (key: string) => string; + /** A line of its own (the phone list), not runs inside the legend. */ + block?: boolean; +}) { + const { tCount } = useTranslations(); + const lines = labSeries.filter((s) => s.excluded > 0); + if (lines.length === 0) return null; + const notes = lines.map((s) => ( + + {tCount("timeline.labs.excluded", s.excluded, { name: label(s.key) })} + + )); + return block ? ( +
+ {notes} +
+ ) : ( + notes + ); +} + +/** + * The lab lines as a table, for a screen reader: one row per reading, its + * marker, day, value, verdict, range and report. Visually hidden by a + * wrapper, as the other chart tables are. + */ +export function LabSeriesTable({ + labSeries, + window, + label, + valueText, + intl, +}: { + labSeries: readonly TimelineLabSeries[]; + window: { from: string; to: string }; + label: (key: string) => string; + valueText: LabValueText; + intl: string; +}) { + const { t } = useTranslations(); + const verdict = useLabVerdict(); + const reference = useLabReference(); + const rows = labReadingsIn(labSeries, window.from, window.to).sort((a, b) => + a.point.date < b.point.date ? 1 : -1, + ); + if (rows.length === 0) return null; + return ( +
+ + + + + + + + + + + {rows.map(({ key, unit, point }) => ( + + + + + + ))} + +
{t("timeline.table.labsCaption")}
{t("timeline.table.marker")}{t("timeline.table.date")}{t("timeline.table.value")}
{label(key)}{formatDayMonthYear(point.date, intl)} + {[ + valueText(point.value, point.sourceValue, unit), + verdict(point.rangeStatus), + reference(point, unit), + point.report + ? point.report.title + ? t("timeline.labs.report", { + title: point.report.title, + }) + : t("timeline.labs.reportUntitled") + : null, + ] + .filter(Boolean) + .join(", ")} +
+
+ ); +} diff --git a/src/components/timeline/label-keys.ts b/src/components/timeline/label-keys.ts index f4de96e35..06d50f887 100644 --- a/src/components/timeline/label-keys.ts +++ b/src/components/timeline/label-keys.ts @@ -54,6 +54,17 @@ export const TIMELINE_LEGEND_MEAN_KEY: Readonly< day: "timeline.legendMeanDay", }; +/** + * A lab reading outside its reference range, worded beside the value + * (#1299). In range and unknown read as the value alone. + */ +export const TIMELINE_LAB_RANGE_KEY: Readonly< + Record<"below" | "above", string> +> = { + below: "timeline.labs.below", + above: "timeline.labs.above", +}; + /** 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> diff --git a/src/components/timeline/selection-bar.tsx b/src/components/timeline/selection-bar.tsx index 9c821c9af..282fe79eb 100644 --- a/src/components/timeline/selection-bar.tsx +++ b/src/components/timeline/selection-bar.tsx @@ -20,7 +20,10 @@ * above it, not in a box of its own. An entry's parts are joined by commas, * a period still open reads "since 3 Mar." (it needs no "ongoing"), and a * medication is one entry however many of its items touch the bucket - * (`medicationPeriodItem`). + * (`medicationPeriodItem`). The chosen lab lines' readings in the bucket + * follow the means, each as measured, with its day where the bucket is + * longer than one, a word where it lies outside its range, and a link to the + * report it was read from (#1299). */ import type { TimelineResponse } from "@/lib/day/contract"; import { resolveIntlLocale } from "@/lib/format-locale"; @@ -38,6 +41,7 @@ import { formatMonthYear, } from "./timeline-dates"; import { useItemWords } from "./item-words"; +import { LabReadingsLine, labReadingsIn, useLabValueText } from "./lab-lines"; import { itemsInPeriod } from "./timeline-geometry"; import { MEAN_PARTS_ROW, MeanPartsLine, laneDotStyle } from "./timeline-chart"; import { @@ -82,9 +86,12 @@ export function SelectionBar({ const periodTo = dayKey(dayNumber(bucketAfter(periodFrom, bucket)) - 1); const entries = itemsInPeriod(timeline.lanes, periodFrom, periodTo, today); const means = bucketValues(timeline.series, timeline.bucket, selected); + const labReadings = labReadingsIn(timeline.labSeries, periodFrom, periodTo); + const labValueText = useLabValueText(); // A bucket with a value but no entry is not empty: the means below say // what it holds, so "No entries" is kept for a stretch with nothing. - const hasValue = means.values.some((v) => v.mean !== null); + const hasValue = + means.values.some((v) => v.mean !== null) || labReadings.length > 0; // "Ø {values}": the words around the values, so the values can carry // their colour dots. const [meanBefore, meanAfter = ""] = t("timeline.selection.mean", { @@ -218,6 +225,18 @@ export function SelectionBar({ {meanAfter ? {meanAfter} : null}
)} + {labReadings.length > 0 && ( + + + + )} ); } diff --git a/src/components/timeline/series-geometry.ts b/src/components/timeline/series-geometry.ts new file mode 100644 index 000000000..2974adbd4 --- /dev/null +++ b/src/components/timeline/series-geometry.ts @@ -0,0 +1,245 @@ +/** + * The value lines and lab lines under the timeline's lanes, as pure + * geometry (v1.42, #613; lab lines #1299). `timeline-geometry.ts` stacks + * them under the lanes; each row is laid out here on its own scale. + */ +import type { + TimelineBucket, + TimelineLabPoint, + TimelineLabSeries, + TimelineSeries, +} from "@/lib/day/contract"; + +import { bucketAfter, bucketIndex, dayNumber } from "./timeline-dates"; +import type { Scale, TimeWindow } from "./timeline-geometry"; + +/** Height of one value line, label included. */ +export const SERIES_HEIGHT = 60; + +/* ─── Value lines ─────────────────────────────────────────────────────────── */ + +export interface SeriesPointLayout { + /** The bucket's first day. */ + t: string; + mean: number; + /** Readings behind the mean. */ + count: number; + x: number; + y: number; + /** Fewer than {@link THIN_BUCKET_COUNT} readings: drawn hollow. */ + thin: boolean; +} + +export interface SeriesLayout { + key: string; + unit: string | null; + top: number; + height: number; + /** SVG path data of the solid runs; empty when no two points touch. */ + path: string; + /** SVG path data of the dashed bridges over short gaps; may be empty. */ + bridges: string; + /** The most recent mean inside the window, or null. */ + latest: number | null; + points: SeriesPointLayout[]; +} + +/** A bucket mean from fewer readings than this is drawn hollow. */ +export const THIN_BUCKET_COUNT = 3; + +/** + * The most missing buckets a dashed bridge spans. Beyond it the line stops + * and starts again, so a long silence reads as one and not as a trend. + * + * The rule is one length, about six months between the two points a bridge + * joins, never more, and never more than a third of what the zoom shows: + * one missing quarter (the points are six months apart), two missing months + * in a year (three months apart, a sixth of the window) and four missing + * weeks in three months (five weeks apart, the window's third). A longer + * bridge would draw a slope through a stretch where nothing was measured. + */ +export const MAX_BRIDGED_GAP: Readonly> = { + quarter: 1, + month: 2, + week: 4, + // Days come only with a chosen range under six weeks. Three missing days + // (points four days apart) bridge a weekend away or a forgotten cuff; + // a longer silence in a window that short is a break worth seeing, and + // the bridge stays well under a third of even a two-week range. + day: 3, +}; + +/** + * One neutral line per series, scaled to its own range inside the window. + * A point sits in the middle of its bucket, and every bucket with a reading + * gets its point, so one month with no neighbours stays visible. Missing + * buckets stay missing: a short gap is bridged by a dashed stroke + * ({@link MAX_BRIDGED_GAP}) that joins the two real points and invents none + * between them, a longer one breaks the line. + */ +export function layoutSeries( + series: readonly TimelineSeries[], + top: number, + window: TimeWindow, + scale: Scale, + bucket: TimelineBucket, +): SeriesLayout[] { + return series.map((s, index) => { + const rowTop = top + index * SERIES_HEIGHT + 6; + const height = SERIES_HEIGHT - 16; + // A bucket that starts before the window still belongs to it while any + // of its days lie inside. + const inside = s.points + .filter((p) => p.t <= window.to && bucketAfter(p.t, bucket) > window.from) + .sort((a, b) => (a.t < b.t ? -1 : 1)); + if (inside.length === 0) { + return { + key: s.key, + unit: s.unit, + top: rowTop, + height, + path: "", + bridges: "", + latest: null, + points: [], + }; + } + let lo = Math.min(...inside.map((p) => p.mean)); + let hi = Math.max(...inside.map((p) => p.mean)); + const pad = Math.max((hi - lo) * 0.12, Math.abs(hi) * 0.01, 0.5); + lo -= pad; + hi += pad; + const y = (v: number) => rowTop + height * (1 - (v - lo) / (hi - lo)); + const points = inside.map((p) => { + const mid = (dayNumber(p.t) + dayNumber(bucketAfter(p.t, bucket))) / 2; + const x = Math.min(scale.x1, Math.max(scale.x0, scale.x(mid))); + return { + t: p.t, + mean: p.mean, + count: p.count, + x, + y: y(p.mean), + thin: p.count < THIN_BUCKET_COUNT, + }; + }); + // eslint-disable-next-line healthlog/no-unlocalised-decimal -- SVG coordinates, read by the renderer, not a person + const xy = (p: SeriesPointLayout) => `${p.x.toFixed(1)} ${p.y.toFixed(1)}`; + let path = ""; + let bridges = ""; + for (let i = 1; i < points.length; i++) { + const prev = points[i - 1]; + const cur = points[i]; + const missing = + bucketIndex(cur.t, bucket) - bucketIndex(prev.t, bucket) - 1; + if (missing === 0) { + // Continue the run, or open a new one at the previous point. + path += path.endsWith(xy(prev)) + ? `L${xy(cur)}` + : `M${xy(prev)}L${xy(cur)}`; + } else if (missing <= MAX_BRIDGED_GAP[bucket]) { + bridges += `M${xy(prev)}L${xy(cur)}`; + } + } + return { + key: s.key, + unit: s.unit, + top: rowTop, + height, + path, + bridges, + latest: inside[inside.length - 1].mean, + points, + }; + }); +} + +/* ─── Lab lines ───────────────────────────────────────────────────────────── */ + +export interface LabPointLayout extends TimelineLabPoint { + x: number; + y: number; +} + +export interface LabSeriesLayout { + key: string; + biomarkerId: string; + unit: string; + top: number; + height: number; + /** SVG path data joining the points in time order; empty under two. */ + path: string; + /** + * The reference range as y positions inside the row: `top` / `bottom` of + * the band, null on a side the range leaves open. + */ + band: { top: number | null; bottom: number | null } | null; + /** The newest point inside the window, or null. */ + latest: LabPointLayout | null; + points: LabPointLayout[]; + excluded: number; +} + +/** + * One row per lab marker, below the value lines and in their rhythm, each on + * its own scale. A lab line is its readings: a point per reading on the day + * of the draw, joined in time order by a thin stroke, never a mean and never + * a value between readings (#1299). The scale holds the points and the + * reference range together, as the labs page's chart does, so the band + * behind the points is always in view and a point outside it reads as + * outside at a glance. + */ +export function layoutLabSeries( + series: readonly TimelineLabSeries[], + top: number, + window: TimeWindow, + scale: Scale, +): LabSeriesLayout[] { + return series.map((s, index) => { + const rowTop = top + index * SERIES_HEIGHT + 6; + const height = SERIES_HEIGHT - 16; + const inside = s.points.filter( + (p) => p.date >= window.from && p.date <= window.to, + ); + const bounds = [s.band?.low, s.band?.high].filter( + (b): b is number => typeof b === "number", + ); + const span = [...inside.map((p) => p.value), ...bounds]; + let lo = span.length > 0 ? Math.min(...span) : 0; + let hi = span.length > 0 ? Math.max(...span) : 1; + // A lone value, or a range of one number, still needs a span to sit in. + const pad = hi > lo ? (hi - lo) * 0.12 : Math.abs(hi) * 0.1 || 1; + lo -= pad; + hi += pad; + const y = (v: number) => rowTop + height * (1 - (v - lo) / (hi - lo)); + const points = inside.map((p) => ({ + ...p, + x: Math.min(scale.x1, Math.max(scale.x0, scale.xMid(p.date))), + y: y(p.value), + })); + // eslint-disable-next-line healthlog/no-unlocalised-decimal -- SVG coordinates, read by the renderer, not a person + const xy = (p: LabPointLayout) => `${p.x.toFixed(1)} ${p.y.toFixed(1)}`; + const path = + points.length > 1 + ? points.map((p, i) => `${i === 0 ? "M" : "L"}${xy(p)}`).join("") + : ""; + const band = + s.band && (s.band.low !== null || s.band.high !== null) + ? { + top: s.band.high === null ? null : y(s.band.high), + bottom: s.band.low === null ? null : y(s.band.low), + } + : null; + return { + key: s.key, + biomarkerId: s.biomarkerId, + unit: s.unit, + top: rowTop, + height, + path, + band, + latest: points.at(-1) ?? null, + points, + excluded: s.excluded, + }; + }); +} diff --git a/src/components/timeline/series-point-tips.tsx b/src/components/timeline/series-point-tips.tsx index 84230b7cb..e707103cf 100644 --- a/src/components/timeline/series-point-tips.tsx +++ b/src/components/timeline/series-point-tips.tsx @@ -23,6 +23,11 @@ * * The card is placed above the point and kept inside the chart's width * (`tipLeft`), so it never adds a horizontal scroll of its own. + * + * A lab line's point (#1299) is one reading, not a mean: its card names the + * day of the draw, the value, whether it lies outside its reference range + * and the range itself, and the report it was read from. A tap pins the card + * with a link to that report beside "View the whole day". */ import { useEffect, @@ -30,13 +35,22 @@ import { useRef, useState, type KeyboardEvent, + type ReactNode, } from "react"; +import Link from "next/link"; -import { RichChartTooltip } from "@/components/charts/chart-tooltip"; +import { + RichChartTooltip, + type RichTooltipRow, +} from "@/components/charts/chart-tooltip"; import { TooltipDayAction } from "@/components/day/chart-day"; +import { InlineAction } from "@/components/ui/inline-action"; +import { resolveIntlLocale } from "@/lib/format-locale"; import { useTranslations } from "@/lib/i18n/context"; +import { useLabReference, useLabValueText, useLabVerdict } from "./lab-lines"; import { formatSeriesValue, type SeriesValueFormat } from "./series-format"; +import { formatDayMonthYear } from "./timeline-dates"; import type { TimelineLayout } from "./timeline-geometry"; /** Hit target across, in px: bigger than the dot, a comfortable aim. */ @@ -61,6 +75,21 @@ interface Active { pinned: boolean; } +/** One point's target and card, a value line's or a lab line's alike. */ +interface TipPoint { + /** The point's id within its line: a bucket start, or a reading's id. */ + t: string; + x: number; + y: number; + /** The day a second tap, Enter or a double click opens. */ + day: string; + ariaLabel: string; + label: string; + row: RichTooltipRow; + /** Under the row: the report a reading came from. */ + report: { href: string; text: string } | null; +} + export function SeriesPointTips({ layout, today, @@ -81,7 +110,6 @@ export function SeriesPointTips({ onSelect: (date: string) => void; onOpenDay: (date: string) => void; }) { - const { tCount } = useTranslations(); const [active, setActive] = useState(null); // Per line, the point that holds its one tab stop. const [stops, setStops] = useState>({}); @@ -115,21 +143,24 @@ export function SeriesPointTips({ return () => document.removeEventListener("pointerdown", away); }, [pinned]); - // The first day of a point's stretch that has happened, the day it opens. - const dayOf = (t: string) => (t > today ? today : t); + const lines = useTipLines({ + layout, + today, + seriesLabel, + seriesColor, + fmt, + bucketLabel, + }); - const series = layout.series.find((s) => s.key === active?.key); - const point = series?.points.find((p) => p.t === active?.t); - - const words = (key: string, t: string, mean: number, unit: string | null) => - `${seriesLabel(key)}, ${bucketLabel(t)}: ${formatSeriesValue(key, mean, unit, fmt)}`; + const line = lines.find((l) => l.key === active?.key); + const point = line?.points.find((p) => p.t === active?.t); function move( event: KeyboardEvent, key: string, index: number, ) { - const points = layout.series.find((s) => s.key === key)?.points ?? []; + const points = lines.find((l) => l.key === key)?.points ?? []; let next: number | null = null; if (event.key === "ArrowLeft") next = Math.max(0, index - 1); else if (event.key === "ArrowRight") @@ -149,7 +180,7 @@ export function SeriesPointTips({ return (
- {layout.series.map((s) => + {lines.map((s) => s.points.map((p, i) => { const id = `${s.key}:${p.t}`; const stop = stops[s.key] ?? s.points.at(-1)?.t; @@ -165,7 +196,7 @@ export function SeriesPointTips({ data-series={s.key} data-t={p.t} tabIndex={p.t === stop ? 0 : -1} - aria-label={`${words(s.key, p.t, p.mean, s.unit)}, ${tCount("timeline.values.readings", p.count)}`} + aria-label={p.ariaLabel} onPointerDown={(event) => { pointerType.current = event.pointerType; event.stopPropagation(); @@ -189,7 +220,7 @@ export function SeriesPointTips({ event.stopPropagation(); if (event.key === "Enter" || event.key === " ") { event.preventDefault(); - onOpenDay(dayOf(p.t)); + onOpenDay(p.day); return; } move(event, s.key, i); @@ -197,7 +228,7 @@ export function SeriesPointTips({ onClick={(event) => { event.stopPropagation(); if (pointerType.current === "mouse") { - onSelect(dayOf(p.t)); + onSelect(p.day); return; } // A tap shows the card; a second tap on the same point opens @@ -207,14 +238,14 @@ export function SeriesPointTips({ active.key === s.key && active.t === p.t ) { - onOpenDay(dayOf(p.t)); + onOpenDay(p.day); return; } setActive({ key: s.key, t: p.t, pinned: true }); }} onDoubleClick={(event) => { event.stopPropagation(); - onOpenDay(dayOf(p.t)); + onOpenDay(p.day); }} className="focus-visible:ring-ring/50 absolute rounded-full outline-none focus-visible:ring-2" style={{ @@ -227,11 +258,11 @@ export function SeriesPointTips({ ); }), )} - {series && point ? ( + {line && point ? (
onOpenDay(dayOf(point.t))} /> - ) : undefined - } + label={point.label} + rows={[point.row]} + action={tipAction(point, active?.pinned ?? false, () => + onOpenDay(point.day), + )} />
) : null}
); } + +/** + * Under a card's row: a reading's report (a link once the card is pinned, its + * name while it only follows the pointer) and, pinned, the way into the day. + */ +function tipAction( + point: TipPoint, + pinned: boolean, + openDay: () => void, +): ReactNode { + if (!point.report) + return pinned ? : undefined; + return ( + <> +
+ {pinned ? ( + + event.stopPropagation()} + > + {point.report.text} + + + ) : ( + + {point.report.text} + + )} +
+ {pinned ? : null} + + ); +} + +/** + * Every line's points as targets and cards, value lines first, then lab + * lines, in the order they are drawn: a value line's point names its bucket + * and the readings behind its mean, a lab line's point the day of its draw, + * its verdict, its range and its report. + */ +function useTipLines({ + layout, + today, + seriesLabel, + seriesColor, + fmt, + bucketLabel, +}: { + layout: TimelineLayout; + today: string; + seriesLabel: (key: string) => string; + seriesColor: (key: string) => string; + fmt: SeriesValueFormat; + bucketLabel: (t: string) => string; +}): Array<{ key: string; points: TipPoint[] }> { + const { t: tr, tCount, locale } = useTranslations(); + const intl = resolveIntlLocale(locale); + const verdict = useLabVerdict(); + const labValueText = useLabValueText(); + const reference = useLabReference(); + // The first day of a point's stretch that has happened, the day it opens. + const dayOf = (t: string) => (t > today ? today : t); + + const words = (key: string, t: string, mean: number, unit: string | null) => + `${seriesLabel(key)}, ${bucketLabel(t)}: ${formatSeriesValue(key, mean, unit, fmt)}`; + + return [ + ...layout.series.map((s) => ({ + key: s.key, + points: s.points.map((p) => ({ + t: p.t, + x: p.x, + y: p.y, + day: dayOf(p.t), + ariaLabel: `${words(s.key, p.t, p.mean, s.unit)}, ${tCount("timeline.values.readings", p.count)}`, + label: bucketLabel(p.t), + row: { + name: seriesLabel(s.key), + value: formatSeriesValue(s.key, p.mean, s.unit, fmt), + color: seriesColor(s.key), + delta: tCount("timeline.values.readings", p.count), + }, + report: null, + })), + })), + ...layout.labSeries.map((s) => ({ + key: s.key, + points: s.points.map((p) => { + const value = labValueText(p.value, p.sourceValue, s.unit); + const detail = [verdict(p.rangeStatus), reference(p, s.unit)] + .filter(Boolean) + .join(", "); + const report = p.report + ? { + href: p.report.href, + text: p.report.title + ? tr("timeline.labs.report", { title: p.report.title }) + : tr("timeline.labs.reportUntitled"), + } + : null; + const date = formatDayMonthYear(p.date, intl); + return { + t: p.id, + x: p.x, + y: p.y, + day: dayOf(p.date), + ariaLabel: [ + `${seriesLabel(s.key)}, ${date}: ${value}`, + detail, + report?.text, + ] + .filter(Boolean) + .join(", "), + label: date, + row: { + name: seriesLabel(s.key), + value, + color: seriesColor(s.key), + ...(detail ? { delta: detail } : {}), + }, + report, + }; + }), + })), + ]; +} diff --git a/src/components/timeline/timeline-chart.tsx b/src/components/timeline/timeline-chart.tsx index 69b042257..16969fd81 100644 --- a/src/components/timeline/timeline-chart.tsx +++ b/src/components/timeline/timeline-chart.tsx @@ -54,28 +54,29 @@ import type { import { resolveIntlLocale } from "@/lib/format-locale"; import { useTranslations } from "@/lib/i18n/context"; -import { itemLine, useItemWords, type ItemWordsFn } from "./item-words"; -import { medicationListItems } from "./medication-rows"; +import { useItemWords, type ItemWordsFn } from "./item-words"; +import { + LabLines, + LabSeriesTable, + useLabValueText, + type LabValueText, +} from "./lab-lines"; +import { SeriesTable, TimelineTable, bucketText } from "./timeline-tables"; import { TIMELINE_LANE_LABEL_KEY } from "./label-keys"; import { - bucketAfter, bucketStart, formatAtPrecision, - formatBucket, formatMonthShort, formatMonthYear, - dayNumber, } from "./timeline-dates"; import { AXIS_HEIGHT, LANE_COLOR, dateAtPointer, estimateTextWidth, - SERIES_HEIGHT, fitText, seriesNameLines, gridTicks, - isSpan, todayLabelFits, wrapToWidth, layoutTimeline, @@ -85,6 +86,7 @@ import { type PlacedSpan, type TimeWindow, } from "./timeline-geometry"; +import { SERIES_HEIGHT } from "./series-geometry"; import { formatSeriesValue, type MeanPart, @@ -157,6 +159,7 @@ export function TimelineChart({ const { t, locale } = useTranslations(); const words = useItemWords(); const fmt = seriesFormat; + const labValueText = useLabValueText(); const intl = resolveIntlLocale(locale); const { ref, width } = useElementWidth(); @@ -172,13 +175,24 @@ export function TimelineChart({ window, lanes, series: timeline.series, + labSeries: timeline.labSeries, bucket: timeline.bucket, words, startMissing: t("timeline.startUnknown"), today, }) : null, - [width, window, lanes, timeline.series, timeline.bucket, words, t, today], + [ + width, + window, + lanes, + timeline.series, + timeline.labSeries, + timeline.bucket, + words, + t, + today, + ], ); const floor = timeline.range.dataFrom ?? window.from; @@ -266,6 +280,7 @@ export function TimelineChart({ intl={intl} t={t} fmt={fmt} + labValueText={labValueText} /> ) : ( @@ -301,6 +316,13 @@ export function TimelineChart({ fmt={fmt} intl={intl} /> + ); } @@ -320,10 +342,23 @@ export function SeriesNameLinks({ layout: NonNullable>; seriesLabel: (key: string) => string; }) { + // A lab line's name leads to its marker's page on the labs list. + const rows = [ + ...layout.series.map((s) => ({ + key: s.key, + top: s.top, + href: metricPageHref(s.key), + })), + ...layout.labSeries.map((s) => ({ + key: s.key, + top: s.top, + href: `/labs/${encodeURIComponent(s.biomarkerId)}`, + })), + ]; return ( <> - {layout.series.map((series) => { - const href = metricPageHref(series.key); + {rows.map((series) => { + const href = series.href; if (!href) return null; return ( ["t"]; fmt: SeriesValueFormat; + labValueText: LabValueText; } function ChartBody({ @@ -375,6 +411,7 @@ function ChartBody({ intl, t, fmt, + labValueText, }: ChartBodyProps) { const { scale, height, width } = layout; const todayInWindow = today >= window.from && today <= window.to; @@ -409,30 +446,12 @@ function ChartBody({ {noDataUntil !== null && ( - - - {wrapToWidth( - t("timeline.noDataYet"), - noDataUntil - scale.x0 - 12, - ).map((line, i, lines) => ( - - {line} - - ))} - + )} {/* Grid and axis labels. */} @@ -530,6 +549,16 @@ function ChartBody({ fmt={fmt} /> + {/* Lab lines (#1299): single readings, under the value lines. */} + + {/* Today. */} {todayInWindow && ( @@ -606,6 +635,46 @@ function ChartBody({ ); } +/** + * The stretch before the record's first entry, shaded and named, so an empty + * start reads as "no data yet" and not as a quiet period. + */ +function NoDataShade({ + x0, + until, + height, + label, +}: { + x0: number; + until: number; + height: number; + label: string; +}) { + return ( + + + {wrapToWidth(label, until - x0 - 12).map((line, i, lines) => ( + + {line} + + ))} + + ); +} + /** * The value lines under the lanes, each in its own colour: the swatch beside * the name, the line, the dashed bridge over a gap, the points (hollow where @@ -964,156 +1033,6 @@ export function PointMark({ ); } -/** - * The chart as a table, for a screen reader: every entry with its lane and - * dates. Visually hidden; the phone chronicle is the visible list form. - */ -function TimelineTable({ - timeline, - hiddenLanes, - intl, -}: { - timeline: TimelineResponse; - hiddenLanes: ReadonlySet; - intl: string; -}) { - const { t } = useTranslations(); - const words = useItemWords(); - const rows = timeline.lanes - .filter((lane) => !hiddenLanes.has(lane.key)) - .flatMap((lane) => - (lane.key === "medications" - ? medicationListItems(lane.items) - : lane.items - ).map((item) => ({ lane: lane.key, item })), - ) - .sort((a, b) => dayNumber(b.item.start) - dayNumber(a.item.start)); - return ( - // The table is hidden by a wrapper, never by `sr-only` on the table - // itself: `overflow: hidden` does not apply to a table box, so a - // visually hidden table still laid out its rows at full height under - // the page and made `
` scroll past its last card into nothing. -
- - - - - - - - - - - - {rows.map(({ lane, item }) => ( - - - - - - - ))} - -
{t("timeline.table.caption")}
{t("timeline.table.lane")}{t("timeline.table.entry")}{t("timeline.table.start")}{t("timeline.table.end")}
{t(TIMELINE_LANE_LABEL_KEY[lane])}{itemLine(item, words, t("timeline.startUnknown"))}{formatAtPrecision(item.start, item.precision, intl)} - {!item.open && isSpan(item) && item.end - ? formatAtPrecision(item.end, item.precision, intl) - : ""} -
-
- ); -} - -/** "Januar 2026", "Jan bis März 2026", "5. Jan. bis 11. Jan. 2026". */ -export function bucketText( - start: string, - bucket: TimelineBucket, - intl: string, - t: ReturnType["t"], -): string { - const label = formatBucket(start, bucket, intl); - return typeof label === "string" - ? label - : t("timeline.selection.range", label); -} - -/** - * The value lines as a table, for a screen reader: one row per bucket from - * the first to the last with a reading in the window, one column per - * series. A bucket without a reading says so; nothing is filled in. - */ -function SeriesTable({ - timeline, - window, - seriesLabel, - fmt, - intl, -}: { - timeline: TimelineResponse; - window: { from: string; to: string }; - seriesLabel: (key: string) => string; - fmt: SeriesValueFormat; - intl: string; -}) { - const { t, tCount } = useTranslations(); - const { bucket, series } = timeline; - const inWindow = (start: string) => - start <= window.to && bucketAfter(start, bucket) > window.from; - const starts = series - .flatMap((s) => s.points.map((p) => p.t)) - .filter(inWindow) - .sort(); - if (starts.length === 0) return null; - const rows: string[] = []; - for ( - let start = bucketStart(starts[0], bucket); - start <= starts[starts.length - 1]; - start = bucketAfter(start, bucket) - ) { - rows.push(start); - } - const byKey = new Map( - series.map((s) => [s.key, new Map(s.points.map((p) => [p.t, p]))]), - ); - return ( - // The table is hidden by a wrapper, never by `sr-only` on the table - // itself: `overflow: hidden` does not apply to a table box, so a - // visually hidden table still laid out its rows at full height under - // the page and made `
` scroll past its last card into nothing. -
- - - - - - {series.map((s) => ( - - ))} - - - - {rows.reverse().map((start) => ( - - - {series.map((s) => { - const point = byKey.get(s.key)?.get(start); - return ( - - ); - })} - - ))} - -
{t("timeline.table.seriesCaption")}
{t("timeline.table.period")} - {seriesLabel(s.key)} -
{bucketText(start, bucket, intl, t)} - {point - ? `${formatSeriesValue(s.key, point.mean, s.unit, fmt)}, ${tCount("timeline.values.readings", point.count)}` - : t("timeline.values.noValue")} -
-
- ); -} - /** * A means line with each value marked by its line's colour: "● 129/82 mmHg * ● 82,6 kg". The values stand apart by space, not by a separator glyph; diff --git a/src/components/timeline/timeline-chronicle.tsx b/src/components/timeline/timeline-chronicle.tsx index f8c1ee212..ceff3aff3 100644 --- a/src/components/timeline/timeline-chronicle.tsx +++ b/src/components/timeline/timeline-chronicle.tsx @@ -7,6 +7,9 @@ * rails beside the rows an episode or a course spans, and empty stretches * named rather than skipped. Built from `chronicle-model.ts`. * + * A lab day's row lists the chosen lab lines' readings of that day under its + * names, each as measured (#1299); its report is a step further, in the day. + * * A dated row opens its day (the same `?day=` layer the chart hands off to); * a life event opens its edit sheet instead, because the event is the * person's own entry and that is where it is changed. @@ -42,6 +45,7 @@ import { type ChronicleRails, } from "./chronicle-model"; import { useItemWords } from "./item-words"; +import { ChronicleLabReadings } from "./lab-lines"; import { TIMELINE_CHRONICLE_NOTABLE_KEY } from "./label-keys"; import { formatAtPrecision, @@ -50,12 +54,8 @@ import { formatMonthYear, } from "./timeline-dates"; import { LANE_COLOR } from "./timeline-geometry"; -import { - MEAN_PARTS_ROW, - MeanPartsLine, - bucketText, - laneDotStyle, -} from "./timeline-chart"; +import { MEAN_PARTS_ROW, MeanPartsLine, laneDotStyle } from "./timeline-chart"; +import { bucketText } from "./timeline-tables"; import { chronicleMeans, meanParts, @@ -131,6 +131,52 @@ function Rails({ rails }: { rails: ChronicleRails }) { ); } +/** A chronicle row's title and the muted line under it. */ +function entryText( + entry: ChronicleEntry, + { + t, + tCount, + words, + }: { + t: ReturnType["t"]; + tCount: ReturnType["tCount"]; + words: ReturnType; + }, +): { + title: string; + meta: string | null; +} { + if (entry.kind === "notable") { + return { + title: t(TIMELINE_CHRONICLE_NOTABLE_KEY[entry.notable]), + meta: t("timeline.chronicle.notableMeta"), + }; + } + const { item, role } = entry; + const { label, sub, tag } = words(item); + if (role === "end") { + // A pause that ends is the medication taken up again, not ended. + return { + 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, + }; + } + // A life event's category sits on the muted line under its title. + const meta = [ + sub ?? tag ?? null, + item.startKnown ? null : t("timeline.startUnknown"), + ] + .filter(Boolean) + .join(", "); + return { title: label, meta: meta || null }; +} + export function TimelineChronicle({ timeline, today, @@ -185,40 +231,6 @@ export function TimelineChronicle({ }); }; - function entryText(entry: ChronicleEntry): { - title: string; - meta: string | null; - } { - if (entry.kind === "notable") { - return { - title: t(TIMELINE_CHRONICLE_NOTABLE_KEY[entry.notable]), - meta: t("timeline.chronicle.notableMeta"), - }; - } - const { item, role } = entry; - const { label, sub, tag } = words(item); - if (role === "end") { - // A pause that ends is the medication taken up again, not ended. - return { - 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, - }; - } - // A life event's category sits on the muted line under its title. - const meta = [ - sub ?? tag ?? null, - item.startKnown ? null : t("timeline.startUnknown"), - ] - .filter(Boolean) - .join(", "); - return { title: label, meta: meta || null }; - } - return (
{chips.length > 0 && ( @@ -314,7 +326,7 @@ export function TimelineChronicle({ ); } const { entry } = row; - const { title, meta } = entryText(entry); + const { title, meta } = entryText(entry, { t, tCount, words }); const Icon = entry.kind === "notable" ? Sparkles @@ -367,6 +379,12 @@ export function TimelineChronicle({ {meta} )} + ); diff --git a/src/components/timeline/timeline-geometry.ts b/src/components/timeline/timeline-geometry.ts index 2bb2b49f4..f6213f0ed 100644 --- a/src/components/timeline/timeline-geometry.ts +++ b/src/components/timeline/timeline-geometry.ts @@ -20,6 +20,7 @@ import type { TimelineBucket, TimelineItem, TimelineItemKind, + TimelineLabSeries, TimelineLane, TimelineLaneKey, TimelineSeries, @@ -27,6 +28,13 @@ import type { } from "@/lib/day/contract"; import { itemLine, type ItemWordsFn } from "./item-words"; +import { + SERIES_HEIGHT, + layoutLabSeries, + layoutSeries, + type LabSeriesLayout, + type SeriesLayout, +} from "./series-geometry"; import { doseSegments, medicationGroups, @@ -36,8 +44,6 @@ import { } from "./medication-rows"; import { addMonths, - bucketAfter, - bucketIndex, dayKey, dayNumber, endOfMonth, @@ -57,8 +63,6 @@ export const ROW_HEIGHT = 22; export const LANE_PAD_BOTTOM = 18; /** Gap between the last lane and the first value line. */ export const SERIES_GAP = 18; -/** Height of one value line, label included. */ -export const SERIES_HEIGHT = 60; /** Label font size in the lanes, px. */ export const LABEL_FONT_PX = 11; /** Estimated advance of one label character at 11 px, on the wide side. */ @@ -1051,143 +1055,6 @@ function placeLabels( return labels; } -/* ─── Value lines ─────────────────────────────────────────────────────────── */ - -export interface SeriesPointLayout { - /** The bucket's first day. */ - t: string; - mean: number; - /** Readings behind the mean. */ - count: number; - x: number; - y: number; - /** Fewer than {@link THIN_BUCKET_COUNT} readings: drawn hollow. */ - thin: boolean; -} - -export interface SeriesLayout { - key: string; - unit: string | null; - top: number; - height: number; - /** SVG path data of the solid runs; empty when no two points touch. */ - path: string; - /** SVG path data of the dashed bridges over short gaps; may be empty. */ - bridges: string; - /** The most recent mean inside the window, or null. */ - latest: number | null; - points: SeriesPointLayout[]; -} - -/** A bucket mean from fewer readings than this is drawn hollow. */ -export const THIN_BUCKET_COUNT = 3; - -/** - * The most missing buckets a dashed bridge spans. Beyond it the line stops - * and starts again, so a long silence reads as one and not as a trend. - * - * The rule is one length, about six months between the two points a bridge - * joins, never more, and never more than a third of what the zoom shows: - * one missing quarter (the points are six months apart), two missing months - * in a year (three months apart, a sixth of the window) and four missing - * weeks in three months (five weeks apart, the window's third). A longer - * bridge would draw a slope through a stretch where nothing was measured. - */ -export const MAX_BRIDGED_GAP: Readonly> = { - quarter: 1, - month: 2, - week: 4, - // Days come only with a chosen range under six weeks. Three missing days - // (points four days apart) bridge a weekend away or a forgotten cuff; - // a longer silence in a window that short is a break worth seeing, and - // the bridge stays well under a third of even a two-week range. - day: 3, -}; - -/** - * One neutral line per series, scaled to its own range inside the window. - * A point sits in the middle of its bucket, and every bucket with a reading - * gets its point, so one month with no neighbours stays visible. Missing - * buckets stay missing: a short gap is bridged by a dashed stroke - * ({@link MAX_BRIDGED_GAP}) that joins the two real points and invents none - * between them, a longer one breaks the line. - */ -export function layoutSeries( - series: readonly TimelineSeries[], - top: number, - window: TimeWindow, - scale: Scale, - bucket: TimelineBucket, -): SeriesLayout[] { - return series.map((s, index) => { - const rowTop = top + index * SERIES_HEIGHT + 6; - const height = SERIES_HEIGHT - 16; - // A bucket that starts before the window still belongs to it while any - // of its days lie inside. - const inside = s.points - .filter((p) => p.t <= window.to && bucketAfter(p.t, bucket) > window.from) - .sort((a, b) => (a.t < b.t ? -1 : 1)); - if (inside.length === 0) { - return { - key: s.key, - unit: s.unit, - top: rowTop, - height, - path: "", - bridges: "", - latest: null, - points: [], - }; - } - let lo = Math.min(...inside.map((p) => p.mean)); - let hi = Math.max(...inside.map((p) => p.mean)); - const pad = Math.max((hi - lo) * 0.12, Math.abs(hi) * 0.01, 0.5); - lo -= pad; - hi += pad; - const y = (v: number) => rowTop + height * (1 - (v - lo) / (hi - lo)); - const points = inside.map((p) => { - const mid = (dayNumber(p.t) + dayNumber(bucketAfter(p.t, bucket))) / 2; - const x = Math.min(scale.x1, Math.max(scale.x0, scale.x(mid))); - return { - t: p.t, - mean: p.mean, - count: p.count, - x, - y: y(p.mean), - thin: p.count < THIN_BUCKET_COUNT, - }; - }); - // eslint-disable-next-line healthlog/no-unlocalised-decimal -- SVG coordinates, read by the renderer, not a person - const xy = (p: SeriesPointLayout) => `${p.x.toFixed(1)} ${p.y.toFixed(1)}`; - let path = ""; - let bridges = ""; - for (let i = 1; i < points.length; i++) { - const prev = points[i - 1]; - const cur = points[i]; - const missing = - bucketIndex(cur.t, bucket) - bucketIndex(prev.t, bucket) - 1; - if (missing === 0) { - // Continue the run, or open a new one at the previous point. - path += path.endsWith(xy(prev)) - ? `L${xy(cur)}` - : `M${xy(prev)}L${xy(cur)}`; - } else if (missing <= MAX_BRIDGED_GAP[bucket]) { - bridges += `M${xy(prev)}L${xy(cur)}`; - } - } - return { - key: s.key, - unit: s.unit, - top: rowTop, - height, - path, - bridges, - latest: inside[inside.length - 1].mean, - points, - }; - }); -} - /* ─── Whole chart ─────────────────────────────────────────────────────────── */ export interface TimelineLayout { @@ -1197,6 +1064,8 @@ export interface TimelineLayout { lanes: LaneLayout[]; seriesTop: number; series: SeriesLayout[]; + /** The lab lines, below the value lines (#1299). */ + labSeries: LabSeriesLayout[]; } /** Width of the lane-name column at a given chart width. */ @@ -1209,6 +1078,8 @@ export function layoutTimeline(input: { window: TimeWindow; lanes: readonly TimelineLane[]; series: readonly TimelineSeries[]; + /** Lab lines, drawn under the value lines; none by default. */ + labSeries?: readonly TimelineLabSeries[]; bucket: TimelineBucket; words: ItemWordsFn; startMissing: string; @@ -1231,7 +1102,9 @@ export function layoutTimeline(input: { lanes.push(layout); top += layout.height; } - const seriesTop = top + (input.series.length > 0 ? SERIES_GAP : 0); + const labInput = input.labSeries ?? []; + const seriesTop = + top + (input.series.length + labInput.length > 0 ? SERIES_GAP : 0); const series = layoutSeries( input.series, seriesTop, @@ -1239,8 +1112,23 @@ export function layoutTimeline(input: { scale, input.bucket, ); - const height = seriesTop + series.length * SERIES_HEIGHT + 4; - return { width: input.width, height, scale, lanes, seriesTop, series }; + const labSeries = layoutLabSeries( + labInput, + seriesTop + series.length * SERIES_HEIGHT, + input.window, + scale, + ); + const height = + seriesTop + (series.length + labSeries.length) * SERIES_HEIGHT + 4; + return { + width: input.width, + height, + scale, + lanes, + seriesTop, + series, + labSeries, + }; } /* ─── Interaction ─────────────────────────────────────────────────────────── */ @@ -1364,6 +1252,7 @@ export function latestDataDate( timeline: { lanes: readonly TimelineLane[]; series: readonly TimelineSeries[]; + labSeries?: readonly TimelineLabSeries[]; }, today: string, window: TimeWindow, @@ -1391,5 +1280,9 @@ export function latestDataDate( for (const series of timeline.series) { for (const point of series.points) consider(point.t); } + // A lab reading counts on its own day. + for (const series of timeline.labSeries ?? []) { + for (const point of series.points) consider(point.date); + } return best; } diff --git a/src/components/timeline/timeline-legend.tsx b/src/components/timeline/timeline-legend.tsx new file mode 100644 index 000000000..ef1b57ad8 --- /dev/null +++ b/src/components/timeline/timeline-legend.tsx @@ -0,0 +1,193 @@ +"use client"; + +/** + * The timeline's legend (v1.42, #613; lab marks #1299). + */ +import type { TimelineBucket, TimelineLabSeries } from "@/lib/day/contract"; +import { useTranslations } from "@/lib/i18n/context"; + +import { TIMELINE_LEGEND_MEAN_KEY } from "./label-keys"; +import { LabExcludedNote } from "./lab-lines"; + +/** + * What the marks mean. Under the selection bar and set off from it by a + * hairline. With value lines, the two marks a line can carry besides its + * points are explained by an example of each: the dashed stroke over a short + * gap without measurements, and the hollow point of a mean resting on fewer + * than three. + */ +export function TimelineLegend({ + bucket, + hasSeries, + labSeries = [], + label = (key) => key, +}: { + bucket: TimelineBucket; + hasSeries: boolean; + /** + * The lab lines drawn: their three marks are explained, and what a line + * left out is said here, where the marks are (#1299). + */ + labSeries?: readonly TimelineLabSeries[]; + label?: (key: string) => string; +}) { + const { t } = useTranslations(); + const hasLabs = labSeries.length > 0; + return ( +
+ + + + + {hasSeries && ( + <> + + + + + {t("timeline.legendGap")} + + + + {t("timeline.legendThin")} + + + )} + {hasLabs && ( + <> + + + {t("timeline.legendLabValue")} + + + + {t("timeline.legendLabOutside")} + + + + {t("timeline.legendLabRange")} + + + + )} +
+ ); +} diff --git a/src/components/timeline/timeline-menus.tsx b/src/components/timeline/timeline-menus.tsx index e33d583de..a20abefac 100644 --- a/src/components/timeline/timeline-menus.tsx +++ b/src/components/timeline/timeline-menus.tsx @@ -7,7 +7,9 @@ * * The value menu is one compact button, "Values (3)". The chosen lines are * not listed again beside it: each one is a named row under the lanes, and - * the menu marks it with its colour. + * the menu marks it with its colour. Below the measurements it offers the + * lab markers the record has values for (#1299), under the groups the labs + * list sorts them into; a lab line is chosen and stored like any other. */ import { ChartLine, ChevronDown, Layers } from "lucide-react"; @@ -16,44 +18,94 @@ import { DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, + DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; -import type { TimelineLaneKey } from "@/lib/day/contract"; +import { + timelineLabMarkerId, + type TimelineLaneKey, + type TimelineResponse, +} from "@/lib/day/contract"; +import { resolveIntlLocale } from "@/lib/format-locale"; import { useTranslations } from "@/lib/i18n/context"; import { TIMELINE_LANE_LABEL_KEY } from "./label-keys"; +/** Lab markers under one group of the labs list (null: no group). */ +export interface LabOptionGroup { + panel: string | null; + keys: readonly string[]; +} + export function ValueSeriesMenu({ options, + labGroups = [], selected, label, color, onChange, + iconOnly = false, }: { options: readonly string[]; + /** The record's lab markers, grouped as the labs list groups them. */ + labGroups?: readonly LabOptionGroup[]; selected: readonly string[]; label: (key: string) => string; /** A chosen line's colour, shown as a dot beside its name. */ color: (key: string) => string; onChange: (next: string[]) => void; + /** The phone toolbar's square button, named by its label. */ + iconOnly?: boolean; }) { const { t } = useTranslations(); + const option = (key: string) => { + const checked = selected.includes(key); + return ( + event.preventDefault()} + onCheckedChange={(next) => + onChange( + next ? [...selected, key] : selected.filter((k) => k !== key), + ) + } + > + {/* The dot is the line's colour on the chart; an unchosen + value has no line yet, so its place stays empty. */} + + ); + }; + const trigger = t("timeline.values.trigger", { count: selected.length }); + // A group is named once there is more than one, or when it has a name. + const namedGroups = + labGroups.length > 1 || labGroups.some((g) => g.panel !== null); return ( {t("timeline.values.menuTitle")} - {options.map((key) => { - const checked = selected.includes(key); - return ( - event.preventDefault()} - onCheckedChange={(next) => - onChange( - next ? [...selected, key] : selected.filter((k) => k !== key), - ) - } - > - {/* The dot is the line's colour on the chart; an unchosen - value has no line yet, so its place stays empty. */} - - ); - })} + {options.map(option)} + {labGroups.length > 0 && ( + <> + + + {t("timeline.labs.menuTitle")} + + {labGroups.map((group) => ( + + {namedGroups && ( + + {group.panel ?? t("timeline.labs.otherPanel")} + + )} + {group.keys.map(option)} + + ))} + + )} ); @@ -147,3 +194,102 @@ export function LayersMenu({ ); } + +/** + * The lab markers of the menu, grouped as the labs list groups them: by the + * marker's group, named groups first, each in the reader's alphabetical + * order, markers inside a group likewise. + */ +export function labOptionGroups( + markers: ReadonlyArray<{ key: string; name: string; panel: string | null }>, + intl: string, +): LabOptionGroup[] { + const collator = new Intl.Collator(intl, { sensitivity: "base" }); + const groups = new Map(); + const seen = new Set(); + for (const marker of markers) { + if (seen.has(marker.key)) continue; + seen.add(marker.key); + const panel = marker.panel?.trim() || null; + groups.set(panel, [...(groups.get(panel) ?? []), marker]); + } + return [...groups] + .sort(([a], [b]) => + a === null ? 1 : b === null ? -1 : collator.compare(a, b), + ) + .map(([panel, list]) => ({ + panel, + keys: [...list] + .sort((a, b) => collator.compare(a.name, b.name)) + .map((m) => m.key), + })); +} + +/** Each lab marker's name by its key, from what the server sent. */ +export function labMarkerNames( + timeline: Pick | undefined, +): Map { + return new Map( + [...(timeline?.availableLabs ?? []), ...(timeline?.labSeries ?? [])].map( + (m) => [m.key, m.name], + ), + ); +} + +/** + * The values menu as the page holds it: every series the record has, the + * chosen ones kept even when they have nothing yet, in `rank`'s order and + * then the record's; then the record's lab markers by group. It counts and + * toggles the lines it can name, so a stored marker the record no longer + * has drops out with the next change. + */ +export function TimelineValuesMenu({ + timeline, + values, + allowed, + rank, + labsOn, + label, + color, + onChange, + iconOnly, +}: { + timeline: TimelineResponse | undefined; + values: readonly string[]; + allowed: (key: string) => boolean; + rank: readonly string[]; + labsOn: boolean; + label: (key: string) => string; + color: (key: string) => string; + onChange: (next: string[]) => void; + iconOnly: boolean; +}) { + const { locale } = useTranslations(); + const options = [ + ...new Set([ + ...(timeline?.availableSeries ?? []).filter(allowed), + ...values.filter((key) => timelineLabMarkerId(key) === null), + ]), + ].sort((a, b) => rank.indexOf(a) - rank.indexOf(b)); + const markers = new Map( + [...(timeline?.availableLabs ?? []), ...(timeline?.labSeries ?? [])].map( + (m) => [m.key, m], + ), + ); + const labGroups = labsOn + ? labOptionGroups([...markers.values()], resolveIntlLocale(locale)) + : []; + return ( + timelineLabMarkerId(key) === null || markers.has(key), + )} + label={label} + color={color} + onChange={onChange} + iconOnly={iconOnly} + /> + ); +} diff --git a/src/components/timeline/timeline-tables.tsx b/src/components/timeline/timeline-tables.tsx new file mode 100644 index 000000000..93bbecce8 --- /dev/null +++ b/src/components/timeline/timeline-tables.tsx @@ -0,0 +1,177 @@ +"use client"; + +/** + * The timeline's chart as tables, for a screen reader (v1.42, #613): the + * lane entries with their dates, and the value lines' means per bucket. + * Both are visually hidden; the phone chronicle is the visible list form. + * The lab lines' table sits with the lab lines (`lab-lines.tsx`). + */ +import type { + TimelineBucket, + TimelineLaneKey, + TimelineResponse, +} from "@/lib/day/contract"; +import { useTranslations } from "@/lib/i18n/context"; + +import { itemLine, useItemWords } from "./item-words"; +import { TIMELINE_LANE_LABEL_KEY } from "./label-keys"; +import { medicationListItems } from "./medication-rows"; +import { formatSeriesValue, type SeriesValueFormat } from "./series-format"; +import { + bucketAfter, + bucketStart, + dayNumber, + formatAtPrecision, + formatBucket, +} from "./timeline-dates"; +import { isSpan } from "./timeline-geometry"; + +/** + * The chart as a table, for a screen reader: every entry with its lane and + * dates. Visually hidden; the phone chronicle is the visible list form. + */ +export function TimelineTable({ + timeline, + hiddenLanes, + intl, +}: { + timeline: TimelineResponse; + hiddenLanes: ReadonlySet; + intl: string; +}) { + const { t } = useTranslations(); + const words = useItemWords(); + const rows = timeline.lanes + .filter((lane) => !hiddenLanes.has(lane.key)) + .flatMap((lane) => + (lane.key === "medications" + ? medicationListItems(lane.items) + : lane.items + ).map((item) => ({ lane: lane.key, item })), + ) + .sort((a, b) => dayNumber(b.item.start) - dayNumber(a.item.start)); + return ( + // The table is hidden by a wrapper, never by `sr-only` on the table + // itself: `overflow: hidden` does not apply to a table box, so a + // visually hidden table still laid out its rows at full height under + // the page and made `
` scroll past its last card into nothing. +
+ + + + + + + + + + + + {rows.map(({ lane, item }) => ( + + + + + + + ))} + +
{t("timeline.table.caption")}
{t("timeline.table.lane")}{t("timeline.table.entry")}{t("timeline.table.start")}{t("timeline.table.end")}
{t(TIMELINE_LANE_LABEL_KEY[lane])}{itemLine(item, words, t("timeline.startUnknown"))}{formatAtPrecision(item.start, item.precision, intl)} + {!item.open && isSpan(item) && item.end + ? formatAtPrecision(item.end, item.precision, intl) + : ""} +
+
+ ); +} + +/** "Januar 2026", "Jan bis März 2026", "5. Jan. bis 11. Jan. 2026". */ +export function bucketText( + start: string, + bucket: TimelineBucket, + intl: string, + t: ReturnType["t"], +): string { + const label = formatBucket(start, bucket, intl); + return typeof label === "string" + ? label + : t("timeline.selection.range", label); +} + +/** + * The value lines as a table, for a screen reader: one row per bucket from + * the first to the last with a reading in the window, one column per + * series. A bucket without a reading says so; nothing is filled in. + */ +export function SeriesTable({ + timeline, + window, + seriesLabel, + fmt, + intl, +}: { + timeline: TimelineResponse; + window: { from: string; to: string }; + seriesLabel: (key: string) => string; + fmt: SeriesValueFormat; + intl: string; +}) { + const { t, tCount } = useTranslations(); + const { bucket, series } = timeline; + const inWindow = (start: string) => + start <= window.to && bucketAfter(start, bucket) > window.from; + const starts = series + .flatMap((s) => s.points.map((p) => p.t)) + .filter(inWindow) + .sort(); + if (starts.length === 0) return null; + const rows: string[] = []; + for ( + let start = bucketStart(starts[0], bucket); + start <= starts[starts.length - 1]; + start = bucketAfter(start, bucket) + ) { + rows.push(start); + } + const byKey = new Map( + series.map((s) => [s.key, new Map(s.points.map((p) => [p.t, p]))]), + ); + return ( + // The table is hidden by a wrapper, never by `sr-only` on the table + // itself: `overflow: hidden` does not apply to a table box, so a + // visually hidden table still laid out its rows at full height under + // the page and made `
` scroll past its last card into nothing. +
+ + + + + + {series.map((s) => ( + + ))} + + + + {rows.reverse().map((start) => ( + + + {series.map((s) => { + const point = byKey.get(s.key)?.get(start); + return ( + + ); + })} + + ))} + +
{t("timeline.table.seriesCaption")}
{t("timeline.table.period")} + {seriesLabel(s.key)} +
{bucketText(start, bucket, intl, t)} + {point + ? `${formatSeriesValue(s.key, point.mean, s.unit, fmt)}, ${tCount("timeline.values.readings", point.count)}` + : t("timeline.values.noValue")} +
+
+ ); +} diff --git a/src/components/timeline/timeline-view.tsx b/src/components/timeline/timeline-view.tsx index ac8a9c97b..229f4da63 100644 --- a/src/components/timeline/timeline-view.tsx +++ b/src/components/timeline/timeline-view.tsx @@ -14,6 +14,10 @@ * and Back closes it. An incoming `?day=` selects that day and zooms to * three months, so a day's "In the timeline" lands on itself. * + * Lab markers the record has values for are offered beside the measurements + * (#1299): a chosen marker is stored and requested like any other line, as + * `lab:`, and drawn as its single readings, never as means. + * * At 768 px and up the chart; below it the chronicle, which is also the * list form of the chart. The readiness card sits on top while a lane is * thin and the person has not closed it; empty lanes are never drawn. @@ -35,8 +39,8 @@ import { useRecordCapabilities } from "@/hooks/use-record-capabilities"; import { DAY_QUERY_PARAM, TIMELINE_ZOOMS, + timelineLabMarkerId, type LifeEventDTO, - type TimelineBucket, type TimelineLaneKey, type TimelineResponse, type TimelineZoom, @@ -49,9 +53,10 @@ 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 { LabExcludedNote } from "./lab-lines"; +import { TimelineLegend } from "./timeline-legend"; import { LifeEventSheet } from "./life-event-sheet"; import { ReadinessCard } from "./readiness-card"; import { @@ -78,7 +83,11 @@ import { type TimelineRange, } from "./timeline-url"; import { LANE_ORDER, latestDataDate, windowFor } from "./timeline-geometry"; -import { LayersMenu, ValueSeriesMenu } from "./timeline-menus"; +import { + LayersMenu, + TimelineValuesMenu, + labMarkerNames, +} from "./timeline-menus"; import { isBoolean, isStringArray, @@ -140,13 +149,56 @@ function timelineIsEmpty(data: TimelineResponse): boolean { return ( data.lanes.every((l) => l.items.length === 0) && data.standing.length === 0 && - data.series.every((s) => s.points.length === 0) + data.series.every((s) => s.points.length === 0) && + data.labSeries.every((s) => s.points.length === 0) ); } const VALUES_KEY = "healthlog.timeline.values"; const HIDDEN_LANES_KEY = "healthlog.timeline.hiddenLanes"; +/** + * The value lines asked for: the stored choice, minus anything unknown and + * minus a line whose module is off, and one colour per chosen line. + */ +function useValueLines( + modules: Partial> | undefined, +) { + const [storedValues, setStoredValues] = useDevicePreference( + VALUES_KEY, + DEFAULT_VALUES, + isStringArray, + ); + // What is asked for: the stored choice, minus anything unknown and minus + // a line whose module is off. The server narrows it further to what the + // caller may see. + const allowed = useCallback( + (key: string) => { + // A lab line belongs to the labs module; whether the record has the + // marker is the server's to say. + if (timelineLabMarkerId(key) !== null) return modules?.labs !== false; + if (!KNOWN_SERIES.has(key)) return false; + const owner: ModuleKey | null | undefined = + VALUE_OPTION_MODULE[key as keyof typeof VALUE_OPTION_MODULE]; + return !owner || modules?.[owner] !== false; + }, + [modules], + ); + const values = useMemo( + () => storedValues.filter(allowed), + [storedValues, allowed], + ); + // One colour per chosen line, shared by the chart, the selection bar, the + // menu and the phone chronicle; contested colours go by the menu's order. + const seriesColors = useMemo( + () => assignSeriesColors(values, SERIES_RANK), + [values], + ); + const seriesColor = (key: string) => + seriesColors.get(key) ?? SERIES_PALETTE[0]; + return { values, allowed, setStoredValues, seriesColor }; +} + export function TimelineView() { const { t } = useTranslations(); const { user } = useAuth(); @@ -183,11 +235,6 @@ export function TimelineView() { const [lifeEventOpen, setLifeEventOpen] = useState(false); const [editing, setEditing] = useState(null); - const [storedValues, setStoredValues] = useDevicePreference( - VALUES_KEY, - DEFAULT_VALUES, - isStringArray, - ); const [hiddenList, setHiddenList] = useDevicePreference( HIDDEN_LANES_KEY, [], @@ -200,31 +247,9 @@ export function TimelineView() { ); const modules = user?.modules; - // What is asked for: the stored choice, minus anything unknown and minus - // a line whose module is off. The server narrows it further to what the - // caller may see. - const allowed = useCallback( - (key: string) => { - if (!KNOWN_SERIES.has(key)) return false; - const owner: ModuleKey | null | undefined = - VALUE_OPTION_MODULE[key as keyof typeof VALUE_OPTION_MODULE]; - return !owner || modules?.[owner] !== false; - }, - [modules], - ); - const values = useMemo( - () => storedValues.filter(allowed), - [storedValues, allowed], - ); - // One colour per chosen line, shared by the chart, the selection bar, the - // menu and the phone chronicle; contested colours go by the menu's order. - const seriesColors = useMemo( - () => assignSeriesColors(values, SERIES_RANK), - [values], - ); + const { values, allowed, setStoredValues, seriesColor } = + useValueLines(modules); const seriesFormat = useSeriesValueFormat(); - const seriesColor = (key: string) => - seriesColors.get(key) ?? SERIES_PALETTE[0]; const hidden = useMemo( () => new Set(hiddenList as TimelineLaneKey[]), [hiddenList], @@ -334,14 +359,17 @@ export function TimelineView() { setLifeEventOpen(true); } + const labNames = labMarkerNames(data); // Blood pressure reads as its own name here; the list labels ("Sys", - // "Dia") lean on a column header the chart does not have. + // "Dia") lean on a column header the chart does not have. A lab marker + // reads by its own name, as on the labs list. const seriesLabel = (key: string) => key === "BLOOD_PRESSURE_SYS" || key === "BLOOD_PRESSURE_DIA" ? t(TIMELINE_BLOOD_PRESSURE_SERIES_KEY[key]) : key === "MOOD" ? t("timeline.readiness.lanes.mood") - : t(MEASUREMENT_TYPE_LABEL_KEYS[key as MeasurementType] ?? key); + : (labNames.get(key) ?? + t(MEASUREMENT_TYPE_LABEL_KEYS[key as MeasurementType] ?? key)); const shownWindow = data ? windowFor( @@ -367,12 +395,19 @@ export function TimelineView() { today, data?.range.dataFrom ?? null, ); - // The selector offers every series the record has, the chosen ones kept - // even when they have nothing yet, in the selector's order and then the - // record's. - const valueOptions = [ - ...new Set([...(data?.availableSeries ?? []).filter(allowed), ...values]), - ].sort((a, b) => SERIES_RANK.indexOf(a) - SERIES_RANK.indexOf(b)); + const valuesMenu = (iconOnly: boolean) => ( + + ); const presentLanes = (data?.lanes ?? []) .filter((l) => l.items.length > 0) .map((l) => l.key) @@ -532,13 +567,7 @@ export function TimelineView() { onApply={(next) => navigate("range", next)} />
- + {valuesMenu(false)} {layers(false)}
@@ -571,6 +600,8 @@ export function TimelineView() { 0} + labSeries={data.labSeries} + label={seriesLabel} /> @@ -593,6 +624,7 @@ export function TimelineView() { slot="timeline-grouping" />
+ {valuesMenu(true)}