Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -101,3 +101,7 @@ next-env.d.ts
# Mutation testing (stryker.config.mjs): reports and the sandbox copy.
/reports/mutation
/.stryker-tmp

# Local planning and code-review notes (agent output, never part of the
# public docs surface or the shipped tree).
/plans/
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,16 @@

## [Unreleased]

### Added

- **Biomarker list filtering and sorting.** The labs overview list gains
search, reference-range status (out of range, in range, above, below),
category, and date-range filtering alongside on-page sort options. Filter
state is synced to the URL (`?q`, `?status`, `?category`, `?from`, `?to`,
`?sort`) for shareable and navigable views, with local day-boundary alignment,
singular/few/other pluralized counts, and reactive group header counts and
statuses.

## [1.47.1] — 2026-11-07

### Added
Expand Down
10 changes: 5 additions & 5 deletions docs/api/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -77172,10 +77172,9 @@ components:
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."
spelling, or the localised \"Other\"). `markerKeys` names the `markers[].key` values in the category. `counts`
and `status` describe the full category without filters; a client renders these values directly when unfiltered,
and may summarize the visible row subset client-side while filters are active."
LabOverviewCategoryCounts:
type: object
properties:
Expand All @@ -77201,7 +77200,8 @@ components:
- above
- unknown
additionalProperties: false
description: How many members' newest readings are in range, below, above, or unassessed (qualitative, or no range).
description: How many members' newest readings are in range, below, above, or unassessed (qualitative, or no range) for
the full category without filters.
GetLabResultResponse:
type: object
properties:
Expand Down
15 changes: 10 additions & 5 deletions docs/ui-guidelines.md
Original file line number Diff line number Diff line change
Expand Up @@ -236,15 +236,20 @@ lists) uses the unified `<FilterBar>` rail from
`src/components/ui/filter-bar.tsx` — never a per-page row of large
labelled `<Select>`s and date inputs.

