mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 06:52:04 +00:00
feat(labs): add biomarker filtering and sorting
This commit is contained in:
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");
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user