diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a037e06d..31dee42ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## [1.43.1] — 2026-10-16 + +### Fixed + +- **Lab values keep their precision** in the doctor report, the share view, + the day view, the scan review and the Labs page: a stored value keeps every + digit it has, a converted value keeps the precision of the printed one. + Reference ranges follow the same rule, and so do GLP-1 doses and the + measurement rows of the report (Refs #1228). +- **Every illness episode stays reachable.** An ended episode that belongs to + a condition now shows in both the card and the list view, flares nested two + levels deep included, and the illness page loads past the first fifty + episodes (Refs #1224). + ## [1.43.0] — 2026-10-16 Records that link to each other, supplement composition as a nutrient source, diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 72eefa31d..615d0b0e3 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.43.0 + version: 1.43.1 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. diff --git a/package.json b/package.json index 8be123aca..3d2c7e236 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.43.0", + "version": "1.43.1", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "PolyForm-Noncommercial-1.0.0", "homepage": "https://healthlog.dev", diff --git a/public/sw.js b/public/sw.js index 58a50872c..026aad604 100644 --- a/public/sw.js +++ b/public/sw.js @@ -36,7 +36,7 @@ try { // v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild. const CACHE_VERSION = (typeof self !== "undefined" && self.__APP_VERSION__) || - /* @sw-version-fallback */ "v1.43.0"; + /* @sw-version-fallback */ "v1.43.1"; const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`; const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`; // v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*` diff --git a/src/__tests__/share-view-leaf-render-guard.test.ts b/src/__tests__/share-view-leaf-render-guard.test.ts index 50b591b21..841f63ffa 100644 --- a/src/__tests__/share-view-leaf-render-guard.test.ts +++ b/src/__tests__/share-view-leaf-render-guard.test.ts @@ -208,6 +208,7 @@ const DISPOSITIONS: Record = { value: 42, valueText: null, unit: "ng/mL", + sourceValue: null, referenceLow: 30, referenceHigh: 400, catalogReferenceLow: 30, diff --git a/src/components/clinician/__tests__/clinician-view.test.tsx b/src/components/clinician/__tests__/clinician-view.test.tsx index 22fb9d4e0..d109b52b0 100644 --- a/src/components/clinician/__tests__/clinician-view.test.tsx +++ b/src/components/clinician/__tests__/clinician-view.test.tsx @@ -176,6 +176,7 @@ describe("", () => { value: 5.2, valueText: null, unit: "mmol/L", + sourceValue: null, referenceLow: 3.9, referenceHigh: 5.4, catalogReferenceLow: 3.5, @@ -194,6 +195,55 @@ describe("", () => { expect(html).toContain("saved range 3.5–5"); }); + it("prints a lab value and its range with every digit it was recorded with", () => { + // Two decimals at most read a PSA of 0.034 as "0.03" and a TSH window of + // 0.275 as "0.28": the share page dropped what the lab reported. + const html = render( + makeReport({ + labResults: [ + { + panel: null, + analyte: "PSA", + value: 0.034, + valueText: null, + unit: "ng/mL", + sourceValue: null, + referenceLow: 0.275, + referenceHigh: 4.2, + catalogReferenceLow: 0.275, + catalogReferenceHigh: 4.2, + sourceReferenceText: null, + referenceOrigin: "catalog", + referenceDivergesFromCatalog: false, + takenAt: "2026-01-20T09:00:00.000Z", + count: 1, + }, + ], + }), + ); + expect(html).toContain("0.034 ng/mL"); + expect(html).toContain("Reference 0.275–4.2"); + }); + + it("keeps the digits of a measurement read below one", () => { + const html = render( + makeReport({ + stats: { + WAIST_TO_HEIGHT: { + avg: 0.5, + min: 0.495, + max: 0.505, + count: 2, + latest: 0.505, + }, + }, + }), + { selection: selectionFromLeaves(["WAIST_TO_HEIGHT"]) }, + ); + expect(html).toContain("0.505"); + expect(html).toContain("0.495"); + }); + it("renders the fenced wellness card with the descriptive disclaimer", () => { const html = render(makeReport()); expect(html).toContain("Wellness scores"); @@ -241,8 +291,9 @@ describe("", () => { }, }), ); - expect(html).toContain("latest 79 kg (avg 80 kg, range 78–82 kg)"); - expect(html).toContain("latest 7.5 h (avg 7.5 h, range 7–8 h)"); + // One precision per row, the one the PDF prints the row with. + expect(html).toContain("latest 79.0 kg (avg 80.0 kg, range 78.0–82.0 kg)"); + expect(html).toContain("latest 7.5 h (avg 7.5 h, range 7.0–8.0 h)"); }); it("shows glucose in the record owner's unit, never raw mg/dL", () => { @@ -259,10 +310,10 @@ describe("", () => { ); // 99 mg/dL = 5.5 mmol/L, 108 = 6.0, 90 = 5.0, 126 = 7.0, 95 = 5.3. expect(html).toContain( - "latest 5.5 mmol/L (avg 6 mmol/L, range 5–7 mmol/L)", + "latest 5.5 mmol/L (avg 6.0 mmol/L, range 5.0–7.0 mmol/L)", ); expect(html).toContain( - "latest 5.3 mmol/L (avg 5.5 mmol/L, range 5–6 mmol/L)", + "latest 5.3 mmol/L (avg 5.5 mmol/L, range 5.0–6.0 mmol/L)", ); expect(html).not.toContain("mg/dL"); expect(html).not.toContain("latest 99"); diff --git a/src/components/clinician/clinician-view.tsx b/src/components/clinician/clinician-view.tsx index 52cf315ef..d7ab42b1f 100644 --- a/src/components/clinician/clinician-view.tsx +++ b/src/components/clinician/clinician-view.tsx @@ -30,6 +30,7 @@ * document list in `./documents-list`, the downloads in `./download-actions`. */ import type { DoctorReportData } from "@/lib/doctor-report-data"; +import { formatLabValue, type LabNumberFormat } from "@/lib/labs/format-value"; import { makeFormatters, type DateFormatPreference, @@ -152,6 +153,10 @@ export function ClinicianView({ // Two decimals at most, in the owner's locale: a German reader gets // "80,5", not the "80.5" a bare number prints. const fmtNum = (n: number) => fmt.number(Math.round(n * 100) / 100); + // A lab figure or a dose is what was recorded, not a statistic: it keeps + // every digit it holds, so a PSA of 0.034 does not read as 0.03. + const fmtRecorded: LabNumberFormat = (n, printed) => + formatLabValue(n, fmt.number, printed); const scope = makeLeafScope(selection, unavailableLeaves); return ( @@ -198,7 +203,7 @@ export function ClinicianView({ t={t} report={report} scope={scope} - fmtNum={fmtNum} + fmtDigits={fmt.number} /> {report.bmi !== null && report.bmi !== undefined && @@ -219,14 +224,14 @@ export function ClinicianView({ t={t} report={report} scope={scope} - fmtNum={fmtNum} + fmtDigits={fmt.number} /> {/* ── medications ──────────────────────────────────────── */} @@ -242,13 +247,14 @@ export function ClinicianView({ scope={scope} fmtDate={fmtDate} fmtNum={fmtNum} + fmtRecorded={fmtRecorded} /> {/* ── history ──────────────────────────────────────────── */} diff --git a/src/components/clinician/history-sections.tsx b/src/components/clinician/history-sections.tsx index e49ff28ab..308f74ea4 100644 --- a/src/components/clinician/history-sections.tsx +++ b/src/components/clinician/history-sections.tsx @@ -17,6 +17,7 @@ import { encounterKindLabelKey, lateralityLabelKey, } from "@/lib/encounters/kind-label"; +import type { LabNumberFormat } from "@/lib/labs/format-value"; import { formatReferenceRange } from "@/lib/labs/reference-range"; import type { DoctorReportData } from "@/lib/doctor-report-data"; import { @@ -47,13 +48,14 @@ export function LabResultsSection({ report, scope, fmtDate, - fmtNum, + fmtLab, }: { t: Translate; report: DoctorReportData; scope: LeafScope; fmtDate: (iso: string) => string; - fmtNum: (n: number) => string; + /** Lab figures keep the precision they were recorded at (`formatLabValue`). */ + fmtLab: LabNumberFormat; }) { const results = report.labResults ?? []; @@ -71,9 +73,13 @@ export function LabResultsSection({ const qualitative = lab.value === null; const reading = qualitative ? lab.valueText - : `${fmtNum(lab.value as number)} ${lab.unit}`.trim(); - const range = (low: number | null, high: number | null) => - formatReferenceRange(low, high, fmtNum, { + : `${fmtLab(lab.value as number, lab.sourceValue)} ${lab.unit}`.trim(); + const range = ( + low: number | null, + high: number | null, + printed?: number | null, + ) => + formatReferenceRange(low, high, (n) => fmtLab(n, printed), { emptyText: "", }); // The window the reading was judged against, printed as the source @@ -83,7 +89,11 @@ export function LabResultsSection({ ? null : lab.referenceOrigin === "source" && lab.sourceReferenceText ? lab.sourceReferenceText - : range(lab.referenceLow, lab.referenceHigh); + : range( + lab.referenceLow, + lab.referenceHigh, + lab.referenceOrigin === "source" ? lab.sourceValue : null, + ); // When the report's own window and the saved band disagree about the // same number, both are named. Showing one of two disagreeing windows // is a partial answer, and the PDF's footnote says so; here it fits on diff --git a/src/components/clinician/report-sections.tsx b/src/components/clinician/report-sections.tsx index 919013988..2868ba6d7 100644 --- a/src/components/clinician/report-sections.tsx +++ b/src/components/clinician/report-sections.tsx @@ -20,9 +20,11 @@ import { dateOnlyAtNoonUtc } from "@/lib/tz/date-only"; import type { MeasurementType } from "@/generated/prisma/client"; import type { DoctorReportData } from "@/lib/doctor-report-data"; import { + reportStatFractionDigits, reportStatUnit, reportStatValue, } from "@/lib/doctor-report/stat-display"; +import type { FractionDigitsFormat } from "@/lib/format/recorded-number"; import { convertGlucose, resolveGlucoseUnit } from "@/lib/glucose"; import { MEASUREMENT_TYPE_LABEL_KEYS } from "@/lib/measurements/type-label-keys"; import { @@ -203,12 +205,13 @@ export function MeasurementGroups({ t, report, scope, - fmtNum, + fmtDigits, }: { t: Translate; report: DoctorReportData; scope: LeafScope; - fmtNum: (n: number) => string; + /** A locale number with exactly the given decimals. */ + fmtDigits: FractionDigitsFormat; }) { const groups = REPORT_GROUPS.map((group) => ({ labelKey: group.labelKey, @@ -225,8 +228,9 @@ export function MeasurementGroups({ // mass, length and temperature, the owner's unit for glucose, hours for // sleep. A clinician must never read a figure without knowing its unit. const glucoseUnit = resolveGlucoseUnit(report.glucoseUnit ?? null); - const show = (type: string, value: number) => - fmtNum(reportStatValue(type, value, glucoseUnit)); + // ...and in the same precision: one per row, the one its readings carry. + const show = (type: string, value: number, digits: number) => + fmtDigits(reportStatValue(type, value, glucoseUnit), digits); return ( <> @@ -236,18 +240,21 @@ export function MeasurementGroups({ title={t(group.labelKey)} leaves={group.rows.map((row) => row.type)} > - {group.rows.map(({ type, stat }) => ( - - ))} + {group.rows.map(({ type, stat }) => { + const digits = reportStatFractionDigits(type, stat!, glucoseUnit); + return ( + + ); + })} ))} @@ -278,19 +285,22 @@ export function GlucoseSection({ t, report, scope, - fmtNum, + fmtDigits, }: { t: Translate; report: DoctorReportData; scope: LeafScope; - fmtNum: (n: number) => string; + /** A locale number with exactly the given decimals. */ + fmtDigits: FractionDigitsFormat; }) { const entries = Object.entries(report.glucoseStats).filter( ([, s]) => s.count > 0, ); // Stored in mg/dL; shown in the record owner's unit, as the PDF shows it. const glucoseUnit = resolveGlucoseUnit(report.glucoseUnit ?? null); - const show = (value: number) => fmtNum(convertGlucose(value, glucoseUnit)); + // The unit's own precision, the one the PDF's glucose rows print with. + const show = (value: number, digits: number) => + fmtDigits(convertGlucose(value, glucoseUnit), digits); return ( - {entries.map(([ctx, s]) => ( - - ))} + {entries.map(([ctx, s]) => { + const digits = reportStatFractionDigits( + "BLOOD_GLUCOSE", + s, + glucoseUnit, + ); + return ( + + ); + })} ); } diff --git a/src/components/clinician/therapy-sections.tsx b/src/components/clinician/therapy-sections.tsx index 0c250a44d..d467562c6 100644 --- a/src/components/clinician/therapy-sections.tsx +++ b/src/components/clinician/therapy-sections.tsx @@ -42,12 +42,15 @@ export function Glp1Section({ scope, fmtDate, fmtNum, + fmtRecorded, }: { t: Translate; report: DoctorReportData; scope: LeafScope; fmtDate: (iso: string) => string; fmtNum: (n: number) => string; + /** A dose keeps every digit it was recorded with (0.125 mg, not 0.13). */ + fmtRecorded: (n: number) => string; }) { const glp1 = report.glp1 ?? null; const medications = glp1?.medications ?? []; @@ -89,7 +92,7 @@ export function Glp1Section({ value={ med.currentDose ? t("doctorReport.glp1CurrentDose", { - value: fmtNum(med.currentDose.value), + value: fmtRecorded(med.currentDose.value), unit: med.currentDose.unit, since: fmtDate(med.currentDose.since), }) @@ -112,7 +115,7 @@ export function Glp1Section({ key={`${change.effectiveFrom}-${index}`} label={fmtDate(change.effectiveFrom)} value={ - `${fmtNum(change.value)} ${change.unit}` + + `${fmtRecorded(change.value)} ${change.unit}` + (change.note ? ` · ${change.note}` : "") } /> @@ -167,13 +170,14 @@ export function DoseLogSection({ report, scope, fmtDateTime, - fmtNum, + fmtRecorded, }: { t: Translate; report: DoctorReportData; scope: LeafScope; fmtDateTime: (iso: string) => string; - fmtNum: (n: number) => string; + /** A dose keeps every digit it was recorded with (0.125 mg, not 0.13). */ + fmtRecorded: (n: number) => string; }) { const administrations = report.medicationAdministrations ?? []; // Newest first: a clinician reads "when did they last take it", not "when @@ -212,7 +216,7 @@ export function DoseLogSection({ ? t("doctorReport.colTaken") : t("doctorReport.colSkipped")) + (dose.dose - ? ` · ${fmtNum(dose.dose.value)} ${dose.dose.unit}` + ? ` · ${fmtRecorded(dose.dose.value)} ${dose.dose.unit}` : dose.doseText ? ` · ${dose.doseText}` : "") + diff --git a/src/components/illness/__tests__/illness-view-nesting.test.tsx b/src/components/illness/__tests__/illness-view-nesting.test.tsx new file mode 100644 index 000000000..547a4a20d --- /dev/null +++ b/src/components/illness/__tests__/illness-view-nesting.test.tsx @@ -0,0 +1,174 @@ +/** + * Every episode in the journal stays reachable from the illness page. + * + * A recurring bout filed under its condition nests inside that condition's + * card. The list view drew no nested episodes at all, so an ended recurring + * bout of an ended condition (both in "Resolved") had no row anywhere and was + * found only through search; a bout nested under an already-nested parent was + * lost in either view. + */ +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import type { IllnessEpisodeDTO } from "../types"; + +const state = vi.hoisted(() => ({ + episodes: [] as unknown[], + view: "list" as "list" | "cards", +})); + +vi.mock("../use-illness", () => ({ + useIllnessEpisodes: () => ({ + data: state.episodes, + isLoading: false, + isError: false, + refetch: () => {}, + }), + useResolveEpisode: () => ({ isPending: false, mutate: () => {} }), +})); + +vi.mock("@/lib/module-list-prefs", () => ({ + useModuleListPrefs: () => ({ prefs: { view: state.view, order: [] } }), + applyOrder: (items: T[]) => items, +})); + +vi.mock("@/hooks/use-record-capabilities", () => ({ + useRecordCapabilities: () => ({ + canWriteDomain: () => true, + canManageDomain: () => false, + inSharedRecord: false, + }), +})); + +vi.mock("../illness-insights-card", () => ({ + IllnessInsightsCard: () => null, +})); +vi.mock("@/components/symptoms/symptoms-section", () => ({ + SymptomsSection: () => null, +})); +vi.mock("../new-episode-sheet", () => ({ NewEpisodeSheet: () => null })); +vi.mock("../log-day-sheet", () => ({ LogDaySheet: () => null })); +vi.mock("@/components/day/day-link", () => ({ + DAY_LINK_SLOT: "{date}", + DayLinkStated: ({ children }: { children: React.ReactNode }) => children, + withDayLinkSlot: (text: string, node: React.ReactNode) => [text, node], +})); + +import { I18nProvider } from "@/lib/i18n/context"; +import { groupEpisodes } from "../group-episodes"; +import { IllnessView } from "../illness-view"; + +function episode(over: Partial): IllnessEpisodeDTO { + return { + id: "e", + label: "Episode", + type: "CHRONIC", + lifecycle: "ACUTE", + onsetAt: "2026-10-08T12:00:00.000Z", + resolvedAt: null, + parentConditionId: null, + note: null, + bodySite: null, + laterality: null, + createdAt: "2026-10-08T12:00:00.000Z", + updatedAt: "2026-10-08T12:00:00.000Z", + ...over, + }; +} + +function render(): string { + return renderToStaticMarkup( + + + , + ); +} + +const condition = episode({ + id: "cond", + label: "Back pain", + lifecycle: "CHRONIC_ONGOING", +}); +const bout = episode({ + id: "bout", + label: "Back pain bout", + lifecycle: "RECURRING", + parentConditionId: "cond", +}); + +describe("illness page nesting", () => { + it.each(["list", "cards"] as const)( + "shows a recurring bout inside its condition in the %s view", + (view) => { + state.view = view; + state.episodes = [condition, bout]; + const html = render(); + expect(html).toContain("Back pain bout"); + expect(html).toContain('href="/illness/bout"'); + // A recurring bout is named as one, not counted as a flare. + expect(html).toContain("Recurring"); + expect(html).not.toContain("1 flare"); + }, + ); + + it("places every episode exactly once, ended recurring bouts included", () => { + const resolvedCondition = episode({ + id: "old", + label: "Prostatitis", + resolvedAt: "2023-05-04T12:00:00.000Z", + }); + const endedBout = episode({ + id: "ended", + label: "Ended bout", + lifecycle: "RECURRING", + parentConditionId: "old", + resolvedAt: "2026-10-14T12:00:00.000Z", + }); + const flareOfBout = episode({ + id: "flare", + label: "Flare of the bout", + lifecycle: "FLARE", + parentConditionId: "ended", + resolvedAt: "2026-10-12T12:00:00.000Z", + }); + const activeBoutOfEnded = episode({ + id: "again", + label: "Back again", + lifecycle: "RECURRING", + parentConditionId: "old", + }); + const loopA = episode({ id: "a", parentConditionId: "b" }); + const loopB = episode({ id: "b", parentConditionId: "a" }); + const list = [ + resolvedCondition, + endedBout, + flareOfBout, + activeBoutOfEnded, + loopA, + loopB, + ]; + + const grouped = groupEpisodes(list); + const placed = [ + ...grouped.activeParents, + ...grouped.resolvedParents, + ...[...grouped.childrenByParent.values()].flat(), + ].map((e) => e.id); + expect(placed.sort()).toEqual(list.map((e) => e.id).sort()); + + const shownCards = new Set( + [...grouped.activeParents, ...grouped.resolvedParents].map((e) => e.id), + ); + // Every nested episode hangs off a card that is itself shown. + for (const parentId of grouped.childrenByParent.keys()) { + expect(shownCards.has(parentId)).toBe(true); + } + // The ended bout and the flare under it both sit in the ended + // condition's card; the still-running bout is a card of its own. + expect(grouped.childrenByParent.get("old")?.map((e) => e.id)).toEqual([ + "ended", + "flare", + ]); + expect(grouped.activeParents.map((e) => e.id)).toContain("again"); + }); +}); diff --git a/src/components/illness/__tests__/use-illness-episodes-paging.test.ts b/src/components/illness/__tests__/use-illness-episodes-paging.test.ts new file mode 100644 index 000000000..ae403d1b5 --- /dev/null +++ b/src/components/illness/__tests__/use-illness-episodes-paging.test.ts @@ -0,0 +1,50 @@ +/** + * The journal reads every episode, not the list route's first page. + * + * The route pages at 50 by default; one unpaged call left the oldest episodes + * off the illness page, its settings and every picker once a record held more + * than that, and they could then be found only through search. + */ +import { describe, expect, it, vi } from "vitest"; + +const calls = vi.hoisted(() => [] as string[]); + +vi.mock("@tanstack/react-query", () => ({ + // The hook's options are what is under test; return them as they are. + useQuery: (options: unknown) => options, + useMutation: () => ({}), + useQueryClient: () => ({}), +})); + +vi.mock("@/lib/api/api-fetch", () => ({ + apiGet: async (url: string) => { + calls.push(url); + // The route's own paging: 50 unless asked otherwise, from offset 0. + const params = new URL(url, "http://x").searchParams; + const offset = Number(params.get("offset") ?? 0); + const limit = Number(params.get("limit") ?? 50); + const total = 230; + const count = Math.max(0, Math.min(limit, total - offset)); + return Array.from({ length: count }, (_, i) => ({ id: `e${offset + i}` })); + }, + apiPost: vi.fn(), + apiPatch: vi.fn(), + apiDelete: vi.fn(), +})); + +import { useIllnessEpisodes } from "../use-illness"; + +describe("useIllnessEpisodes", () => { + it("reads page after page until the whole journal is in", async () => { + const options = useIllnessEpisodes(true) as unknown as { + queryFn: () => Promise<{ id: string }[]>; + }; + const episodes = await options.queryFn(); + expect(episodes).toHaveLength(230); + expect(new Set(episodes.map((e) => e.id)).size).toBe(230); + expect(calls).toHaveLength(3); + expect(calls.every((url) => url.includes("includeResolved=true"))).toBe( + true, + ); + }); +}); diff --git a/src/components/illness/group-episodes.ts b/src/components/illness/group-episodes.ts new file mode 100644 index 000000000..4dbb5d97c --- /dev/null +++ b/src/components/illness/group-episodes.ts @@ -0,0 +1,62 @@ +import type { IllnessEpisodeDTO } from "./types"; + +export interface GroupedEpisodes { + activeParents: IllnessEpisodeDTO[]; + resolvedParents: IllnessEpisodeDTO[]; + /** Episodes shown inside a top-level card, keyed by that card's id. */ + childrenByParent: Map; +} + +const isActive = (e: IllnessEpisodeDTO) => e.resolvedAt === null; + +/** + * Split the journal into active and resolved, nesting each flare or recurring + * bout inside the card of the condition it belongs to. + * + * Every episode lands in exactly one place: as a card of its own, or inside a + * card that is itself shown. An episode nests under the highest ancestor that + * shares its status, so a bout of a bout still reaches a visible card; one + * whose parent sits in the other group (an active flare of a resolved + * condition, or an ended recurring bout of a still-running one) is a card of + * its own in its own group. Nesting only under the direct parent lost the + * grandchild of a nested parent, because a nested card does not render its + * own children. + */ +export function groupEpisodes(list: IllnessEpisodeDTO[]): GroupedEpisodes { + const byId = new Map(list.map((e) => [e.id, e])); + const activeParents: IllnessEpisodeDTO[] = []; + const resolvedParents: IllnessEpisodeDTO[] = []; + const childrenByParent = new Map(); + + const parentOf = (e: IllnessEpisodeDTO) => + e.parentConditionId ? byId.get(e.parentConditionId) : undefined; + + for (const e of list) { + let root = e; + const seen = new Set([e.id]); + let parent = parentOf(e); + while (parent !== undefined && isActive(parent) === isActive(e)) { + if (seen.has(parent.id)) { + // A parent chain that loops has no top; every member stays a card + // of its own rather than all of them nesting out of sight. + root = e; + break; + } + seen.add(parent.id); + root = parent; + parent = parentOf(parent); + } + + if (root !== e) { + const bucket = childrenByParent.get(root.id) ?? []; + bucket.push(e); + childrenByParent.set(root.id, bucket); + } else if (isActive(e)) { + activeParents.push(e); + } else { + resolvedParents.push(e); + } + } + + return { activeParents, resolvedParents, childrenByParent }; +} diff --git a/src/components/illness/illness-view.tsx b/src/components/illness/illness-view.tsx index 4133f5ecf..68f774bc1 100644 --- a/src/components/illness/illness-view.tsx +++ b/src/components/illness/illness-view.tsx @@ -47,6 +47,7 @@ import { NewEpisodeSheet } from "./new-episode-sheet"; import { EpisodeMenu } from "./episode-menu"; import { IllnessInsightsCard } from "./illness-insights-card"; import { SymptomsSection } from "@/components/symptoms/symptoms-section"; +import { groupEpisodes } from "./group-episodes"; import { useIllnessEpisodes, useResolveEpisode } from "./use-illness"; import type { IllnessEpisodeDTO } from "./types"; @@ -59,6 +60,51 @@ function todayLocal(): string { return `${y}-${m}-${d}`; } +/** + * The flares and recurring bouts filed under a condition, inside its card or + * list row. Each one names its course, since a recurring bout is not a flare, + * and an ended one says so; the count heading reads "flares" only when that is + * what they all are. + */ +function NestedEpisodes({ episodes }: { episodes?: IllnessEpisodeDTO[] }) { + const { t } = useTranslations(); + const fmt = useFormatters(); + const count = episodes?.length ?? 0; + if (!episodes || count === 0) return null; + const allFlares = episodes.every((e) => e.lifecycle === "FLARE"); + return ( +
+ {allFlares ? ( +

+ {count === 1 + ? t("illness.flares.countOne") + : t("illness.flares.countOther", { count })} +

+ ) : null} +
    + {episodes.map((child) => ( +
  • + + {child.label} + + {allFlares + ? fmt.dateShortSmart(new Date(child.onsetAt)) + : `${t(`illness.lifecycle.${child.lifecycle}`)} · ${fmt.dateShortSmart(new Date(child.onsetAt))}`} + {child.resolvedAt + ? `–${fmt.dateShortSmart(new Date(child.resolvedAt))}` + : ""} + + +
  • + ))} +
