diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index df9b1aa07..63591a0c4 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -17183,7 +17183,8 @@ paths: summary: Per-marker overview of the caller's lab history description: "One entry per biomarker over every live lab result, newest reading first: the newest reading, the reading count and a short trend. Unlike `GET /api/labs` it is not paged, so a marker measured only long ago is never cut - off by a page size." + off by a page size. `categories` and `grouped` carry the list's categories, resolved server-side; their labels + follow the caller's locale (the language cookie, else the caller's stored language, else `Accept-Language`)." parameters: [] responses: "200": @@ -76635,8 +76636,21 @@ components: type: array items: $ref: "#/components/schemas/LabOverviewMarker" + categories: + type: array + items: + $ref: "#/components/schemas/LabOverviewCategory" + description: "The list's categories in display order: catalog panels in catalog order, then the person's own panels by + label, then `other`." + grouped: + type: boolean + description: "Whether grouping adds anything: true when there are two categories or more and at least one is not + `other`. A client that shows categories (the `groupBy` list preference) shows a flat list while this is + false." required: - markers + - categories + - grouped additionalProperties: false LabOverviewMarker: type: object @@ -76665,6 +76679,66 @@ components: `analyte:` for a legacy unlinked one. `count` is every live reading of the marker. `trend` holds the values of its newest readings (at most 50), oldest first, null for a qualitative reading. `latest` is the newest reading, serialised exactly like a `GET /api/labs` row. + LabOverviewCategory: + type: object + properties: + key: + type: string + label: + type: string + markerKeys: + type: array + items: + type: string + counts: + $ref: "#/components/schemas/LabOverviewCategoryCounts" + status: + type: string + enum: + - in-range + - below + - above + - unknown + required: + - key + - label + - markerKeys + - counts + - status + additionalProperties: false + description: "One category of the labs list, resolved by the server. `key` is opaque: a catalog panel key, + `custom:` for the person's own panel, or `other`; it carries no user text and is stable, so a client may + key collapse state on it. `label` is rendered as given (a catalog panel in the caller's locale, the person's own + spelling, or the localised \"Other\"). `markerKeys` names the `markers[].key` values in the category. `status` + combines the members' newest readings: `above` when any is above its range, else `below` when any is below, else + `in-range` when any was assessed, else `unknown`. A client renders these values and never rebuilds the category, + the order or the status." + LabOverviewCategoryCounts: + type: object + properties: + inRange: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + below: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + above: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + unknown: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + required: + - inRange + - below + - above + - unknown + additionalProperties: false + description: How many members' newest readings are in range, below, above, or unassessed (qualitative, or no range). GetLabResultResponse: type: object properties: diff --git a/e2e/labs-list-groups.spec.ts b/e2e/labs-list-groups.spec.ts new file mode 100644 index 000000000..360224e7b --- /dev/null +++ b/e2e/labs-list-groups.spec.ts @@ -0,0 +1,104 @@ +import AxeBuilder from "@axe-core/playwright"; +import type { Page } from "@playwright/test"; + +import { expect, test } from "./setup/test"; +import { MOBILE_ROUTES_STORAGE_STATE_PATH } from "./setup/global-setup"; +import { + expectNoHorizontalOverflow, + settleForOverflowMeasurement, +} from "./utils/horizontal-overflow"; + +/** + * The labs list under its categories. + * + * The account is the one `globalSetup` seeds for the mobile route sweep: one + * marker under its own panel and one qualitative reading without a panel, so + * the server answers two categories and `grouped: true`. The grouping choice + * is served to this page only (the layout read is answered in the browser + * with the account's own layout plus `groupBy: "category"`), so the shared + * account's stored preferences stay as other specs expect them. + */ + +const LAYOUT = "**/api/settings/module-list-layout"; +const GROUP = '[data-slot="lab-list-group"]'; + +async function serveGrouped(page: Page, groupsOpen = "expanded") { + await page.route(LAYOUT, async (route) => { + if (route.request().method() !== "GET") return route.continue(); + const response = await route.fetch(); + const body = (await response.json()) as { + data: { lists: { labs: Record } }; + }; + body.data.lists.labs = { + ...body.data.lists.labs, + groupBy: "category", + groupsOpen, + saved: true, + }; + await route.fulfill({ response, json: body }); + }); +} + +test.describe("the lab list under its categories", () => { + test.use({ storageState: MOBILE_ROUTES_STORAGE_STATE_PATH }); + + test.beforeEach(async ({ context }, testInfo) => { + const baseURL = testInfo.project.use.baseURL ?? "http://localhost:3000"; + await context.addCookies([ + { name: "healthlog-locale", value: "de", url: baseURL }, + ]); + }); + + test("shows one disclosure per category and folds a category away", async ({ + page, + }) => { + await serveGrouped(page); + await page.goto("/labs", { waitUntil: "domcontentloaded" }); + const headers = page.locator(GROUP); + await expect(headers).toHaveCount(2); + await expect(headers.first()).toHaveAttribute("aria-expanded", "true"); + + const names = page.locator('[data-slot="lab-list-analyte"]'); + const before = await names.count(); + expect(before).toBeGreaterThan(1); + + await headers.first().click(); + await expect(headers.first()).toHaveAttribute("aria-expanded", "false"); + await expect(names).toHaveCount(before - 1); + + // The choice holds across a reload in this browser. + await page.reload({ waitUntil: "domcontentloaded" }); + await expect(page.locator(GROUP).first()).toHaveAttribute( + "aria-expanded", + "false", + ); + }); + + test("starts with every category closed when the person chose so", async ({ + page, + }) => { + await serveGrouped(page, "collapsed"); + await page.goto("/labs", { waitUntil: "domcontentloaded" }); + const headers = page.locator(GROUP); + await expect(headers).toHaveCount(2); + for (const header of await headers.all()) { + await expect(header).toHaveAttribute("aria-expanded", "false"); + } + await expect(page.locator('[data-slot="lab-list-analyte"]')).toHaveCount(0); + }); + + test("fits a phone and passes axe", async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }); + await serveGrouped(page); + await page.goto("/labs", { waitUntil: "domcontentloaded" }); + await expect(page.locator(GROUP)).toHaveCount(2); + await settleForOverflowMeasurement(page); + await expectNoHorizontalOverflow(page, "/labs grouped at 390 px"); + + const results = await new AxeBuilder({ page }) + .include('[data-slot="lab-list"]') + .withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"]) + .analyze(); + expect(results.violations.map((v) => v.id)).toEqual([]); + }); +}); diff --git a/messages/de.json b/messages/de.json index 3d1972dc1..62b9f18f8 100644 --- a/messages/de.json +++ b/messages/de.json @@ -10505,6 +10505,23 @@ "loadMoreReadings": "Weitere Werte laden", "documentsLink": "Befund-Dokumente", "notLinkedYet": "Noch keinem Biomarker zugeordnet", + "groups": { + "other": "Sonstige", + "allInRange": "Alle im Bereich", + "outsideCountOne": "{count} von {total} außerhalb des Bereichs", + "outsideCountFew": "{count} von {total} außerhalb des Bereichs", + "outsideCountOther": "{count} von {total} außerhalb des Bereichs", + "heading": "Kategorien", + "description": "Gruppiert die Werte nach Panel, etwa Lipide oder Leber, mit einem Status je Gruppe.", + "label": "Gruppierung", + "none": "Keine", + "category": "Nach Kategorie", + "openLabel": "Beim Öffnen aufgeklappt", + "openExpanded": "Alle", + "openFlagged": "Nur außerhalb des Bereichs", + "openCollapsed": "Keine", + "orderWithinGroups": "Die Reihenfolge gilt innerhalb jeder Kategorie." + }, "sourceRange": { "fromReport": "Befundbereich {range}", "catalogDiffers": "dein Bereich {range}", diff --git a/messages/en.json b/messages/en.json index 2e22e2384..d19458fd1 100644 --- a/messages/en.json +++ b/messages/en.json @@ -10505,6 +10505,23 @@ "loadMoreReadings": "Load more readings", "documentsLink": "Lab report documents", "notLinkedYet": "Not linked to a biomarker yet", + "groups": { + "other": "Other", + "allInRange": "All in range", + "outsideCountOne": "{count} of {total} outside range", + "outsideCountFew": "{count} of {total} outside range", + "outsideCountOther": "{count} of {total} outside range", + "heading": "Categories", + "description": "Group the values by panel, such as Lipids or Liver, with a range status per group.", + "label": "Grouping", + "none": "None", + "category": "By category", + "openLabel": "Open on load", + "openExpanded": "All", + "openFlagged": "Outside range only", + "openCollapsed": "None", + "orderWithinGroups": "The order applies within each category." + }, "sourceRange": { "fromReport": "Report range {range}", "catalogDiffers": "your range {range}", diff --git a/messages/es.json b/messages/es.json index 784875196..bbafebc0f 100644 --- a/messages/es.json +++ b/messages/es.json @@ -10505,6 +10505,23 @@ "loadMoreReadings": "Cargar más mediciones", "documentsLink": "Documentos de resultados", "notLinkedYet": "Aún no vinculado a un biomarcador", + "groups": { + "other": "Otros", + "allInRange": "Todo en rango", + "outsideCountOne": "{count} de {total} fuera de rango", + "outsideCountFew": "{count} de {total} fuera de rango", + "outsideCountOther": "{count} de {total} fuera de rango", + "heading": "Categorías", + "description": "Agrupa los valores por panel, como Lípidos o Hígado, con un estado por grupo.", + "label": "Agrupación", + "none": "Ninguna", + "category": "Por categoría", + "openLabel": "Abiertas al cargar", + "openExpanded": "Todas", + "openFlagged": "Solo fuera de rango", + "openCollapsed": "Ninguna", + "orderWithinGroups": "El orden se aplica dentro de cada categoría." + }, "sourceRange": { "fromReport": "Rango del informe {range}", "catalogDiffers": "tu rango {range}", diff --git a/messages/fr.json b/messages/fr.json index 594bd8707..723697b62 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -10505,6 +10505,23 @@ "loadMoreReadings": "Charger plus de mesures", "documentsLink": "Documents de résultats", "notLinkedYet": "Pas encore lié à un biomarqueur", + "groups": { + "other": "Autres", + "allInRange": "Tout dans la norme", + "outsideCountOne": "{count} sur {total} hors norme", + "outsideCountFew": "{count} sur {total} hors norme", + "outsideCountOther": "{count} sur {total} hors norme", + "heading": "Catégories", + "description": "Regroupe les valeurs par panel, par exemple Lipides ou Foie, avec un statut par groupe.", + "label": "Regroupement", + "none": "Aucun", + "category": "Par catégorie", + "openLabel": "Ouvertes à l’affichage", + "openExpanded": "Toutes", + "openFlagged": "Hors norme seulement", + "openCollapsed": "Aucune", + "orderWithinGroups": "L’ordre s’applique au sein de chaque catégorie." + }, "sourceRange": { "fromReport": "Plage du compte rendu {range}", "catalogDiffers": "votre plage {range}", diff --git a/messages/it.json b/messages/it.json index a2d6decf1..df8ff7e41 100644 --- a/messages/it.json +++ b/messages/it.json @@ -10505,6 +10505,23 @@ "loadMoreReadings": "Carica altre misurazioni", "documentsLink": "Documenti dei referti", "notLinkedYet": "Non ancora collegato a un biomarcatore", + "groups": { + "other": "Altri", + "allInRange": "Tutti nella norma", + "outsideCountOne": "{count} su {total} fuori norma", + "outsideCountFew": "{count} su {total} fuori norma", + "outsideCountOther": "{count} su {total} fuori norma", + "heading": "Categorie", + "description": "Raggruppa i valori per panel, ad esempio Lipidi o Fegato, con uno stato per gruppo.", + "label": "Raggruppamento", + "none": "Nessuno", + "category": "Per categoria", + "openLabel": "Aperte al caricamento", + "openExpanded": "Tutte", + "openFlagged": "Solo fuori norma", + "openCollapsed": "Nessuna", + "orderWithinGroups": "L’ordine vale all’interno di ogni categoria." + }, "sourceRange": { "fromReport": "Intervallo del referto {range}", "catalogDiffers": "il tuo intervallo {range}", diff --git a/messages/ko.json b/messages/ko.json index 98e810f3e..5cbc33397 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -10505,6 +10505,23 @@ "loadMoreReadings": "측정값 더 보기", "documentsLink": "검사 결과지 문서", "notLinkedYet": "아직 바이오마커에 연결되지 않았어요", + "groups": { + "other": "기타", + "allInRange": "모두 범위 내", + "outsideCountOne": "{total}개 중 {count}개 범위 밖", + "outsideCountFew": "{total}개 중 {count}개 범위 밖", + "outsideCountOther": "{total}개 중 {count}개 범위 밖", + "heading": "카테고리", + "description": "지질, 간처럼 패널별로 값을 묶고 그룹마다 상태를 보여줘요.", + "label": "묶기", + "none": "없음", + "category": "카테고리별", + "openLabel": "열 때 펼치기", + "openExpanded": "모두", + "openFlagged": "범위 밖만", + "openCollapsed": "없음", + "orderWithinGroups": "순서는 각 카테고리 안에서 적용돼요." + }, "sourceRange": { "fromReport": "결과지 범위 {range}", "catalogDiffers": "내 범위 {range}", diff --git a/messages/nl.json b/messages/nl.json index 6150f3a99..d1d713304 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -10505,6 +10505,23 @@ "loadMoreReadings": "Meer metingen laden", "documentsLink": "Documenten met labrapporten", "notLinkedYet": "Nog niet gekoppeld aan een biomarker", + "groups": { + "other": "Overig", + "allInRange": "Alles binnen bereik", + "outsideCountOne": "{count} van {total} buiten bereik", + "outsideCountFew": "{count} van {total} buiten bereik", + "outsideCountOther": "{count} van {total} buiten bereik", + "heading": "Categorieën", + "description": "Groepeert de waarden per groep, zoals Lipiden of Lever, met een status per groep.", + "label": "Groeperen", + "none": "Niet", + "category": "Per categorie", + "openLabel": "Open bij laden", + "openExpanded": "Alle", + "openFlagged": "Alleen buiten bereik", + "openCollapsed": "Geen", + "orderWithinGroups": "De volgorde geldt binnen elke categorie." + }, "sourceRange": { "fromReport": "Bereik volgens rapport: {range}", "catalogDiffers": "jouw bereik: {range}", diff --git a/messages/pl.json b/messages/pl.json index b5b4b8c3a..377c44cb9 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -10505,6 +10505,23 @@ "loadMoreReadings": "Wczytaj więcej pomiarów", "documentsLink": "Dokumenty wyników badań", "notLinkedYet": "Jeszcze niepowiązane z biomarkerem", + "groups": { + "other": "Inne", + "allInRange": "Wszystkie w normie", + "outsideCountOne": "{count} z {total} poza normą", + "outsideCountFew": "{count} z {total} poza normą", + "outsideCountOther": "{count} z {total} poza normą", + "heading": "Kategorie", + "description": "Grupuje wartości według panelu, np. Lipidy lub Wątroba, ze statusem dla każdej grupy.", + "label": "Grupowanie", + "none": "Brak", + "category": "Według kategorii", + "openLabel": "Rozwinięte po otwarciu", + "openExpanded": "Wszystkie", + "openFlagged": "Tylko poza normą", + "openCollapsed": "Żadne", + "orderWithinGroups": "Kolejność obowiązuje w obrębie każdej kategorii." + }, "sourceRange": { "fromReport": "Zakres z wyniku {range}", "catalogDiffers": "twój zakres {range}", diff --git a/messages/pt-BR.json b/messages/pt-BR.json index f748f31d0..cb9f463a2 100644 --- a/messages/pt-BR.json +++ b/messages/pt-BR.json @@ -10505,6 +10505,23 @@ "loadMoreReadings": "Carregar mais leituras", "documentsLink": "Documentos de laudos laboratoriais", "notLinkedYet": "Ainda não vinculado a um biomarcador", + "groups": { + "other": "Outros", + "allInRange": "Tudo na faixa", + "outsideCountOne": "{count} de {total} fora da faixa", + "outsideCountFew": "{count} de {total} fora da faixa", + "outsideCountOther": "{count} de {total} fora da faixa", + "heading": "Categorias", + "description": "Agrupa os valores por painel, como Lipídios ou Fígado, com um status por grupo.", + "label": "Agrupamento", + "none": "Nenhum", + "category": "Por categoria", + "openLabel": "Abertas ao carregar", + "openExpanded": "Todas", + "openFlagged": "Só fora da faixa", + "openCollapsed": "Nenhuma", + "orderWithinGroups": "A ordem vale dentro de cada categoria." + }, "sourceRange": { "fromReport": "Faixa do laudo {range}", "catalogDiffers": "sua faixa {range}", diff --git a/messages/ru.json b/messages/ru.json index 8aa60b5ae..b2bcb4b9a 100644 --- a/messages/ru.json +++ b/messages/ru.json @@ -10505,6 +10505,23 @@ "loadMoreReadings": "Загрузить больше измерений", "documentsLink": "Документы анализов", "notLinkedYet": "Пока не связано с показателем", + "groups": { + "other": "Другое", + "allInRange": "Все в диапазоне", + "outsideCountOne": "{count} из {total} вне диапазона", + "outsideCountFew": "{count} из {total} вне диапазона", + "outsideCountOther": "{count} из {total} вне диапазона", + "heading": "Категории", + "description": "Группирует значения по панелям, например Липиды или Печень, со статусом для каждой группы.", + "label": "Группировка", + "none": "Нет", + "category": "По категориям", + "openLabel": "Раскрыто при открытии", + "openExpanded": "Все", + "openFlagged": "Только вне диапазона", + "openCollapsed": "Ни одной", + "orderWithinGroups": "Порядок действует внутри каждой категории." + }, "sourceRange": { "fromReport": "Диапазон из отчёта {range}", "catalogDiffers": "ваш диапазон {range}", diff --git a/src/__tests__/baselines/source-file-lines.json b/src/__tests__/baselines/source-file-lines.json index 649b99be4..ca838ad10 100644 --- a/src/__tests__/baselines/source-file-lines.json +++ b/src/__tests__/baselines/source-file-lines.json @@ -1,13 +1,13 @@ { "$comment": "Production files over 800 lines and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.", "count": 84, - "total": 94064, + "total": 94047, "entries": { "app/medications/page-client.tsx": 839, "components/charts/mood-chart.tsx": 1166, "components/cycle/log-day-sheet.tsx": 1639, "components/insights/coach-panel/chat-bubble.tsx": 822, - "components/insights/coach-panel/coach-conversation.tsx": 1286, + "components/insights/coach-panel/coach-conversation.tsx": 1278, "components/insights/coach-panel/use-coach.ts": 1486, "components/insights/derived/vitals-dashboard.tsx": 1033, "components/insights/health-score-card.tsx": 1023, @@ -26,8 +26,8 @@ "lib/ai/coach/persistence.ts": 1218, "lib/ai/coach/results/metric-table-tool.ts": 1102, "lib/ai/coach/snapshot.ts": 1346, - "lib/ai/coach/stream-events.ts": 817, - "lib/ai/coach/types.ts": 1011, + "lib/ai/coach/stream-events.ts": 811, + "lib/ai/coach/types.ts": 1008, "lib/ai/codex-client.ts": 907, "lib/ai/prompts/insight-generator.ts": 1115, "lib/ai/provider.ts": 1628, diff --git a/src/__tests__/baselines/source-function-lines.json b/src/__tests__/baselines/source-function-lines.json index 8490f21f6..ad1e29f61 100644 --- a/src/__tests__/baselines/source-function-lines.json +++ b/src/__tests__/baselines/source-function-lines.json @@ -1,7 +1,7 @@ { "$comment": "Production functions over 200 lines, keyed \"file :: name\", and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.", - "count": 345, - "total": 111889, + "count": 344, + "total": 111568, "entries": { "app/achievements/page.tsx :: AchievementsPage": 206, "app/api/admin/backups/upload/route.ts :: apiHandler(arg)": 275, @@ -103,7 +103,7 @@ "components/illness/new-episode-sheet.tsx :: NewEpisodeSheet": 264, "components/insights/coach-panel/attachment-picker.tsx :: AttachmentPicker": 208, "components/insights/coach-panel/chat-bubble.tsx :: ChatBubbleImpl": 419, - "components/insights/coach-panel/coach-conversation.tsx :: CoachConversation": 1011, + "components/insights/coach-panel/coach-conversation.tsx :: CoachConversation": 1002, "components/insights/coach-panel/coach-input.tsx :: CoachInput": 259, "components/insights/coach-panel/coach-model-picker.tsx :: CoachModelPicker": 212, "components/insights/coach-panel/conversations-panel.tsx :: ConversationsPanel": 317, @@ -137,7 +137,6 @@ "components/labs/lab-biomarker-detail.tsx :: LabBiomarkerDetail": 481, "components/labs/lab-form.tsx :: LabForm": 473, "components/labs/lab-history-list.tsx :: LabHistoryList": 534, - "components/labs/lab-list.tsx :: LabList": 308, "components/labs/ocr-review-dialog.tsx :: OcrReviewDialog": 359, "components/labs/ocr-row-editor.tsx :: OcrRowEditor": 378, "components/layout/auth-shell.tsx :: AuthShell": 518, @@ -247,7 +246,7 @@ "lib/ai/coach/glp1-snapshot.ts :: buildGlp1SnapshotBlock": 218, "lib/ai/coach/snapshot.ts :: buildCoachSnapshotImpl": 880, "lib/ai/coach/tools/loop.ts :: runCoachToolLoop": 438, - "lib/ai/coach/turn/model.ts :: runTurnModel": 538, + "lib/ai/coach/turn/model.ts :: runTurnModel": 534, "lib/ai/coach/turn/pipeline.ts :: runTurn": 274, "lib/ai/coach/turn/reply-guards.ts :: guardReply": 331, "lib/ai/codex-client.ts :: parseResponse": 302, diff --git a/src/__tests__/baselines/version-stamp-comments.json b/src/__tests__/baselines/version-stamp-comments.json index f89a99ecd..f6105ed7e 100644 --- a/src/__tests__/baselines/version-stamp-comments.json +++ b/src/__tests__/baselines/version-stamp-comments.json @@ -1,7 +1,7 @@ { "$comment": "Comment lines that open with a release version, per file under src/. Maintained by src/__tests__/version-stamp-ratchet.test.ts.", - "count": 2894, - "total": 9223, + "count": 2892, + "total": 9220, "entries": { "__tests__/account-payload-consumer-guard.test.ts": 2, "__tests__/acting-account-boundary-guard.test.ts": 7, @@ -1072,7 +1072,7 @@ "components/labs/lab-biomarker-detail.tsx": 13, "components/labs/lab-form.tsx": 6, "components/labs/lab-history-list.tsx": 4, - "components/labs/lab-list.tsx": 6, + "components/labs/lab-list.tsx": 5, "components/labs/lab-reference-range-bar.tsx": 1, "components/labs/lab-trend-card.tsx": 1, "components/labs/lab-trend-sparkline.tsx": 1, diff --git a/src/__tests__/marker-category-coverage.test.ts b/src/__tests__/marker-category-coverage.test.ts new file mode 100644 index 000000000..2e6ce394f --- /dev/null +++ b/src/__tests__/marker-category-coverage.test.ts @@ -0,0 +1,69 @@ +/** + * Every analyte the catalog can recognise by name has a catalog category. + * + * The labs list groups a marker without a panel by the analyte its name + * resolves to (`catalogKeyForName`), and that analyte's panel comes from its + * seed or, for an analyte with no seed, from `ALIAS_PANEL`. An alias key added + * to `ANALYTE_ALIASES` or `LAB_NAME_ALIASES` without an `ALIAS_PANEL` entry + * would put every marker of that analyte under "Other" without a word. This + * holds the lists together. + * + * The check is a function of the alias table, so the guard proves it can + * fail: the same check over the table minus one entry must name that entry. + */ +import { describe, expect, it } from "vitest"; + +import { + ALIAS_PANEL, + ANALYTE_ALIASES, + BIOMARKER_CATALOG, + BIOMARKER_PANELS, + LAB_NAME_ALIASES, + type BiomarkerPanelKey, +} from "@/lib/labs/biomarker-catalog"; + +function uncategorised( + aliasPanel: Readonly>, +): { checked: number; missing: string[] } { + const panels = new Set(BIOMARKER_PANELS); + const seedPanel = new Map(BIOMARKER_CATALOG.map((s) => [s.slug, s.panel])); + const keys = new Set([ + ...BIOMARKER_CATALOG.map((s) => s.slug), + ...ANALYTE_ALIASES.map((a) => a.key), + ...LAB_NAME_ALIASES.map((a) => a.key), + ]); + const missing: string[] = []; + for (const key of keys) { + const panel = seedPanel.get(key) ?? aliasPanel[key]; + if (panel === undefined || !panels.has(panel)) missing.push(key); + } + return { checked: keys.size, missing }; +} + +describe("marker category coverage", () => { + it("gives every seed and alias key a catalog panel", () => { + const { checked, missing } = uncategorised(ALIAS_PANEL); + expect(checked).toBeGreaterThan(BIOMARKER_CATALOG.length); + expect(missing).toEqual([]); + }); + + it("lists no alias panel for a key the catalog does not know", () => { + const known = new Set([ + ...ANALYTE_ALIASES.map((a) => a.key), + ...LAB_NAME_ALIASES.map((a) => a.key), + ]); + const seeds = new Set(BIOMARKER_CATALOG.map((s) => s.slug)); + const stale = Object.keys(ALIAS_PANEL).filter( + (key) => !known.has(key) || seeds.has(key), + ); + expect(stale).toEqual([]); + }); + + it("fails when an alias panel entry is removed", () => { + const [dropped] = Object.keys(ALIAS_PANEL); + const reduced = Object.fromEntries( + Object.entries(ALIAS_PANEL).filter(([key]) => key !== dropped), + ); + expect(uncategorised(reduced).missing).toEqual([dropped]); + }); +}); diff --git a/src/app/api/labs/overview/route.ts b/src/app/api/labs/overview/route.ts index c25a41ec5..5b4d4ac2a 100644 --- a/src/app/api/labs/overview/route.ts +++ b/src/app/api/labs/overview/route.ts @@ -2,6 +2,8 @@ import { NextRequest } from "next/server"; import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { apiSuccess, returnAllZodIssues } from "@/lib/api-response"; +import { resolveServerLocale } from "@/lib/i18n/server-locale"; +import { categoriseOverview } from "@/lib/labs/marker-category"; import { loadLabOverview } from "@/lib/labs/overview"; import { annotate } from "@/lib/logging/context"; import { labOverviewQuerySchema } from "@/lib/validations/labs"; @@ -12,9 +14,14 @@ import { labOverviewQuerySchema } from "@/lib/validations/labs"; * The Labs list renders from this instead of grouping a capped page of raw * rows, so a marker measured only long ago still has its row. Same read gate * as `GET /api/labs` (`requireRecordAuth("read", "labs")`). + * + * `categories` and `grouped` are the list's categories, resolved here once + * (`src/lib/labs/marker-category.ts`): order, label, members, counts and a + * combined status. Labels follow the viewer's locale, so on a managed record + * the caller's own language wins, not the record owner's. */ export const GET = apiHandler(async (request: NextRequest) => { - const { user } = await requireRecordAuth("read", "labs"); + const { user, actor } = await requireRecordAuth("read", "labs"); const parsed = labOverviewQuerySchema.safeParse( Object.fromEntries(request.nextUrl.searchParams), @@ -28,9 +35,14 @@ export const GET = apiHandler(async (request: NextRequest) => { } const markers = await loadLabOverview(user.id); + const locale = await resolveServerLocale({ + request, + userLocale: actor.locale ?? null, + }); + const { categories, grouped } = categoriseOverview(markers, locale); annotate({ action: { name: "labs.overview" }, - meta: { markers: markers.length }, + meta: { markers: markers.length, categories: categories.length, grouped }, }); - return apiSuccess({ markers }); + return apiSuccess({ markers, categories, grouped }); }); diff --git a/src/components/labs/__tests__/lab-list-columns.test.tsx b/src/components/labs/__tests__/lab-list-columns.test.tsx index 3b3f0e0ea..5546fe33c 100644 --- a/src/components/labs/__tests__/lab-list-columns.test.tsx +++ b/src/components/labs/__tests__/lab-list-columns.test.tsx @@ -4,7 +4,15 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { I18nProvider } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; -import type { LabOverviewResponse, LabResultDto } from "../types"; +import { + resolveModuleListLayout, + type ModuleListPrefs, +} from "@/lib/settings/module-list-layout"; +import type { + LabOverviewCategoryDto, + LabOverviewResponse, + LabResultDto, +} from "../types"; /** * Every row of the compact list is five grid cells from `lg` up. The range @@ -51,7 +59,14 @@ function reading(over: Partial): LabResultDto { } as LabResultDto; } -function renderList(results: LabResultDto[]) { +function renderList( + results: LabResultDto[], + options: { + prefs?: Partial; + categories?: LabOverviewCategoryDto[]; + grouped?: boolean; + } = {}, +) { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity } }, }); @@ -76,8 +91,16 @@ function renderList(results: LabResultDto[]) { latest: ordered[ordered.length - 1], }; }), + categories: options.categories ?? [], + grouped: options.grouped ?? false, }; - queryClient.setQueryData(queryKeys.labResultsOverview(), data); + queryClient.setQueryData(queryKeys.labResultsOverview("en"), data); + if (options.prefs) { + queryClient.setQueryData( + queryKeys.moduleListLayout(), + resolveModuleListLayout({ version: 1, labs: options.prefs }), + ); + } return renderToStaticMarkup( @@ -197,3 +220,110 @@ describe(" columns", () => { expect(html).toContain("converted from 5.6 mmol/L"); }); }); + +describe(" categories", () => { + const readings = [ + reading({ id: "l1", biomarkerId: "b1", analyte: "LDL", panel: "Lipids" }), + reading({ + id: "l2", + biomarkerId: "b2", + analyte: "HDL", + panel: "Lipids", + rangeStatus: "above", + }), + reading({ id: "t1", biomarkerId: "b3", analyte: "TSH", panel: "Thyroid" }), + ]; + const categories: LabOverviewCategoryDto[] = [ + { + key: "lipids", + label: "Lipids", + markerKeys: ["bm:b1", "bm:b2"], + counts: { inRange: 1, below: 0, above: 1, unknown: 0 }, + status: "above", + }, + { + key: "thyroid", + label: "Thyroid", + markerKeys: ["bm:b3"], + counts: { inRange: 1, below: 0, above: 0, unknown: 0 }, + status: "in-range", + }, + ]; + const headers = (html: string) => + [...html.matchAll(/]*data-slot="lab-list-group"[^>]*>/g)].map( + (m) => m[0], + ); + + it("stays flat while grouping is off, or while the server says it adds nothing", () => { + expect( + headers(renderList(readings, { categories, grouped: true })), + ).toHaveLength(0); + expect( + headers( + renderList(readings, { + categories, + grouped: false, + prefs: { groupBy: "category" }, + }), + ), + ).toHaveLength(0); + }); + + it("renders one disclosure per category, open by default, with the status in its name", () => { + const html = renderList(readings, { + categories, + grouped: true, + prefs: { groupBy: "category" }, + }); + const found = headers(html); + expect(found).toHaveLength(2); + for (const button of found) { + expect(button).toContain('aria-expanded="true"'); + expect(button).toMatch(/aria-controls="[^"]+"/); + } + expect(html).toContain("1 of 2 outside range"); + expect(html).toContain("All in range"); + expect(count(html, 'href="/labs/')).toBe(3); + // Server order: lipids first. + expect(html.indexOf("Lipids")).toBeLessThan(html.indexOf("Thyroid")); + // The header spans the table's columns, the rows keep the subgrid. + expect(html).toContain("lg:col-span-full"); + expect(html).toContain("lg:grid-cols-subgrid"); + }); + + it("opens only flagged categories, or none, as the person chose", () => { + const flagged = renderList(readings, { + categories, + grouped: true, + prefs: { groupBy: "category", groupsOpen: "flagged" }, + }); + const [lipids, thyroid] = headers(flagged); + expect(lipids).toContain('aria-expanded="true"'); + expect(thyroid).toContain('aria-expanded="false"'); + expect(count(flagged, 'href="/labs/')).toBe(2); + + const collapsed = renderList(readings, { + categories, + grouped: true, + prefs: { groupBy: "category", groupsOpen: "collapsed" }, + }); + expect( + headers(collapsed).every((h) => h.includes('aria-expanded="false"')), + ).toBe(true); + expect(count(collapsed, 'href="/labs/')).toBe(0); + }); + + it("names the unit instead of the panel on a card under its category", () => { + const grouped = renderList(readings, { + categories, + grouped: true, + prefs: { groupBy: "category", view: "cards" }, + }); + expect(headers(grouped)).toHaveLength(2); + // Each panel label appears once, in its header, not again on the cards. + expect(count(grouped, ">Lipids<")).toBe(1); + + const flat = renderList(readings, { prefs: { view: "cards" } }); + expect(count(flat, ">Lipids<")).toBe(2); + }); +}); diff --git a/src/components/labs/__tests__/lab-list-groups.test.ts b/src/components/labs/__tests__/lab-list-groups.test.ts new file mode 100644 index 000000000..0f9e38a72 --- /dev/null +++ b/src/components/labs/__tests__/lab-list-groups.test.ts @@ -0,0 +1,83 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + groupMarkerRows, + readStoredOpen, + writeStoredOpen, +} from "../lab-list-groups"; +import type { MarkerRow } from "../lab-list-rows"; +import type { LabOverviewCategoryDto } from "../types"; + +function category(key: string, markerKeys: string[]): LabOverviewCategoryDto { + return { + key, + label: key, + markerKeys, + counts: { inRange: markerKeys.length, below: 0, above: 0, unknown: 0 }, + status: "in-range", + }; +} + +const row = (key: string) => ({ key }) as MarkerRow; + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("groupMarkerRows", () => { + it("keeps the rows' own order inside a category and the server order across them", () => { + const groups = groupMarkerRows( + [row("c"), row("a"), row("b")], + [category("second", ["a", "b"]), category("first", ["c"])], + ); + expect(groups.map((g) => g.category.key)).toEqual(["second", "first"]); + expect(groups[0].rows.map((r) => r.key)).toEqual(["a", "b"]); + }); + + it("drops a category with no row present", () => { + expect( + groupMarkerRows([row("a")], [category("x", ["a"]), category("y", ["z"])]), + ).toHaveLength(1); + }); +}); + +describe("stored open state", () => { + function stubStorage(store: Map) { + vi.stubGlobal("window", { + localStorage: { + getItem: (k: string) => store.get(k) ?? null, + setItem: (k: string, v: string) => void store.set(k, v), + }, + }); + } + + it("round-trips the toggles made under one choice and forgets them under another", () => { + const store = new Map(); + stubStorage(store); + writeStoredOpen("flagged", { lipids: true, "custom:abc": false }); + expect(readStoredOpen("flagged")).toEqual({ + lipids: true, + "custom:abc": false, + }); + expect(readStoredOpen("expanded")).toEqual({}); + }); + + it("survives a storage that throws or holds garbage", () => { + vi.stubGlobal("window", { + localStorage: { + getItem: () => { + throw new Error("denied"); + }, + setItem: () => { + throw new Error("denied"); + }, + }, + }); + expect(readStoredOpen("expanded")).toEqual({}); + expect(() => writeStoredOpen("expanded", { a: true })).not.toThrow(); + + const store = new Map([["healthlog:labs-groups-open", "{not json"]]); + stubStorage(store); + expect(readStoredOpen("expanded")).toEqual({}); + }); +}); diff --git a/src/components/labs/__tests__/labs-settings-groups.test.tsx b/src/components/labs/__tests__/labs-settings-groups.test.tsx new file mode 100644 index 000000000..b578d0793 --- /dev/null +++ b/src/components/labs/__tests__/labs-settings-groups.test.tsx @@ -0,0 +1,60 @@ +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +import { I18nProvider } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; +import { + resolveModuleListLayout, + type ModuleListPrefs, +} from "@/lib/settings/module-list-layout"; + +vi.mock("@/lib/api/api-fetch", () => ({ + apiGet: () => new Promise(() => {}), + apiPut: vi.fn(), +})); + +import { LabsSettings } from "../labs-settings"; + +function render(prefs: Partial) { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false, staleTime: Infinity } }, + }); + queryClient.setQueryData( + queryKeys.moduleListLayout(), + resolveModuleListLayout({ version: 1, labs: prefs }), + ); + queryClient.setQueryData(queryKeys.biomarkers(), { biomarkers: [] }); + return renderToStaticMarkup( + + + + + , + ); +} + +describe(" categories", () => { + it("hides the open-on-load choice while categories are off", () => { + const html = render({}); + expect(html).toContain('data-slot="labs-group-by-none"'); + expect(html).not.toContain('data-slot="labs-groups-open"'); + }); + + it("offers the three open-on-load choices while categories are on", () => { + const html = render({ groupBy: "category", groupsOpen: "flagged" }); + expect(html).toContain('data-slot="labs-groups-open"'); + for (const value of ["expanded", "flagged", "collapsed"]) { + expect(html).toContain(`data-slot="labs-groups-open-${value}"`); + } + }); + + it("says the manual order applies within each category", () => { + expect(render({ groupBy: "category", sortDir: "manual" })).toContain( + "The order applies within each category.", + ); + expect(render({ sortDir: "manual" })).not.toContain( + "The order applies within each category.", + ); + }); +}); diff --git a/src/components/labs/lab-list-groups.tsx b/src/components/labs/lab-list-groups.tsx new file mode 100644 index 000000000..9d3f89695 --- /dev/null +++ b/src/components/labs/lab-list-groups.tsx @@ -0,0 +1,202 @@ +"use client"; + +/** + * The categories of the labs list: which markers sit under which header, the + * header itself, and which categories are open. + * + * Everything about a category comes from the server (`categories` on + * `GET /api/labs/overview`): its members, label, order, counts and status. + * This module only places the already-sorted rows under them and builds the + * status sentence from the counts; it never decides a category or a status. + */ +import { useMemo, useState } from "react"; +import { ChevronDown } from "lucide-react"; + +import { ScorePill } from "@/components/ui/score-pill"; +import { TagChip } from "@/components/ui/tag-chip"; +import { useMounted } from "@/hooks/use-mounted"; +import { useTranslations } from "@/lib/i18n/context"; +import type { ModuleGroupsOpen } from "@/lib/module-list-prefs"; +import { cn } from "@/lib/utils"; + +import type { MarkerRow } from "./lab-list-rows"; +import type { LabOverviewCategoryDto } from "./types"; + +export interface MarkerGroup { + category: LabOverviewCategoryDto; + rows: MarkerRow[]; +} + +/** + * The rows under each category, in the order the rows already have (the + * person's sort applies inside a category); the categories keep the server + * order. A category none of whose rows is present drops out. + */ +export function groupMarkerRows( + rows: readonly MarkerRow[], + categories: readonly LabOverviewCategoryDto[], +): MarkerGroup[] { + const categoryOf = new Map(); + for (const category of categories) { + for (const key of category.markerKeys) categoryOf.set(key, category.key); + } + const byCategory = new Map(); + for (const row of rows) { + const key = categoryOf.get(row.key); + if (key === undefined) continue; + const list = byCategory.get(key) ?? []; + list.push(row); + byCategory.set(key, list); + } + return categories + .map((category) => ({ + category, + rows: byCategory.get(category.key) ?? [], + })) + .filter((group) => group.rows.length > 0); +} + +const STORAGE_KEY = "healthlog:labs-groups-open"; + +interface StoredOpen { + /** The `groupsOpen` choice the toggles were made under. */ + mode: ModuleGroupsOpen; + open: Record; +} + +export function readStoredOpen( + mode: ModuleGroupsOpen, +): Record { + try { + const raw = window.localStorage.getItem(STORAGE_KEY); + if (!raw) return {}; + const parsed = JSON.parse(raw) as Partial; + if (parsed.mode !== mode || typeof parsed.open !== "object") return {}; + const out: Record = {}; + for (const [key, value] of Object.entries(parsed.open ?? {})) { + if (typeof value === "boolean") out[key] = value; + } + return out; + } catch { + return {}; + } +} + +export function writeStoredOpen( + mode: ModuleGroupsOpen, + open: Record, +): void { + try { + window.localStorage.setItem( + STORAGE_KEY, + JSON.stringify({ mode, open } satisfies StoredOpen), + ); + } catch { + // Storage unavailable: the toggle still holds for this page. + } +} + +function openByDefault( + category: LabOverviewCategoryDto, + mode: ModuleGroupsOpen, +): boolean { + if (mode === "expanded") return true; + if (mode === "collapsed") return false; + return category.status === "above" || category.status === "below"; +} + +/** + * Which categories are open. The `groupsOpen` choice sets the start; a + * category the person opened or closed by hand stays so in this browser + * (keyed by the opaque category key, never by its label) until the choice + * itself changes. Storage is read only after hydration and may be missing. + */ +export function useGroupsOpen(mode: ModuleGroupsOpen): { + isOpen: (category: LabOverviewCategoryDto) => boolean; + toggle: (category: LabOverviewCategoryDto) => void; +} { + const mounted = useMounted(); + const stored = useMemo( + () => (mounted ? readStoredOpen(mode) : {}), + [mounted, mode], + ); + const [toggled, setToggled] = useState({ mode, open: {} }); + const own = toggled.mode === mode ? toggled.open : {}; + + const isOpen = (category: LabOverviewCategoryDto) => + own[category.key] ?? stored[category.key] ?? openByDefault(category, mode); + const toggle = (category: LabOverviewCategoryDto) => { + const next = { ...stored, ...own, [category.key]: !isOpen(category) }; + setToggled({ mode, open: next }); + writeStoredOpen(mode, next); + }; + return { isOpen, toggle }; +} + +/** The status of a category, from the counts the server sent. */ +function GroupStatus({ category }: { category: LabOverviewCategoryDto }) { + const { t, tCount } = useTranslations(); + if (category.status === "unknown") return null; + if (category.status === "in-range") { + return {t("labs.groups.allInRange")}; + } + const { inRange, below, above, unknown } = category.counts; + return ( + + {tCount("labs.groups.outsideCount", above + below, { + total: inRange + below + above + unknown, + })} + + ); +} + +/** + * A category header: a heading around one disclosure button holding the + * chevron, the label, the member count and the status, so the status is part + * of what the button announces. No region role: a list can carry more than a + * handful of categories. + */ +export function LabGroupHeader({ + category, + open, + onToggle, + panelId, + className, +}: { + category: LabOverviewCategoryDto; + open: boolean; + onToggle: () => void; + panelId: string; + className?: string; +}) { + return ( +

