mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 14:16:12 +00:00
feat(labs): record qualitative lab results
Add first-class qualitative lab findings (positive / negative / borderline serology) alongside the existing numeric path. A LabResult is now EITHER a numeric reading (value + unit, optional reference bounds) OR a qualitative one (valueText, e.g. "negativ"), never both, never neither. - schema: make `value` nullable, add `value_text`; migration 0181 is additive (DROP NOT NULL + ADD COLUMN), every existing numeric row is untouched. - validation: numeric-XOR-qualitative refine on create + update; unit/bounds apply to the numeric path only; valueText capped at 120 chars. The single resource PUT rejects a cross-type edit. - serialise: carry valueText through both DTOs; a qualitative row reports the neutral `unknown` range verdict (no number to classify). - FHIR: a qualitative result emits valueCodeableConcept — SNOMED qualifier value for confidently-known terms (Negative 260385009, Positive 10828004, Not detected 260415000, Detected 260373001), text-only for borderline / grenzwertig and any unrecognised term. The raw text always rides .text. - UI: a numeric/qualitative toggle in the lab form; list, history, detail, chart, sparkline, and the doctor-report PDF all render qualitative readings as their text and never plot or divide on a null value. - i18n: new labs.form keys across all six locales. - openapi: response carries a nullable value + valueText; regenerated.
This commit is contained in:
1 parent
e631396af3
commit
a5b3715a46
30 files changed
+819
-123
No files matched your search
@@ -0,0 +1,51 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
|
||||
/**
|
||||
* v1.18.9 — the lab form's numeric ↔ qualitative result-type toggle.
|
||||
*
|
||||
* SSR-only smoke render (the suite's `renderToStaticMarkup` dialect): the form
|
||||
* mounts in its default NUMERIC mode, so the test asserts the toggle is present
|
||||
* (both mode labels render) and the numeric value field is shown by default.
|
||||
* Interactive mode-switching is exercised by the validation + serialise unit
|
||||
* tests, which pin the numeric-XOR-qualitative contract this toggle feeds.
|
||||
*/
|
||||
|
||||
vi.mock("@/lib/api/api-fetch", () => ({
|
||||
// Catalog never resolves within the synchronous SSR render.
|
||||
apiGet: () => new Promise(() => {}),
|
||||
apiPost: vi.fn(),
|
||||
}));
|
||||
|
||||
import { LabForm } from "../lab-form";
|
||||
|
||||
function render(node: React.ReactNode) {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
return renderToStaticMarkup(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<I18nProvider initialLocale="en">{node}</I18nProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("<LabForm> result-type toggle", () => {
|
||||
it("renders both numeric and qualitative mode options", () => {
|
||||
const html = render(<LabForm />);
|
||||
expect(html).toContain("Numeric");
|
||||
expect(html).toContain("Qualitative");
|
||||
expect(html).toContain("Result type");
|
||||
});
|
||||
|
||||
it("shows the numeric value field by default (numeric mode)", () => {
|
||||
const html = render(<LabForm />);
|
||||
// The numeric value input carries the id `lab-value`; the qualitative input
|
||||
// (`lab-valueText`) is hidden until the user switches modes.
|
||||
expect(html).toContain('id="lab-value"');
|
||||
expect(html).not.toContain('id="lab-valueText"');
|
||||
});
|
||||
});
|
||||
@@ -68,9 +68,14 @@ export function LabBiomarkerChart({
|
||||
const [nowMs] = useState(() => Date.now());
|
||||
|
||||
const points = useMemo<ChartPoint[]>(() => {
|
||||
const sorted = [...readings].sort(
|
||||
(a, b) => new Date(a.takenAt).getTime() - new Date(b.takenAt).getTime(),
|
||||
);
|
||||
// v1.18.9 — qualitative readings (numeric value null) have nothing to plot;
|
||||
// drop them so the line never renders a NaN / zero point. A series of only
|
||||
// qualitative readings yields no points → the honest empty state below.
|
||||
const sorted = [...readings]
|
||||
.filter((r): r is LabResultDto & { value: number } => r.value !== null)
|
||||
.sort(
|
||||
(a, b) => new Date(a.takenAt).getTime() - new Date(b.takenAt).getTime(),
|
||||
);
|
||||
const days = RANGE_DAYS.find((r) => r.key === range)?.days ?? 0;
|
||||
const filtered =
|
||||
days > 0
|
||||
|
||||
@@ -101,13 +101,15 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
const latestStatus = latest
|
||||
? classifyReferenceRange(
|
||||
latest.value,
|
||||
marker?.lowerBound ?? null,
|
||||
marker?.upperBound ?? null,
|
||||
)
|
||||
: "unknown";
|
||||
// A qualitative latest reading (numeric value null) has no range verdict.
|
||||
const latestStatus =
|
||||
latest && latest.value !== null
|
||||
? classifyReferenceRange(
|
||||
latest.value,
|
||||
marker?.lowerBound ?? null,
|
||||
marker?.upperBound ?? null,
|
||||
)
|
||||
: "unknown";
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -147,10 +149,17 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
|
||||
{latest ? (
|
||||
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
|
||||
<span className="text-2xl font-bold tabular-nums">
|
||||
{formatLabValue(latest.value)}{" "}
|
||||
<span className="text-muted-foreground text-base font-normal">
|
||||
{marker?.unit ?? latest.unit}
|
||||
</span>
|
||||
{latest.value !== null ? (
|
||||
<>
|
||||
{formatLabValue(latest.value)}{" "}
|
||||
<span className="text-muted-foreground text-base font-normal">
|
||||
{marker?.unit ?? latest.unit}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
// Qualitative latest reading — show the result text, no unit.
|
||||
(latest.valueText ?? "")
|
||||
)}
|
||||
</span>
|
||||
<ReferenceRangeBadge status={latestStatus} />
|
||||
</div>
|
||||
|
||||
@@ -93,7 +93,14 @@ export function LabForm({
|
||||
const [biomarkerId, setBiomarkerId] = useState<string>(
|
||||
lockedBiomarkerId ?? "",
|
||||
);
|
||||
// v1.18.9 — numeric vs qualitative result mode. Numeric mode enters a number
|
||||
// against the marker's unit/range; qualitative mode enters a result text
|
||||
// ("negativ" / "positiv" / "grenzwertig" / free text) and hides unit/range.
|
||||
const [resultType, setResultType] = useState<"numeric" | "qualitative">(
|
||||
"numeric",
|
||||
);
|
||||
const [value, setValue] = useState("");
|
||||
const [valueText, setValueText] = useState("");
|
||||
const [takenAt, setTakenAt] = useState(defaultTakenAtValue);
|
||||
const [note, setNote] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
@@ -125,21 +132,36 @@ export function LabForm({
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
|
||||
const numericValue = parseDecimal(value);
|
||||
if (!biomarkerId) {
|
||||
setError(t("labs.form.pickBiomarkerError"));
|
||||
return;
|
||||
}
|
||||
if (numericValue === null) {
|
||||
setError(t("labs.form.requiredError"));
|
||||
return;
|
||||
|
||||
const isQualitative = resultType === "qualitative";
|
||||
let numericValue: number | null = null;
|
||||
let qualitativeValue: string | null = null;
|
||||
if (isQualitative) {
|
||||
const trimmed = valueText.trim();
|
||||
if (trimmed === "") {
|
||||
setError(t("labs.form.requiredError"));
|
||||
return;
|
||||
}
|
||||
qualitativeValue = trimmed;
|
||||
} else {
|
||||
numericValue = parseDecimal(value);
|
||||
if (numericValue === null) {
|
||||
setError(t("labs.form.requiredError"));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const created = await apiPost<LabResultDto>("/api/labs", {
|
||||
biomarkerId,
|
||||
value: numericValue,
|
||||
...(isQualitative
|
||||
? { valueText: qualitativeValue }
|
||||
: { value: numericValue }),
|
||||
takenAt: new Date(takenAt).toISOString(),
|
||||
...(note.trim() ? { note: note.trim() } : {}),
|
||||
});
|
||||
@@ -216,25 +238,75 @@ export function LabForm({
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t("labs.form.resultType")}</Label>
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={resultType === "numeric" ? "secondary" : "ghost"}
|
||||
className="h-8 flex-1"
|
||||
onClick={() => setResultType("numeric")}
|
||||
aria-pressed={resultType === "numeric"}
|
||||
>
|
||||
{t("labs.form.numeric")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={resultType === "qualitative" ? "secondary" : "ghost"}
|
||||
className="h-8 flex-1"
|
||||
onClick={() => setResultType("qualitative")}
|
||||
aria-pressed={resultType === "qualitative"}
|
||||
>
|
||||
{t("labs.form.qualitative")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="lab-value">
|
||||
{t("labs.form.value")}
|
||||
{selected ? (
|
||||
<span className="text-muted-foreground font-normal">
|
||||
{" "}
|
||||
({selected.unit})
|
||||
</span>
|
||||
) : null}
|
||||
</Label>
|
||||
<Input
|
||||
id="lab-value"
|
||||
inputMode="decimal"
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
placeholder="0.0"
|
||||
required
|
||||
/>
|
||||
{resultType === "numeric" ? (
|
||||
<>
|
||||
<Label htmlFor="lab-value">
|
||||
{t("labs.form.value")}
|
||||
{selected ? (
|
||||
<span className="text-muted-foreground font-normal">
|
||||
{" "}
|
||||
({selected.unit})
|
||||
</span>
|
||||
) : null}
|
||||
</Label>
|
||||
<Input
|
||||
id="lab-value"
|
||||
inputMode="decimal"
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
placeholder="0.0"
|
||||
required
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Label htmlFor="lab-valueText">
|
||||
{t("labs.form.qualitativeResult")}
|
||||
</Label>
|
||||
<Input
|
||||
id="lab-valueText"
|
||||
list="lab-qualitative-options"
|
||||
value={valueText}
|
||||
onChange={(e) => setValueText(e.target.value)}
|
||||
placeholder={t("labs.form.qualitativePlaceholder")}
|
||||
maxLength={120}
|
||||
required
|
||||
/>
|
||||
<datalist id="lab-qualitative-options">
|
||||
<option value={t("labs.form.qualNegative")} />
|
||||
<option value={t("labs.form.qualPositive")} />
|
||||
<option value={t("labs.form.qualBorderline")} />
|
||||
</datalist>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="lab-takenAt">{t("labs.form.takenAt")}</Label>
|
||||
@@ -248,7 +320,7 @@ export function LabForm({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selected && referenceText ? (
|
||||
{resultType === "numeric" && selected && referenceText ? (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("labs.referenceLabel")} {referenceText} {selected.unit}
|
||||
</p>
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
apiPut,
|
||||
} from "@/lib/api/api-fetch";
|
||||
import { formatDateShort } from "@/lib/format";
|
||||
import { formatLabValue } from "@/lib/labs/format-value";
|
||||
import { formatLabReading } from "@/lib/labs/format-value";
|
||||
import { resolveNoteForUpdate } from "@/lib/labs/note-update";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
@@ -57,6 +57,10 @@ export function LabHistoryList({ readings }: { readings: LabResultDto[] }) {
|
||||
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [editValue, setEditValue] = useState("");
|
||||
// v1.18.9 — true when the row being edited is a qualitative reading; the
|
||||
// editor then shows a text field and the PUT carries `valueText`.
|
||||
const [editIsQualitative, setEditIsQualitative] = useState(false);
|
||||
const [editValueText, setEditValueText] = useState("");
|
||||
const [editTakenAt, setEditTakenAt] = useState("");
|
||||
const [editNote, setEditNote] = useState("");
|
||||
const [editLoading, setEditLoading] = useState(false);
|
||||
@@ -99,14 +103,17 @@ export function LabHistoryList({ readings }: { readings: LabResultDto[] }) {
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (body: {
|
||||
id: string;
|
||||
value: number;
|
||||
// Exactly one of value / valueText is set, matching the row's type.
|
||||
value: number | null;
|
||||
valueText: string | null;
|
||||
takenAt: string;
|
||||
// `undefined` → omit `note` from the PUT (leave the stored note
|
||||
// untouched); `null` → clear it; a string → set it.
|
||||
note: string | null | undefined;
|
||||
}) =>
|
||||
apiPut<LabResultDto>(`/api/labs/${body.id}`, {
|
||||
value: body.value,
|
||||
...(body.value !== null ? { value: body.value } : {}),
|
||||
...(body.valueText !== null ? { valueText: body.valueText } : {}),
|
||||
takenAt: body.takenAt,
|
||||
...(body.note === undefined ? {} : { note: body.note }),
|
||||
}),
|
||||
@@ -122,7 +129,10 @@ export function LabHistoryList({ readings }: { readings: LabResultDto[] }) {
|
||||
|
||||
async function openEdit(reading: LabResultDto) {
|
||||
setEditingId(reading.id);
|
||||
setEditValue(String(reading.value));
|
||||
const qualitative = reading.value === null;
|
||||
setEditIsQualitative(qualitative);
|
||||
setEditValue(reading.value !== null ? String(reading.value) : "");
|
||||
setEditValueText(reading.valueText ?? "");
|
||||
setEditTakenAt(toDateTimeLocal(reading.takenAt));
|
||||
setEditNote("");
|
||||
setEditError(null);
|
||||
@@ -154,10 +164,21 @@ export function LabHistoryList({ readings }: { readings: LabResultDto[] }) {
|
||||
function submitEdit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!editingId) return;
|
||||
const numericValue = parseDecimal(editValue);
|
||||
if (numericValue === null) {
|
||||
setEditError(t("labs.form.requiredError"));
|
||||
return;
|
||||
let numericValue: number | null = null;
|
||||
let qualitativeValue: string | null = null;
|
||||
if (editIsQualitative) {
|
||||
const trimmed = editValueText.trim();
|
||||
if (trimmed === "") {
|
||||
setEditError(t("labs.form.requiredError"));
|
||||
return;
|
||||
}
|
||||
qualitativeValue = trimmed;
|
||||
} else {
|
||||
numericValue = parseDecimal(editValue);
|
||||
if (numericValue === null) {
|
||||
setEditError(t("labs.form.requiredError"));
|
||||
return;
|
||||
}
|
||||
}
|
||||
const takenDate = new Date(editTakenAt);
|
||||
if (Number.isNaN(takenDate.getTime())) {
|
||||
@@ -167,6 +188,7 @@ export function LabHistoryList({ readings }: { readings: LabResultDto[] }) {
|
||||
updateMutation.mutate({
|
||||
id: editingId,
|
||||
value: numericValue,
|
||||
valueText: qualitativeValue,
|
||||
takenAt: takenDate.toISOString(),
|
||||
// `undefined` → omit `note` (server preserves the stored note when its
|
||||
// decrypted load failed); `null` → clear; text → set.
|
||||
@@ -193,7 +215,7 @@ export function LabHistoryList({ readings }: { readings: LabResultDto[] }) {
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-baseline gap-x-2">
|
||||
<span className="text-foreground font-semibold tabular-nums">
|
||||
{formatLabValue(r.value)} {r.unit}
|
||||
{formatLabReading(r)}
|
||||
</span>
|
||||
<ReferenceRangeBadge status={r.rangeStatus} />
|
||||
</div>
|
||||
@@ -236,14 +258,28 @@ export function LabHistoryList({ readings }: { readings: LabResultDto[] }) {
|
||||
<form onSubmit={submitEdit} className="space-y-5">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="lab-edit-value">{t("labs.form.value")}</Label>
|
||||
<Input
|
||||
id="lab-edit-value"
|
||||
inputMode="decimal"
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<Label htmlFor="lab-edit-value">
|
||||
{editIsQualitative
|
||||
? t("labs.form.qualitativeResult")
|
||||
: t("labs.form.value")}
|
||||
</Label>
|
||||
{editIsQualitative ? (
|
||||
<Input
|
||||
id="lab-edit-value"
|
||||
value={editValueText}
|
||||
onChange={(e) => setEditValueText(e.target.value)}
|
||||
maxLength={120}
|
||||
required
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
id="lab-edit-value"
|
||||
inputMode="decimal"
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
required
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="lab-edit-takenAt">{t("labs.form.takenAt")}</Label>
|
||||
|
||||
@@ -12,7 +12,7 @@ import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
import { formatDate } from "@/lib/format";
|
||||
import { formatReferenceRange } from "@/lib/labs/reference-range";
|
||||
import { formatLabValue } from "@/lib/labs/format-value";
|
||||
import { formatLabReading, formatLabValue } from "@/lib/labs/format-value";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { applyOrder, useModuleListPrefs } from "@/lib/module-list-prefs";
|
||||
@@ -178,7 +178,7 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) {
|
||||
</div>
|
||||
<div className="text-muted-foreground flex flex-wrap items-center gap-x-2 text-xs">
|
||||
<span className="text-foreground font-semibold tabular-nums">
|
||||
{formatLabValue(group.latest.value)} {group.latest.unit}
|
||||
{formatLabReading(group.latest)}
|
||||
</span>
|
||||
<span>{formatDate(group.latest.takenAt)}</span>
|
||||
</div>
|
||||
@@ -236,10 +236,11 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) {
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="text-muted-foreground flex flex-wrap items-center gap-x-2 text-sm">
|
||||
<span className="text-foreground font-semibold tabular-nums">
|
||||
{formatLabValue(group.latest.value)} {group.latest.unit}
|
||||
{formatLabReading(group.latest)}
|
||||
</span>
|
||||
{group.latest.referenceLow !== null ||
|
||||
group.latest.referenceHigh !== null ? (
|
||||
{group.latest.value !== null &&
|
||||
(group.latest.referenceLow !== null ||
|
||||
group.latest.referenceHigh !== null) ? (
|
||||
<span className="text-xs">
|
||||
{t("labs.referenceLabel")}{" "}
|
||||
{formatReferenceRange(
|
||||
|
||||
@@ -13,21 +13,24 @@
|
||||
* polyline vs Recharts) is used by the doctor-report PDF. The two are not
|
||||
* drift; do not unify them.
|
||||
*
|
||||
* Rendered only when an analyte has ≥ 2 readings. Stroke uses the neutral
|
||||
* `currentColor` so it inherits the calm muted-foreground tone of its row —
|
||||
* no status colour, in keeping with the no-alarming-colour ethos.
|
||||
* Rendered only when an analyte has ≥ 2 NUMERIC readings. Stroke uses the
|
||||
* neutral `currentColor` so it inherits the calm muted-foreground tone of its
|
||||
* row — no status colour, in keeping with the no-alarming-colour ethos.
|
||||
*
|
||||
* `values` are passed oldest → newest.
|
||||
* `values` are passed oldest → newest. v1.18.9 — qualitative readings carry no
|
||||
* number (`null`); they are filtered out here so a series with qualitative
|
||||
* entries plots only its numeric points and never a NaN.
|
||||
*/
|
||||
export function LabTrendSparkline({
|
||||
values,
|
||||
values: rawValues,
|
||||
width = 72,
|
||||
height = 20,
|
||||
}: {
|
||||
values: number[];
|
||||
values: (number | null)[];
|
||||
width?: number;
|
||||
height?: number;
|
||||
}) {
|
||||
const values = rawValues.filter((v): v is number => v !== null);
|
||||
if (values.length < 2) return null;
|
||||
|
||||
const min = Math.min(...values);
|
||||
|
||||
@@ -7,7 +7,10 @@ export interface LabResultDto {
|
||||
biomarkerId: string | null;
|
||||
panel: string | null;
|
||||
analyte: string;
|
||||
value: number;
|
||||
/** Numeric reading; null for a qualitative row (see `valueText`). v1.18.9. */
|
||||
value: number | null;
|
||||
/** v1.18.9 — qualitative result text ("negativ" / …); null for a numeric row. */
|
||||
valueText: string | null;
|
||||
unit: string;
|
||||
referenceLow: number | null;
|
||||
referenceHigh: number | null;
|
||||
|
||||
Reference in new issue
Block a user