feat(labs): add biomarker filtering and sorting

This commit is contained in:
Konrad Garbal committed 2026-11-10 20:46:57 +01:00
1 parent 22b85ff97d
commit b4ac67ee7c
36 files changed
+3290 -172

No files matched your search

@@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";
import {
classifyReferenceRange,
formatReferenceRange,
priorityReferenceRangeStatus,
} from "@/lib/labs/reference-range";
describe("classifyReferenceRange", () => {
@@ -63,3 +64,23 @@ describe("formatReferenceRange", () => {
expect(formatReferenceRange(4, 6, oneDecimal)).toBe("4.0–6.0");
});
});
describe("priorityReferenceRangeStatus", () => {
it("implements canonical priority: above > below > in-range > unknown with counts", () => {
expect(
priorityReferenceRangeStatus({ above: 1, below: 1, inRange: 1, unknown: 1 }),
).toBe("above");
expect(
priorityReferenceRangeStatus({ above: 0, below: 2, inRange: 5, unknown: 1 }),
).toBe("below");
expect(
priorityReferenceRangeStatus({ above: 0, below: 0, inRange: 3, unknown: 2 }),
).toBe("in-range");
expect(
priorityReferenceRangeStatus({ above: 0, below: 0, inRange: 0, unknown: 4 }),
).toBe("unknown");
expect(
priorityReferenceRangeStatus({ above: 0, below: 0, inRange: 0, unknown: 0 }),
).toBe("unknown");
});
});
+11 -17
View File
@@ -4,8 +4,10 @@
* 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.
* resolves the category, its label, its order, and combined counts and status
* for the full category once; clients (the web list and the iOS app) render
* that as given when unfiltered, while the web list summarizes the visible
* subset client-side when filters are active.
*
* Resolution, first match wins:
* 1. the panel folds to a catalog panel label in any shipped locale
@@ -25,7 +27,11 @@ 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 {
priorityReferenceRangeStatus,
type ReferenceRangeStatus,
type ReferenceRangeStatusCounts,
} from "@/lib/labs/reference-range";
import { catalogKeyForName } from "./analyte-match";
import {
@@ -45,12 +51,7 @@ export interface MarkerCategoryRef {
kind: MarkerCategoryKind;
}
export interface CategoryCounts {
inRange: number;
below: number;
above: number;
unknown: number;
}
export type CategoryCounts = ReferenceRangeStatusCounts;
export interface LabOverviewCategory {
/** A catalog panel key, `custom:<hash>`, or `other`. Opaque to clients. */
@@ -129,14 +130,7 @@ export function summariseCategory(statuses: readonly ReferenceRangeStatus[]): {
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";
const status = priorityReferenceRangeStatus(counts);
return { counts, status };
}
+27
View File
@@ -204,3 +204,30 @@ export function classifyAgainstEffectiveRange(
if (value === null) return "unknown";
return classifyReferenceRange(value, effective.low, effective.high);
}
export interface ReferenceRangeStatusCounts {
inRange: number;
below: number;
above: number;
unknown: number;
}
/**
* Resolve combined reference-range status according to the canonical priority rule:
* `above` > `below` > `in-range` > `unknown`.
*
* Any value above its range makes the group "above", else any below makes it "below",
* else any in-range makes it "in-range"; only when all members are unassessed does it
* resolve to "unknown".
*/
export function priorityReferenceRangeStatus(
input: ReferenceRangeStatusCounts,
): ReferenceRangeStatus {
return input.above > 0
? "above"
: input.below > 0
? "below"
: input.inRange > 0
? "in-range"
: "unknown";
}
+31 -5
View File
@@ -246,6 +246,7 @@ export async function runSetModuleListPrefs(deps: {
*/
export function useModuleListPrefs(list: ModuleListKey): {
prefs: ModuleListPrefs;
isLoaded: boolean;
setView: (view: ModuleListView) => void;
setOrder: (order: string[]) => void;
setSortDir: (sortDir: ModuleSortDir) => void;
@@ -261,17 +262,21 @@ export function useModuleListPrefs(list: ModuleListKey): {
staleTime: 5 * 60 * 1000,
});
const isLoaded = data !== undefined;
useEffect(() => {
if (!data) return;
void carryOverLegacyModuleListPrefs({ list, layout: data, queryClient });
}, [data, list, queryClient]);
// The browser copy is read only after hydration, so the server render and
// the first client paint agree (React #418).
// the first client paint agree (React #418). While the server entry is
// unsaved (saved: false), the browser copy is preferred until carry-over
// completes and a saved server entry arrives.
const mounted = useMounted();
const entry = data?.lists[list];
const prefs = useMemo<ModuleListPrefs>(() => {
if (entry) {
if (entry?.saved) {
return {
view: entry.view,
order: entry.order,
@@ -281,8 +286,21 @@ export function useModuleListPrefs(list: ModuleListKey): {
};
}
const defaults = moduleListDefaults(list);
if (!mounted) return defaults;
return parseModuleListPrefs(readLegacy(list), defaults);
const fallback: ModuleListPrefs = entry
? {
view: entry.view,
order: entry.order,
sortDir: entry.sortDir,
groupBy: entry.groupBy,
groupsOpen: entry.groupsOpen,
}
: defaults;
if (!mounted) return fallback;
const legacy = readLegacy(list);
if (legacy !== null) {
return parseModuleListPrefs(legacy, fallback);
}
return fallback;
}, [entry, list, mounted]);
const write = useCallback(
@@ -310,5 +328,13 @@ export function useModuleListPrefs(list: ModuleListKey): {
[write],
);
return { prefs, setView, setOrder, setSortDir, setGroupBy, setGroupsOpen };
return {
prefs,
isLoaded,
setView,
setOrder,
setSortDir,
setGroupBy,
setGroupsOpen,
};
}
+2 -2
View File
@@ -149,7 +149,7 @@ export const labOverviewCategoryCounts = z
.meta({
id: "LabOverviewCategoryCounts",
description:
"How many members' newest readings are in range, below, above, or unassessed (qualitative, or no range).",
"How many members' newest readings are in range, below, above, or unassessed (qualitative, or no range) for the full category without filters.",
});
export const labOverviewCategory = z
@@ -163,7 +163,7 @@ export const labOverviewCategory = z
.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.",
"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. `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.",
});
export const labOverviewResponse = z