+ +

+ ); +} diff --git a/src/components/labs/lab-list-rows.tsx b/src/components/labs/lab-list-rows.tsx new file mode 100644 index 000000000..f01a388fd --- /dev/null +++ b/src/components/labs/lab-list-rows.tsx @@ -0,0 +1,305 @@ +"use client"; + +/** + * One marker of the labs list, in both view shapes: a row of the compact + * five-column table and a tile of the card grid. `lab-list.tsx` decides which + * markers show and in what order (flat, or under their categories); these + * render one marker each. + */ +import Link from "next/link"; +import { ChevronRight } from "lucide-react"; + +import { MedicationCardHeader } from "@/components/medications/medication-card-header"; +import { Card, CardContent } from "@/components/ui/card"; +import { useTranslations } from "@/lib/i18n/context"; +import { labUnitLabel } from "@/lib/labs/dimensionless"; +import { formatLabReading } from "@/lib/labs/format-value"; +import { formatReferenceRange } from "@/lib/labs/reference-range"; +import { cn } from "@/lib/utils"; + +import { LabConvertedNote } from "./lab-converted-note"; +import { LabReferenceRangeBar } from "./lab-reference-range-bar"; +import { LabTrendSparkline } from "./lab-trend-sparkline"; +import { ReferenceRangeBadge } from "./reference-range-badge"; +import { SourceRangeNote } from "./source-range-note"; +import type { LabOverviewMarkerDto, LabResultDto } from "./types"; +import { useLabDate, useLabNumber } from "./use-lab-format"; + +export interface MarkerRow { + /** Stable key: `bm:`, or `analyte:` for legacy rows. */ + key: string; + /** Link target when the marker is catalog-linked; null for legacy rows. */ + biomarkerId: string | null; + analyte: string; + panel: string | null; + /** The marker's unit as the card names it ("No unit" for a ratio). */ + unit: string; + /** Every live reading of the marker, counted by the server. */ + count: number; + /** Values of the newest readings, oldest first, for the sparkline. */ + trend: (number | null)[]; + latest: LabResultDto; +} + +/** + * One row per marker, as the server resolved it over the whole history. A + * catalog-linked row deep-links to its detail chart; legacy rows render inert + * until the backfill links them. The server already orders by the newest + * reading. + */ +export function toMarkerRows( + markers: LabOverviewMarkerDto[], + t: (key: string) => string, +): MarkerRow[] { + return markers.map((marker) => ({ + key: marker.key, + biomarkerId: marker.latest.biomarkerId, + analyte: marker.latest.analyte, + panel: marker.latest.panel, + unit: labUnitLabel(marker.latest.unit, t), + count: marker.count, + trend: marker.trend, + latest: marker.latest, + })); +} + +/** + * The compact list is a five-column table from `lg` up: name and reading, range + * badge, range bar, trend, chevron. Below that the same five cells fold into + * two lines per row, because the fixed columns alone (badge + 12rem bar + 72px + * trend + gaps) are wider than a phone, and the only flexible column — the + * name — was the one that gave way: it collapsed to nothing and the row + * panned sideways off the card. + * + * Phone layout, per row: + * + * name / reading · date badge › + * range bar trend + * + * The placement classes are all `max-lg:` so the desktop table keeps exactly + * the subgrid it had. + * + * `@container` on the card makes the columns follow the width the list has, + * not the window's: below `@5xl` (64rem of card) the row is the `lg` table it + * always was (the two tiers exclude each other, so the result does not depend + * on the order the CSS is emitted in), from there up the range bar takes part + * of the extra width instead of all of it going to the name. The trend column + * stays 72px in both tiers: the sparkline is a fixed 72px glyph, and a wider + * column would only open an empty strip between it and the chevron. + */ +export const LIST_GRID_CLASS = + "divide-border grid grid-cols-1 divide-y p-0 lg:gap-x-3 lg:@max-5xl:grid-cols-[minmax(0,1fr)_max-content_12rem_72px_auto] @5xl:grid-cols-[minmax(0,1fr)_max-content_minmax(12rem,18rem)_72px_auto]"; + +/** A block of rows inside the table that keeps the table's five columns. */ +export const LIST_SUBGRID_CLASS = + "divide-border grid min-w-0 grid-cols-1 divide-y lg:col-span-full lg:grid-cols-subgrid"; + +const ROW_CLASS = + "grid min-w-0 grid-cols-[minmax(0,1fr)_auto_auto] items-center gap-x-3 gap-y-2 px-4 py-2.5 lg:col-span-full lg:grid-cols-subgrid lg:gap-y-0"; + +const MOBILE_CELL = { + reading: "max-lg:col-start-1 max-lg:row-start-1", + badge: "max-lg:col-start-2 max-lg:row-start-1 max-lg:justify-self-end", + rangeBar: "max-lg:col-start-1 max-lg:row-start-2 max-lg:empty:hidden", + trend: + "max-lg:col-start-2 max-lg:row-start-2 max-lg:justify-self-end max-lg:empty:hidden", + end: "max-lg:col-start-3 max-lg:row-start-1", + notLinked: "max-lg:col-span-full max-lg:row-start-3", +} as const; + +/** + * The badge's grid cell. The badge renders nothing for a reading without a + * reference range, and a missing cell would pull the range bar and the trend + * one column to the left; the wrapper keeps every row at five cells. + */ +function RangeBadgeCell({ status }: { status: LabResultDto["rangeStatus"] }) { + return ( +
+ +
+ ); +} + +function LabRangeBarSlot({ reading }: { reading: LabResultDto }) { + return ( +
+ {/* A phone keeps the bar's own 12rem cap; from `lg` the bar fills its + column, which is 12rem until the list is wide enough to grow it. */} + +
+ ); +} + +function TrendCell({ row }: { row: MarkerRow }) { + return ( +
+ +
+ ); +} + +/** One marker as a row of the compact table. */ +export function LabListRow({ row }: { row: MarkerRow }) { + const { t } = useTranslations(); + const labNumber = useLabNumber(); + const labDate = useLabDate(); + const inner = ( +
+
+ {/* One line with an ellipsis on a phone; from `lg` the name wraps + instead, so a long one (the schema allows 120 characters) always + reads in full. It wraps between words and hyphenates in the page + language; a word is only cut when it is wider than the column on + its own (`anywhere` cut "Hämoglobin" in the middle of the word + whenever the column was a little short). */} + + {row.analyte} + +
+
+ + {formatLabReading(row.latest, labNumber)} + + {labDate(row.latest.takenAt)} + +
+
+ ); + // The whole row navigates into the detail (where delete lives). + if (row.biomarkerId) { + return ( + + {inner} + + + + {/* With a pointer the row's hover already says it opens; a phone has + no hover, so the chevron stays there. */} + + + ); + } + // Un-linked marker (not backfilled to a catalog biomarker): render inert + // but say so, so it doesn't read as a broken link next to its clickable + // neighbours. + return ( +
+ {inner} + + + + + {t("labs.notLinkedYet")} + +
+ ); +} + +/** + * One marker as a tile. The tile reuses the medication module's + * `MedicationCardHeader` so a lab tile reads identically to a medication or + * Vorsorge tile side-by-side: name on line 1, the reference-range badge as the + * line-1 chip, the panel as the category badge. Under a category heading the + * panel would say the heading again, so the badge names the unit there. No + * delete control on the tile; the trash icon lives only on the value detail + * view. + */ +export function LabCard({ + row, + underCategory, +}: { + row: MarkerRow; + underCategory: boolean; +}) { + const { t } = useTranslations(); + const labNumber = useLabNumber(); + const labDate = useLabDate(); + const latest = row.latest; + return ( +
  • + + } + href={row.biomarkerId ? `/labs/${row.biomarkerId}` : undefined} + linkLabel={row.analyte} + /> + +
    +
    + + {formatLabReading(latest, labNumber)} + + {latest.value !== null && + (latest.referenceLow !== null || + latest.referenceHigh !== null) ? ( + + {t("labs.referenceLabel")}{" "} + {formatReferenceRange( + latest.referenceLow, + latest.referenceHigh, + labNumber, + )} + + ) : null} + + {labDate(latest.takenAt)} + + {row.count > 1 ? ( + + {t("labs.readingsCount", { count: row.count })} + + ) : null} + +
    + +
    + {/* Un-linked marker: the header renders no link, so name the reason + rather than leaving a card that silently does nothing on tap. */} + {!row.biomarkerId ? ( +

    + {t("labs.notLinkedYet")} +

    + ) : null} +
    +
    +
  • + ); +} diff --git a/src/components/labs/lab-list.tsx b/src/components/labs/lab-list.tsx index 43ddc437a..d2feca236 100644 --- a/src/components/labs/lab-list.tsx +++ b/src/components/labs/lab-list.tsx @@ -1,133 +1,186 @@ "use client"; import { useRecordCapabilities } from "@/hooks/use-record-capabilities"; -import { useMemo, useEffect } from "react"; -import Link from "next/link"; +import { useId, useMemo, useEffect } from "react"; import { useQuery } from "@tanstack/react-query"; -import { ChevronRight, FlaskConical } from "lucide-react"; +import { FlaskConical } from "lucide-react"; -import { MedicationCardHeader } from "@/components/medications/medication-card-header"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; import { EmptyState } from "@/components/ui/empty-state"; import { QueryErrorCard } from "@/components/ui/query-error-card"; import { Skeleton } from "@/components/ui/skeleton"; import { apiGet } from "@/lib/api/api-fetch"; -import { formatReferenceRange } from "@/lib/labs/reference-range"; -import { formatLabReading } from "@/lib/labs/format-value"; -import { LabConvertedNote } from "./lab-converted-note"; import { useTranslations } from "@/lib/i18n/context"; import { cn } from "@/lib/utils"; import { queryKeys } from "@/lib/query-keys"; -import { applyOrder, useModuleListPrefs } from "@/lib/module-list-prefs"; +import { + applyOrder, + useModuleListPrefs, + type ModuleListPrefs, +} from "@/lib/module-list-prefs"; -import { LabTrendSparkline } from "./lab-trend-sparkline"; -import { useLabDate, useLabNumber } from "./use-lab-format"; -import { LabReferenceRangeBar } from "./lab-reference-range-bar"; -import { ReferenceRangeBadge } from "./reference-range-badge"; -import { SourceRangeNote } from "./source-range-note"; -import type { - LabOverviewMarkerDto, - LabOverviewResponse, - LabResultDto, -} from "./types"; -import { labUnitLabel } from "@/lib/labs/dimensionless"; - -interface MarkerGroup { - /** Stable group key: `bm:`, or `analyte:` for legacy rows. */ - key: string; - /** Link target when the group is catalog-linked; null for legacy rows. */ - biomarkerId: string | null; - analyte: string; - panel: string | null; - /** The marker's unit as the card names it ("No unit" for a ratio). */ - unit: string; - /** Every live reading of the marker, counted by the server. */ - count: number; - /** Values of the newest readings, oldest first, for the sparkline. */ - trend: (number | null)[]; - latest: LabResultDto; -} +import { + groupMarkerRows, + LabGroupHeader, + useGroupsOpen, + type MarkerGroup, +} from "./lab-list-groups"; +import { + LabCard, + LabListRow, + LIST_GRID_CLASS, + LIST_SUBGRID_CLASS, + toMarkerRows, + type MarkerRow, +} from "./lab-list-rows"; +import type { LabOverviewResponse } from "./types"; /** - * One group per marker, as the server resolved it over the whole history. A - * catalog-linked group deep-links to its detail chart; legacy groups render - * inert until the backfill links them. The server already orders by the - * newest reading. + * The person's Labs sort over the server's most-recent-first order: + * `recentAsc` reverses, the alpha options sort by analyte name (#43), and + * `manual` applies the persisted biomarker order (legacy un-linked rows, which + * carry no biomarkerId, sort after the ordered block). Under categories the + * same order holds inside each category. */ -function toGroups( - markers: LabOverviewMarkerDto[], - t: (key: string) => string, -): MarkerGroup[] { - return markers.map((marker) => ({ - key: marker.key, - biomarkerId: marker.latest.biomarkerId, - analyte: marker.latest.analyte, - panel: marker.latest.panel, - unit: labUnitLabel(marker.latest.unit, t), - count: marker.count, - trend: marker.trend, - latest: marker.latest, - })); +function sortRows(rows: MarkerRow[], prefs: ModuleListPrefs): MarkerRow[] { + if (prefs.sortDir === "recentAsc") return [...rows].reverse(); + if (prefs.sortDir === "alphaAsc" || prefs.sortDir === "alphaDesc") { + const dir = prefs.sortDir === "alphaAsc" ? 1 : -1; + return [...rows].sort( + (a, b) => + dir * + a.analyte.localeCompare(b.analyte, undefined, { sensitivity: "base" }), + ); + } + if (prefs.sortDir === "manual") { + return applyOrder(rows, prefs.order, (row) => row.biomarkerId ?? row.key); + } + return rows; } -/** - * The badge's grid cell. The badge renders nothing for a reading without a - * reference range, and a missing cell would pull the range bar and the trend - * one column to the left; the wrapper keeps every row at five cells. - */ -function RangeBadgeCell({ status }: { status: LabResultDto["rangeStatus"] }) { +type GroupsOpen = ReturnType; + +/** The compact table: one bordered card of divided rows (#40). */ +function LabTable({ + rows, + groups, + groupsOpen, +}: { + rows: MarkerRow[]; + groups: MarkerGroup[] | null; + groupsOpen: GroupsOpen; +}) { return ( -
    - + // `lab-list` marks the read results in both view shapes. The loading + // branch carries `lab-list-loading` instead, so the marker cannot stand + // for a silhouette. +
    + + + {groups + ? groups.map((group) => ( + + )) + : rows.map((row) => )} + +
    ); } -function LabRangeBarSlot({ reading }: { reading: LabResultDto }) { +function useGroupToggle(group: MarkerGroup, groupsOpen: GroupsOpen) { + const panelId = `${useId()}-panel`; + return { + open: groupsOpen.isOpen(group.category), + onToggle: () => groupsOpen.toggle(group.category), + panelId, + }; +} + +/** + * One category inside the table. The header spans every column and the rows + * below keep the table's subgrid, so the columns line up across categories. + */ +function TableGroup({ + group, + groupsOpen, +}: { + group: MarkerGroup; + groupsOpen: GroupsOpen; +}) { + const { open, onToggle, panelId } = useGroupToggle(group, groupsOpen); return ( -
    - {/* A phone keeps the bar's own 12rem cap; from `lg` the bar fills its - column, which is 12rem until the list is wide enough to grow it. */} - + +
    ); } -/** - * The compact list is a five-column table from `lg` up: name and reading, range - * badge, range bar, trend, chevron. Below that the same five cells fold into - * two lines per row, because the fixed columns alone (badge + 12rem bar + 72px - * trend + gaps) are wider than a phone, and the only flexible column — the - * name — was the one that gave way: it collapsed to nothing and the row - * panned sideways off the card. - * - * Phone layout, per row: - * - * name / reading · date badge › - * range bar trend - * - * The placement classes are all `max-lg:` so the desktop table keeps exactly - * the subgrid it had. - */ -const ROW_CLASS = - "grid min-w-0 grid-cols-[minmax(0,1fr)_auto_auto] items-center gap-x-3 gap-y-2 px-4 py-2.5 lg:col-span-full lg:grid-cols-subgrid lg:gap-y-0"; +function CardGrid({ + rows, + underCategory, +}: { + rows: MarkerRow[]; + underCategory: boolean; +}) { + return ( +
      1 && "sm:grid-cols-2", + )} + > + {rows.map((row) => ( + + ))} +
    + ); +} -const MOBILE_CELL = { - reading: "max-lg:col-start-1 max-lg:row-start-1", - badge: "max-lg:col-start-2 max-lg:row-start-1 max-lg:justify-self-end", - rangeBar: "max-lg:col-start-1 max-lg:row-start-2 max-lg:empty:hidden", - trend: - "max-lg:col-start-2 max-lg:row-start-2 max-lg:justify-self-end max-lg:empty:hidden", - end: "max-lg:col-start-3 max-lg:row-start-1", - notLinked: "max-lg:col-span-full max-lg:row-start-3", -} as const; +function CardGroup({ + group, + groupsOpen, +}: { + group: MarkerGroup; + groupsOpen: GroupsOpen; +}) { + const { open, onToggle, panelId } = useGroupToggle(group, groupsOpen); + return ( +
    + + +
    + ); +} export function LabList({ onAddFirst, @@ -137,12 +190,11 @@ export function LabList({ /** Told whether the list is empty, so the page can drop its header add. */ onEmptyChange?: (empty: boolean) => void; } = {}) { - const { t } = useTranslations(); - const labNumber = useLabNumber(); - const labDate = useLabDate(); + const { t, locale } = useTranslations(); const { canWriteDomain } = useRecordCapabilities(); const canAddLab = canWriteDomain("labs"); const { prefs } = useModuleListPrefs("labs"); + const groupsOpen = useGroupsOpen(prefs.groupsOpen); // v1.22 — a short, factual line under each marker heading describing what the // biomarker measures, sourced from the catalog via i18n. Free-text markers @@ -152,35 +204,24 @@ export function LabList({ // One entry per biomarker over the whole history, grouped server-side: a // capped page of raw rows dropped every marker measured only long ago. const { data, isLoading, isError, error, refetch } = useQuery({ - queryKey: queryKeys.labResultsOverview(), + queryKey: queryKeys.labResultsOverview(locale), queryFn: () => apiGet("/api/labs/overview"), }); - const groups = useMemo(() => { - const base = toGroups(data?.markers ?? [], t); - // v1.18.6 — honour the user's Labs sort choice. The server - // order is most-recent-first; `recentAsc` reverses, the alpha - // options sort by analyte name (#43), and `manual` applies the persisted - // biomarker order (legacy un-linked groups, which carry no biomarkerId, - // sort after the ordered block). - if (prefs.sortDir === "recentAsc") return [...base].reverse(); - if (prefs.sortDir === "alphaAsc" || prefs.sortDir === "alphaDesc") { - const dir = prefs.sortDir === "alphaAsc" ? 1 : -1; - return [...base].sort( - (a, b) => - dir * - a.analyte.localeCompare(b.analyte, undefined, { - sensitivity: "base", - }), - ); - } - if (prefs.sortDir === "manual") { - return applyOrder(base, prefs.order, (g) => g.biomarkerId ?? g.key); - } - return base; - }, [data?.markers, prefs.sortDir, prefs.order, t]); + const rows = useMemo( + () => sortRows(toMarkerRows(data?.markers ?? [], t), prefs), + [data?.markers, prefs, t], + ); + // Categories only when the person asked for them and the server says they + // add something; otherwise the flat list it always was. + const grouped = prefs.groupBy === "category" && data?.grouped === true; + const categories = data?.categories; + const groups = useMemo( + () => (grouped && categories ? groupMarkerRows(rows, categories) : null), + [grouped, categories, rows], + ); - const listEmpty = !isLoading && !isError && groups.length === 0; + const listEmpty = !isLoading && !isError && rows.length === 0; useEffect(() => { onEmptyChange?.(listEmpty); }, [listEmpty, onEmptyChange]); @@ -215,7 +256,7 @@ export function LabList({ ); } - if (groups.length === 0) { + if (rows.length === 0) { return ( } @@ -234,206 +275,29 @@ export function LabList({ // not on these overview rows. The list/tile rows navigate; the trash icon was // removed here so a stray tap can't delete a biomarker from the overview. - // v1.18.6 — compact list view: one bordered card holding tight - // divided rows instead of a card per biomarker. This is the default view - // (#40); the card/tile view is the alternative the settings toggle selects. + // v1.18.6 — compact list view is the default (#40); the card/tile view is + // the alternative the settings toggle selects. if (prefs.view === "list") { + return ; + } + + // v1.18.9 (#40) — card/tile view. + if (groups) { return ( - // `lab-list` marks the read results in both view shapes. The loading - // branch above carries `lab-list-loading` instead, so the marker - // cannot stand for a silhouette. -
    - {/* `@container` makes the columns follow the width the list has, not the - window's: below `@5xl` (64rem of card) the row is the `lg` table it - always was (the two tiers exclude each other, so the result does not - depend on the order the CSS is emitted in), from there up the range - bar takes part of the extra width instead of all of it going to the - name. The trend column stays 72px in both tiers: the sparkline is a - fixed 72px glyph, and a wider column would only open an empty strip - between it and the chevron. */} - - - {groups.map((group) => { - const inner = ( -
    -
    - {/* One line with an ellipsis on a phone; from `lg` the name - wraps instead, so a long one (the schema allows 120 - characters) always reads in full. It wraps between - words and hyphenates in the page language; a word is - only cut when it is wider than the column on its own - (`anywhere` cut "Hämoglobin" in the middle of the - word whenever the column was a little short). */} - - {group.analyte} - -
    -
    - - {formatLabReading(group.latest, labNumber)} - - {labDate(group.latest.takenAt)} - -
    -
    - ); - // The whole row navigates into the detail (where delete lives). - return group.biomarkerId ? ( - - {inner} - - -
    - -
    - {/* With a pointer the row's hover already says it opens; a - phone has no hover, so the chevron stays there. */} - - - ) : ( - // Un-linked group (not backfilled to a catalog biomarker): - // render inert but say so, so it doesn't read as a broken - // link next to its clickable neighbours. -
    - {inner} - - -
    - -
    - - {t("labs.notLinkedYet")} - -
    - ); - })} -
    -
    +
    + {groups.map((group) => ( + + ))}
    ); } - - // v1.18.9 (#40) — card/tile view. The tile reuses the medication module's - // `MedicationCardHeader` so a lab tile reads identically to a medication or - // Vorsorge tile side-by-side: name on line 1, the reference-range badge as - // the line-1 chip, the panel as the category badge. v1.18.10 (#3) — no delete - // control on the tile; the trash icon lives only on the value detail view. return ( -
      1 && "sm:grid-cols-2", - )} - > - {groups.map((group) => { - return ( -
    • - - - } - href={ - group.biomarkerId ? `/labs/${group.biomarkerId}` : undefined - } - linkLabel={group.analyte} - /> - -
      -
      - - {formatLabReading(group.latest, labNumber)} - - {group.latest.value !== null && - (group.latest.referenceLow !== null || - group.latest.referenceHigh !== null) ? ( - - {t("labs.referenceLabel")}{" "} - {formatReferenceRange( - group.latest.referenceLow, - group.latest.referenceHigh, - labNumber, - )} - - ) : null} - - - {labDate(group.latest.takenAt)} - - - {group.count > 1 ? ( - - {t("labs.readingsCount", { - count: group.count, - })} - - ) : null} - -
      - -
      - {/* Un-linked group: the header renders no link, so name the - reason rather than leaving a card that silently does - nothing on tap. */} - {!group.biomarkerId ? ( -

      - {t("labs.notLinkedYet")} -

      - ) : null} -
      -
      -
    • - ); - })} -
    +
    + +
    ); } diff --git a/src/components/labs/labs-settings.tsx b/src/components/labs/labs-settings.tsx index 709f550b5..f8404ec92 100644 --- a/src/components/labs/labs-settings.tsx +++ b/src/components/labs/labs-settings.tsx @@ -5,7 +5,8 @@ * * `LabsSettings` — Settings > Arrangement > Labs: the card-vs-list view * (MOD-03), the sort (recency, alphabetical or manual, MOD-04) and the manual - * order of the biomarkers. Order and view only, persisted per account through + * order of the biomarkers, and whether the list shows its categories and + * which of them start open. Persisted per account through * `useModuleListPrefs("labs")`. * * `LabsModuleSettings` — Settings > Modules > Labs: the on-device text @@ -17,6 +18,7 @@ import { toastWrittenOutcome } from "@/components/outcome/outcome-toast"; import { ArrowDownUp, FlaskConical, + FolderTree, LayoutGrid, ListOrdered, ScanText, @@ -27,6 +29,7 @@ import { SettingsCardHeader } from "@/components/settings/_card-header"; import { NativeSelect } from "@/components/ui/native-select"; import { Label } from "@/components/ui/label"; import { Skeleton } from "@/components/ui/skeleton"; +import { Segmented } from "@/components/ui/segmented"; import { Switch } from "@/components/ui/switch"; import { apiGet } from "@/lib/api/api-fetch"; import { showErrorToast } from "@/lib/errors/error-toast"; @@ -40,6 +43,8 @@ import { import { applyOrder, useModuleListPrefs, + type ModuleGroupBy, + type ModuleGroupsOpen, type ModuleSortDir, } from "@/lib/module-list-prefs"; @@ -50,7 +55,8 @@ import { labUnitLabel } from "@/lib/labs/dimensionless"; export function LabsSettings() { const { t } = useTranslations(); - const { prefs, setView, setOrder, setSortDir } = useModuleListPrefs("labs"); + const { prefs, setView, setOrder, setSortDir, setGroupBy, setGroupsOpen } = + useModuleListPrefs("labs"); const { data, isLoading } = useQuery({ queryKey: queryKeys.biomarkers(), @@ -107,11 +113,23 @@ export function LabsSettings() {
    + + {manual ? ( {isLoading ? (
    @@ -128,6 +146,64 @@ export function LabsSettings() { ); } +/** + * Categories on or off, and which of them start open. The second choice only + * shows while categories are on; it means nothing for a flat list. + */ +function LabsGroupingCard({ + groupBy, + groupsOpen, + onGroupBy, + onGroupsOpen, +}: { + groupBy: ModuleGroupBy; + groupsOpen: ModuleGroupsOpen; + onGroupBy: (value: ModuleGroupBy) => void; + onGroupsOpen: (value: ModuleGroupsOpen) => void; +}) { + const { t } = useTranslations(); + return ( + + +
    +

    {t("labs.groups.label")}

    + `labs-group-by-${value}`} + options={[ + { value: "none", label: t("labs.groups.none") }, + { value: "category", label: t("labs.groups.category") }, + ]} + /> +
    + {groupBy === "category" ? ( +
    +

    {t("labs.groups.openLabel")}

    + `labs-groups-open-${value}`} + options={[ + { value: "expanded", label: t("labs.groups.openExpanded") }, + { value: "flagged", label: t("labs.groups.openFlagged") }, + { value: "collapsed", label: t("labs.groups.openCollapsed") }, + ]} + /> +
    + ) : null} +
    + ); +} + export function LabsModuleSettings() { const { t } = useTranslations(); // v1.18.10 — local (in-browser) OCR opt-in. Lets a user whose AI provider diff --git a/src/components/labs/types.ts b/src/components/labs/types.ts index d84d55db9..c721eb137 100644 --- a/src/components/labs/types.ts +++ b/src/components/labs/types.ts @@ -80,8 +80,22 @@ export interface LabOverviewMarkerDto { latest: LabResultDto; } +/** One category of the labs list, resolved by the server; rendered as given. */ +export interface LabOverviewCategoryDto { + /** Opaque: a catalog panel key, `custom:`, or `other`. */ + key: string; + label: string; + /** The `markers[].key` values in this category. */ + markerKeys: string[]; + counts: { inRange: number; below: number; above: number; unknown: number }; + status: LabResultDto["rangeStatus"]; +} + export interface LabOverviewResponse { markers: LabOverviewMarkerDto[]; + categories: LabOverviewCategoryDto[]; + /** True when grouping adds anything; a flat list otherwise. */ + grouped: boolean; } /** A user-scoped Biomarker catalog entry as the API serialises it. */ diff --git a/src/lib/labs/__tests__/marker-category.test.ts b/src/lib/labs/__tests__/marker-category.test.ts new file mode 100644 index 000000000..56ff193cc --- /dev/null +++ b/src/lib/labs/__tests__/marker-category.test.ts @@ -0,0 +1,224 @@ +import { describe, expect, it } from "vitest"; + +import { locales } from "@/lib/i18n/config"; +import { labelIn } from "@/lib/i18n/localised-label-index"; +import type { ReferenceRangeStatus } from "@/lib/labs/reference-range"; + +import { ALIAS_PANEL, BIOMARKER_PANELS } from "../biomarker-catalog"; +import { + categoriseOverview, + customCategoryLabel, + customPanelSuggestions, + resolveMarkerCategory, + summariseCategory, + type CategorisableMarker, +} from "../marker-category"; + +function marker( + key: string, + analyte: string, + panel: string | null, + rangeStatus: ReferenceRangeStatus = "in-range", +): CategorisableMarker { + return { key, latest: { analyte, panel, rangeStatus } }; +} + +describe("resolveMarkerCategory", () => { + it("folds every catalog panel label in every locale to its key", () => { + let checked = 0; + for (const key of BIOMARKER_PANELS) { + for (const locale of locales) { + const label = labelIn(locale, `labs.catalog.panel.${key}`); + expect(label, `${locale} labs.catalog.panel.${key}`).not.toBeNull(); + for (const spelling of [label!, label!.toUpperCase(), ` ${label} `]) { + expect(resolveMarkerCategory({ panel: spelling, name: "x" })).toEqual( + { key, kind: "builtin" }, + ); + checked += 1; + } + } + } + expect(checked).toBe(BIOMARKER_PANELS.length * locales.length * 3); + }); + + it("keys the person's own panel by a hash that ignores case and spacing", () => { + const a = resolveMarkerCategory({ panel: "Großes Blutbild", name: "x" }); + const b = resolveMarkerCategory({ panel: "großes BLUTBILD", name: "y" }); + const c = resolveMarkerCategory({ panel: "Kleines Blutbild", name: "x" }); + expect(a.kind).toBe("custom"); + expect(a.key).toMatch(/^custom:[0-9a-f]{12}$/); + expect(a.key).not.toContain("Blutbild"); + expect(b.key).toBe(a.key); + expect(c.key).not.toBe(a.key); + }); + + it("lets the person's panel win over the name's catalog panel", () => { + expect( + resolveMarkerCategory({ panel: "Annual check", name: "LDL" }).kind, + ).toBe("custom"); + }); + + it("falls back to the catalog panel of the name, glosses included", () => { + expect(resolveMarkerCategory({ panel: null, name: "LDL" })).toEqual({ + key: "lipids", + kind: "builtin", + }); + expect(resolveMarkerCategory({ panel: " ", name: "TSH" }).key).toBe( + "thyroid", + ); + expect( + resolveMarkerCategory({ panel: null, name: "Bilirubin (Total)" }).key, + ).toBe("liver"); + }); + + it("places each of the ten seedless alias keys", () => { + const names: Record = { + urea: "Harnstoff", + "blood-urea-nitrogen": "BUN", + "uric-acid": "Harnsäure", + ldh: "LDH", + "calcium-total": "Calcium", + crp: "CRP", + rbc: "Erythrozyten", + mcv: "MCV", + mch: "MCH", + mchc: "MCHC", + }; + expect(Object.keys(names).sort()).toEqual(Object.keys(ALIAS_PANEL).sort()); + for (const [key, name] of Object.entries(names)) { + expect( + resolveMarkerCategory({ panel: null, name }).key, + `${name} (${key})`, + ).toBe(ALIAS_PANEL[key]); + } + }); + + it("puts an unknown name without a panel under other", () => { + expect( + resolveMarkerCategory({ panel: null, name: "Something rare" }), + ).toEqual({ key: "other", kind: "other" }); + }); +}); + +describe("summariseCategory", () => { + it.each<[ReferenceRangeStatus[], ReferenceRangeStatus]>([ + [["below", "above", "in-range"], "above"], + [["below", "in-range"], "below"], + [["unknown", "unknown"], "unknown"], + [["unknown", "in-range"], "in-range"], + [[], "unknown"], + ])("%j -> %s", (statuses, status) => { + expect(summariseCategory(statuses).status).toBe(status); + }); + + it("counts each status", () => { + expect( + summariseCategory(["in-range", "below", "above", "above", "unknown"]) + .counts, + ).toEqual({ inRange: 1, below: 1, above: 2, unknown: 1 }); + }); +}); + +describe("customCategoryLabel", () => { + it("takes the most frequent spelling, ties to the smallest", () => { + expect(customCategoryLabel(["Check", "check", "check"])).toBe("check"); + expect(customCategoryLabel(["b panel", "a panel"])).toBe("a panel"); + expect(customCategoryLabel(["a panel", "b panel"])).toBe("a panel"); + }); +}); + +describe("customPanelSuggestions", () => { + it("lists own panels once per fold and leaves catalog panels out", () => { + expect( + customPanelSuggestions([ + "Lipide", + "Annual check", + "annual check", + "Annual check", + null, + "", + "Hormones", + ]), + ).toEqual(["Annual check", "Hormones"]); + }); +}); + +describe("categoriseOverview", () => { + it("orders catalog panels by catalog, then own panels by label, then other", () => { + const { categories, grouped } = categoriseOverview( + [ + marker("bm:1", "Mystery", null), + marker("bm:2", "TSH", null, "above"), + marker("bm:3", "Zinc", "Trace elements"), + marker("bm:4", "LDL", "Lipids", "below"), + marker("bm:5", "HDL", "Lipide"), + marker("bm:6", "Cortisol", "Hormones", "unknown"), + ], + "en", + ); + expect(grouped).toBe(true); + expect(categories.map((c) => c.label)).toEqual([ + "Lipids", + "Thyroid", + "Hormones", + "Trace elements", + "Other", + ]); + const lipids = categories[0]; + expect(lipids.key).toBe("lipids"); + expect(lipids.markerKeys).toEqual(["bm:4", "bm:5"]); + expect(lipids.counts).toEqual({ + inRange: 1, + below: 1, + above: 0, + unknown: 0, + }); + expect(lipids.status).toBe("below"); + expect(categories[1].status).toBe("above"); + expect(categories[2].status).toBe("unknown"); + expect(categories[2].key).toMatch(/^custom:/); + }); + + it("labels catalog panels and other in the requested locale", () => { + const { categories } = categoriseOverview( + [marker("a", "LDL", null), marker("b", "Mystery", null)], + "de", + ); + expect(categories.map((c) => c.label)).toEqual(["Lipide", "Sonstige"]); + }); + + it("keeps a custom label stable however the newest reading is spelled", () => { + const { categories } = categoriseOverview( + [ + marker("a", "x", "annual CHECK"), + marker("b", "y", "Annual check"), + marker("c", "z", "Annual check"), + ], + "en", + ); + expect(categories).toHaveLength(1); + expect(categories[0].label).toBe("Annual check"); + }); + + it("groups only when it adds something", () => { + expect(categoriseOverview([], "en").grouped).toBe(false); + expect( + categoriseOverview( + [marker("a", "LDL", null), marker("b", "HDL", null)], + "en", + ).grouped, + ).toBe(false); + expect( + categoriseOverview( + [marker("a", "Mystery", null), marker("b", "Unknown", null)], + "en", + ).grouped, + ).toBe(false); + expect( + categoriseOverview( + [marker("a", "LDL", null), marker("b", "Mystery", null)], + "en", + ).grouped, + ).toBe(true); + }); +}); diff --git a/src/lib/labs/biomarker-catalog.ts b/src/lib/labs/biomarker-catalog.ts index f19ace3eb..3cb8058c1 100644 --- a/src/lib/labs/biomarker-catalog.ts +++ b/src/lib/labs/biomarker-catalog.ts @@ -294,6 +294,29 @@ export const BIOMARKER_PANELS = [ "bloodCount", ] as const; +export type BiomarkerPanelKey = (typeof BIOMARKER_PANELS)[number]; + +/** + * The panel an analyte with no seed belongs to, for grouping the labs list. + * Every key in `ANALYTE_ALIASES` and `LAB_NAME_ALIASES` that is not a seed + * slug is listed here, so a marker recognised by one of those names lands in + * a catalog category instead of "Other" (`marker-category-coverage.test.ts` + * holds the two lists together). Uric acid sits with the kidney markers and + * LDH with the liver markers, the way German lab reports print them. + */ +export const ALIAS_PANEL: Readonly> = { + urea: "renal", + "blood-urea-nitrogen": "renal", + "uric-acid": "renal", + ldh: "liver", + "calcium-total": "electrolytes", + crp: "inflammation", + rbc: "bloodCount", + mcv: "bloodCount", + mch: "bloodCount", + mchc: "bloodCount", +}; + /** * Names a marker may carry for an analyte the unit-conversion registry knows * (`src/lib/labs/unit-conversion/registry.ts`), keyed by the analyte key a diff --git a/src/lib/labs/marker-category.ts b/src/lib/labs/marker-category.ts new file mode 100644 index 000000000..238f27ae1 --- /dev/null +++ b/src/lib/labs/marker-category.ts @@ -0,0 +1,274 @@ +/** + * Categories for the labs list (`GET /api/labs/overview`). + * + * A marker's category is its panel: the serialised `latest.panel`, which is + * the biomarker's panel for a linked marker and the row's own for a legacy + * unlinked one (the same value the card shows as its chip). The server + * resolves the category, its label, its order and a combined status once; + * the web list and the iOS app render that as given and never rebuild it. + * + * Resolution, first match wins: + * 1. the panel folds to a catalog panel label in any shipped locale + * ("Lipide", "Lipids", "Lipides" are one category); + * 2. any other panel is the person's own grouping, keyed by a hash of its + * fold so the key carries no user text; + * 3. a marker without a panel whose name the catalog knows lands in that + * analyte's catalog panel; + * 4. everything else is "other". + * + * SERVER-ONLY: it reads every message bundle through the label index. + */ +import { createHash } from "node:crypto"; + +import { foldLabel } from "@/lib/i18n/fold-label"; +import { getServerTranslator } from "@/lib/i18n/server-translator"; +import { buildLocalisedLabelIndex } from "@/lib/i18n/localised-label-index"; +import type { Locale } from "@/lib/i18n/config"; +import { resolveIntlLocale } from "@/lib/format-locale"; +import type { ReferenceRangeStatus } from "@/lib/labs/reference-range"; + +import { catalogKeyForName } from "./analyte-match"; +import { + ALIAS_PANEL, + BIOMARKER_CATALOG, + BIOMARKER_PANELS, + type BiomarkerPanelKey, +} from "./biomarker-catalog"; + +export const OTHER_CATEGORY = "other"; +const CUSTOM_PREFIX = "custom:"; + +export type MarkerCategoryKind = "builtin" | "custom" | "other"; + +export interface MarkerCategoryRef { + key: string; + kind: MarkerCategoryKind; +} + +export interface CategoryCounts { + inRange: number; + below: number; + above: number; + unknown: number; +} + +export interface LabOverviewCategory { + /** A catalog panel key, `custom:`, or `other`. Opaque to clients. */ + key: string; + /** Rendered as given. */ + label: string; + /** The overview `markers[].key` values in this category, in list order. */ + markerKeys: string[]; + counts: CategoryCounts; + /** Combined status of the members' newest readings. */ + status: ReferenceRangeStatus; +} + +let panelIndex: ReadonlyMap | null = null; + +/** Folded catalog panel label (every locale) -> panel key. Built once. */ +export function panelLabelIndex(): ReadonlyMap { + panelIndex ??= buildLocalisedLabelIndex( + BIOMARKER_PANELS.map((key) => ({ + id: key, + messageKey: `labs.catalog.panel.${key}`, + value: key, + })), + ).index; + return panelIndex; +} + +const SEED_PANEL = new Map( + BIOMARKER_CATALOG.map((seed) => [seed.slug, seed.panel as BiomarkerPanelKey]), +); + +/** The catalog panel of an analyte key, from its seed or the alias table. */ +export function panelForAnalyte(key: string): BiomarkerPanelKey | null { + return SEED_PANEL.get(key) ?? ALIAS_PANEL[key] ?? null; +} + +function customKey(folded: string): string { + return ( + CUSTOM_PREFIX + + createHash("sha256").update(folded, "utf8").digest("hex").slice(0, 12) + ); +} + +export function resolveMarkerCategory(input: { + panel: string | null; + name: string; +}): MarkerCategoryRef { + const panel = input.panel?.trim() ?? ""; + if (panel !== "") { + const folded = foldLabel(panel); + const builtin = panelLabelIndex().get(folded); + if (builtin) return { key: builtin, kind: "builtin" }; + if (folded !== "") return { key: customKey(folded), kind: "custom" }; + } + const analyte = catalogKeyForName(input.name); + const fromName = analyte ? panelForAnalyte(analyte) : null; + if (fromName) return { key: fromName, kind: "builtin" }; + return { key: OTHER_CATEGORY, kind: "other" }; +} + +/** + * Counts and one status for a category. Any value above its range makes the + * category "above", else any below makes it "below", else one assessed value + * makes it "in-range"; a category of qualitative or rangeless values only is + * "unknown". Never an alarm tone: the client maps these to the same calm + * tones the per-reading badge uses. + */ +export function summariseCategory(statuses: readonly ReferenceRangeStatus[]): { + counts: CategoryCounts; + status: ReferenceRangeStatus; +} { + const counts: CategoryCounts = { inRange: 0, below: 0, above: 0, unknown: 0 }; + for (const status of statuses) { + if (status === "in-range") counts.inRange += 1; + else if (status === "below") counts.below += 1; + else if (status === "above") counts.above += 1; + else counts.unknown += 1; + } + const status: ReferenceRangeStatus = + counts.above > 0 + ? "above" + : counts.below > 0 + ? "below" + : counts.inRange > 0 + ? "in-range" + : "unknown"; + return { counts, status }; +} + +/** + * The label of a custom category: the spelling most members carry, ties to + * the smallest by `localeCompare`, so it does not flip when a newer reading + * lands. + */ +export function customCategoryLabel(spellings: readonly string[]): string { + const tally = new Map(); + for (const spelling of spellings) { + tally.set(spelling, (tally.get(spelling) ?? 0) + 1); + } + let best: string | null = null; + let bestCount = 0; + for (const [spelling, count] of tally) { + if ( + best === null || + count > bestCount || + (count === bestCount && spelling.localeCompare(best) < 0) + ) { + best = spelling; + bestCount = count; + } + } + return best ?? ""; +} + +/** + * The person's panels that are no catalog panel, one spelling per fold, for + * the panel suggestions in the marker form. + */ +export function customPanelSuggestions( + panels: readonly (string | null)[], +): string[] { + const byFold = new Map(); + for (const raw of panels) { + const panel = raw?.trim() ?? ""; + if (panel === "") continue; + const folded = foldLabel(panel); + if (folded === "" || panelLabelIndex().has(folded)) continue; + const list = byFold.get(folded) ?? []; + list.push(panel); + byFold.set(folded, list); + } + return [...byFold.values()] + .map(customCategoryLabel) + .sort((a, b) => a.localeCompare(b)); +} + +export interface CategorisableMarker { + key: string; + latest: { + panel: string | null; + analyte: string; + rangeStatus: ReferenceRangeStatus; + }; +} + +/** + * Every category of an overview, in display order (catalog panels in catalog + * order, then the person's own by label, then "other"), and whether the list + * groups at all: it does when there are two categories or more and at least + * one is not "other". One category, or only "other", stays a flat list. + */ +export function categoriseOverview( + markers: readonly CategorisableMarker[], + locale: Locale, +): { categories: LabOverviewCategory[]; grouped: boolean } { + const { t } = getServerTranslator(locale); + const buckets = new Map< + string, + { + kind: MarkerCategoryKind; + keys: string[]; + spellings: string[]; + statuses: ReferenceRangeStatus[]; + } + >(); + for (const marker of markers) { + const ref = resolveMarkerCategory({ + panel: marker.latest.panel, + name: marker.latest.analyte, + }); + let bucket = buckets.get(ref.key); + if (!bucket) { + bucket = { kind: ref.kind, keys: [], spellings: [], statuses: [] }; + buckets.set(ref.key, bucket); + } + bucket.keys.push(marker.key); + bucket.statuses.push(marker.latest.rangeStatus); + if (ref.kind === "custom" && marker.latest.panel) { + bucket.spellings.push(marker.latest.panel.trim()); + } + } + + const categories: LabOverviewCategory[] = []; + for (const [key, bucket] of buckets) { + const label = + bucket.kind === "builtin" + ? t(`labs.catalog.panel.${key}`) + : bucket.kind === "custom" + ? customCategoryLabel(bucket.spellings) + : t("labs.groups.other"); + categories.push({ + key, + label, + markerKeys: bucket.keys, + ...summariseCategory(bucket.statuses), + }); + } + + const intlLocale = resolveIntlLocale(locale); + const rank = (category: LabOverviewCategory): number => { + const builtin = (BIOMARKER_PANELS as readonly string[]).indexOf( + category.key, + ); + if (builtin >= 0) return builtin; + return category.key === OTHER_CATEGORY + ? BIOMARKER_PANELS.length + 1 + : BIOMARKER_PANELS.length; + }; + categories.sort((a, b) => { + const byRank = rank(a) - rank(b); + if (byRank !== 0) return byRank; + return ( + a.label.localeCompare(b.label, intlLocale) || (a.key < b.key ? -1 : 1) + ); + }); + + const grouped = + categories.length >= 2 && + categories.some((category) => category.key !== OTHER_CATEGORY); + return { categories, grouped }; +} diff --git a/src/lib/openapi/routes/labs.ts b/src/lib/openapi/routes/labs.ts index 279011802..42a7849ca 100644 --- a/src/lib/openapi/routes/labs.ts +++ b/src/lib/openapi/routes/labs.ts @@ -139,8 +139,45 @@ const labOverviewMarker = z "One biomarker in the caller's lab history. `key` is `bm:` for a catalog-linked marker or `analyte:` for a legacy unlinked one. `count` is every live reading of the marker. `trend` holds the values of its newest readings (at most 50), oldest first, null for a qualitative reading. `latest` is the newest reading, serialised exactly like a `GET /api/labs` row.", }); -const labOverviewResponse = z - .object({ markers: z.array(labOverviewMarker) }) +export const labOverviewCategoryCounts = z + .object({ + inRange: z.number().int(), + below: z.number().int(), + above: z.number().int(), + unknown: z.number().int(), + }) + .meta({ + id: "LabOverviewCategoryCounts", + description: + "How many members' newest readings are in range, below, above, or unassessed (qualitative, or no range).", + }); + +export const labOverviewCategory = z + .object({ + key: z.string(), + label: z.string(), + markerKeys: z.array(z.string()), + counts: labOverviewCategoryCounts, + status: z.enum(["in-range", "below", "above", "unknown"]), + }) + .meta({ + id: "LabOverviewCategory", + description: + "One category of the labs list, resolved by the server. `key` is opaque: a catalog panel key, `custom:` for the person's own panel, or `other`; it carries no user text and is stable, so a client may key collapse state on it. `label` is rendered as given (a catalog panel in the caller's locale, the person's own spelling, or the localised \"Other\"). `markerKeys` names the `markers[].key` values in the category. `status` combines the members' newest readings: `above` when any is above its range, else `below` when any is below, else `in-range` when any was assessed, else `unknown`. A client renders these values and never rebuilds the category, the order or the status.", + }); + +export const labOverviewResponse = z + .object({ + markers: z.array(labOverviewMarker), + categories: z.array(labOverviewCategory).meta({ + description: + "The list's categories in display order: catalog panels in catalog order, then the person's own panels by label, then `other`.", + }), + grouped: z.boolean().meta({ + description: + "Whether grouping adds anything: true when there are two categories or more and at least one is not `other`. A client that shows categories (the `groupBy` list preference) shows a flat list while this is false.", + }), + }) .meta({ id: "LabOverviewResponse" }); const notFound = { @@ -207,7 +244,7 @@ export const labsPaths: NonNullable = { tags: ["Labs"], summary: "Per-marker overview of the caller's lab history", description: - "One entry per biomarker over every live lab result, newest reading first: the newest reading, the reading count and a short trend. Unlike `GET /api/labs` it is not paged, so a marker measured only long ago is never cut off by a page size.", + "One entry per biomarker over every live lab result, newest reading first: the newest reading, the reading count and a short trend. Unlike `GET /api/labs` it is not paged, so a marker measured only long ago is never cut off by a page size. `categories` and `grouped` carry the list's categories, resolved server-side; their labels follow the caller's locale (the language cookie, else the caller's stored language, else `Accept-Language`).", requestParams: { query: labOverviewQuery }, responses: { ...recordRefusal(), diff --git a/src/lib/query-keys/labs.ts b/src/lib/query-keys/labs.ts index aa1a6291b..3cdcb8e01 100644 --- a/src/lib/query-keys/labs.ts +++ b/src/lib/query-keys/labs.ts @@ -31,8 +31,14 @@ export const labKeys = { // The Labs list: one entry per biomarker over the whole history // (`GET /api/labs/overview`). The `lab-results` prefix lets every result - // mutation's `labResults()` invalidation evict it. - labResultsOverview: () => ["lab-results", "overview"] as const, + // mutation's `labResults()` invalidation evict it. The optional locale + // segment gives each language its own cell, because the response carries + // localised category labels; the zero-arg form stays the prefix of every + // locale cell, so a prefix invalidation still reaches them all. + labResultsOverview: (locale?: string) => + locale + ? (["lab-results", "overview", locale] as const) + : (["lab-results", "overview"] as const), // One reading's detail (`GET /api/labs/{id}`): the decrypted note and the // records it relates to. Under the `lab-results` prefix so every result diff --git a/tests/integration/labs-overview-long-history.test.ts b/tests/integration/labs-overview-long-history.test.ts index 35c6704f8..be3ddffc4 100644 --- a/tests/integration/labs-overview-long-history.test.ts +++ b/tests/integration/labs-overview-long-history.test.ts @@ -270,3 +270,152 @@ describe("GET /api/labs/overview on a 25-year history", () => { expect(((await res.json()) as OverviewBody).data.markers).toEqual([]); }); }); + +interface CategoryBody { + data: { + markers: Array<{ key: string }>; + grouped: boolean; + categories: Array<{ + key: string; + label: string; + markerKeys: string[]; + counts: { + inRange: number; + below: number; + above: number; + unknown: number; + }; + status: string; + }>; + }; +} + +describe("GET /api/labs/overview categories", () => { + async function seedCategories() { + const prisma = getPrismaClient(); + const mk = (name: string, panel: string | null, low = 1, high = 10) => + prisma.biomarker.create({ + data: { + userId: USER_ID, + name, + unit: "U/L", + lowerBound: low, + upperBound: high, + panel, + }, + }); + const ldl = await mk("LDL", "Lipide"); + const hdl = await mk("HDL", null); + const tsh = await mk("TSH", "Thyroid"); + const own1 = await mk("Zinc", "Trace elements"); + const own2 = await mk("Selenium", "trace ELEMENTS"); + const own3 = await mk("Copper", "trace ELEMENTS"); + const reading = (id: string, analyte: string, value: number, day: number) => + ({ + userId: USER_ID, + biomarkerId: id, + analyte, + value, + unit: "U/L", + takenAt: daysAgo(day), + }) as const; + await prisma.labResult.createMany({ + data: [ + reading(ldl.id, "LDL", 20, 3), + reading(hdl.id, "HDL", 0.5, 2), + reading(tsh.id, "TSH", 5, 1), + // The newest reading of the custom group carries the rarer spelling. + reading(own1.id, "Zinc", 5, 0), + reading(own2.id, "Selenium", 5, 6), + reading(own3.id, "Copper", 5, 4), + ], + }); + // A legacy unlinked row, panel-less, recognised by name. + await prisma.labResult.create({ + data: { + userId: USER_ID, + analyte: "Mystery marker", + value: 3, + unit: "U/L", + takenAt: daysAgo(5), + }, + }); + return { ldl, hdl, tsh }; + } + + it("resolves order, members, counts and status on the server", async () => { + const { ldl, hdl, tsh } = await seedCategories(); + cookieJar.set("healthlog-locale", "en"); + const res = await getOverview(); + expect(res.status).toBe(200); + const { categories, grouped, markers } = ( + (await res.json()) as CategoryBody + ).data; + expect(grouped).toBe(true); + expect(categories.map((c) => c.label)).toEqual([ + "Lipids", + "Thyroid", + "trace ELEMENTS", + "Other", + ]); + const lipids = categories[0]; + expect(lipids.key).toBe("lipids"); + expect([...lipids.markerKeys].sort()).toEqual( + [`bm:${ldl.id}`, `bm:${hdl.id}`].sort(), + ); + // Mixed above and below: the warning tone, never a third state. + expect(lipids.counts).toEqual({ + inRange: 0, + below: 1, + above: 1, + unknown: 0, + }); + expect(lipids.status).toBe("above"); + expect(categories[1]).toMatchObject({ + markerKeys: [`bm:${tsh.id}`], + status: "in-range", + }); + const custom = categories[2]; + expect(custom.key).toMatch(/^custom:[0-9a-f]{12}$/); + expect(custom.markerKeys).toHaveLength(3); + // The most frequent spelling, not the newest reading's. + expect(custom.label).toBe("trace ELEMENTS"); + expect(categories[3].markerKeys).toEqual(["analyte:mystery marker"]); + // Every marker sits in exactly one category. + expect(categories.flatMap((c) => c.markerKeys).sort()).toEqual( + markers.map((m) => m.key).sort(), + ); + }); + + it("labels in the caller's language", async () => { + await seedCategories(); + cookieJar.set("healthlog-locale", "de"); + const { categories } = ( + (await (await getOverview()).json()) as CategoryBody + ).data; + expect(categories.map((c) => c.label)).toEqual([ + "Lipide", + "Schilddrüse", + "trace ELEMENTS", + "Sonstige", + ]); + }); + + it("stays flat when grouping adds nothing", async () => { + const prisma = getPrismaClient(); + await prisma.labResult.create({ + data: { + userId: USER_ID, + analyte: "Mystery marker", + value: 3, + unit: "U/L", + takenAt: daysAgo(5), + }, + }); + const { categories, grouped } = ( + (await (await getOverview()).json()) as CategoryBody + ).data; + expect(grouped).toBe(false); + expect(categories).toHaveLength(1); + }); +});