mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 14:16:12 +00:00
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:
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",
|
||||
|
||||
@@ -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>;
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user