mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +00:00
A lab runs its own method on its own device, so the window a physician evaluates a value against is the one printed next to it on that report, not a catalog band averaged over every lab. Reading a report pulled the value and dropped that range: the extraction asked for it, the commit handed it to the biomarker resolver, and the resolver only uses it when it mints a NEW marker. For every analyte the user already had — the normal case after a first panel — the printed range was read and then thrown away. Add `sourceReferenceLow` / `sourceReferenceHigh` / `sourceReferenceText` to `LabResult` and carry the range from the report to the reading. The text is stored whether or not numbers can be read out of it, because "bis 5,0" and "negativ" state a window too; a range that could not be parsed is kept verbatim, never replaced with invented bounds. `resolveEffectiveReferenceRange` is the one place the precedence lives: the source window wins for that single reading, and the catalog stays the net for readings without one. A printed string with no derivable bound does not displace the catalog. Every surface that judges a reading routes through it — the API verdict, the doctor report, the insights blocks and per-marker assessment, the coach snapshot, the MCP read and the FHIR `referenceRange` — so a value cannot read in range on one surface and out on another. The API keeps `referenceLow` / `referenceHigh` as the window in force, so a client that reads only those two renders the right range and the right verdict with no change of its own; the catalog band, the source window, its origin and a divergence flag ride alongside for clients that want to show both. Read side: the chart draws the source window as a second, visually distinct band with the reading it came from named beneath it, the reading rows name the printed range and the catalog band where the two disagree, and the doctor-report PDF prints the report's own string with a footnote naming the saved range. Entry side: one optional field on the lab form, folded away until asked for, and the printed range is editable on the scan review screen. The range parser covers the notations German and English reports actually print — two-sided, one-sided, comma or dot decimals, thousands separators, trailing units — reads units without ever converting them, and refuses to guess on anything ambiguous.
313 lines
11 KiB
TypeScript
313 lines
11 KiB
TypeScript
/**
|
|
* v1.17.1 — structured lab-result store.
|
|
*
|
|
* A minimal, generic `LabResult` capture path: a self-hoster records a
|
|
* biomarker reading (HbA1c, LDL, ferritin, TSH, …) with its value, unit,
|
|
* the lab's reference range, and the sample date. Pairs with the v1.17.1
|
|
* Vorsorge annual-blood-panel reminder, which otherwise has nowhere to
|
|
* record the result it reminds the user to obtain.
|
|
*
|
|
* The model is intentionally free-form on `analyte` + `unit` (no closed
|
|
* LOINC enum): a lab report prints whatever name and unit it likes, and a
|
|
* forced taxonomy would reject real-world values. The optional `panel`
|
|
* groups analytes that arrived together (e.g. "Großes Blutbild").
|
|
*/
|
|
import { z } from "zod/v4";
|
|
|
|
import { SOURCE_REFERENCE_TEXT_MAX } from "@/lib/labs/parse-reference-range";
|
|
import { validateEntryInstant } from "@/lib/validations/entry-instant";
|
|
|
|
/**
|
|
* Far-past floor for a lab sample. A self-hoster legitimately backdates an
|
|
* old paper report, so the window is generous (50 years) but still rejects
|
|
* epoch-zero / corrupted-import instants via the shared 1900 floor.
|
|
*/
|
|
const LAB_TAKEN_AT_MAX_AGE_MS = 50 * 365.25 * 24 * 60 * 60 * 1000;
|
|
|
|
/** Shared `takenAt` field: ISO string → Date, plausibility-bounded. */
|
|
const takenAtField = validateEntryInstant(
|
|
z
|
|
.string()
|
|
.datetime({ message: "takenAt must be an ISO 8601 datetime" })
|
|
.transform((s) => new Date(s)),
|
|
{
|
|
maxAgeMs: LAB_TAKEN_AT_MAX_AGE_MS,
|
|
pastMessage: "Sample date is too far in the past",
|
|
},
|
|
);
|
|
|
|
/** Trimmed, bounded free-text field with a min-length guard. */
|
|
const requiredText = (max: number) => z.string().trim().min(1).max(max);
|
|
|
|
const optionalPanel = z
|
|
.string()
|
|
.trim()
|
|
.min(1)
|
|
.max(120)
|
|
.optional()
|
|
// An empty-string panel is normalised to "no panel" so the grouping
|
|
// stays NULL-distinct rather than carrying a blank label.
|
|
.or(z.literal("").transform(() => undefined));
|
|
|
|
const optionalNote = z
|
|
.string()
|
|
.trim()
|
|
.max(2000)
|
|
.optional()
|
|
.or(z.literal("").transform(() => undefined));
|
|
|
|
/**
|
|
* Reference bounds are independently optional (a lab may report only an
|
|
* upper bound, e.g. LDL < 116). When BOTH are present the low bound must
|
|
* not exceed the high bound — a transposed range is a client error.
|
|
*/
|
|
const referenceLow = z.number().finite().optional();
|
|
const referenceHigh = z.number().finite().optional();
|
|
|
|
/**
|
|
* The reference range the SOURCE REPORT printed for this one reading.
|
|
*
|
|
* Distinct from `referenceLow` / `referenceHigh` above, which seed (and are
|
|
* stamped from) the catalog marker's band. A lab runs its own method on its
|
|
* own device, so the window a physician evaluates against is the one on that
|
|
* report; these fields carry it, and `resolveEffectiveReferenceRange` lets it
|
|
* win for that reading alone.
|
|
*
|
|
* `sourceReferenceText` is what was printed, verbatim. It is accepted with or
|
|
* without derivable bounds: a window stated as "negativ" or "siehe Befund"
|
|
* still belongs on the record, and dropping it because no number could be read
|
|
* out of it is exactly the loss this field exists to stop.
|
|
*/
|
|
const sourceReferenceLow = z.number().finite().optional();
|
|
const sourceReferenceHigh = z.number().finite().optional();
|
|
const sourceReferenceText = z
|
|
.string()
|
|
.trim()
|
|
.max(SOURCE_REFERENCE_TEXT_MAX)
|
|
.optional()
|
|
.or(z.literal("").transform(() => undefined));
|
|
|
|
/**
|
|
* 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();
|
|
|
|
/**
|
|
* v1.18.9 — a qualitative reading ("negativ" / "positiv" / "grenzwertig" /
|
|
* "nicht nachweisbar"). Trimmed, non-empty, sensibly capped. Mutually
|
|
* exclusive with the numeric `value` (see the create/update refinements).
|
|
*/
|
|
const valueText = requiredText(120).optional();
|
|
|
|
/**
|
|
* v1.25 (iOS #36) — optional provenance marker. The on-device-OCR ingestion
|
|
* path posts `source: "OCR"` so a reading captured from a phone-camera lab
|
|
* photo records its provenance; the raw image never touches the server on this
|
|
* path. Omitting the field reads as `"MANUAL"`, preserving the legacy
|
|
* hand-entry contract exactly. Mirrors the `source: "OCR"` the server-side
|
|
* `/api/labs/ocr/commit` path already stamps.
|
|
*/
|
|
const source = z.enum(["MANUAL", "OCR"]).optional();
|
|
|
|
export const createLabResultSchema = z
|
|
.object({
|
|
biomarkerId,
|
|
panel: optionalPanel,
|
|
analyte: requiredText(120).optional(),
|
|
// v1.18.9 — `value` is no longer required: a reading is EITHER numeric
|
|
// (`value`) OR qualitative (`valueText`), never both, never neither.
|
|
value: z.number().finite().optional(),
|
|
valueText,
|
|
unit: requiredText(40).optional(),
|
|
referenceLow,
|
|
referenceHigh,
|
|
sourceReferenceLow,
|
|
sourceReferenceHigh,
|
|
sourceReferenceText,
|
|
takenAt: takenAtField,
|
|
note: optionalNote,
|
|
source,
|
|
})
|
|
// 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"],
|
|
})
|
|
// Numeric XOR qualitative: exactly one of `value` / `valueText` is set.
|
|
.refine((d) => (d.value !== undefined) !== (d.valueText !== undefined), {
|
|
message:
|
|
"Provide exactly one of value (numeric) or valueText (qualitative)",
|
|
path: ["value"],
|
|
})
|
|
// A numeric reading without a catalog link still needs a unit (the catalog
|
|
// otherwise supplies it server-side). A qualitative reading needs no unit —
|
|
// its result is the text, and a unit/range is meaningless for it.
|
|
.refine(
|
|
(d) =>
|
|
d.valueText !== undefined ||
|
|
d.biomarkerId !== undefined ||
|
|
d.unit !== undefined,
|
|
{
|
|
message:
|
|
"unit is required for a numeric reading when no biomarkerId is given",
|
|
path: ["unit"],
|
|
},
|
|
)
|
|
// Reference bounds apply to the numeric path only; skip them entirely for a
|
|
// qualitative reading.
|
|
.refine(
|
|
(d) =>
|
|
d.valueText !== undefined ||
|
|
d.referenceLow === undefined ||
|
|
d.referenceHigh === undefined ||
|
|
d.referenceLow <= d.referenceHigh,
|
|
{
|
|
message: "referenceLow must not exceed referenceHigh",
|
|
path: ["referenceLow"],
|
|
},
|
|
)
|
|
// Same guard on the source window. A transposed window is a client error
|
|
// here for the same reason: nothing downstream can tell it from a real one.
|
|
.refine(
|
|
(d) =>
|
|
d.valueText !== undefined ||
|
|
d.sourceReferenceLow === undefined ||
|
|
d.sourceReferenceHigh === undefined ||
|
|
d.sourceReferenceLow <= d.sourceReferenceHigh,
|
|
{
|
|
message: "sourceReferenceLow must not exceed sourceReferenceHigh",
|
|
path: ["sourceReferenceLow"],
|
|
},
|
|
);
|
|
|
|
export type CreateLabResultInput = z.infer<typeof createLabResultSchema>;
|
|
|
|
/**
|
|
* Update schema: every field optional (partial edit). `panel` and `note`
|
|
* accept an explicit `null` to clear the stored value — distinct from an
|
|
* omitted key, which leaves the column untouched.
|
|
*/
|
|
export const updateLabResultSchema = z
|
|
.object({
|
|
panel: optionalPanel.or(z.null()),
|
|
analyte: requiredText(120).optional(),
|
|
value: z.number().finite().optional(),
|
|
// v1.18.9 — edit the qualitative result. A row stays single-typed: a PUT
|
|
// must not set BOTH `value` and `valueText` (the refine below). Switching a
|
|
// row's type (numeric ↔ qualitative) is intentionally not supported here —
|
|
// delete and re-add — so an omitted key leaves the row's existing type.
|
|
valueText: requiredText(120).optional(),
|
|
unit: requiredText(40).optional(),
|
|
referenceLow: referenceLow.or(z.null()),
|
|
referenceHigh: referenceHigh.or(z.null()),
|
|
sourceReferenceLow: sourceReferenceLow.or(z.null()),
|
|
sourceReferenceHigh: sourceReferenceHigh.or(z.null()),
|
|
sourceReferenceText: sourceReferenceText.or(z.null()),
|
|
takenAt: takenAtField.optional(),
|
|
note: optionalNote.or(z.null()),
|
|
})
|
|
// A single edit never carries both a numeric and a qualitative value.
|
|
.refine((d) => d.value === undefined || d.valueText === undefined, {
|
|
message: "Provide value (numeric) or valueText (qualitative), not both",
|
|
path: ["value"],
|
|
})
|
|
.refine(
|
|
(d) =>
|
|
d.referenceLow === undefined ||
|
|
d.referenceLow === null ||
|
|
d.referenceHigh === undefined ||
|
|
d.referenceHigh === null ||
|
|
d.referenceLow <= d.referenceHigh,
|
|
{
|
|
message: "referenceLow must not exceed referenceHigh",
|
|
path: ["referenceLow"],
|
|
},
|
|
)
|
|
.refine(
|
|
(d) =>
|
|
d.sourceReferenceLow === undefined ||
|
|
d.sourceReferenceLow === null ||
|
|
d.sourceReferenceHigh === undefined ||
|
|
d.sourceReferenceHigh === null ||
|
|
d.sourceReferenceLow <= d.sourceReferenceHigh,
|
|
{
|
|
message: "sourceReferenceLow must not exceed sourceReferenceHigh",
|
|
path: ["sourceReferenceLow"],
|
|
},
|
|
);
|
|
|
|
export type UpdateLabResultInput = z.infer<typeof updateLabResultSchema>;
|
|
|
|
/**
|
|
* Inverted-range merge guard for a PARTIAL bound update.
|
|
*
|
|
* The schema-level `referenceLow <= referenceHigh` refine only fires when both
|
|
* bounds arrive in the SAME request. A partial PUT that moves a single bound
|
|
* past the row's existing other bound slips through the schema. The route
|
|
* resolves the EFFECTIVE bounds (the parsed value when present, else the
|
|
* stored value) and calls this to reject a transposed window with a 422.
|
|
*
|
|
* Returns true when both effective bounds are concrete numbers and low > high.
|
|
*/
|
|
export function isInvertedRange(
|
|
effectiveLow: number | null,
|
|
effectiveHigh: number | null,
|
|
): boolean {
|
|
return (
|
|
effectiveLow !== null &&
|
|
effectiveHigh !== null &&
|
|
effectiveLow > effectiveHigh
|
|
);
|
|
}
|
|
|
|
/** Resolve an effective bound: the parsed value when present, else the stored. */
|
|
export function effectiveBound(
|
|
parsed: number | null | undefined,
|
|
stored: number | null,
|
|
): number | null {
|
|
return parsed !== undefined ? parsed : stored;
|
|
}
|
|
|
|
/** 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
|
|
.string()
|
|
.datetime()
|
|
.transform((s) => new Date(s))
|
|
.optional(),
|
|
to: z
|
|
.string()
|
|
.datetime()
|
|
.transform((s) => new Date(s))
|
|
.optional(),
|
|
limit: z.coerce.number().int().min(1).max(500).default(100),
|
|
offset: z.coerce.number().int().min(0).default(0),
|
|
sortDir: z.enum(["asc", "desc"]).default("desc"),
|
|
});
|
|
|
|
export type ListLabResultsInput = z.infer<typeof listLabResultsSchema>;
|
|
|
|
/**
|
|
* Reference-range classification lives in `@/lib/labs/reference-range` so the
|
|
* API response, the doctor-report PDF, and the lab list card all share one
|
|
* implementation. Re-exported here for the existing import sites.
|
|
*/
|
|
export {
|
|
classifyAgainstEffectiveRange,
|
|
classifyReferenceRange,
|
|
formatReferenceRange,
|
|
resolveEffectiveReferenceRange,
|
|
type EffectiveReferenceRange,
|
|
type ReferenceRangeOrigin,
|
|
type ReferenceRangeStatus,
|
|
type SourceReferenceRange,
|
|
} from "@/lib/labs/reference-range";
|