From b8a983bdec3e66e9d2b87426bfb60aacbc4c0f62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 29 Oct 2026 20:10:07 +0100 Subject: [PATCH] feat(timeline): draw lab markers as lines under the values With the server naming lab markers as value lines, the page offers them: the values menu lists the markers the record has numeric readings for, under the groups the labs list sorts them into, and a choice is stored and requested like any measurement line. The phone toolbar gains the same menu as an icon button, since the chronicle shows the chosen lines too. A lab row sits under the value lines in their rhythm, on its own scale. Each reading is its own point on the day of the draw, joined in time order by a thin stroke and never averaged. The reference range is painted behind the points as the labs page paints it, a faint band with dashed edges, and the scale holds it so the band is always in view. A reading outside its range is a triangle pointing the way it left and is worded as outside wherever it is written out, so colour is never the only sign. A point reads on its own by pointer, keyboard and tap: the day, the value with every digit it was stored with, the verdict, the range and the report it was read from (a link once pinned). The selection bar lists the bucket's readings with their report, a lab day's row in the phone chronicle lists that day's readings, a screen-reader table lists them all, and a marker with readings in a unit that does not convert says how many were left out. Nothing else on the timeline changes. To make room without growing the large timeline files, the legend, the screen-reader tables and the value-line geometry move into modules of their own, and the chart, the chronicle, the point cards and the page each hand a part to a helper; the size baselines are written back lower. Refs #1299 --- messages/de.json | 23 +- messages/en.json | 23 +- messages/es.json | 23 +- messages/fr.json | 23 +- messages/it.json | 23 +- messages/ko.json | 23 +- messages/nl.json | 23 +- messages/pl.json | 23 +- messages/pt-BR.json | 23 +- .../baselines/source-file-lines.json | 6 +- .../baselines/source-function-lines.json | 21 +- .../timeline/__tests__/lab-lines.test.tsx | 417 ++++++++++++++ .../__tests__/no-raw-code-guard.test.tsx | 34 +- .../__tests__/timeline-geometry.test.ts | 2 +- .../__tests__/timeline-labs.browser.test.tsx | 297 ++++++++++ .../__tests__/timeline-rework.test.tsx | 9 +- src/components/timeline/lab-lines.tsx | 540 ++++++++++++++++++ src/components/timeline/label-keys.ts | 11 + src/components/timeline/selection-bar.tsx | 23 +- src/components/timeline/series-geometry.ts | 245 ++++++++ src/components/timeline/series-point-tips.tsx | 218 +++++-- src/components/timeline/timeline-chart.tsx | 287 ++++------ .../timeline/timeline-chronicle.tsx | 100 ++-- src/components/timeline/timeline-geometry.ts | 181 ++---- src/components/timeline/timeline-legend.tsx | 193 +++++++ src/components/timeline/timeline-menus.tsx | 212 +++++-- src/components/timeline/timeline-tables.tsx | 177 ++++++ src/components/timeline/timeline-view.tsx | 232 +++----- 28 files changed, 2783 insertions(+), 629 deletions(-) create mode 100644 src/components/timeline/__tests__/lab-lines.test.tsx create mode 100644 src/components/timeline/__tests__/timeline-labs.browser.test.tsx create mode 100644 src/components/timeline/lab-lines.tsx create mode 100644 src/components/timeline/series-geometry.ts create mode 100644 src/components/timeline/timeline-legend.tsx create mode 100644 src/components/timeline/timeline-tables.tsx 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)}