- **Grammar**: `[icon] [Zeitraum] [Typ] [Quelle] [extras…]
[Zurücksetzen] … [count]`. Canonical dimension order is date range,
type, source; surface-specific dimensions append after.
- **Grammar**: `[search] [icon] [Zeitraum] [Typ] [Quelle] [extras…]
[Zurücksetzen] … [count]`. Where search is supported (such as labs), the
search input is integrated as the leading element in the rail. Canonical
dimension order remains date range, type, source; surface-specific
dimensions append after.
- **Pill = trigger + chip**: inactive pills show only the dimension
name; active pills show `Name: Wert`, pick up the primary accent and
grow a removable ✕. No separate chip row.
- **Reset** renders only while at least one dimension is active.
- **Mobile** (< `sm`): the rail is a horizontal scroll row (hidden
scrollbar, edge-bleed); the result count wraps to its own line.
- **Mobile** (< `sm`): standard pill rails scroll horizontally (hidden
scrollbar, edge-bleed); the result count wraps to its own line. When an
integrated search input is present in the rail (such as in labs), the
rail wraps (`max-sm:flex-wrap`) so search spans full width and pills
wrap underneath, avoiding horizontal scroll while typing.
- The component is presentation-only — filter state, query keys and
fetching stay in the owning page. Use `FilterBarSelect` for enum
dimensions and `FilterBarDateRange` for the from/to pair.
Expand Down
15 changes: 15 additions & 0 deletions messages/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -10664,6 +10664,21 @@
"unit": {
"none": "Keine Einheit",
"noneHint": "Für einen Quotienten oder Index wie INR oder Cholesterin/HDL."
},
"filter": {
"searchPlaceholder": "Biomarker suchen...",
"searchLabel": "Biomarker suchen",
"clearSearch": "Suche leeren",
"statusLabel": "Status",
"statusAll": "Alle Status",
"statusAbnormal": "Außerhalb des Bereichs",
"categoryLabel": "Kategorie",
"categoryAll": "Alle Kategorien",
"markerCountOne": "{count} Biomarker",
"markerCountFew": "{count} Biomarker",
"markerCountOther": "{count} Biomarker",
"noMatchesTitle": "Keine passenden Biomarker",
"noMatchesDescription": "Passe deinen Suchbegriff oder die Filter an."
}
},
"modules": {
Expand Down
15 changes: 15 additions & 0 deletions messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -10664,6 +10664,21 @@
"unit": {
"none": "No unit",
"noneHint": "For a ratio or an index such as INR or cholesterol/HDL."
},
"filter": {
"searchPlaceholder": "Search biomarkers...",
"searchLabel": "Search biomarkers",
"clearSearch": "Clear search",
"statusLabel": "Status",
"statusAll": "All statuses",
"statusAbnormal": "Out of range",
"categoryLabel": "Category",
"categoryAll": "All categories",
"markerCountOne": "{count} biomarker",
"markerCountFew": "{count} biomarkers",
"markerCountOther": "{count} biomarkers",
"noMatchesTitle": "No matching biomarkers",
"noMatchesDescription": "Try adjusting your search query or filters."
}
},
"modules": {
Expand Down
15 changes: 15 additions & 0 deletions messages/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -10664,6 +10664,21 @@
"unit": {
"none": "Sin unidad",
"noneHint": "Para un cociente o un índice como el INR o colesterol/HDL."
},
"filter": {
"searchPlaceholder": "Buscar biomarcadores...",
"searchLabel": "Buscar biomarcadores",
"clearSearch": "Borrar búsqueda",
"statusLabel": "Estado",
"statusAll": "Todos los estados",
"statusAbnormal": "Fuera de rango",
"categoryLabel": "Categoría",
"categoryAll": "Todas las categorías",
"markerCountOne": "{count} biomarcador",
"markerCountFew": "{count} biomarcadores",
"markerCountOther": "{count} biomarcadores",
"noMatchesTitle": "No se encontraron biomarcadores",
"noMatchesDescription": "Intenta cambiar los términos de búsqueda o los filtros."
}
},
"modules": {
Expand Down
15 changes: 15 additions & 0 deletions messages/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -10664,6 +10664,21 @@
"unit": {
"none": "Sans unité",
"noneHint": "Pour un rapport ou un indice comme l'INR ou cholestérol/HDL."
},
"filter": {
"searchPlaceholder": "Rechercher des biomarqueurs...",
"searchLabel": "Rechercher des biomarqueurs",
"clearSearch": "Effacer la recherche",
"statusLabel": "Statut",
"statusAll": "Tous les statuts",
"statusAbnormal": "Hors normes",
"categoryLabel": "Catégorie",
"categoryAll": "Toutes les catégories",
"markerCountOne": "{count} biomarqueur",
"markerCountFew": "{count} biomarqueurs",
"markerCountOther": "{count} biomarqueurs",
"noMatchesTitle": "Aucun biomarqueur trouvé",
"noMatchesDescription": "Essayez d'ajuster votre recherche ou vos filtres."
}
},
"modules": {
Expand Down
15 changes: 15 additions & 0 deletions messages/it.json
Original file line number Diff line number Diff line change
Expand Up @@ -10664,6 +10664,21 @@
"unit": {
"none": "Nessuna unità",
"noneHint": "Per un rapporto o un indice come l'INR o colesterolo/HDL."
},
"filter": {
"searchPlaceholder": "Cerca biomarcatori...",
"searchLabel": "Cerca biomarcatori",
"clearSearch": "Cancella ricerca",
"statusLabel": "Stato",
"statusAll": "Tutti gli stati",
"statusAbnormal": "Fuori intervallo",
"categoryLabel": "Categoria",
"categoryAll": "Tutte le categorie",
"markerCountOne": "{count} biomarcatore",
"markerCountFew": "{count} biomarcatori",
"markerCountOther": "{count} biomarcatori",
"noMatchesTitle": "Nessun biomarcatore trovato",
"noMatchesDescription": "Prova a modificare i termini di ricerca o i filtri."
}
},
"modules": {
Expand Down
15 changes: 15 additions & 0 deletions messages/ko.json
Original file line number Diff line number Diff line change
Expand Up @@ -10664,6 +10664,21 @@
"unit": {
"none": "단위 없음",
"noneHint": "INR이나 콜레스테롤/HDL 같은 비율 또는 지수에 사용해요."
},
"filter": {
"searchPlaceholder": "바이오마커 검색...",
"searchLabel": "바이오마커 검색",
"clearSearch": "검색어 지우기",
"statusLabel": "상태",
"statusAll": "모든 상태",
"statusAbnormal": "범위 벗어남",
"categoryLabel": "카테고리",
"categoryAll": "모든 카테고리",
"markerCountOne": "{count}개 바이오마커",
"markerCountFew": "{count}개 바이오마커",
"markerCountOther": "{count}개 바이오마커",
"noMatchesTitle": "일치하는 바이오마커 없음",
"noMatchesDescription": "검색어 또는 필터를 조정해 보세요."
}
},
"modules": {
Expand Down
15 changes: 15 additions & 0 deletions messages/nl.json
Original file line number Diff line number Diff line change
Expand Up @@ -10664,6 +10664,21 @@
"unit": {
"none": "Geen eenheid",
"noneHint": "Voor een verhouding of index zoals INR of cholesterol/HDL."
},
"filter": {
"searchPlaceholder": "Biomarkers zoeken...",
"searchLabel": "Biomarkers zoeken",
"clearSearch": "Zoekopdracht wissen",
"statusLabel": "Status",
"statusAll": "Alle statussen",
"statusAbnormal": "Buiten bereik",
"categoryLabel": "Categorie",
"categoryAll": "Alle categorieën",
"markerCountOne": "{count} biomarker",
"markerCountFew": "{count} biomarkers",
"markerCountOther": "{count} biomarkers",
"noMatchesTitle": "Geen passende biomarkers",
"noMatchesDescription": "Probeer je zoekopdracht of filters aan te passen."
}
},
"modules": {
Expand Down
15 changes: 15 additions & 0 deletions messages/pl.json
Original file line number Diff line number Diff line change
Expand Up @@ -10664,6 +10664,21 @@
"unit": {
"none": "Bez jednostki",
"noneHint": "Dla ilorazu lub wskaźnika, np. INR albo cholesterol/HDL."
},
"filter": {
"searchPlaceholder": "Szukaj biomarkerów...",
"searchLabel": "Szukaj biomarkerów",
"clearSearch": "Wyczyść wyszukiwanie",
"statusLabel": "Status",
"statusAll": "Wszystkie statusy",
"statusAbnormal": "Poza zakresem",
"categoryLabel": "Kategoria",
"categoryAll": "Wszystkie kategorie",
"markerCountOne": "{count} biomarker",
"markerCountFew": "{count} biomarkery",
"markerCountOther": "{count} biomarkerów",
"noMatchesTitle": "Brak pasujących biomarkerów",
"noMatchesDescription": "Spróbuj zmienić zapytanie lub zresetować filtry."
}
},
"modules": {
Expand Down
15 changes: 15 additions & 0 deletions messages/pt-BR.json
Original file line number Diff line number Diff line change
Expand Up @@ -10664,6 +10664,21 @@
"unit": {
"none": "Sem unidade",
"noneHint": "Para uma razão ou um índice como INR ou colesterol/HDL."
},
"filter": {
"searchPlaceholder": "Buscar biomarcadores...",
"searchLabel": "Buscar biomarcadores",
"clearSearch": "Limpar busca",
"statusLabel": "Status",
"statusAll": "Todos os status",
"statusAbnormal": "Fora da faixa",
"categoryLabel": "Categoria",
"categoryAll": "Todas as categorias",
"markerCountOne": "{count} biomarcador",
"markerCountFew": "{count} biomarcadores",
"markerCountOther": "{count} biomarcadores",
"noMatchesTitle": "Nenhum biomarcador encontrado",
"noMatchesDescription": "Tente ajustar sua busca ou os filtros."
}
},
"modules": {
Expand Down
15 changes: 15 additions & 0 deletions messages/ru.json
Original file line number Diff line number Diff line change
Expand Up @@ -10664,6 +10664,21 @@
"unit": {
"none": "Без единицы",
"noneHint": "Для коэффициента или индекса, например МНО или холестерин/ЛПВП."
},
"filter": {
"searchPlaceholder": "Поиск биомаркеров...",
"searchLabel": "Поиск биомаркеров",
"clearSearch": "Очистить поиск",
"statusLabel": "Статус",
"statusAll": "Все статусы",
"statusAbnormal": "Вне нормы",
"categoryLabel": "Категория",
"categoryAll": "Все категории",
"markerCountOne": "{count} биомаркер",
"markerCountFew": "{count} биомаркера",
"markerCountOther": "{count} биомаркеров",
"noMatchesTitle": "Биомаркеры не найдены",
"noMatchesDescription": "Попробуйте изменить поисковый запрос или фильтры."
}
},
"modules": {
Expand Down
41 changes: 38 additions & 3 deletions src/__tests__/labs-overview-response-consumer-guard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,9 +93,10 @@ const READERS: Record<string, Reader> = {
file: GROUPS,
pattern: COUNTS_DESTRUCTURE("unknown"),
},
// Code lines only: a comment that names `category.status` is not a reader.
"overview:categories[].status": {
file: GROUPS,
pattern: /\bcategory\.status\s*===\s*"above"/,
pattern: /^(?![ \t]*(?:\/\/|\/?\*)).*\bcategory\.status\b/m,
},
"biomarkers:customPanels[]": {
file: FORM_PANEL,
Expand Down Expand Up @@ -132,8 +133,9 @@ describe("labs categories response/consumer guard", () => {
const owned = ownedLeaves();

it("walks the schemas it guards", () => {
// Nine category leaves, `grouped`, the panels and the adopted panel, plus
// the exempt marker rows; far fewer means the walk broke.
// Category leaves (key, label, markerKeys[], counts.*, status), `grouped`,
// the panels and the adopted panel, plus the exempt marker rows; far fewer
// means the walk broke.
expect(owned.filter((leaf) => !isExempt(leaf)).length).toBe(
Object.keys(READERS).length,
);
Expand All @@ -159,8 +161,41 @@ describe("labs categories response/consumer guard", () => {
expect(
COUNTS_DESTRUCTURE("inRange").test("const { below } = category.counts;"),
).toBe(false);
expect(
READERS["overview:categories[].status"].pattern.test(
"category.otherStatus",
),
).toBe(false);
expect(
READERS["overview:categories[].status"].pattern.test(
" // Consumers supply category.status for unfiltered categories.",
),
).toBe(false);
expect(
READERS["overview:categories[].status"].pattern.test(
" status: statusProp ?? category.status,",
),
).toBe(true);
expect(READERS["overview:grouped"].pattern.test("data?.groupedX")).toBe(
false,
);
});

it("guards filtered summary branch for counts and status", () => {
// When filters narrow the visible rows, LabGroupHeader reads `summary.counts`
// and `summary.status` (summarised client-side), rather than unfiltered server category totals.
const groupsSource = readSource(GROUPS, [
/\bsummary\.counts\b/,
/\bsummary\.status\b/,
]);
expect(/\bsummary\.counts\b/.test(groupsSource)).toBe(true);
expect(/\bsummary\.status\b/.test(groupsSource)).toBe(true);

const listSource = readSource(LIST, [
/\bsummariseGroupRows\b/,
/\bsummary\?\.status\b/,
]);
expect(/\bsummariseGroupRows\b/.test(listSource)).toBe(true);
expect(/\bsummary\?\.status\b/.test(listSource)).toBe(true);
});
});
Loading
Loading