diff --git a/src/app/api/insights/labs-changes/route.ts b/src/app/api/insights/labs-changes/route.ts index 3436b15b6..18099e912 100644 --- a/src/app/api/insights/labs-changes/route.ts +++ b/src/app/api/insights/labs-changes/route.ts @@ -48,6 +48,7 @@ export const GET = apiHandler(async () => { biomarkerId: true, unit: true, value: true, + sourceValue: true, referenceLow: true, referenceHigh: true, sourceReferenceLow: true, @@ -65,6 +66,7 @@ export const GET = apiHandler(async () => { biomarkerId: r.biomarkerId, unit: r.unit, value: r.value as number, + sourceValue: r.sourceValue, referenceLow: r.referenceLow, referenceHigh: r.referenceHigh, sourceReferenceLow: r.sourceReferenceLow, diff --git a/src/components/insights/__tests__/labs-changes-card.test.tsx b/src/components/insights/__tests__/labs-changes-card.test.tsx new file mode 100644 index 000000000..ac78f10e4 --- /dev/null +++ b/src/components/insights/__tests__/labs-changes-card.test.tsx @@ -0,0 +1,60 @@ +/** + * The card prints a lab figure the way the labs page does: in the reader's + * notation and with the digits the reading holds. It used to print + * `String(value)`, so a German reader saw "4.6" beside a labs page that says + * "4,6", and a converted reading showed every digit of the conversion. + */ +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +vi.mock("@tanstack/react-query", () => ({ + useQuery: () => ({ + data: { + present: true, + latestDate: "2026-06-01", + previousDate: "2026-03-01", + generatedAt: "2026-06-02T00:00:00.000Z", + changes: [ + { + analyte: "TSH", + unit: "mIU/L", + latest: 0.009, + previous: 0.004, + delta: 0.005, + direction: "up", + status: "unknown", + }, + { + analyte: "Potassium", + unit: "mmol/L", + latest: 4.6, + previous: 4.9, + delta: -0.3, + direction: "down", + status: "unknown", + }, + ], + }, + }), +})); + +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ isAuthenticated: true }), +})); + +import { I18nProvider } from "@/lib/i18n/context"; +import { LabsChangesCard } from "../labs-changes-card"; + +describe("", () => { + it("prints values and deltas in the reader's notation", () => { + const html = renderToStaticMarkup( + + + , + ); + expect(html).toContain("0,009 mIU/L"); + expect(html).toContain("+0,005 mIU/L"); + expect(html).toContain("4,6 mmol/L"); + expect(html).toContain("-0,3 mmol/L"); + }); +}); diff --git a/src/components/insights/labs-changes-card.tsx b/src/components/insights/labs-changes-card.tsx index 30a8e146a..f912b27c4 100644 --- a/src/components/insights/labs-changes-card.tsx +++ b/src/components/insights/labs-changes-card.tsx @@ -12,6 +12,7 @@ import { SectionHeading } from "@/components/ui/section-heading"; import type { LabChange } from "@/lib/insights/labs-changes"; import { InsightSectionCard } from "./insight-section-card"; import { withLabUnit } from "@/lib/labs/dimensionless"; +import { useLabNumber } from "@/components/labs/use-lab-format"; /** * v1.25 — "what changed since your last panel" card. @@ -55,6 +56,7 @@ export function LabsChangesCard({ }: LabsChangesCardProps) { const { t } = useTranslations(); const { isAuthenticated } = useAuth(); + const labNumber = useLabNumber(); const { data } = useQuery({ queryKey: queryKeys.insightsLabsChanges(), @@ -82,7 +84,10 @@ export function LabsChangesCard({ const signedDelta = c.direction === "flat" ? t("insights.labsChanges.directionFlat") - : withLabUnit(`${c.delta > 0 ? "+" : ""}${c.delta}`, c.unit); + : withLabUnit( + `${c.delta > 0 ? "+" : ""}${labNumber(c.delta)}`, + c.unit, + ); return (

- {withLabUnit(String(c.latest), c.unit)} + {withLabUnit(labNumber(c.latest), c.unit)} {labelKey ? ` · ${t(labelKey)}` : ""}

diff --git a/src/lib/insights/__tests__/labs-changes.test.ts b/src/lib/insights/__tests__/labs-changes.test.ts index f1f003b72..d5bf7744a 100644 --- a/src/lib/insights/__tests__/labs-changes.test.ts +++ b/src/lib/insights/__tests__/labs-changes.test.ts @@ -27,6 +27,7 @@ function row( sourceHigh?: number | null; sourceText?: string | null; biomarkerId?: string | null; + sourceValue?: number | null; } = {}, ): LabChangeRow { return { @@ -34,6 +35,7 @@ function row( biomarkerId: opts.biomarkerId ?? null, unit: opts.unit ?? "mg/dL", value, + sourceValue: opts.sourceValue ?? null, referenceLow: opts.low ?? null, referenceHigh: opts.high ?? null, sourceReferenceLow: opts.sourceLow ?? null, @@ -179,6 +181,53 @@ describe("summariseLabChanges", () => { expect(out.present).toBe(false); }); + it("calls a small change in a small value a change", () => { + // Two decimals for every analyte rounded this delta to 0: "no change" + // for a TSH that more than doubled. + const out = summariseLabChanges( + [ + row("TSH", 0.009, "2026-06-01", { unit: "mIU/L" }), + row("TSH", 0.004, "2026-03-01", { unit: "mIU/L" }), + ], + NOW, + ); + expect(out.changes).toEqual([ + expect.objectContaining({ delta: 0.005, direction: "up" }), + ]); + }); + + it("reads a converted value at the precision its report printed", () => { + // 104.83 nmol/L converted to ng/mL is 41.999198717948715. + const out = summariseLabChanges( + [ + row("Vitamin D", 41.999198717948715, "2026-06-01", { + unit: "ng/mL", + sourceValue: 104.83, + }), + row("Vitamin D", 30, "2026-03-01", { unit: "ng/mL" }), + ], + NOW, + ); + expect(out.changes).toEqual([ + expect.objectContaining({ + latest: 41.999, + previous: 30, + delta: 11.999, + direction: "up", + }), + ]); + }); + + it("still calls an unchanged reading flat", () => { + const out = summariseLabChanges( + [row("LDL", 130, "2026-06-01"), row("LDL", 130, "2026-03-01")], + NOW, + ); + expect(out.changes).toEqual([ + expect.objectContaining({ delta: 0, direction: "flat" }), + ]); + }); + it("reports no delta across two units", () => { const out = summariseLabChanges( [ diff --git a/src/lib/insights/labs-changes.ts b/src/lib/insights/labs-changes.ts index 455e02c6f..f3f732515 100644 --- a/src/lib/insights/labs-changes.ts +++ b/src/lib/insights/labs-changes.ts @@ -15,6 +15,7 @@ * the newest panel is older than `LAB_CHANGE_RECENCY_DAYS`. Neutral framing * only — a delta is not a diagnosis. */ +import { recordedPrecision } from "@/lib/format/recorded-number"; import { classifyAgainstEffectiveRange, resolveEffectiveReferenceRange, @@ -33,6 +34,11 @@ export interface LabChangeRow extends SourceReferenceRange { biomarkerId?: string | null; unit: string; value: number; + /** + * The figure the source report printed when `value` was converted from + * another unit; it sets the precision the converted value is read at. + */ + sourceValue?: number | null; referenceLow: number | null; referenceHigh: number | null; takenAt: Date; @@ -41,9 +47,13 @@ export interface LabChangeRow extends SourceReferenceRange { export interface LabChange { analyte: string; unit: string; + /** The readings at the precision they were recorded at (`recordedPrecision`). */ latest: number; previous: number; - /** Signed latest − previous, rounded to 2dp. */ + /** + * Signed latest − previous, at the finer of the two readings' precisions, + * so TSH 0.004 → 0.009 is +0.005 and not a rounded-away 0. + */ delta: number; direction: "up" | "down" | "flat"; status: ReferenceRangeStatus; @@ -108,8 +118,6 @@ function sameUnit(a: LabChangeRow, b: LabChangeRow): boolean { return a.unit.trim().toLowerCase() === b.unit.trim().toLowerCase(); } -const round2 = (n: number): number => Math.round(n * 100) / 100; - /** * Summarise the change between the two most-recent numeric lab panels. `rows` * should already be the user's live (non-deleted) numeric results; this helper @@ -161,14 +169,30 @@ export function summariseLabChanges( const previousRow = previous.get(key); // A delta across two units is no change, it is a conversion; skip it. if (!previousRow || !sameUnit(latestRow, previousRow)) continue; - const delta = round2(latestRow.value - previousRow.value); + // Each reading at the digits it was recorded with; the delta at the + // finer of the two, so it says what the printed figures say. + const latestReading = recordedPrecision( + latestRow.value, + latestRow.sourceValue, + ); + const previousReading = recordedPrecision( + previousRow.value, + previousRow.sourceValue, + ); + const digits = Math.max( + latestReading.fractionDigits, + previousReading.fractionDigits, + ); + const delta = Number( + (latestReading.value - previousReading.value).toFixed(digits), + ); const direction: LabChange["direction"] = delta > 0 ? "up" : delta < 0 ? "down" : "flat"; changes.push({ analyte: latestRow.analyte, unit: latestRow.unit, - latest: latestRow.value, - previous: previousRow.value, + latest: latestReading.value, + previous: previousReading.value, delta, direction, status: classifyAgainstEffectiveRange(