mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
fix(labs): store the catalog slug as a marker's analyte key
The matcher stored the registry entry's key, so LDL and HDL markers were recorded as total-cholesterol. The key now names what the marker is (the catalog slug, or a registry-only key for an analyte with no seed), and the registry resolves ldl and hdl to the cholesterol entry for the conversion as before. A key already written in the old form is rewritten by the same fill step that brings a stale key in line with its name, the first time a reading needs it. The OpenAPI descriptions say what the key is and that it follows the marker's name and unit.
This commit is contained in:
1 parent
cd1449e63e
commit
07f09e1b61
11 files changed
+91
-33
No files matched your search
+11
-9
@@ -68258,9 +68258,9 @@ components:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: "null"
|
||||
description: The analyte the matched marker is (the unit-conversion registry's key, e.g. `fasting-glucose`), or null
|
||||
when the marker does not exist yet or its name matches none. With `markerUnit` it tells a client whether a
|
||||
row in another unit can be converted on commit.
|
||||
description: The analyte the matched marker is (the catalog slug, e.g. `fasting-glucose` or `ldl`, or a registry-only
|
||||
key such as `urea`), or null when the marker does not exist yet or its name matches none. With `markerUnit`
|
||||
it tells a client whether a row in another unit can be converted on commit.
|
||||
duplicateOf:
|
||||
anyOf:
|
||||
- type: string
|
||||
@@ -68439,12 +68439,14 @@ components:
|
||||
- createdAt
|
||||
- updatedAt
|
||||
additionalProperties: false
|
||||
description: A user-scoped catalog marker. `context` is the decrypted per-marker note (or null); `hasContext` flags its
|
||||
presence. `analyteKey` is the analyte the marker was recognised as (the unit-conversion registry's key, e.g.
|
||||
`fasting-glucose`), or null when its name matches none; a reading in another unit is converted only for a marker
|
||||
that has one. `hidden` drops the marker from the active catalog list and the lab-entry pickers without deleting
|
||||
it (its readings and unit/range survive). A `LabResult` linking this marker resolves its unit + reference bounds
|
||||
from here.
|
||||
description: "A user-scoped catalog marker. `context` is the decrypted per-marker note (or null); `hasContext` flags its
|
||||
presence. `analyteKey` is the analyte the marker was recognised as: the catalog slug its name belongs to (e.g.
|
||||
`fasting-glucose`, `ldl`, `hdl`), or a key of the unit-conversion registry for an analyte with no catalog seed
|
||||
(e.g. `urea`, `uric-acid`), or null when its name matches none. It is derived from `name` and `unit`, never
|
||||
chosen: it is set on create and re-derived (or cleared) when an update changes either. A reading in another unit
|
||||
is converted only for a marker that has one. `hidden` drops the marker from the active catalog list and the
|
||||
lab-entry pickers without deleting it (its readings and unit/range survive). A `LabResult` linking this marker
|
||||
resolves its unit + reference bounds from here."
|
||||
CreateBiomarkerResponse:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
@@ -30,7 +30,7 @@ export interface OcrReviewRow {
|
||||
* Save when it can be (see `readingConversion`) and cannot be saved when not.
|
||||
*/
|
||||
markerUnit: string | null;
|
||||
/** Which analyte the matched marker is (conversion registry key), or null. */
|
||||
/** Which analyte the matched marker is (catalog slug or registry-only key), or null. */
|
||||
markerAnalyteKey: string | null;
|
||||
/** Non-null when the server flagged a likely duplicate of a live reading. */
|
||||
duplicateOf: string | null;
|
||||
|
||||
@@ -75,7 +75,7 @@ export interface BiomarkerDto {
|
||||
panel: string | null;
|
||||
/**
|
||||
* Which analyte the marker is, for converting a reading in another unit
|
||||
* (the unit-conversion registry's key), or null when it is none that is known.
|
||||
* (the catalog slug, e.g. `ldl`, or a registry-only key such as `urea`), or null when it is none that is known.
|
||||
*/
|
||||
analyteKey: string | null;
|
||||
hasContext: boolean;
|
||||
|
||||
@@ -26,15 +26,22 @@ describe("the catalog's own names, in every language", () => {
|
||||
["Gesamtcholesterin", "mg/dL", "total-cholesterol"],
|
||||
["Cholestérol total", "mmol/L", "total-cholesterol"],
|
||||
["LDL cholesterol", "mg/dL", "ldl"],
|
||||
["LDL-Cholesterin", "mg/dL", "total-cholesterol"],
|
||||
["Colesterolo HDL", "mg/dL", "total-cholesterol"],
|
||||
["LDL-Cholesterin", "mg/dL", "ldl"],
|
||||
["Colesterolo HDL", "mg/dL", "hdl"],
|
||||
["Vitamin D (25-OH)", "ng/mL", "vitamin-d"],
|
||||
["Créatinine", "mg/dL", "creatinine"],
|
||||
["Kreatynina", "µmol/L", "creatinine"],
|
||||
])("%s (%s) is %s", (name, unit, key) => {
|
||||
// LDL and HDL are aliases of cholesterol: the registry reports one key.
|
||||
const expected = analyteConversionFor(key)!.key;
|
||||
expect(match(name, unit)).toBe(expected);
|
||||
// The key is the catalog slug the name belongs to, so LDL and HDL keep
|
||||
// their own identity even though they convert with the cholesterol entry.
|
||||
expect(match(name, unit)).toBe(key);
|
||||
});
|
||||
|
||||
it("resolves an LDL or HDL key to the cholesterol entry for the conversion", () => {
|
||||
expect(match("LDL cholesterol", "mg/dL")).toBe("ldl");
|
||||
expect(analyteConversionFor("ldl")?.key).toBe("total-cholesterol");
|
||||
expect(analyteConversionFor("hdl")?.key).toBe("total-cholesterol");
|
||||
expect(analyteAcceptsUnit("ldl", "mmol/L")).toBe(true);
|
||||
});
|
||||
|
||||
it("recognises a seed typed without the app's gloss", () => {
|
||||
@@ -65,7 +72,7 @@ describe("typed names", () => {
|
||||
|
||||
it("folds case, accents, hyphens and spacing the same on both sides", () => {
|
||||
expect(match(" HARNSÄURE ", "mg/dL")).toBe("uric-acid");
|
||||
expect(match("ldl-c", "mg/dL")).toBe(analyteConversionFor("ldl")!.key);
|
||||
expect(match("ldl-c", "mg/dL")).toBe("ldl");
|
||||
expect(match("blood urea-nitrogen", "mg/dL")).toBe("blood-urea-nitrogen");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,8 +12,12 @@
|
||||
* - the marker's own unit must be one the analyte reaches, so a marker called
|
||||
* "Glucose" in a unit the registry cannot convert is left unlinked.
|
||||
*
|
||||
* Returns the registry key, or null. Pure apart from reading the message
|
||||
* bundles once.
|
||||
* Returns the catalog slug the name was recognised as (`ldl`, `hdl`,
|
||||
* `fasting-glucose`), or the registry-only key for an analyte with no seed
|
||||
* (`urea`, `uric-acid`), or null. LDL stays `ldl` even though the registry
|
||||
* converts it with the cholesterol entry: the key says which marker this is,
|
||||
* and `analyteConversionFor` resolves it to its entry. Pure apart from reading
|
||||
* the message bundles once.
|
||||
*/
|
||||
import { foldForMatch } from "@/lib/i18n/fold-for-match";
|
||||
import { localisedValues } from "@/lib/i18n/shared-resolve";
|
||||
@@ -30,7 +34,7 @@ function withoutGloss(folded: string): string {
|
||||
let cache: ReadonlyMap<string, string> | null = null;
|
||||
|
||||
/**
|
||||
* folded name -> registry key, with every name two analytes claim removed.
|
||||
* folded name -> analyte key, with every name two keys claim removed.
|
||||
* Exported for the guard test, which asserts nothing was dropped silently.
|
||||
*/
|
||||
export function analyteNameIndex(): {
|
||||
@@ -47,18 +51,16 @@ export function analyteNameIndex(): {
|
||||
};
|
||||
|
||||
for (const seed of BIOMARKER_CATALOG) {
|
||||
const entry = analyteConversionFor(seed.slug);
|
||||
if (!entry) continue;
|
||||
if (!analyteConversionFor(seed.slug)) continue;
|
||||
for (const name of localisedValues(`labs.catalog.${seed.slug}`)) {
|
||||
claim(name, entry.key);
|
||||
claim(name, seed.slug);
|
||||
// The bare name too, for a marker typed without the app's gloss.
|
||||
claim(withoutGloss(foldForMatch(name)), entry.key);
|
||||
claim(withoutGloss(foldForMatch(name)), seed.slug);
|
||||
}
|
||||
}
|
||||
for (const { key, names } of ANALYTE_ALIASES) {
|
||||
const entry = analyteConversionFor(key);
|
||||
if (!entry) continue;
|
||||
for (const name of names) claim(name, entry.key);
|
||||
if (!analyteConversionFor(key)) continue;
|
||||
for (const name of names) claim(name, key);
|
||||
}
|
||||
|
||||
const index = new Map<string, string>();
|
||||
|
||||
@@ -279,7 +279,9 @@ export const BIOMARKER_PANELS = [
|
||||
|
||||
/**
|
||||
* Names a marker may carry for an analyte the unit-conversion registry knows
|
||||
* (`src/lib/labs/unit-conversion/registry.ts`), keyed by the registry's key.
|
||||
* (`src/lib/labs/unit-conversion/registry.ts`), keyed by the analyte key a
|
||||
* marker stores: the catalog slug (`ldl`, `hdl`, `fasting-glucose`), or a
|
||||
* registry-only key for an analyte with no seed.
|
||||
*
|
||||
* A `Biomarker` row keeps only its name, not the seed it came from, so this is
|
||||
* how a marker is told which analyte it is: its folded name equals one of these,
|
||||
@@ -296,7 +298,7 @@ export const BIOMARKER_PANELS = [
|
||||
* names live only here.
|
||||
*/
|
||||
export interface AnalyteAliases {
|
||||
/** The registry key of the analyte. */
|
||||
/** The analyte key: a catalog slug, or a registry-only key. */
|
||||
key: string;
|
||||
names: readonly string[];
|
||||
}
|
||||
|
||||
@@ -57,7 +57,7 @@ export interface ResolvedBiomarker {
|
||||
lowerBound: number | null;
|
||||
upperBound: number | null;
|
||||
panel: string | null;
|
||||
/** Which analyte the marker is (unit-conversion registry key), if known. */
|
||||
/** Which analyte the marker is (catalog slug or registry-only key), if known. */
|
||||
analyteKey?: string | null;
|
||||
}
|
||||
|
||||
|
||||
@@ -63,7 +63,7 @@ const biomarkerRow = z
|
||||
.meta({
|
||||
id: "Biomarker",
|
||||
description:
|
||||
"A user-scoped catalog marker. `context` is the decrypted per-marker note (or null); `hasContext` flags its presence. `analyteKey` is the analyte the marker was recognised as (the unit-conversion registry's key, e.g. `fasting-glucose`), or null when its name matches none; a reading in another unit is converted only for a marker that has one. `hidden` drops the marker from the active catalog list and the lab-entry pickers without deleting it (its readings and unit/range survive). A `LabResult` linking this marker resolves its unit + reference bounds from here.",
|
||||
"A user-scoped catalog marker. `context` is the decrypted per-marker note (or null); `hasContext` flags its presence. `analyteKey` is the analyte the marker was recognised as: the catalog slug its name belongs to (e.g. `fasting-glucose`, `ldl`, `hdl`), or a key of the unit-conversion registry for an analyte with no catalog seed (e.g. `urea`, `uric-acid`), or null when its name matches none. It is derived from `name` and `unit`, never chosen: it is set on create and re-derived (or cleared) when an update changes either. A reading in another unit is converted only for a marker that has one. `hidden` drops the marker from the active catalog list and the lab-entry pickers without deleting it (its readings and unit/range survive). A `LabResult` linking this marker resolves its unit + reference bounds from here.",
|
||||
});
|
||||
|
||||
const listResponse = z
|
||||
|
||||
@@ -79,7 +79,7 @@ const extractedRow = z
|
||||
}),
|
||||
markerAnalyteKey: z.string().nullable().meta({
|
||||
description:
|
||||
"The analyte the matched marker is (the unit-conversion registry's key, e.g. `fasting-glucose`), or null when the marker does not exist yet or its name matches none. With `markerUnit` it tells a client whether a row in another unit can be converted on commit.",
|
||||
"The analyte the matched marker is (the catalog slug, e.g. `fasting-glucose` or `ldl`, or a registry-only key such as `urea`), or null when the marker does not exist yet or its name matches none. With `markerUnit` it tells a client whether a row in another unit can be converted on commit.",
|
||||
}),
|
||||
duplicateOf: z.string().nullable(),
|
||||
})
|
||||
|
||||
@@ -132,7 +132,7 @@ export interface OcrExtractedRowDto {
|
||||
*/
|
||||
markerUnit: string | null;
|
||||
/**
|
||||
* Which analyte the matched marker is (unit-conversion registry key), or null
|
||||
* Which analyte the matched marker is (catalog slug or registry-only key), or null
|
||||
* when the marker is new or none is known. With `markerUnit` it tells the
|
||||
* review screen whether a row in another unit can be converted on Save.
|
||||
*/
|
||||
|
||||
@@ -88,6 +88,33 @@ describe("POST /api/biomarkers", () => {
|
||||
expect(data.analyteKey).toBe("total-cholesterol");
|
||||
});
|
||||
|
||||
it("stores the catalog slug for LDL and HDL, and converts them as cholesterol", async () => {
|
||||
for (const [name, key] of [
|
||||
["LDL cholesterol", "ldl"],
|
||||
["HDL-Cholesterin", "hdl"],
|
||||
]) {
|
||||
const res = await post({ name, unit: "mg/dL" });
|
||||
const { data } = (await res.json()) as { data: { analyteKey: string } };
|
||||
expect(data.analyteKey, name).toBe(key);
|
||||
}
|
||||
const ldl = await prisma.biomarker.findFirstOrThrow({
|
||||
where: { userId: USER, name: "LDL cholesterol" },
|
||||
});
|
||||
const reading = await postReading({
|
||||
biomarkerId: ldl.id,
|
||||
value: 3,
|
||||
unit: "mmol/L",
|
||||
takenAt: "2026-05-04T08:00:00.000Z",
|
||||
});
|
||||
expect(reading.status).toBe(201);
|
||||
const { data: dto } = (await reading.json()) as {
|
||||
data: { value: number; converted: boolean };
|
||||
};
|
||||
expect(dto.converted).toBe(true);
|
||||
// 3 mmol/L x 38.6664 (C27H46O, 386.664 g/mol).
|
||||
expect(dto.value.toFixed(2)).toBe("116.00");
|
||||
});
|
||||
|
||||
it("leaves a free name, and a known name in an unreachable unit, unlinked", async () => {
|
||||
for (const [name, unit] of [
|
||||
["My own marker", "mg/dL"],
|
||||
@@ -277,6 +304,24 @@ describe("ensureAnalyteKey", () => {
|
||||
).toBe("urea");
|
||||
});
|
||||
|
||||
it("corrects a key stored as the registry entry rather than the catalog slug", async () => {
|
||||
// Before the slug was stored, an LDL marker got the cholesterol entry's
|
||||
// key. Same conversion, wrong identity; the fill step rewrites it once.
|
||||
const marker = await prisma.biomarker.create({
|
||||
data: {
|
||||
userId: USER,
|
||||
name: "LDL-Cholesterin",
|
||||
unit: "mg/dL",
|
||||
analyteKey: "total-cholesterol",
|
||||
},
|
||||
});
|
||||
expect(await ensureAnalyteKey(prisma, marker)).toBe("ldl");
|
||||
const row = await prisma.biomarker.findUnique({ where: { id: marker.id } });
|
||||
expect(row?.analyteKey).toBe("ldl");
|
||||
// And a second run changes nothing.
|
||||
expect(await ensureAnalyteKey(prisma, row!)).toBe("ldl");
|
||||
});
|
||||
|
||||
it("converges when two callers fill the same marker", async () => {
|
||||
const marker = await prisma.biomarker.create({
|
||||
data: { userId: USER, name: "Kreatinin", unit: "mg/dL" },
|
||||
|
||||
Reference in new issue
Block a user