merge: Labs overhaul — biomarker catalog, structured entry, dashboard-style chart, edit/undo, backfill

# Conflicts:
#	src/app/api/labs/route.ts
This commit is contained in:
Marc-André Bombeck committed 2026-06-16 21:17:09 +02:00
commit cc32e2adf7
40 files changed
+4403 -360

No files matched your search

@@ -0,0 +1,78 @@
import { describe, expect, it } from "vitest";
import {
createBiomarkerSchema,
updateBiomarkerSchema,
} from "../biomarkers";
describe("createBiomarkerSchema", () => {
it("accepts a minimal marker (name + unit only)", () => {
const r = createBiomarkerSchema.safeParse({ name: "LDL", unit: "mg/dL" });
expect(r.success).toBe(true);
});
it("accepts both bounds when low <= high", () => {
const r = createBiomarkerSchema.safeParse({
name: "Fasting glucose",
unit: "mg/dL",
lowerBound: 70,
upperBound: 100,
});
expect(r.success).toBe(true);
});
it("accepts an open-ended upper bound (lower omitted)", () => {
const r = createBiomarkerSchema.safeParse({
name: "LDL",
unit: "mg/dL",
upperBound: 116,
});
expect(r.success).toBe(true);
});
it("rejects a transposed range (low > high)", () => {
const r = createBiomarkerSchema.safeParse({
name: "LDL",
unit: "mg/dL",
lowerBound: 200,
upperBound: 100,
});
expect(r.success).toBe(false);
});
it("rejects an empty name", () => {
const r = createBiomarkerSchema.safeParse({ name: " ", unit: "mg/dL" });
expect(r.success).toBe(false);
});
it("normalises an empty context to undefined", () => {
const r = createBiomarkerSchema.safeParse({
name: "TSH",
unit: "mIU/L",
context: "",
});
expect(r.success).toBe(true);
if (r.success) expect(r.data.context).toBeUndefined();
});
});
describe("updateBiomarkerSchema", () => {
it("accepts a partial edit", () => {
const r = updateBiomarkerSchema.safeParse({ unit: "mmol/L" });
expect(r.success).toBe(true);
});
it("accepts an explicit null to clear a bound", () => {
const r = updateBiomarkerSchema.safeParse({ lowerBound: null });
expect(r.success).toBe(true);
if (r.success) expect(r.data.lowerBound).toBeNull();
});
it("still rejects a transposed range on update", () => {
const r = updateBiomarkerSchema.safeParse({
lowerBound: 10,
upperBound: 5,
});
expect(r.success).toBe(false);
});
});
@@ -75,6 +75,32 @@ describe("createLabResultSchema", () => {
expect(r.success).toBe(true);
});
it("accepts a structured entry (biomarkerId, no analyte/unit)", () => {
const r = createLabResultSchema.safeParse({
biomarkerId: "bm_123",
value: 110,
takenAt: RECENT,
});
expect(r.success).toBe(true);
});
it("rejects an entry with neither biomarkerId nor analyte", () => {
const r = createLabResultSchema.safeParse({
value: 110,
takenAt: RECENT,
});
expect(r.success).toBe(false);
});
it("rejects a free-text entry missing the unit", () => {
const r = createLabResultSchema.safeParse({
analyte: "LDL",
value: 110,
takenAt: RECENT,
});
expect(r.success).toBe(false);
});
it("normalises an empty-string panel to undefined", () => {
const r = createLabResultSchema.safeParse({
analyte: "TSH",
+89
View File
@@ -0,0 +1,89 @@
/**
* v1.18.1 — user-scoped Biomarker catalog validation.
*
* A `Biomarker` defines a lab marker ONCE: canonical name, unit, reference
* bounds, and an optional context note. Recording a value later is just
* picking the biomarker — the unit and reference range are resolved
* server-side from the catalog row, never re-entered (ending the per-row
* free-text divergence: "LDL" vs "ldl", `mg/dL` vs `mg/dl`).
*
* The reference window is the lab/sex/age-dependent "normal" range; the
* server's neutral range verdict (`classifyReferenceRange`) is computed
* against these bounds. Both are independently optional (a marker may report
* only an upper bound, e.g. LDL < 116); when both are present the lower must
* not exceed the upper.
*/
import { z } from "zod/v4";
const requiredText = (max: number) => z.string().trim().min(1).max(max);
const lowerBound = z.number().finite().optional();
const upperBound = z.number().finite().optional();
const optionalContext = z
.string()
.trim()
.max(2000)
.optional()
// An empty / whitespace-only context normalises to "no context" so a
// blank field clears rather than stores an empty string.
.transform((v) => (v && v.length > 0 ? v : undefined));
const optionalPanel = z
.string()
.trim()
.min(1)
.max(120)
.optional()
.or(z.literal("").transform(() => undefined));
export const createBiomarkerSchema = z
.object({
name: requiredText(120),
unit: requiredText(40),
lowerBound,
upperBound,
context: optionalContext,
panel: optionalPanel,
})
.refine(
(d) =>
d.lowerBound === undefined ||
d.upperBound === undefined ||
d.lowerBound <= d.upperBound,
{
message: "lowerBound must not exceed upperBound",
path: ["lowerBound"],
},
);
export type CreateBiomarkerInput = z.infer<typeof createBiomarkerSchema>;
/**
* Update schema: every field optional (partial edit). `context` / `panel` /
* a bound accept an explicit `null` to clear the stored value — distinct from
* an omitted key, which leaves the column untouched.
*/
export const updateBiomarkerSchema = z
.object({
name: requiredText(120).optional(),
unit: requiredText(40).optional(),
lowerBound: lowerBound.or(z.null()),
upperBound: upperBound.or(z.null()),
context: optionalContext.or(z.null()),
panel: optionalPanel.or(z.null()),
})
.refine(
(d) =>
d.lowerBound === undefined ||
d.lowerBound === null ||
d.upperBound === undefined ||
d.upperBound === null ||
d.lowerBound <= d.upperBound,
{
message: "lowerBound must not exceed upperBound",
path: ["lowerBound"],
},
);
export type UpdateBiomarkerInput = z.infer<typeof updateBiomarkerSchema>;
+26 -3
View File
@@ -68,17 +68,38 @@ const optionalNote = z
const referenceLow = z.number().finite().optional();
const referenceHigh = z.number().finite().optional();
/**
* v1.18.1 — optional link to a user-scoped `Biomarker` (the catalog row).
* When set, the structured-entry path resolves the unit + reference range
* from the biomarker server-side, so `analyte` / `unit` may be omitted (the
* route fills them from the catalog row). A free-text reading without a
* catalog link stays valid (quick-capture / legacy path).
*/
const biomarkerId = z.string().trim().min(1).max(64).optional();
export const createLabResultSchema = z
.object({
biomarkerId,
panel: optionalPanel,
analyte: requiredText(120),
analyte: requiredText(120).optional(),
value: z.number().finite(),
unit: requiredText(40),
unit: requiredText(40).optional(),
referenceLow,
referenceHigh,
takenAt: takenAtField,
note: optionalNote,
})
// Either a catalog link OR a free-text analyte must be present.
.refine((d) => d.biomarkerId !== undefined || d.analyte !== undefined, {
message: "Either biomarkerId or analyte is required",
path: ["analyte"],
})
// Without a catalog link the unit is required (the catalog otherwise
// supplies it server-side).
.refine((d) => d.biomarkerId !== undefined || d.unit !== undefined, {
message: "unit is required when no biomarkerId is given",
path: ["unit"],
})
.refine(
(d) =>
d.referenceLow === undefined ||
@@ -123,8 +144,10 @@ export const updateLabResultSchema = z
export type UpdateLabResultInput = z.infer<typeof updateLabResultSchema>;
/** List query: filter by analyte (exact) + date range, paginate. */
/** List query: filter by biomarker / analyte (exact) + date range, paginate. */
export const listLabResultsSchema = z.object({
// v1.18.1 — filter to one catalog marker's readings (the detail chart feed).
biomarkerId: z.string().trim().min(1).max(64).optional(),
analyte: z.string().trim().min(1).max(120).optional(),
panel: z.string().trim().min(1).max(120).optional(),
from: z