+
+ ); +} + interface EpisodeCardProps { episode: IllnessEpisodeDTO; /** Flares that hang off this episode (rendered nested), already filtered. */ @@ -85,7 +131,6 @@ function EpisodeCard({ const fmt = useFormatters(); const active = episode.resolvedAt === null; const isChronic = episode.lifecycle === "CHRONIC_ONGOING"; - const flareCount = flares?.length ?? 0; // v1.18.6 (MOD-03) — compact list row: a single divided line per episode // (label + status chips + onset; tap navigates to the detail surface). @@ -117,6 +162,9 @@ function EpisodeCard({ + {/* The list row carries the nested episodes too: a bout filed + under this condition has no row of its own. */} + {canManageIllness && (
@@ -202,30 +250,7 @@ function EpisodeCard({ ) : null}

- {flareCount > 0 ? ( -
-

- {flareCount === 1 - ? t("illness.flares.countOne") - : t("illness.flares.countOther", { count: flareCount })} -

-
    - {flares!.map((flare) => ( -
  • - - {flare.label} - - {fmt.dateShortSmart(new Date(flare.onsetAt))} - - -
  • - ))} -
-
- ) : null} + {/* Bottom-pinned single primary action — the daily-use "log a day" flow, promoted from a busy inline cluster. The card navigates to @@ -360,38 +385,13 @@ export function IllnessView() { const logEpisode = episodes?.find((e) => e.id === logEpisodeId) ?? null; /** - * Group the flat list into active / resolved, nesting flares under their - * parent condition. A flare nests only when its parent lives in the same - * status group (so an active flare under a resolved parent still surfaces as - * its own active card rather than vanishing); otherwise it renders as a - * standalone parent card in its own group. + * Group the flat list into active / resolved, nesting flares and recurring + * bouts inside the card of the condition they belong to (`groupEpisodes`). */ const grouped = useMemo(() => { - const list = episodes ?? []; - const byId = new Map(list.map((e) => [e.id, e])); - const isActive = (e: IllnessEpisodeDTO) => e.resolvedAt === null; - - const activeParents: IllnessEpisodeDTO[] = []; - const resolvedParents: IllnessEpisodeDTO[] = []; - const childrenByParent = new Map(); - - for (const e of list) { - const parent = e.parentConditionId - ? byId.get(e.parentConditionId) - : undefined; - const nestsUnderParent = - parent !== undefined && isActive(parent) === isActive(e); - - if (nestsUnderParent) { - const bucket = childrenByParent.get(parent.id) ?? []; - bucket.push(e); - childrenByParent.set(parent.id, bucket); - } else if (isActive(e)) { - activeParents.push(e); - } else { - resolvedParents.push(e); - } - } + const { activeParents, resolvedParents, childrenByParent } = groupEpisodes( + episodes ?? [], + ); return { // v1.18.6 (MOD-03) — honour the user's persisted manual order within diff --git a/src/components/illness/use-illness.ts b/src/components/illness/use-illness.ts index 71858888b..b4b69288a 100644 --- a/src/components/illness/use-illness.ts +++ b/src/components/illness/use-illness.ts @@ -29,15 +29,30 @@ import type { IllnessInsightsResponse, } from "./types"; -/** Newest-first episode list. */ +/** The list route's largest page. */ +const EPISODE_PAGE_SIZE = 100; + +/** + * Every episode, newest first. The list route pages (50 by default, 100 at + * most), and the journal, its settings and every picker read this as the + * whole record; a single unpaged call dropped the oldest episodes once there + * were more than one page of them, and a person then found an old condition + * only through search. So this reads page after page until a short one. + */ export function useIllnessEpisodes(includeResolved = true, enabled = true) { return useQuery({ queryKey: queryKeys.illnessEpisodes(includeResolved), enabled, - queryFn: () => - apiGet( - `/api/illness/episodes?includeResolved=${includeResolved}`, - ), + queryFn: async () => { + const all: IllnessEpisodeDTO[] = []; + for (let offset = 0; ; offset += EPISODE_PAGE_SIZE) { + const page = await apiGet( + `/api/illness/episodes?includeResolved=${includeResolved}&limit=${EPISODE_PAGE_SIZE}&offset=${offset}`, + ); + all.push(...page); + if (page.length < EPISODE_PAGE_SIZE) return all; + } + }, }); } diff --git a/src/components/labs/__tests__/lab-converted-note.test.tsx b/src/components/labs/__tests__/lab-converted-note.test.tsx index 2f0c79c49..af70a67d7 100644 --- a/src/components/labs/__tests__/lab-converted-note.test.tsx +++ b/src/components/labs/__tests__/lab-converted-note.test.tsx @@ -33,10 +33,10 @@ describe("", () => { ).toContain("umgerechnet aus 5,6 mmol/L"); }); - it("trims a long fraction the way the reading itself is trimmed", () => { + it("prints the figure the report printed, with every digit", () => { expect( render({ converted: true, sourceValue: 5.6123456, sourceUnit: "mmol/L" }), - ).toContain("converted from 5.61 mmol/L"); + ).toContain("converted from 5.6123456 mmol/L"); }); it.each([ diff --git a/src/components/labs/__tests__/lab-reference-range-bar.test.tsx b/src/components/labs/__tests__/lab-reference-range-bar.test.tsx index 6b53a51f8..cc5ef1f97 100644 --- a/src/components/labs/__tests__/lab-reference-range-bar.test.tsx +++ b/src/components/labs/__tests__/lab-reference-range-bar.test.tsx @@ -53,8 +53,8 @@ describe("", () => { "de", ); expect(html).toContain('aria-label="0,4–4,05 mIU/L"'); - // Lab precision: at most two decimals, then the locale's separator. - expect(html).toContain("1,83 mIU/L"); + // Lab precision: every recorded digit, in the locale's separator. + expect(html).toContain("1,8333 mIU/L"); expect(html).not.toMatch(/\d\.\d+ mIU\/L/); }); }); diff --git a/src/components/labs/__tests__/ocr-row-editor.test.tsx b/src/components/labs/__tests__/ocr-row-editor.test.tsx index 12f012634..971170ffb 100644 --- a/src/components/labs/__tests__/ocr-row-editor.test.tsx +++ b/src/components/labs/__tests__/ocr-row-editor.test.tsx @@ -175,7 +175,8 @@ describe(" unit differs but converts", () => { it("says what Save will store and what the report stated, not that it is blocked", () => { const html = render(undefined, "en", converts); expect(html).toContain('data-slot="ocr-row-converted-badge"'); - expect(html).toContain("Saved as 100.9 mg/dL, converted from 5.6 mmol/L"); + // The converted value keeps the precision of the printed 5.6. + expect(html).toContain("Saved as 101 mg/dL, converted from 5.6 mmol/L"); expect(html).not.toContain("Tracked in"); expect(html).not.toContain('aria-invalid="true"'); }); diff --git a/src/components/labs/lab-biomarker-chart.tsx b/src/components/labs/lab-biomarker-chart.tsx index 7a8828340..01cca67b8 100644 --- a/src/components/labs/lab-biomarker-chart.tsx +++ b/src/components/labs/lab-biomarker-chart.tsx @@ -75,6 +75,8 @@ interface ChartPoint { referenceHigh: number | null; referenceOrigin: LabResultDto["referenceOrigin"]; sourceReferenceText: string | null; + /** What the report printed, for a converted reading; sets its precision. */ + sourceValue: number | null; } /** The source window drawn as the second band, resolved from the readings in view. */ @@ -153,6 +155,7 @@ export function LabBiomarkerChart({ referenceHigh: r.referenceHigh, referenceOrigin: r.referenceOrigin, sourceReferenceText: r.sourceReferenceText, + sourceValue: r.sourceValue, })); }, [readings, range, nowMs]); @@ -221,7 +224,7 @@ export function LabBiomarkerChart({ const before = at > 0 ? ordered[at - 1] : undefined; return { label: readings[0]?.analyte ?? "", - value: labNumber(point.value), + value: labNumber(point.value, point.sourceValue), unit, compare: before && before.value !== null @@ -229,7 +232,8 @@ export function LabBiomarkerChart({ label: t("day.previousReading", { date: labShortDate(before.takenAt), }), - value: `${labNumber(before.value)} ${unit}`.trim(), + value: + `${labNumber(before.value, before.sourceValue)} ${unit}`.trim(), } : undefined, }; @@ -381,7 +385,7 @@ export function LabBiomarkerChart({ const rows: RichTooltipRow[] = [ { name: t("labs.chart.valueLabel"), - value: `${labNumber(point.value)} ${unit}`, + value: `${labNumber(point.value, point.sourceValue)} ${unit}`, color: primary, }, ]; diff --git a/src/components/labs/lab-converted-note.tsx b/src/components/labs/lab-converted-note.tsx index dfbdc9e85..a66041585 100644 --- a/src/components/labs/lab-converted-note.tsx +++ b/src/components/labs/lab-converted-note.tsx @@ -1,7 +1,6 @@ "use client"; import { useTranslations } from "@/lib/i18n/context"; -import { formatLabValue } from "@/lib/labs/format-value"; import { useLabNumber } from "./use-lab-format"; @@ -36,7 +35,7 @@ export function LabConvertedNote({ className={className ?? "text-muted-foreground text-xs"} > {t("labs.convertedFrom", { - value: formatLabValue(reading.sourceValue, labNumber), + value: labNumber(reading.sourceValue), unit: reading.sourceUnit, })} diff --git a/src/components/labs/lab-history-list.tsx b/src/components/labs/lab-history-list.tsx index 2d8f16c2e..f555f1154 100644 --- a/src/components/labs/lab-history-list.tsx +++ b/src/components/labs/lab-history-list.tsx @@ -102,7 +102,7 @@ export function LabHistoryList({ readings }: { readings: LabResultDto[] }) { value: reading.value === null ? (reading.valueText ?? "") - : labNumber(reading.value), + : labNumber(reading.value, reading.sourceValue), unit: reading.value === null ? undefined : reading.unit, compare: before ? { diff --git a/src/components/labs/ocr-row-editor.tsx b/src/components/labs/ocr-row-editor.tsx index 8219eb601..d140e861d 100644 --- a/src/components/labs/ocr-row-editor.tsx +++ b/src/components/labs/ocr-row-editor.tsx @@ -206,7 +206,10 @@ export function OcrRowEditor({ {t("labs.ocr.unitConvertedBadge", { - value: labNumber(conversion.value), + value: labNumber( + conversion.value, + conversion.statedValue, + ), markerUnit: conversion.unit, statedValue: labNumber(conversion.statedValue), unit: conversion.statedUnit, diff --git a/src/components/labs/use-lab-format.ts b/src/components/labs/use-lab-format.ts index ef7820fc2..78edcaa0e 100644 --- a/src/components/labs/use-lab-format.ts +++ b/src/components/labs/use-lab-format.ts @@ -8,13 +8,15 @@ import { formatLabValue, type LabNumberFormat } from "@/lib/labs/format-value"; import { isNoonUtcAnchor } from "@/lib/tz/date-only"; /** - * A lab value in the reader's number format, trimmed the way every lab - * surface trims it (whole numbers bare, at most two decimals). + * A lab value in the reader's number format, with every digit the reading + * holds (see `formatLabValue`). Pass the reading's `sourceValue` as `printed` + * for a converted reading so it keeps the precision its report printed. */ export function useLabNumber(): LabNumberFormat { const fmt = useFormatters(); return useCallback( - (value: number) => formatLabValue(value, (n) => fmt.number(n)), + (value: number, printed?: number | null) => + formatLabValue(value, fmt.number, printed), [fmt], ); } diff --git a/src/lib/__tests__/doctor-report-pdf-core.test.ts b/src/lib/__tests__/doctor-report-pdf-core.test.ts index 031ed6de3..d8c9c2f8d 100644 --- a/src/lib/__tests__/doctor-report-pdf-core.test.ts +++ b/src/lib/__tests__/doctor-report-pdf-core.test.ts @@ -635,6 +635,106 @@ describe("doctor-report clinical glucose panel", () => { // v1.18.1 P4 — illness / condition episodes section. Retrospective + factual: // label, type, course, onset, resolved. Present only when the aggregator // populated `illnessEpisodes` (module on + episode in window). +describe("doctor-report recorded precision", () => { + function lab( + over: Partial[number]>, + ): NonNullable[number] { + return { + panel: null, + analyte: "PSA", + value: 0.034, + valueText: null, + unit: "ng/mL", + sourceValue: null, + referenceLow: null, + referenceHigh: 4, + catalogReferenceLow: null, + catalogReferenceHigh: 4, + sourceReferenceText: null, + referenceOrigin: "catalog", + referenceDivergesFromCatalog: false, + takenAt: "2026-04-20T08:00:00.000Z", + count: 1, + ...over, + }; + } + + async function render(data: DoctorReportData): Promise { + const bytes = renderDoctorReportPdfBytes(data, { + timeFormat: "AUTO", + dateFormat: "AUTO", + t: getServerTranslator("en").t, + locale: "en", + now: FIXED_NOW, + }); + return extractText(bytes); + } + + it("prints a lab value and its range with the digits they were recorded with", async () => { + const text = await render( + makeData({ + labResults: [ + lab({}), + lab({ + analyte: "TSH", + value: 2.345, + unit: "mIU/L", + referenceLow: 0.27, + referenceHigh: 4.2, + catalogReferenceLow: 0.27, + catalogReferenceHigh: 4.2, + }), + ], + }), + ); + // A one-decimal report printed these as "0.0 ng/mL" and "0.3–4.2". + expect(text).toContain("0.034 ng/mL"); + expect(text).toContain("2.345 mIU/L"); + expect(text).toContain("0.27–4.2"); + }); + + it("prints a converted lab value with the precision its report printed", async () => { + const text = await render( + makeData({ + labResults: [ + lab({ + analyte: "Cholesterol", + value: 5.2 * 38.67, + unit: "mg/dL", + sourceValue: 5.2, + referenceLow: null, + referenceHigh: 5 * 38.67, + catalogReferenceHigh: null, + referenceOrigin: "source", + }), + ], + }), + ); + expect(text).toContain("201 mg/dL"); + // The PDF spells "≤" as "<=" (WinAnsi). + expect(text).toContain("<= 193"); + }); + + it("keeps the digits of a measurement read below one", async () => { + const text = await render( + makeData({ + stats: { + WAIST_TO_HEIGHT: { + avg: 0.525, + min: 0.52, + max: 0.53, + count: 2, + latest: 0.53, + }, + }, + }), + ); + // The fixed single decimal printed every one of these as "0.5". + expect(text).toContain("0.53"); + expect(text).toContain("0.52"); + }); +}); + describe("doctor-report illness section", () => { it("prints the conditions table with label, type, and dates", async () => { const data = makeData({ diff --git a/src/lib/day/records.ts b/src/lib/day/records.ts index e065d9d97..4386287ee 100644 --- a/src/lib/day/records.ts +++ b/src/lib/day/records.ts @@ -29,6 +29,7 @@ import type { DaySectionKey, } from "@/lib/day/contract"; import { prisma } from "@/lib/db"; +import { formatRecordedNumber } from "@/lib/format/recorded-number"; import { SCREENER_INSTRUMENTS } from "@/lib/validations/mental-health"; import { listTargetsBySource } from "@/lib/links/link-service"; import { getEvent } from "@/lib/logging/context"; @@ -686,6 +687,7 @@ async function labsPart(frame: DayFrame): Promise { value: true, valueText: true, unit: true, + sourceValue: true, takenAt: true, biomarkerId: true, noteEncrypted: true, @@ -703,7 +705,9 @@ async function labsPart(frame: DayFrame): Promise { title: row.analyte, meta: row.value !== null - ? `${row.value} ${row.unit}`.trim() + ? // Every recorded digit, without the float noise a converted + // reading carries (the labs page prints the same figure). + `${formatRecordedNumber(row.value, (v, digits) => v.toFixed(digits), row.sourceValue)} ${row.unit}`.trim() : (row.valueText ?? null), note: openText(row.noteEncrypted, "lab note"), href: row.biomarkerId ? `/labs/${row.biomarkerId}` : "/labs", diff --git a/src/lib/doctor-report-pdf/clinical-records-notes-section.ts b/src/lib/doctor-report-pdf/clinical-records-notes-section.ts index eb8488a10..39ad2f552 100644 --- a/src/lib/doctor-report-pdf/clinical-records-notes-section.ts +++ b/src/lib/doctor-report-pdf/clinical-records-notes-section.ts @@ -10,6 +10,7 @@ import { classifyReferenceRange, formatReferenceRange, } from "../labs/reference-range"; +import { formatLabValue } from "../labs/format-value"; import { pdfCursorState, type DoctorReportPdfCursorState, @@ -46,8 +47,19 @@ export function buildClinicalRecordsNotesSection( doc.text(t("doctorReport.labsTitle"), margin, y); y += 6; - const rangeText = (low: number | null, high: number | null): string => - formatReferenceRange(low, high, num, { emptyText: "—" }); + // Lab figures print with the precision they were recorded at, never the + // report's one-decimal default: a PSA of 0.034 is not "0.0". A window + // converted with its reading keeps the precision of what was printed. + const labNum = (value: number, printed?: number | null): string => + formatLabValue(value, num, printed); + const rangeText = ( + low: number | null, + high: number | null, + printed?: number | null, + ): string => + formatReferenceRange(low, high, (n) => labNum(n, printed), { + emptyText: "—", + }); // Quiet, non-colour status glyph. In-range reads as a neutral dash so // the table is not a field of warning marks; out-of-range reads as a // direction arrow the clinician can scan, with no alarm tint. @@ -77,7 +89,11 @@ export function buildClinicalRecordsNotesSection( const printed = lr.referenceOrigin === "source" && lr.sourceReferenceText ? lr.sourceReferenceText - : rangeText(lr.referenceLow, lr.referenceHigh); + : rangeText( + lr.referenceLow, + lr.referenceHigh, + lr.referenceOrigin === "source" ? lr.sourceValue : null, + ); return lr.referenceDivergesFromCatalog ? `${printed} *` : printed; }; @@ -93,7 +109,7 @@ export function buildClinicalRecordsNotesSection( lr.panel ? `${lr.analyte} (${lr.panel})` : lr.analyte, isQualitative ? (lr.valueText ?? "") - : `${num(lr.value as number)} ${lr.unit}`.trim(), + : `${labNum(lr.value as number, lr.sourceValue)} ${lr.unit}`.trim(), isQualitative ? "—" : referenceCell(lr), isQualitative ? "" diff --git a/src/lib/doctor-report-pdf/measurements-charts-section.ts b/src/lib/doctor-report-pdf/measurements-charts-section.ts index 797c56150..672857029 100644 --- a/src/lib/doctor-report-pdf/measurements-charts-section.ts +++ b/src/lib/doctor-report-pdf/measurements-charts-section.ts @@ -6,7 +6,10 @@ import { glucoseBandParams, resolveGlucoseUnit, } from "../glucose"; -import { reportStatValue } from "../doctor-report/stat-display"; +import { + reportStatFractionDigits, + reportStatValue, +} from "../doctor-report/stat-display"; import type { MeasurementType } from "@/generated/prisma/client"; import { MEASUREMENT_TYPE_LABEL_KEYS } from "../measurements/type-label-keys"; import { @@ -223,13 +226,14 @@ export function buildMeasurementsChartsSection( for (const ctx of GLUCOSE_CONTEXTS) { const s = data.glucoseStats?.[ctx]; if (!s) continue; - const conv = (v: number) => convertGlucose(v, glucoseUnit); + const digits = reportStatFractionDigits("BLOOD_GLUCOSE", s, glucoseUnit); + const show = (v: number) => num(convertGlucose(v, glucoseUnit), digits); glucoseContextRows.push([ t(GLUCOSE_LABEL_KEYS[ctx]), - `${num(conv(s.latest))} ${glucoseUnit}`.trim(), - `${num(conv(s.avg))} ${glucoseUnit}`.trim(), - num(conv(s.min)), - num(conv(s.max)), + `${show(s.latest)} ${glucoseUnit}`.trim(), + `${show(s.avg)} ${glucoseUnit}`.trim(), + show(s.min), + show(s.max), String(s.count), ]); } @@ -242,13 +246,17 @@ export function buildMeasurementsChartsSection( const unit = unitFor(type); // Sleep minutes print as hours and glucose in the owner's unit; the // clinician view reads the same helper, so the two cannot drift. - const conv = (v: number) => reportStatValue(type, v, glucoseUnit); + // One precision per row, the one the readings carry (see + // `reportStatFractionDigits`); the share page prints the same. + const digits = reportStatFractionDigits(type, s, glucoseUnit); + const show = (v: number) => + num(reportStatValue(type, v, glucoseUnit), digits); groupRows.push([ t(MEASUREMENT_TYPE_LABEL_KEYS[type as MeasurementType] ?? ""), - `${num(conv(s.latest))} ${unit}`.trim(), - `${num(conv(s.avg))} ${unit}`.trim(), - num(conv(s.min)), - num(conv(s.max)), + `${show(s.latest)} ${unit}`.trim(), + `${show(s.avg)} ${unit}`.trim(), + show(s.min), + show(s.max), String(s.count), ]); } diff --git a/src/lib/doctor-report-pdf/medication-mood-wellness-section.ts b/src/lib/doctor-report-pdf/medication-mood-wellness-section.ts index b3e53dc5f..2aa8715f0 100644 --- a/src/lib/doctor-report-pdf/medication-mood-wellness-section.ts +++ b/src/lib/doctor-report-pdf/medication-mood-wellness-section.ts @@ -2,6 +2,7 @@ import type { jsPDF } from "jspdf"; import { dateOnlyAtNoonUtc } from "@/lib/tz/date-only"; import autoTable from "jspdf-autotable"; import { adherenceRatePercent } from "../doctor-report-data"; +import { formatRecordedNumber } from "../format/recorded-number"; import { pdfCursorState, type DoctorReportPdfCursorState, @@ -184,7 +185,7 @@ export function buildMedicationMoodWellnessSection( if (med.currentDose) { doc.text( t("doctorReport.glp1CurrentDose", { - value: num(med.currentDose.value, 2), + value: formatRecordedNumber(med.currentDose.value, num), unit: med.currentDose.unit, since: fmtDate(med.currentDose.since), }), @@ -212,7 +213,7 @@ export function buildMedicationMoodWellnessSection( if (med.doseHistory.length > 0) { const historyRows = med.doseHistory.map((dc) => [ fmtDate(dc.effectiveFrom), - `${num(dc.value, 2)} ${dc.unit}`, + `${formatRecordedNumber(dc.value, num)} ${dc.unit}`, dc.note ?? "", ]); // A titration history reads as one block — break before it if the diff --git a/src/lib/doctor-report-types.ts b/src/lib/doctor-report-types.ts index f718d9c55..de8a01b22 100644 --- a/src/lib/doctor-report-types.ts +++ b/src/lib/doctor-report-types.ts @@ -283,6 +283,12 @@ export interface DoctorReportData { */ valueText: string | null; unit: string; + /** + * The figure the source report printed when `value` was converted into + * the marker's unit; null for a reading stored as stated. It sets the + * precision the converted value is printed with (`formatLabValue`). + */ + sourceValue: number | null; /** * The reference window this reading is judged against: the range printed * on its own report when it carries one, the catalog band otherwise. diff --git a/src/lib/doctor-report/clinical-records.ts b/src/lib/doctor-report/clinical-records.ts index 05f408e7d..e212f2225 100644 --- a/src/lib/doctor-report/clinical-records.ts +++ b/src/lib/doctor-report/clinical-records.ts @@ -60,6 +60,7 @@ export async function loadLabResults( sourceReferenceHigh: true, sourceReferenceText: true, sourceUnit: true, + sourceValue: true, takenAt: true, biomarkerId: true, biomarker: { select: { name: true, panel: true } }, @@ -93,6 +94,7 @@ export async function loadLabResults( value: r.value, valueText: r.valueText, unit: r.unit, + sourceValue: r.sourceValue, referenceLow: range.low, referenceHigh: range.high, catalogReferenceLow: range.catalogLow, diff --git a/src/lib/doctor-report/stat-display.ts b/src/lib/doctor-report/stat-display.ts index a2ede71f9..10b4762b9 100644 --- a/src/lib/doctor-report/stat-display.ts +++ b/src/lib/doctor-report/stat-display.ts @@ -18,6 +18,8 @@ * it. */ import { convertGlucose, type GlucoseUnit } from "@/lib/glucose"; +import { recordedPrecision } from "@/lib/format/recorded-number"; +import { getReadingTransform } from "@/lib/measurements/display-transform"; import { getUnitForType } from "@/lib/validations/measurement"; /** @@ -77,3 +79,49 @@ export function reportStatValue( if (type === "BLOOD_GLUCOSE") return convertGlucose(value, glucoseUnit); return value; } + +/** Significant digits a statistic keeps before it is cut to decimals. */ +const STAT_SIGNIFICANT_DIGITS = 3; + +/** Decimals a statistic row never goes below, and never above. */ +const STAT_MIN_FRACTION_DIGITS = 1; +const STAT_MAX_FRACTION_DIGITS = 3; + +/** + * Decimals for one statistic row of `type` (latest, average and range alike, + * so a row reads in one precision), for the number {@link reportStatValue} + * returns. + * + * A fixed single decimal printed a waist-to-height ratio of 0.52 as "0.5" and + * a temperature deviation of 0.25 as "0.3": a measurement below or near 1 + * lost the digits that carry it. Each reading now keeps up to + * {@link STAT_SIGNIFICANT_DIGITS} significant digits, but never more decimals + * than it was recorded with, so a weight of 79.5 stays "79.5" and a device + * figure of 45.123456 ms reads "45.1" rather than six decimals of sensor + * noise. Glucose follows its unit's own precision (whole mg/dL, one decimal + * mmol/L) and sleep, a quotient of minutes, keeps one decimal of hours. + */ +export function reportStatFractionDigits( + type: string, + stat: { latest: number; min: number; max: number }, + glucoseUnit: GlucoseUnit, +): number { + if (type === "BLOOD_GLUCOSE") { + // The report stays SI for everything but glucose, so only its unit counts. + return getReadingTransform(type, { system: "metric", glucoseUnit }) + .decimals; + } + if (type === "SLEEP_DURATION") return 1; + const needed = [stat.latest, stat.min, stat.max].map((raw) => { + const value = reportStatValue(type, raw, glucoseUnit); + if (value === 0 || !Number.isFinite(value)) return 0; + const recorded = recordedPrecision(value).fractionDigits; + const magnitude = Math.floor(Math.log10(Math.abs(value))); + const significant = Math.max(0, STAT_SIGNIFICANT_DIGITS - 1 - magnitude); + return Math.min(recorded, significant); + }); + return Math.min( + STAT_MAX_FRACTION_DIGITS, + Math.max(STAT_MIN_FRACTION_DIGITS, ...needed), + ); +} diff --git a/src/lib/fhir/__tests__/lab-observations.test.ts b/src/lib/fhir/__tests__/lab-observations.test.ts index 049958c06..274f0798b 100644 --- a/src/lib/fhir/__tests__/lab-observations.test.ts +++ b/src/lib/fhir/__tests__/lab-observations.test.ts @@ -52,6 +52,7 @@ describe("lab-result FHIR Observations", () => { value: 110, valueText: null, unit: "mg/dL", + sourceValue: null, referenceLow: null, referenceHigh: 116, catalogReferenceLow: null, @@ -68,6 +69,7 @@ describe("lab-result FHIR Observations", () => { value: 5.4, valueText: null, unit: "%", + sourceValue: null, referenceLow: 4, referenceHigh: 5.6, catalogReferenceLow: null, @@ -145,6 +147,7 @@ describe("lab-result FHIR Observations", () => { value: 2.1, valueText: null, unit: "mg/L", + sourceValue: null, referenceLow: null, referenceHigh: null, catalogReferenceLow: null, @@ -178,6 +181,7 @@ describe("lab-result FHIR Observations", () => { value: 100.9, valueText: null, unit: "mg/dL", + sourceValue: null, referenceLow: 70.27, referenceHigh: 99.1, catalogReferenceLow: 70, @@ -237,6 +241,7 @@ describe("lab-result LOINC + canonical UCUM coding (v1.18.8)", () => { value, valueText: null, unit, + sourceValue: null, referenceLow: bounds?.low ?? null, referenceHigh: bounds?.high ?? null, catalogReferenceLow: null, @@ -345,6 +350,7 @@ describe("qualitative lab-result FHIR Observations (v1.18.9)", () => { value: null, valueText, unit: "", + sourceValue: null, referenceLow: null, referenceHigh: null, catalogReferenceLow: null, diff --git a/src/lib/format/recorded-number.ts b/src/lib/format/recorded-number.ts new file mode 100644 index 000000000..6c4d10ef8 --- /dev/null +++ b/src/lib/format/recorded-number.ts @@ -0,0 +1,101 @@ +/** + * How many digits a number somebody recorded is printed with. + * + * A lab value, a dose or a reference bound is a figure a person typed or a + * report printed, and its digits are the precision it was measured at. A + * PSA of 0.034 ng/mL is not "0.03", and a dose of 0.125 mg is not "0.13". + * Every surface that prints such a figure used to pick its own fixed number + * of decimals (one on the PDF, two on the share page, "two above 1, three + * significant below" on the labs page), so the same reading read differently + * in each place and some of them dropped digits that carried meaning. + * + * The rule here keeps every digit the number holds: + * + * - A stored value is printed exactly as stored. Nothing records how many + * trailing zeros were typed, so "4.20" prints as "4.2"; every digit that is + * there survives. The value is first read back at + * {@link STORED_SIGNIFICANT_DIGITS} significant digits, which removes + * binary floating-point noise ("0.30000000000000004") and nothing a person + * could have entered. + * - A value converted from another unit carries no precision of its own, its + * digits are the product of a conversion factor. When the figure the report + * printed is known (`printed`), the converted value keeps as many + * significant digits as that figure had, never fewer than + * {@link CONVERTED_MIN_SIGNIFICANT_DIGITS} and never fewer than its own + * integer digits, so 5.2 mmol/L reads as 201 mg/dL rather than 201.0844. + * + * The caller formats the result in its own locale with exactly + * `fractionDigits` decimals, so the separator follows the reader. + */ + +/** Significant digits a stored value is read back at. */ +const STORED_SIGNIFICANT_DIGITS = 12; + +/** Floor for a converted value's significant digits. */ +const CONVERTED_MIN_SIGNIFICANT_DIGITS = 3; + +/** `Intl.NumberFormat` accepts at most this many fraction digits everywhere. */ +const MAX_FRACTION_DIGITS = 20; + +/** A locale number formatter that prints exactly `fractionDigits` decimals. */ +export type FractionDigitsFormat = ( + value: number, + fractionDigits: number, +) => string; + +/** Significant digits and decimal exponent of a finite, nonzero number. */ +function shape(n: number): { digits: number; exponent: number } { + // Without an argument `toExponential` prints as many digits as it takes to + // identify the number uniquely: the shortest round-trip spelling. + const [mantissa, exponent] = Math.abs(n).toExponential().split("e"); + return { + digits: mantissa.replace(".", "").length, + exponent: Number(exponent), + }; +} + +/** + * The value to print and how many decimals it carries. `printed` is the + * figure the source printed when `value` was converted from it, else absent. + */ +export function recordedPrecision( + value: number, + printed?: number | null, +): { value: number; fractionDigits: number } { + if (!Number.isFinite(value) || value === 0) { + return { value, fractionDigits: 0 }; + } + let significant = STORED_SIGNIFICANT_DIGITS; + if ( + printed !== null && + printed !== undefined && + Number.isFinite(printed) && + printed !== 0 + ) { + const integerDigits = Math.max(1, shape(value).exponent + 1); + significant = Math.max( + shape(printed).digits, + CONVERTED_MIN_SIGNIFICANT_DIGITS, + integerDigits, + ); + } + const rounded = Number(value.toPrecision(Math.min(significant, 100))); + const { digits, exponent } = shape(rounded); + return { + value: rounded, + fractionDigits: Math.min( + MAX_FRACTION_DIGITS, + Math.max(0, digits - 1 - exponent), + ), + }; +} + +/** A recorded number in the caller's locale, with every digit it holds. */ +export function formatRecordedNumber( + value: number, + formatNumber: FractionDigitsFormat, + printed?: number | null, +): string { + const { value: rounded, fractionDigits } = recordedPrecision(value, printed); + return formatNumber(rounded, fractionDigits); +} diff --git a/src/lib/labs/__tests__/format-value.test.ts b/src/lib/labs/__tests__/format-value.test.ts index 85ad43216..5ae63a988 100644 --- a/src/lib/labs/__tests__/format-value.test.ts +++ b/src/lib/labs/__tests__/format-value.test.ts @@ -3,30 +3,65 @@ import { describe, expect, it } from "vitest"; import { makeFormatters } from "@/lib/format-locale"; import { formatLabReading, formatLabValue } from "../format-value"; -const de = (n: number) => makeFormatters("de", "UTC", "AUTO", "AUTO").number(n); -const en = (n: number) => makeFormatters("en", "UTC", "AUTO", "AUTO").number(n); +const de = makeFormatters("de", "UTC", "AUTO", "AUTO").number; +const en = makeFormatters("en", "UTC", "AUTO", "AUTO").number; +const labNumber = (value: number, printed?: number | null) => + formatLabValue(value, de, printed); describe("lab value formatting", () => { - it("trims to two decimals and prints the locale's separator", () => { + // These readings used to be trimmed to two decimals above 1 and three + // significant digits below, so 12.3456 read "12.35" and 0.123456 "0.123": + // digits the lab reported were dropped. A reading now keeps them all. + it("keeps every recorded digit and prints the locale's separator", () => { expect(formatLabValue(1.8, de)).toBe("1,8"); expect(formatLabValue(1.8, en)).toBe("1.8"); - expect(formatLabValue(0.123456, de)).toBe("0,123"); - expect(formatLabValue(12.3456, de)).toBe("12,35"); + expect(formatLabValue(0.123456, de)).toBe("0,123456"); + expect(formatLabValue(12.3456, de)).toBe("12,3456"); expect(formatLabValue(5, de)).toBe("5"); }); - it("never shows a small nonzero reading as zero", () => { + it("never shows a small nonzero reading as zero or shortened", () => { expect(formatLabValue(0.004, de)).toBe("0,004"); expect(formatLabValue(0.012, en)).toBe("0.012"); expect(formatLabValue(0.035, de)).toBe("0,035"); + expect(formatLabValue(0.034, en)).toBe("0.034"); + expect(formatLabValue(0.0012, en)).toBe("0.0012"); + }); + + it("drops binary floating-point noise, never a recorded digit", () => { + expect(formatLabValue(0.1 + 0.2, en)).toBe("0.3"); + expect(formatLabValue(1234.5678, en)).toBe("1,234.5678"); + }); + + it("prints a converted reading with the precision its report printed", () => { + // 5.2 mmol/L of cholesterol converted to mg/dL. + expect(formatLabValue(5.2 * 38.67, en, 5.2)).toBe("201"); + // A four-digit report figure keeps four significant digits. + expect(formatLabValue(5.234 * 38.67, en, 5.234)).toBe("202.4"); + // Never fewer digits than the integer part. + expect(formatLabValue(12.5 * 88.4, en, 12.5)).toBe("1,105"); }); it("formats a numeric reading with its unit and leaves a qualitative one verbatim", () => { expect( - formatLabReading({ value: 61.3, valueText: null, unit: "kg" }, de), + formatLabReading({ value: 61.3, valueText: null, unit: "kg" }, labNumber), ).toBe("61,3 kg"); expect( - formatLabReading({ value: null, valueText: "negativ", unit: "" }, de), + formatLabReading( + { value: null, valueText: "negativ", unit: "" }, + labNumber, + ), ).toBe("negativ"); + expect( + formatLabReading( + { + value: 5.2 * 38.67, + valueText: null, + unit: "mg/dL", + sourceValue: 5.2, + }, + labNumber, + ), + ).toBe("201 mg/dL"); }); }); diff --git a/src/lib/labs/format-value.ts b/src/lib/labs/format-value.ts index 3626d7e4e..cc4baf4ae 100644 --- a/src/lib/labs/format-value.ts +++ b/src/lib/labs/format-value.ts @@ -1,46 +1,57 @@ /** - * v1.18.1 — shared lab-value number formatting. + * Shared lab-value number formatting. * - * The list card, the detail history rows, and the chart tooltip all trim a - * lab reading the same way: whole numbers render bare, fractional values keep - * up to two decimals (no trailing-zero noise), and a value below 1 keeps three - * significant digits so a small reading (TSH 0.004, troponin 0.012) never - * reads as zero. Centralised so the three surfaces stay byte-identical. + * Every surface that prints a lab reading (the list card, the detail history, + * the chart tooltip, the doctor report PDF, the clinician share page) prints + * it through here, so a reading reads the same everywhere. The precision rule + * is the recorded-number rule in `src/lib/format/recorded-number.ts`: a + * reading keeps every digit it was stored with (TSH 0.004, PSA 0.034, a + * troponin of 0.0012 never shortened), and a reading converted from the unit + * its report printed keeps the precision of the printed figure. * - * The digits themselves come from the caller's locale number formatter - * (`useFormatters().number`), so a German reader sees "1,8 mIU/L" where an - * English one sees "1.8 mIU/L". This module only decides the precision. + * The digits themselves come from the caller's locale number formatter, so a + * German reader sees "1,8 mIU/L" where an English one sees "1.8 mIU/L". This + * module only decides the precision. */ -export type LabNumberFormat = (value: number) => string; +import { + formatRecordedNumber, + type FractionDigitsFormat, +} from "@/lib/format/recorded-number"; + +/** + * A lab number in the reader's locale. `printed` is the figure the source + * report printed when the number was converted from it (`sourceValue`). + */ +export type LabNumberFormat = ( + value: number, + printed?: number | null, +) => string; export function formatLabValue( value: number, - formatNumber: LabNumberFormat, + formatNumber: FractionDigitsFormat, + printed?: number | null, ): string { - if (Number.isInteger(value)) return formatNumber(value); - const rounded = - Math.abs(value) < 1 - ? Number(value.toPrecision(3)) - : Number(value.toFixed(2)); - return formatNumber(rounded); + return formatRecordedNumber(value, formatNumber, printed); } /** - * v1.18.9 — render a reading's display value: a qualitative row (numeric - * `value` is null) shows its `valueText` verbatim; a numeric row shows the - * formatted number plus its unit. One helper so the list card, the history - * rows, and the detail header stay consistent. + * Render a reading's display value: a qualitative row (numeric `value` is + * null) shows its `valueText` verbatim; a numeric row shows the formatted + * number plus its unit. One helper so the list card, the history rows, and + * the detail header stay consistent. */ export function formatLabReading( reading: { value: number | null; valueText: string | null; unit: string; + sourceValue?: number | null; }, formatNumber: LabNumberFormat, ): string { if (reading.value === null) { return reading.valueText ?? ""; } - return `${formatLabValue(reading.value, formatNumber)} ${reading.unit}`.trim(); + return `${formatNumber(reading.value, reading.sourceValue)} ${reading.unit}`.trim(); } diff --git a/tests/integration/lab-source-reference-range.test.ts b/tests/integration/lab-source-reference-range.test.ts index 923e72588..7cb112060 100644 --- a/tests/integration/lab-source-reference-range.test.ts +++ b/tests/integration/lab-source-reference-range.test.ts @@ -281,6 +281,15 @@ describe("a printed reference range, end to end", () => { ); expect(labs?.[0].sourceReferenceText).toBeNull(); expect(labs?.[0].referenceLow).toBeCloseTo(3.9 * 18.0182, 6); + // ...but it carries the printed figure, so the converted value prints + // with the precision the report gave it rather than a float's tail. + expect(labs?.[0].sourceValue).toBe(5.6); + const { formatLabValue } = await import("@/lib/labs/format-value"); + const { makeFormatters } = await import("@/lib/format-locale"); + const en = makeFormatters("en", "UTC", "AUTO", "AUTO").number; + expect( + formatLabValue(labs![0].value as number, en, labs![0].sourceValue), + ).toBe("101"); // FHIR: the text, when present, must agree with low and high. Here it is // left out, and the bounds carry the marker's unit like the value. @@ -361,6 +370,8 @@ describe("a printed reference range, end to end", () => { new Date("2026-12-31T23:59:59.000Z"), ); expect(labs?.[0].sourceReferenceText).toBeNull(); + // An edited value is no longer a conversion, and prints as typed. + expect(labs?.[0].sourceValue).toBeNull(); const { labObservations } = await import("@/lib/fhir/resources/labs"); let seq = 0;