mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 22:32:04 +00:00
The description of a labs category lost the status rule and the sentence that a client never rebuilds the category, the order or the status. Both are back as they were, with one line added: a client that shows only the members matching a filter may summarise that subset by the same rule.
302 lines
10 KiB
TypeScript
302 lines
10 KiB
TypeScript
/**
|
|
* 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.
|
|
* While a filter hides some members, the web list summarises the matching
|
|
* ones by the same rule (`priorityReferenceRangeStatus`).
|
|
*
|
|
* 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 {
|
|
priorityReferenceRangeStatus,
|
|
type ReferenceRangeStatus,
|
|
type ReferenceRangeStatusCounts,
|
|
} 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 type CategoryCounts = ReferenceRangeStatusCounts;
|
|
|
|
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 = priorityReferenceRangeStatus(counts);
|
|
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;
|
|
};
|
|
}
|
|
|
|
/**
|
|
* The person's category order (`categoryOrder` in the labs list layout)
|
|
* over categories already in the default order.
|
|
*
|
|
* Listed keys come first, in the listed order; a key the data does not have
|
|
* is skipped. A category the list does not name (a panel added since the
|
|
* person arranged the list) follows the listed ones in the default order.
|
|
* "other" is the one exception: left at the end of the listed categories it
|
|
* stays last, after any category added later, because a catch-all belongs
|
|
* at the bottom unless the person moved it up. An empty order is the
|
|
* default order.
|
|
*/
|
|
export function applyCategoryOrder<T extends { key: string }>(
|
|
categories: readonly T[],
|
|
order: readonly string[],
|
|
): T[] {
|
|
if (order.length === 0) return [...categories];
|
|
const present = new Set(categories.map((category) => category.key));
|
|
const listed = order.filter((key) => present.has(key));
|
|
const rank = new Map(listed.map((key, index) => [key, index]));
|
|
const ordered = categories
|
|
.filter((category) => rank.has(category.key))
|
|
.sort((a, b) => rank.get(a.key)! - rank.get(b.key)!);
|
|
const rest = categories.filter((category) => !rank.has(category.key));
|
|
const otherLast = ordered.at(-1)?.key === OTHER_CATEGORY;
|
|
if (!otherLast || rest.length === 0) return [...ordered, ...rest];
|
|
return [...ordered.slice(0, -1), ...rest, ordered.at(-1)!];
|
|
}
|
|
|
|
/**
|
|
* Every category of an overview, in display order, 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.
|
|
*
|
|
* The default order is catalog panels in catalog order (`BIOMARKER_PANELS`),
|
|
* then the person's own by label, then "other"; `categoryOrder`, the
|
|
* person's own arrangement, is applied over it (`applyCategoryOrder`).
|
|
*/
|
|
export function categoriseOverview(
|
|
markers: readonly CategorisableMarker[],
|
|
locale: Locale,
|
|
categoryOrder: readonly string[] = [],
|
|
): { 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: applyCategoryOrder(categories, categoryOrder), grouped };
|
|
}
|