feat(labs): show the labs list under server-resolved categories

GET /api/labs/overview now answers categories and grouped next to the
markers. A marker's category is its panel: a catalog panel in any
shipped language folds to one key, any other panel becomes the
person's own category under an opaque hash key, and a marker without
a panel falls back to the catalog panel of its name. ALIAS_PANEL
places the ten analytes that have names but no seed. Each category
carries its label in the caller's language, its members, counts per
range status and one combined status (above wins over below; no
alarm tone). grouped is true only when grouping adds something.

The list renders this as given: with groupBy set to category it shows
a disclosure header per category in both views, the person's sort
applies inside each category, and groupsOpen decides which start open.
A category opened or closed by hand stays so in this browser until the
choice changes. Settings > Arrangement > Labs gets both choices. The
overview query key carries the locale, since the labels are localised.

Refs #1349
This commit is contained in:
Marc-André Bombeck committed 2026-11-06 17:49:38 +01:00
1 parent e2170115c7
commit 92638ec089
31 files changed
+2223 -348

No files matched your search

+75 -1
View File
@@ -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:<lower-cased name>` 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:<hash>` 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:
+104
View File
@@ -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<string, unknown> } };
};
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([]);
});
});
+17
View File
@@ -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}",
+17
View File
@@ -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}",
+17
View File
@@ -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}",
+17
View File
@@ -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}",
+17
View File
@@ -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}",
+17
View File
@@ -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}",
+17
View File
@@ -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}",
+17
View File
@@ -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}",
+17
View File
@@ -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}",
+17
View File
@@ -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}",
@@ -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,
@@ -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,
@@ -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,
@@ -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<Record<string, BiomarkerPanelKey>>,
): { checked: number; missing: string[] } {
const panels = new Set<string>(BIOMARKER_PANELS);
const seedPanel = new Map(BIOMARKER_CATALOG.map((s) => [s.slug, s.panel]));
const keys = new Set<string>([
...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<string>([
...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]);
});
});
+15 -3
View File
@@ -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 });
});
@@ -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>): LabResultDto {
} as LabResultDto;
}
function renderList(results: LabResultDto[]) {
function renderList(
results: LabResultDto[],
options: {
prefs?: Partial<ModuleListPrefs>;
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(
<QueryClientProvider client={queryClient}>
<I18nProvider initialLocale="en">
@@ -197,3 +220,110 @@ describe("<LabList> columns", () => {
expect(html).toContain("converted from 5.6 mmol/L");
});
});
describe("<LabList> 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(/<button[^>]*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);
});
});
@@ -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<string, string>) {
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<string, string>();
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({});
});
});
@@ -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<ModuleListPrefs>) {
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(
<QueryClientProvider client={queryClient}>
<I18nProvider initialLocale="en">
<LabsSettings />
</I18nProvider>
</QueryClientProvider>,
);
}
describe("<LabsSettings> 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.",
);
});
});
+202
View File
@@ -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<string, string>();
for (const category of categories) {
for (const key of category.markerKeys) categoryOf.set(key, category.key);
}
const byCategory = new Map<string, MarkerRow[]>();
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<string, boolean>;
}
export function readStoredOpen(
mode: ModuleGroupsOpen,
): Record<string, boolean> {
try {
const raw = window.localStorage.getItem(STORAGE_KEY);
if (!raw) return {};
const parsed = JSON.parse(raw) as Partial<StoredOpen>;
if (parsed.mode !== mode || typeof parsed.open !== "object") return {};
const out: Record<string, boolean> = {};
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<string, boolean>,
): 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<StoredOpen>({ 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 <ScorePill tone="neutral">{t("labs.groups.allInRange")}</ScorePill>;
}
const { inRange, below, above, unknown } = category.counts;
return (
<ScorePill tone={category.status === "above" ? "warning" : "info"}>
{tCount("labs.groups.outsideCount", above + below, {
total: inRange + below + above + unknown,
})}
</ScorePill>
);
}
/**
* 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 (
<h2 className={cn("min-w-0", className)}>
<button
type="button"
data-slot="lab-list-group"
data-category={category.key}
aria-expanded={open}
aria-controls={panelId}
onClick={onToggle}
className="focus-visible:ring-ring/50 flex min-h-11 w-full min-w-0 items-center gap-2 rounded-md text-left outline-none focus-visible:ring-[3px] sm:min-h-9"
>
<ChevronDown
aria-hidden
className={cn(
"text-muted-foreground size-4 shrink-0 transition-transform motion-reduce:transition-none",
!open && "-rotate-90",
)}
/>
<span className="text-muted-foreground min-w-0 truncate text-xs font-medium tracking-wide uppercase">
{category.label}
</span>
<TagChip className="shrink-0 tabular-nums">
{category.markerKeys.length}
</TagChip>
<span className="ml-auto shrink-0">
<GroupStatus category={category} />
</span>
</button>
</h2>
);
}
+305
View File
@@ -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:<biomarkerId>`, or `analyte:<lower>` 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 (
<div data-slot="lab-list-badge-cell" className={MOBILE_CELL.badge}>
<ReferenceRangeBadge status={status} compact className="lg:w-full" />
</div>
);
}
function LabRangeBarSlot({ reading }: { reading: LabResultDto }) {
return (
<div className={cn("w-full", MOBILE_CELL.rangeBar)}>
{/* 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. */}
<LabReferenceRangeBar
className="lg:max-w-none"
value={reading.value}
referenceLow={reading.referenceLow}
referenceHigh={reading.referenceHigh}
unit={reading.unit}
/>
</div>
);
}
function TrendCell({ row }: { row: MarkerRow }) {
return (
<div className={cn("w-[72px]", MOBILE_CELL.trend)}>
<LabTrendSparkline
values={row.trend}
referenceLow={row.latest.referenceLow}
referenceHigh={row.latest.referenceHigh}
/>
</div>
);
}
/** 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 = (
<div className={cn("min-w-0 flex-1", MOBILE_CELL.reading)}>
<div className="flex flex-wrap items-baseline gap-x-2 text-sm">
{/* 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). */}
<span
data-slot="lab-list-analyte"
className="truncate font-medium lg:overflow-visible lg:break-words lg:text-clip lg:hyphens-auto lg:whitespace-normal"
>
{row.analyte}
</span>
</div>
<div className="text-muted-foreground flex flex-wrap items-center gap-x-2 text-xs">
<span className="text-foreground font-semibold tabular-nums">
{formatLabReading(row.latest, labNumber)}
</span>
<span>{labDate(row.latest.takenAt)}</span>
<LabConvertedNote reading={row.latest} />
</div>
</div>
);
// The whole row navigates into the detail (where delete lives).
if (row.biomarkerId) {
return (
<Link
href={`/labs/${row.biomarkerId}`}
className={cn(ROW_CLASS, "hover:bg-muted/40 transition-colors")}
>
{inner}
<RangeBadgeCell status={row.latest.rangeStatus} />
<LabRangeBarSlot reading={row.latest} />
<TrendCell row={row} />
{/* With a pointer the row's hover already says it opens; a phone has
no hover, so the chevron stays there. */}
<ChevronRight
className={cn(
"text-muted-foreground h-4 w-4 shrink-0 lg:invisible",
MOBILE_CELL.end,
)}
/>
</Link>
);
}
// 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 (
<div className={ROW_CLASS}>
{inner}
<RangeBadgeCell status={row.latest.rangeStatus} />
<LabRangeBarSlot reading={row.latest} />
<TrendCell row={row} />
<span
className={cn(
"text-muted-foreground shrink-0 text-xs italic",
MOBILE_CELL.notLinked,
)}
>
{t("labs.notLinkedYet")}
</span>
</div>
);
}
/**
* 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 (
<li className="contents">
<Card className="h-full gap-3">
<MedicationCardHeader
name={row.analyte}
dose=""
categoryLabel={underCategory ? row.unit : (row.panel ?? row.unit)}
nameChip={<ReferenceRangeBadge status={latest.rangeStatus} compact />}
href={row.biomarkerId ? `/labs/${row.biomarkerId}` : undefined}
linkLabel={row.analyte}
/>
<CardContent>
<div className="flex items-end justify-between gap-3">
<div className="text-muted-foreground flex min-w-0 flex-1 flex-wrap items-center gap-x-2 text-sm">
<span className="text-foreground font-semibold tabular-nums">
{formatLabReading(latest, labNumber)}
</span>
{latest.value !== null &&
(latest.referenceLow !== null ||
latest.referenceHigh !== null) ? (
<span className="text-xs">
{t("labs.referenceLabel")}{" "}
{formatReferenceRange(
latest.referenceLow,
latest.referenceHigh,
labNumber,
)}
</span>
) : null}
<SourceRangeNote reading={latest} className="text-xs" />
<span className="text-xs">{labDate(latest.takenAt)}</span>
<LabConvertedNote reading={latest} />
{row.count > 1 ? (
<span className="text-xs">
{t("labs.readingsCount", { count: row.count })}
</span>
) : null}
<LabReferenceRangeBar
value={latest.value}
referenceLow={latest.referenceLow}
referenceHigh={latest.referenceHigh}
unit={latest.unit}
/>
</div>
<LabTrendSparkline
values={row.trend}
referenceLow={latest.referenceLow}
referenceHigh={latest.referenceHigh}
/>
</div>
{/* 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 ? (
<p className="text-muted-foreground text-xs italic">
{t("labs.notLinkedYet")}
</p>
) : null}
</CardContent>
</Card>
</li>
);
}
+186 -322
View File
@@ -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:<biomarkerId>`, or `analyte:<lower>` 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<typeof useGroupsOpen>;
/** The compact table: one bordered card of divided rows (#40). */
function LabTable({
rows,
groups,
groupsOpen,
}: {
rows: MarkerRow[];
groups: MarkerGroup[] | null;
groupsOpen: GroupsOpen;
}) {
return (
<div data-slot="lab-list-badge-cell" className={MOBILE_CELL.badge}>
<ReferenceRangeBadge status={status} compact className="lg:w-full" />
// `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.
<div data-slot="lab-list" className="space-y-3">
<Card className="@container">
<CardContent className={LIST_GRID_CLASS}>
{groups
? groups.map((group) => (
<TableGroup
key={group.category.key}
group={group}
groupsOpen={groupsOpen}
/>
))
: rows.map((row) => <LabListRow key={row.key} row={row} />)}
</CardContent>
</Card>
</div>
);
}
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 (
<div className={cn("w-full", MOBILE_CELL.rangeBar)}>
{/* 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. */}
<LabReferenceRangeBar
className="lg:max-w-none"
value={reading.value}
referenceLow={reading.referenceLow}
referenceHigh={reading.referenceHigh}
unit={reading.unit}
<div className={cn(LIST_SUBGRID_CLASS, "divide-y-0")}>
<LabGroupHeader
category={group.category}
open={open}
onToggle={onToggle}
panelId={panelId}
className="px-4 py-1 lg:col-span-full"
/>
<div
id={panelId}
hidden={!open}
className={cn(LIST_SUBGRID_CLASS, "border-border border-t")}
>
{open
? group.rows.map((row) => <LabListRow key={row.key} row={row} />)
: null}
</div>
</div>
);
}
/**
* 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 (
<ul
className={cn(
"grid list-none gap-4 p-0",
// A lone lab spans the full row rather than orphaning half of it;
// two or more fall into the two-up grid.
rows.length > 1 && "sm:grid-cols-2",
)}
>
{rows.map((row) => (
<LabCard key={row.key} row={row} underCategory={underCategory} />
))}
</ul>
);
}
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 (
<section className="space-y-4">
<LabGroupHeader
category={group.category}
open={open}
onToggle={onToggle}
panelId={panelId}
/>
<div id={panelId} hidden={!open}>
{open ? <CardGrid rows={group.rows} underCategory /> : null}
</div>
</section>
);
}
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<LabOverviewResponse>("/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 (
<EmptyState
icon={<FlaskConical className="size-6" />}
@@ -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 <LabTable rows={rows} groups={groups} groupsOpen={groupsOpen} />;
}
// 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.
<div data-slot="lab-list" className="space-y-3">
{/* `@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. */}
<Card className="@container">
<CardContent className="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]">
{groups.map((group) => {
const inner = (
<div className={cn("min-w-0 flex-1", MOBILE_CELL.reading)}>
<div className="flex flex-wrap items-baseline gap-x-2 text-sm">
{/* 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). */}
<span
data-slot="lab-list-analyte"
className="truncate font-medium lg:overflow-visible lg:break-words lg:text-clip lg:hyphens-auto lg:whitespace-normal"
>
{group.analyte}
</span>
</div>
<div className="text-muted-foreground flex flex-wrap items-center gap-x-2 text-xs">
<span className="text-foreground font-semibold tabular-nums">
{formatLabReading(group.latest, labNumber)}
</span>
<span>{labDate(group.latest.takenAt)}</span>
<LabConvertedNote reading={group.latest} />
</div>
</div>
);
// The whole row navigates into the detail (where delete lives).
return group.biomarkerId ? (
<Link
key={group.key}
href={`/labs/${group.biomarkerId}`}
className={cn(
ROW_CLASS,
"hover:bg-muted/40 transition-colors",
)}
>
{inner}
<RangeBadgeCell status={group.latest.rangeStatus} />
<LabRangeBarSlot reading={group.latest} />
<div className={cn("w-[72px]", MOBILE_CELL.trend)}>
<LabTrendSparkline
values={group.trend}
referenceLow={group.latest.referenceLow}
referenceHigh={group.latest.referenceHigh}
/>
</div>
{/* With a pointer the row's hover already says it opens; a
phone has no hover, so the chevron stays there. */}
<ChevronRight
className={cn(
"text-muted-foreground h-4 w-4 shrink-0 lg:invisible",
MOBILE_CELL.end,
)}
/>
</Link>
) : (
// 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.
<div key={group.key} className={ROW_CLASS}>
{inner}
<RangeBadgeCell status={group.latest.rangeStatus} />
<LabRangeBarSlot reading={group.latest} />
<div className={cn("w-[72px]", MOBILE_CELL.trend)}>
<LabTrendSparkline
values={group.trend}
referenceLow={group.latest.referenceLow}
referenceHigh={group.latest.referenceHigh}
/>
</div>
<span
className={cn(
"text-muted-foreground shrink-0 text-xs italic",
MOBILE_CELL.notLinked,
)}
>
{t("labs.notLinkedYet")}
</span>
</div>
);
})}
</CardContent>
</Card>
<div data-slot="lab-list" className="space-y-10">
{groups.map((group) => (
<CardGroup
key={group.category.key}
group={group}
groupsOpen={groupsOpen}
/>
))}
</div>
);
}
// 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 (
<ul
data-slot="lab-list"
className={cn(
"grid list-none gap-4 p-0",
// A lone lab spans the full row rather than orphaning half of it;
// two or more fall into the two-up grid.
groups.length > 1 && "sm:grid-cols-2",
)}
>
{groups.map((group) => {
return (
<li key={group.key} className="contents">
<Card className="h-full gap-3">
<MedicationCardHeader
name={group.analyte}
dose=""
categoryLabel={group.panel ?? group.unit}
nameChip={
<ReferenceRangeBadge
status={group.latest.rangeStatus}
compact
/>
}
href={
group.biomarkerId ? `/labs/${group.biomarkerId}` : undefined
}
linkLabel={group.analyte}
/>
<CardContent>
<div className="flex items-end justify-between gap-3">
<div className="text-muted-foreground flex min-w-0 flex-1 flex-wrap items-center gap-x-2 text-sm">
<span className="text-foreground font-semibold tabular-nums">
{formatLabReading(group.latest, labNumber)}
</span>
{group.latest.value !== null &&
(group.latest.referenceLow !== null ||
group.latest.referenceHigh !== null) ? (
<span className="text-xs">
{t("labs.referenceLabel")}{" "}
{formatReferenceRange(
group.latest.referenceLow,
group.latest.referenceHigh,
labNumber,
)}
</span>
) : null}
<SourceRangeNote
reading={group.latest}
className="text-xs"
/>
<span className="text-xs">
{labDate(group.latest.takenAt)}
</span>
<LabConvertedNote reading={group.latest} />
{group.count > 1 ? (
<span className="text-xs">
{t("labs.readingsCount", {
count: group.count,
})}
</span>
) : null}
<LabReferenceRangeBar
value={group.latest.value}
referenceLow={group.latest.referenceLow}
referenceHigh={group.latest.referenceHigh}
unit={group.latest.unit}
/>
</div>
<LabTrendSparkline
values={group.trend}
referenceLow={group.latest.referenceLow}
referenceHigh={group.latest.referenceHigh}
/>
</div>
{/* 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 ? (
<p className="text-muted-foreground text-xs italic">
{t("labs.notLinkedYet")}
</p>
) : null}
</CardContent>
</Card>
</li>
);
})}
</ul>
<div data-slot="lab-list">
<CardGrid rows={rows} underCategory={false} />
</div>
);
}
+78 -2
View File
@@ -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() {
</div>
</SettingsCard>
<LabsGroupingCard
groupBy={prefs.groupBy}
groupsOpen={prefs.groupsOpen}
onGroupBy={setGroupBy}
onGroupsOpen={setGroupsOpen}
/>
{manual ? (
<SettingsCard id="labs-order" className="scroll-mt-28">
<SettingsCardHeader
icon={ListOrdered}
title={t("moduleList.reorder.heading")}
description={
prefs.groupBy === "category"
? t("labs.groups.orderWithinGroups")
: undefined
}
/>
{isLoading ? (
<div className="space-y-2">
@@ -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 (
<SettingsCard id="labs-groups" className="scroll-mt-28">
<SettingsCardHeader
icon={FolderTree}
title={t("labs.groups.heading")}
description={t("labs.groups.description")}
/>
<div className="space-y-2">
<p className="text-sm font-medium">{t("labs.groups.label")}</p>
<Segmented
mode="toggle"
value={groupBy}
onChange={onGroupBy}
label={t("labs.groups.label")}
optionSlot={(value) => `labs-group-by-${value}`}
options={[
{ value: "none", label: t("labs.groups.none") },
{ value: "category", label: t("labs.groups.category") },
]}
/>
</div>
{groupBy === "category" ? (
<div className="space-y-2" data-slot="labs-groups-open">
<p className="text-sm font-medium">{t("labs.groups.openLabel")}</p>
<Segmented
mode="toggle"
value={groupsOpen}
onChange={onGroupsOpen}
label={t("labs.groups.openLabel")}
optionSlot={(value) => `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") },
]}
/>
</div>
) : null}
</SettingsCard>
);
}
export function LabsModuleSettings() {
const { t } = useTranslations();
// v1.18.10 — local (in-browser) OCR opt-in. Lets a user whose AI provider
+14
View File
@@ -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:<hash>`, 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. */
@@ -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<string, string> = {
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);
});
});
+23
View File
@@ -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<Record<string, BiomarkerPanelKey>> = {
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
+274
View File
@@ -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:<hash>`, 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<string, BiomarkerPanelKey> | null = null;
/** Folded catalog panel label (every locale) -> panel key. Built once. */
export function panelLabelIndex(): ReadonlyMap<string, BiomarkerPanelKey> {
panelIndex ??= buildLocalisedLabelIndex(
BIOMARKER_PANELS.map((key) => ({
id: key,
messageKey: `labs.catalog.panel.${key}`,
value: key,
})),
).index;
return panelIndex;
}
const SEED_PANEL = new Map<string, BiomarkerPanelKey>(
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<string, number>();
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<string, string[]>();
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 };
}
+40 -3
View File
@@ -139,8 +139,45 @@ const labOverviewMarker = z
"One biomarker in the caller's lab history. `key` is `bm:<biomarkerId>` for a catalog-linked marker or `analyte:<lower-cased name>` 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:<hash>` 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<ZodOpenApiObject["paths"]> = {
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(),
+8 -2
View File
@@ -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
@@ -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);
});
});