mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
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
This commit is contained in:
1 parent
42b2c72e28
commit
b8a983bdec
28 files changed
+2783
-629
No files matched your search
+21
-2
@@ -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",
|
||||
|
||||
+21
-2
@@ -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",
|
||||
|
||||
+21
-2
@@ -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",
|
||||
|
||||
+21
-2
@@ -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",
|
||||
|
||||
+21
-2
@@ -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",
|
||||
|
||||
+21
-2
@@ -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": "삶의 사건",
|
||||
|
||||
+21
-2
@@ -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",
|
||||
|
||||
+21
-2
@@ -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",
|
||||
|
||||
+21
-2
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<I18nProvider initialLocale={locale}>{node}</I18nProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
/** 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(
|
||||
<svg>
|
||||
<LabLines
|
||||
layout={l}
|
||||
selectedBucket="2025-04-01"
|
||||
bucket="quarter"
|
||||
seriesLabel={(key) => (key === FERRITIN.key ? "Ferritin" : "HbA1c")}
|
||||
seriesColor={() => "var(--chart-3)"}
|
||||
valueText={(value, _source, unit) => `${value} ${unit}`}
|
||||
/>
|
||||
</svg>,
|
||||
);
|
||||
}
|
||||
|
||||
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(/<path d="M[^"]+Z"[^>]*data-status="below"/);
|
||||
expect(html).toMatch(/<path d="M[^"]+Z"[^>]*data-status="above"/);
|
||||
expect(html).toMatch(/<circle[^>]*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(
|
||||
<WithFormat
|
||||
build={(format) => (
|
||||
<SelectionBar
|
||||
timeline={timeline}
|
||||
selected={selected}
|
||||
today={TODAY}
|
||||
seriesLabel={(key) =>
|
||||
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(
|
||||
/<a[^>]*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(
|
||||
<WithFormat
|
||||
build={(format) => (
|
||||
<TimelineChronicle
|
||||
timeline={timeline}
|
||||
today={TODAY}
|
||||
grouping="month"
|
||||
selected={null}
|
||||
seriesColor={() => "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(
|
||||
<LabExcludedNote
|
||||
labSeries={[FERRITIN, HBA1C]}
|
||||
label={(key) => (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(
|
||||
<LabSeriesTable
|
||||
labSeries={[FERRITIN]}
|
||||
window={WINDOW}
|
||||
label={() => "Ferritin"}
|
||||
valueText={(value, _source, unit) => `${value} ${unit}`}
|
||||
intl="en-GB"
|
||||
/>,
|
||||
"en",
|
||||
);
|
||||
const rows = table.match(/<tr>/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(<TimelineLegend bucket="quarter" hasSeries />);
|
||||
expect(without).not.toContain("timeline-legend-lab");
|
||||
const withLabs = textOf(
|
||||
render(
|
||||
<TimelineLegend bucket="quarter" hasSeries labSeries={[FERRITIN]} />,
|
||||
"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"] },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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<Locale>(["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);
|
||||
});
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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<string, boolean> = { timeline: true, labs: true },
|
||||
) {
|
||||
if (stored) window.localStorage.setItem(VALUES_KEY, JSON.stringify(stored));
|
||||
setSearch("?zoom=all");
|
||||
const net = stubTimeline();
|
||||
const { screen } = await renderWithApp(<TimelineView />, {
|
||||
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<HTMLButtonElement>(
|
||||
'[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<HTMLButtonElement>('[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<HTMLElement>(
|
||||
'[data-slot="timeline-mobile"]',
|
||||
)!;
|
||||
expect(mobile.scrollWidth).toBeLessThanOrEqual(mobile.clientWidth);
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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<TimelineLabPoint, "referenceLow" | "referenceHigh">,
|
||||
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 (
|
||||
<path
|
||||
d={d}
|
||||
data-slot="timeline-lab-point"
|
||||
data-status={status}
|
||||
fill="currentColor"
|
||||
stroke="var(--card)"
|
||||
strokeWidth={1.2}
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<circle
|
||||
data-slot="timeline-lab-point"
|
||||
data-status={status}
|
||||
cx={x}
|
||||
cy={y}
|
||||
r={3.25}
|
||||
fill="currentColor"
|
||||
stroke="var(--card)"
|
||||
strokeWidth={1.2}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
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 && (
|
||||
<line
|
||||
x1={0}
|
||||
x2={width}
|
||||
y1={layout.seriesTop - 18}
|
||||
y2={layout.seriesTop - 18}
|
||||
stroke="var(--border)"
|
||||
/>
|
||||
)}
|
||||
{rows.map((row) => {
|
||||
const color = seriesColor(row.key);
|
||||
const lines = nameLines(row.key);
|
||||
return (
|
||||
<g
|
||||
key={row.key}
|
||||
data-lab-series={row.key}
|
||||
data-color={color}
|
||||
color={color}
|
||||
>
|
||||
{row.band && (
|
||||
<g data-slot="timeline-lab-band">
|
||||
{row.band.top !== null && row.band.bottom !== null && (
|
||||
<rect
|
||||
x={scale.x0}
|
||||
y={row.band.top}
|
||||
width={Math.max(0, scale.x1 - scale.x0)}
|
||||
height={Math.max(0, row.band.bottom - row.band.top)}
|
||||
fill="currentColor"
|
||||
fillOpacity={0.1}
|
||||
data-slot="timeline-lab-band-fill"
|
||||
/>
|
||||
)}
|
||||
{[row.band.top, row.band.bottom].map((edge, i) =>
|
||||
edge === null ? null : (
|
||||
<line
|
||||
key={i}
|
||||
data-slot="timeline-lab-band-edge"
|
||||
x1={scale.x0}
|
||||
x2={scale.x1}
|
||||
y1={edge}
|
||||
y2={edge}
|
||||
stroke="currentColor"
|
||||
strokeOpacity={0.4}
|
||||
strokeDasharray="4 4"
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</g>
|
||||
)}
|
||||
<line
|
||||
data-slot="timeline-series-swatch"
|
||||
x1={1.5}
|
||||
x2={10.5}
|
||||
y1={row.top + 8}
|
||||
y2={row.top + 8}
|
||||
stroke="currentColor"
|
||||
strokeWidth={3}
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<text
|
||||
data-slot="timeline-series-name"
|
||||
x={TEXT_X}
|
||||
y={row.top + 12}
|
||||
fontSize={13}
|
||||
fontWeight={500}
|
||||
fill="var(--foreground)"
|
||||
>
|
||||
{lines.map((line, i) => (
|
||||
<tspan key={i} x={TEXT_X} dy={i === 0 ? 0 : 15}>
|
||||
{line}
|
||||
</tspan>
|
||||
))}
|
||||
</text>
|
||||
<text
|
||||
x={TEXT_X}
|
||||
y={row.top + 30 + (lines.length - 1) * 15}
|
||||
fontSize={12}
|
||||
fill="var(--muted-foreground)"
|
||||
>
|
||||
{row.latest === null
|
||||
? t("timeline.values.noneInWindow")
|
||||
: valueText(row.latest.value, row.latest.sourceValue, row.unit)}
|
||||
</text>
|
||||
{row.path && (
|
||||
<path
|
||||
d={row.path}
|
||||
data-slot="timeline-lab-line"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.2}
|
||||
strokeOpacity={0.6}
|
||||
strokeLinejoin="round"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
)}
|
||||
{row.points.map((point) => (
|
||||
<g
|
||||
key={point.id}
|
||||
data-slot="timeline-lab-mark"
|
||||
data-id={point.id}
|
||||
data-date={point.date}
|
||||
>
|
||||
<LabPointGlyph
|
||||
x={point.x}
|
||||
y={point.y}
|
||||
status={point.rangeStatus}
|
||||
/>
|
||||
</g>
|
||||
))}
|
||||
{selectedBucket !== null &&
|
||||
selectedTo !== null &&
|
||||
row.points
|
||||
.filter(
|
||||
(point) =>
|
||||
point.date >= selectedBucket && point.date <= selectedTo,
|
||||
)
|
||||
.map((point) => (
|
||||
<circle
|
||||
key={`selected-${point.id}`}
|
||||
data-slot="timeline-series-selected"
|
||||
cx={point.x}
|
||||
cy={point.y}
|
||||
r={6}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
))}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── 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<typeof labReadingsIn>;
|
||||
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 (
|
||||
<span
|
||||
key={`${key}:${point.id}`}
|
||||
data-slot="timeline-lab-reading"
|
||||
data-series={key}
|
||||
data-status={point.rangeStatus}
|
||||
className="inline-flex max-w-full min-w-0 items-center gap-1 whitespace-nowrap"
|
||||
>
|
||||
{i > 0 ? <span className="sr-only">, </span> : null}
|
||||
<span
|
||||
data-slot="timeline-series-dot"
|
||||
className="size-2 shrink-0 rounded-full"
|
||||
style={{ background: seriesColor(key) }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="min-w-0 truncate">{words}</span>
|
||||
{links && point.report ? (
|
||||
<InlineAction asChild tone="muted" size="xs">
|
||||
<Link
|
||||
href={point.report.href}
|
||||
data-slot="timeline-lab-report"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
{point.report.title
|
||||
? t("timeline.labs.report", { title: point.report.title })
|
||||
: t("timeline.labs.reportUntitled")}
|
||||
</Link>
|
||||
</InlineAction>
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<span className={className} data-slot="timeline-chronicle-labs">
|
||||
<LabReadingsLine
|
||||
readings={readings}
|
||||
label={(key) => labSeries.find((s) => s.key === key)?.name ?? key}
|
||||
seriesColor={seriesColor}
|
||||
valueText={valueText}
|
||||
showDate={false}
|
||||
links={false}
|
||||
intl={resolveIntlLocale(locale)}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) => (
|
||||
<span
|
||||
key={s.key}
|
||||
data-slot="timeline-lab-excluded"
|
||||
data-series={s.key}
|
||||
className="inline-flex items-center gap-1.5"
|
||||
>
|
||||
{tCount("timeline.labs.excluded", s.excluded, { name: label(s.key) })}
|
||||
</span>
|
||||
));
|
||||
return block ? (
|
||||
<div
|
||||
className="text-muted-foreground flex flex-wrap gap-x-4 gap-y-1.5 text-xs"
|
||||
data-slot="timeline-mobile-lab-note"
|
||||
>
|
||||
{notes}
|
||||
</div>
|
||||
) : (
|
||||
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 (
|
||||
<div className="sr-only" data-slot="timeline-lab-table-frame">
|
||||
<table data-slot="timeline-lab-table">
|
||||
<caption>{t("timeline.table.labsCaption")}</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">{t("timeline.table.marker")}</th>
|
||||
<th scope="col">{t("timeline.table.date")}</th>
|
||||
<th scope="col">{t("timeline.table.value")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map(({ key, unit, point }) => (
|
||||
<tr key={point.id}>
|
||||
<th scope="row">{label(key)}</th>
|
||||
<td>{formatDayMonthYear(point.date, intl)}</td>
|
||||
<td>
|
||||
{[
|
||||
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(", ")}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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 ? <span>{meanAfter}</span> : null}
|
||||
</span>
|
||||
)}
|
||||
{labReadings.length > 0 && (
|
||||
<span className={MEAN_PARTS_ROW} data-slot="timeline-selection-labs">
|
||||
<LabReadingsLine
|
||||
readings={labReadings}
|
||||
label={seriesLabel}
|
||||
seriesColor={seriesColor}
|
||||
valueText={labValueText}
|
||||
showDate={bucket !== "day"}
|
||||
intl={intl}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<Record<TimelineBucket, number>> = {
|
||||
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,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -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<Active | null>(null);
|
||||
// Per line, the point that holds its one tab stop.
|
||||
const [stops, setStops] = useState<Record<string, string>>({});
|
||||
@@ -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<HTMLButtonElement>,
|
||||
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 (
|
||||
<div data-slot="timeline-point-tips">
|
||||
{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 ? (
|
||||
<div
|
||||
ref={cardRef}
|
||||
data-slot="timeline-point-tip"
|
||||
data-series={series.key}
|
||||
data-series={line.key}
|
||||
data-t={point.t}
|
||||
className={
|
||||
active?.pinned
|
||||
@@ -248,28 +279,141 @@ export function SeriesPointTips({
|
||||
>
|
||||
<RichChartTooltip
|
||||
active
|
||||
label={bucketLabel(point.t)}
|
||||
rows={[
|
||||
{
|
||||
name: seriesLabel(series.key),
|
||||
value: formatSeriesValue(
|
||||
series.key,
|
||||
point.mean,
|
||||
series.unit,
|
||||
fmt,
|
||||
),
|
||||
color: seriesColor(series.key),
|
||||
delta: tCount("timeline.values.readings", point.count),
|
||||
},
|
||||
]}
|
||||
action={
|
||||
active?.pinned ? (
|
||||
<TooltipDayAction onOpen={() => onOpenDay(dayOf(point.t))} />
|
||||
) : undefined
|
||||
}
|
||||
label={point.label}
|
||||
rows={[point.row]}
|
||||
action={tipAction(point, active?.pinned ?? false, () =>
|
||||
onOpenDay(point.day),
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 ? <TooltipDayAction onOpen={openDay} /> : undefined;
|
||||
return (
|
||||
<>
|
||||
<div className="mt-1.5" data-slot="timeline-point-tip-report">
|
||||
{pinned ? (
|
||||
<InlineAction asChild tone="muted" size="xs">
|
||||
<Link
|
||||
href={point.report.href}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
{point.report.text}
|
||||
</Link>
|
||||
</InlineAction>
|
||||
) : (
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{point.report.text}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{pinned ? <TooltipDayAction onOpen={openDay} /> : 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,
|
||||
};
|
||||
}),
|
||||
})),
|
||||
];
|
||||
}
|
||||
@@ -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<HTMLDivElement>();
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</svg>
|
||||
) : (
|
||||
@@ -301,6 +316,13 @@ export function TimelineChart({
|
||||
fmt={fmt}
|
||||
intl={intl}
|
||||
/>
|
||||
<LabSeriesTable
|
||||
labSeries={timeline.labSeries}
|
||||
window={window}
|
||||
label={seriesLabel}
|
||||
valueText={labValueText}
|
||||
intl={intl}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -320,10 +342,23 @@ export function SeriesNameLinks({
|
||||
layout: NonNullable<ReturnType<typeof layoutTimeline>>;
|
||||
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 (
|
||||
<Link
|
||||
@@ -360,6 +395,7 @@ interface ChartBodyProps {
|
||||
intl: string;
|
||||
t: ReturnType<typeof useTranslations>["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({
|
||||
</defs>
|
||||
|
||||
{noDataUntil !== null && (
|
||||
<g data-slot="timeline-no-data">
|
||||
<rect
|
||||
x={scale.x0}
|
||||
y={AXIS_HEIGHT}
|
||||
width={Math.max(0, noDataUntil - scale.x0)}
|
||||
height={height - AXIS_HEIGHT}
|
||||
fill="var(--muted)"
|
||||
opacity={0.4}
|
||||
/>
|
||||
{wrapToWidth(
|
||||
t("timeline.noDataYet"),
|
||||
noDataUntil - scale.x0 - 12,
|
||||
).map((line, i, lines) => (
|
||||
<text
|
||||
key={line}
|
||||
x={scale.x0 + 8}
|
||||
y={height - 8 - (lines.length - 1 - i) * 13}
|
||||
fontSize={11}
|
||||
fill="var(--muted-foreground)"
|
||||
>
|
||||
{line}
|
||||
</text>
|
||||
))}
|
||||
</g>
|
||||
<NoDataShade
|
||||
x0={scale.x0}
|
||||
until={noDataUntil}
|
||||
height={height}
|
||||
label={t("timeline.noDataYet")}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Grid and axis labels. */}
|
||||
@@ -530,6 +549,16 @@ function ChartBody({
|
||||
fmt={fmt}
|
||||
/>
|
||||
|
||||
{/* Lab lines (#1299): single readings, under the value lines. */}
|
||||
<LabLines
|
||||
layout={layout}
|
||||
selectedBucket={selectedBucket}
|
||||
bucket={bucket}
|
||||
seriesLabel={seriesLabel}
|
||||
seriesColor={seriesColor}
|
||||
valueText={labValueText}
|
||||
/>
|
||||
|
||||
{/* Today. */}
|
||||
{todayInWindow && (
|
||||
<g data-slot="timeline-today">
|
||||
@@ -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 (
|
||||
<g data-slot="timeline-no-data">
|
||||
<rect
|
||||
x={x0}
|
||||
y={AXIS_HEIGHT}
|
||||
width={Math.max(0, until - x0)}
|
||||
height={height - AXIS_HEIGHT}
|
||||
fill="var(--muted)"
|
||||
opacity={0.4}
|
||||
/>
|
||||
{wrapToWidth(label, until - x0 - 12).map((line, i, lines) => (
|
||||
<text
|
||||
key={line}
|
||||
x={x0 + 8}
|
||||
y={height - 8 - (lines.length - 1 - i) * 13}
|
||||
fontSize={11}
|
||||
fill="var(--muted-foreground)"
|
||||
>
|
||||
{line}
|
||||
</text>
|
||||
))}
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<TimelineLaneKey>;
|
||||
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 `<main>` scroll past its last card into nothing.
|
||||
<div className="sr-only" data-slot="timeline-table-frame">
|
||||
<table data-slot="timeline-table">
|
||||
<caption>{t("timeline.table.caption")}</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">{t("timeline.table.lane")}</th>
|
||||
<th scope="col">{t("timeline.table.entry")}</th>
|
||||
<th scope="col">{t("timeline.table.start")}</th>
|
||||
<th scope="col">{t("timeline.table.end")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map(({ lane, item }) => (
|
||||
<tr key={`${lane}-${item.id}`}>
|
||||
<td>{t(TIMELINE_LANE_LABEL_KEY[lane])}</td>
|
||||
<td>{itemLine(item, words, t("timeline.startUnknown"))}</td>
|
||||
<td>{formatAtPrecision(item.start, item.precision, intl)}</td>
|
||||
<td>
|
||||
{!item.open && isSpan(item) && item.end
|
||||
? formatAtPrecision(item.end, item.precision, intl)
|
||||
: ""}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** "Januar 2026", "Jan bis März 2026", "5. Jan. bis 11. Jan. 2026". */
|
||||
export function bucketText(
|
||||
start: string,
|
||||
bucket: TimelineBucket,
|
||||
intl: string,
|
||||
t: ReturnType<typeof useTranslations>["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 `<main>` scroll past its last card into nothing.
|
||||
<div className="sr-only" data-slot="timeline-series-table-frame">
|
||||
<table data-slot="timeline-series-table">
|
||||
<caption>{t("timeline.table.seriesCaption")}</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">{t("timeline.table.period")}</th>
|
||||
{series.map((s) => (
|
||||
<th key={s.key} scope="col">
|
||||
{seriesLabel(s.key)}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.reverse().map((start) => (
|
||||
<tr key={start} data-bucket={start}>
|
||||
<th scope="row">{bucketText(start, bucket, intl, t)}</th>
|
||||
{series.map((s) => {
|
||||
const point = byKey.get(s.key)?.get(start);
|
||||
return (
|
||||
<td key={s.key}>
|
||||
{point
|
||||
? `${formatSeriesValue(s.key, point.mean, s.unit, fmt)}, ${tCount("timeline.values.readings", point.count)}`
|
||||
: t("timeline.values.noValue")}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
|
||||
@@ -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<typeof useTranslations>["t"];
|
||||
tCount: ReturnType<typeof useTranslations>["tCount"];
|
||||
words: ReturnType<typeof useItemWords>;
|
||||
},
|
||||
): {
|
||||
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 (
|
||||
<div className="space-y-4" data-slot="timeline-chronicle">
|
||||
{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}
|
||||
</span>
|
||||
)}
|
||||
<ChronicleLabReadings
|
||||
entry={entry}
|
||||
labSeries={timeline.labSeries}
|
||||
seriesColor={seriesColor}
|
||||
className={cn(MEAN_PARTS_ROW, "mt-1")}
|
||||
/>
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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<Record<TimelineBucket, number>> = {
|
||||
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;
|
||||
}
|
||||
@@ -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 (
|
||||
<div
|
||||
className="text-muted-foreground border-border flex flex-wrap items-center gap-x-4 gap-y-1.5 border-t pt-3 text-xs"
|
||||
data-slot="timeline-legend"
|
||||
>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span
|
||||
className="bg-muted-foreground h-1.5 w-4 shrink-0 rounded-full"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{t("timeline.legendSpan")}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span
|
||||
className="bg-muted-foreground size-2 shrink-0 rounded-full"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{t("timeline.legendEvent")}
|
||||
</span>
|
||||
{hasSeries && (
|
||||
<>
|
||||
<span
|
||||
className="inline-flex items-center gap-1.5"
|
||||
data-slot="timeline-legend-mean"
|
||||
data-bucket={bucket}
|
||||
>
|
||||
<span
|
||||
className="bg-foreground h-0.5 w-4 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{t(TIMELINE_LEGEND_MEAN_KEY[bucket])}
|
||||
</span>
|
||||
<span
|
||||
className="inline-flex items-center gap-1.5"
|
||||
data-slot="timeline-legend-gap"
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="8"
|
||||
viewBox="0 0 16 8"
|
||||
className="text-foreground"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<line
|
||||
x1="1"
|
||||
x2="15"
|
||||
y1="4"
|
||||
y2="4"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.2"
|
||||
strokeDasharray="3 3"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
{t("timeline.legendGap")}
|
||||
</span>
|
||||
<span
|
||||
className="inline-flex items-center gap-1.5"
|
||||
data-slot="timeline-legend-thin"
|
||||
>
|
||||
<svg
|
||||
width="8"
|
||||
height="8"
|
||||
viewBox="0 0 8 8"
|
||||
className="text-foreground"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle
|
||||
cx="4"
|
||||
cy="4"
|
||||
r="2.75"
|
||||
fill="var(--card)"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.4"
|
||||
/>
|
||||
</svg>
|
||||
{t("timeline.legendThin")}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{hasLabs && (
|
||||
<>
|
||||
<span
|
||||
className="inline-flex items-center gap-1.5"
|
||||
data-slot="timeline-legend-lab-value"
|
||||
>
|
||||
<svg
|
||||
width="8"
|
||||
height="8"
|
||||
viewBox="0 0 8 8"
|
||||
className="text-foreground"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle cx="4" cy="4" r="3.25" fill="currentColor" />
|
||||
</svg>
|
||||
{t("timeline.legendLabValue")}
|
||||
</span>
|
||||
<span
|
||||
className="inline-flex items-center gap-1.5"
|
||||
data-slot="timeline-legend-lab-outside"
|
||||
>
|
||||
<svg
|
||||
width="10"
|
||||
height="10"
|
||||
viewBox="0 0 10 10"
|
||||
className="text-foreground"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M5 0.5L9.5 8.5L0.5 8.5Z" fill="currentColor" />
|
||||
</svg>
|
||||
{t("timeline.legendLabOutside")}
|
||||
</span>
|
||||
<span
|
||||
className="inline-flex items-center gap-1.5"
|
||||
data-slot="timeline-legend-lab-range"
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="8"
|
||||
viewBox="0 0 16 8"
|
||||
className="text-foreground"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<rect
|
||||
x="0"
|
||||
y="0.5"
|
||||
width="16"
|
||||
height="7"
|
||||
fill="currentColor"
|
||||
fillOpacity="0.1"
|
||||
/>
|
||||
<line
|
||||
x1="0"
|
||||
x2="16"
|
||||
y1="0.5"
|
||||
y2="0.5"
|
||||
stroke="currentColor"
|
||||
strokeOpacity="0.4"
|
||||
strokeDasharray="3 3"
|
||||
/>
|
||||
<line
|
||||
x1="0"
|
||||
x2="16"
|
||||
y1="7.5"
|
||||
y2="7.5"
|
||||
stroke="currentColor"
|
||||
strokeOpacity="0.4"
|
||||
strokeDasharray="3 3"
|
||||
/>
|
||||
</svg>
|
||||
{t("timeline.legendLabRange")}
|
||||
</span>
|
||||
<LabExcludedNote labSeries={labSeries} label={label} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={key}
|
||||
checked={checked}
|
||||
data-slot="timeline-values-option"
|
||||
data-key={key}
|
||||
onSelect={(event) => 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. */}
|
||||
<span
|
||||
data-slot="timeline-values-dot"
|
||||
className="size-2 shrink-0 rounded-full"
|
||||
style={checked ? { background: color(key) } : undefined}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{label(key)}
|
||||
</DropdownMenuCheckboxItem>
|
||||
);
|
||||
};
|
||||
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 (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
size={iconOnly ? "icon" : "sm"}
|
||||
className={iconOnly ? "size-11" : "min-h-11 sm:min-h-9"}
|
||||
aria-label={iconOnly ? trigger : undefined}
|
||||
data-slot="timeline-values-trigger"
|
||||
data-count={selected.length}
|
||||
>
|
||||
<ChartLine className="size-4" aria-hidden="true" />
|
||||
{t("timeline.values.trigger", { count: selected.length })}
|
||||
<ChevronDown className="size-4" aria-hidden="true" />
|
||||
{!iconOnly && trigger}
|
||||
{!iconOnly && <ChevronDown className="size-4" aria-hidden="true" />}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
@@ -62,33 +114,28 @@ export function ValueSeriesMenu({
|
||||
data-slot="timeline-values-menu"
|
||||
>
|
||||
<DropdownMenuLabel>{t("timeline.values.menuTitle")}</DropdownMenuLabel>
|
||||
{options.map((key) => {
|
||||
const checked = selected.includes(key);
|
||||
return (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={key}
|
||||
checked={checked}
|
||||
data-slot="timeline-values-option"
|
||||
data-key={key}
|
||||
onSelect={(event) => 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. */}
|
||||
<span
|
||||
data-slot="timeline-values-dot"
|
||||
className="size-2 shrink-0 rounded-full"
|
||||
style={checked ? { background: color(key) } : undefined}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{label(key)}
|
||||
</DropdownMenuCheckboxItem>
|
||||
);
|
||||
})}
|
||||
{options.map(option)}
|
||||
{labGroups.length > 0 && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuLabel data-slot="timeline-values-labs">
|
||||
{t("timeline.labs.menuTitle")}
|
||||
</DropdownMenuLabel>
|
||||
{labGroups.map((group) => (
|
||||
<DropdownMenuGroup
|
||||
key={group.panel ?? ""}
|
||||
data-slot="timeline-values-lab-group"
|
||||
>
|
||||
{namedGroups && (
|
||||
<DropdownMenuLabel className="text-muted-foreground pt-2 pb-1 text-xs font-medium tracking-wide uppercase">
|
||||
{group.panel ?? t("timeline.labs.otherPanel")}
|
||||
</DropdownMenuLabel>
|
||||
)}
|
||||
{group.keys.map(option)}
|
||||
</DropdownMenuGroup>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
@@ -147,3 +194,102 @@ export function LayersMenu({
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<string | null, typeof markers>();
|
||||
const seen = new Set<string>();
|
||||
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<TimelineResponse, "availableLabs" | "labSeries"> | undefined,
|
||||
): Map<string, string> {
|
||||
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 (
|
||||
<ValueSeriesMenu
|
||||
options={options}
|
||||
labGroups={labGroups}
|
||||
selected={values.filter(
|
||||
(key) => timelineLabMarkerId(key) === null || markers.has(key),
|
||||
)}
|
||||
label={label}
|
||||
color={color}
|
||||
onChange={onChange}
|
||||
iconOnly={iconOnly}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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<TimelineLaneKey>;
|
||||
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 `<main>` scroll past its last card into nothing.
|
||||
<div className="sr-only" data-slot="timeline-table-frame">
|
||||
<table data-slot="timeline-table">
|
||||
<caption>{t("timeline.table.caption")}</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">{t("timeline.table.lane")}</th>
|
||||
<th scope="col">{t("timeline.table.entry")}</th>
|
||||
<th scope="col">{t("timeline.table.start")}</th>
|
||||
<th scope="col">{t("timeline.table.end")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map(({ lane, item }) => (
|
||||
<tr key={`${lane}-${item.id}`}>
|
||||
<td>{t(TIMELINE_LANE_LABEL_KEY[lane])}</td>
|
||||
<td>{itemLine(item, words, t("timeline.startUnknown"))}</td>
|
||||
<td>{formatAtPrecision(item.start, item.precision, intl)}</td>
|
||||
<td>
|
||||
{!item.open && isSpan(item) && item.end
|
||||
? formatAtPrecision(item.end, item.precision, intl)
|
||||
: ""}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** "Januar 2026", "Jan bis März 2026", "5. Jan. bis 11. Jan. 2026". */
|
||||
export function bucketText(
|
||||
start: string,
|
||||
bucket: TimelineBucket,
|
||||
intl: string,
|
||||
t: ReturnType<typeof useTranslations>["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 `<main>` scroll past its last card into nothing.
|
||||
<div className="sr-only" data-slot="timeline-series-table-frame">
|
||||
<table data-slot="timeline-series-table">
|
||||
<caption>{t("timeline.table.seriesCaption")}</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">{t("timeline.table.period")}</th>
|
||||
{series.map((s) => (
|
||||
<th key={s.key} scope="col">
|
||||
{seriesLabel(s.key)}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.reverse().map((start) => (
|
||||
<tr key={start} data-bucket={start}>
|
||||
<th scope="row">{bucketText(start, bucket, intl, t)}</th>
|
||||
{series.map((s) => {
|
||||
const point = byKey.get(s.key)?.get(start);
|
||||
return (
|
||||
<td key={s.key}>
|
||||
{point
|
||||
? `${formatSeriesValue(s.key, point.mean, s.unit, fmt)}, ${tCount("timeline.values.readings", point.count)}`
|
||||
: t("timeline.values.noValue")}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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:<biomarkerId>`, 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<Record<ModuleKey, boolean>> | 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<LifeEventDTO | null>(null);
|
||||
|
||||
const [storedValues, setStoredValues] = useDevicePreference(
|
||||
VALUES_KEY,
|
||||
DEFAULT_VALUES,
|
||||
isStringArray,
|
||||
);
|
||||
const [hiddenList, setHiddenList] = useDevicePreference<string[]>(
|
||||
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) => (
|
||||
<TimelineValuesMenu
|
||||
timeline={data}
|
||||
values={values}
|
||||
allowed={allowed}
|
||||
rank={SERIES_RANK}
|
||||
labsOn={modules?.labs !== false}
|
||||
label={seriesLabel}
|
||||
color={seriesColor}
|
||||
onChange={setStoredValues}
|
||||
iconOnly={iconOnly}
|
||||
/>
|
||||
);
|
||||
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)}
|
||||
/>
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<ValueSeriesMenu
|
||||
options={valueOptions}
|
||||
selected={values}
|
||||
label={seriesLabel}
|
||||
color={seriesColor}
|
||||
onChange={setStoredValues}
|
||||
/>
|
||||
{valuesMenu(false)}
|
||||
{layers(false)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -571,6 +600,8 @@ export function TimelineView() {
|
||||
<TimelineLegend
|
||||
bucket={data.bucket}
|
||||
hasSeries={data.series.length > 0}
|
||||
labSeries={data.labSeries}
|
||||
label={seriesLabel}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -593,6 +624,7 @@ export function TimelineView() {
|
||||
slot="timeline-grouping"
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
{valuesMenu(true)}
|
||||
<Button
|
||||
variant={zoom === "range" ? "secondary" : "outline"}
|
||||
size="icon"
|
||||
@@ -616,6 +648,11 @@ export function TimelineView() {
|
||||
onApply={(next) => navigate("range", next)}
|
||||
onClear={zoom === "range" ? () => setZoom("all") : null}
|
||||
/>
|
||||
<LabExcludedNote
|
||||
labSeries={data.labSeries}
|
||||
label={seriesLabel}
|
||||
block
|
||||
/>
|
||||
<TimelineChronicle
|
||||
timeline={{
|
||||
...data,
|
||||
@@ -648,102 +685,3 @@ export function TimelineView() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
}: {
|
||||
bucket: TimelineBucket;
|
||||
hasSeries: boolean;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
return (
|
||||
<div
|
||||
className="text-muted-foreground border-border flex flex-wrap items-center gap-x-4 gap-y-1.5 border-t pt-3 text-xs"
|
||||
data-slot="timeline-legend"
|
||||
>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span
|
||||
className="bg-muted-foreground h-1.5 w-4 shrink-0 rounded-full"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{t("timeline.legendSpan")}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span
|
||||
className="bg-muted-foreground size-2 shrink-0 rounded-full"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{t("timeline.legendEvent")}
|
||||
</span>
|
||||
{hasSeries && (
|
||||
<>
|
||||
<span
|
||||
className="inline-flex items-center gap-1.5"
|
||||
data-slot="timeline-legend-mean"
|
||||
data-bucket={bucket}
|
||||
>
|
||||
<span
|
||||
className="bg-foreground h-0.5 w-4 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{t(TIMELINE_LEGEND_MEAN_KEY[bucket])}
|
||||
</span>
|
||||
<span
|
||||
className="inline-flex items-center gap-1.5"
|
||||
data-slot="timeline-legend-gap"
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="8"
|
||||
viewBox="0 0 16 8"
|
||||
className="text-foreground"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<line
|
||||
x1="1"
|
||||
x2="15"
|
||||
y1="4"
|
||||
y2="4"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.2"
|
||||
strokeDasharray="3 3"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
{t("timeline.legendGap")}
|
||||
</span>
|
||||
<span
|
||||
className="inline-flex items-center gap-1.5"
|
||||
data-slot="timeline-legend-thin"
|
||||
>
|
||||
<svg
|
||||
width="8"
|
||||
height="8"
|
||||
viewBox="0 0 8 8"
|
||||
className="text-foreground"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle
|
||||
cx="4"
|
||||
cy="4"
|
||||
r="2.75"
|
||||
fill="var(--card)"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.4"
|
||||
/>
|
||||
</svg>
|
||||
{t("timeline.legendThin")}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user