From f3c4fbff2c205e726fb887dc0025b82b434ac729 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 16 Oct 2026 11:00:26 +0200 Subject: [PATCH 1/3] fix(illness): keep every episode reachable from the illness page A recurring bout filed under its condition nests inside that condition's card, but the list view drew no nested episodes, so an ended recurring bout of an ended condition had no row anywhere and was found only through search. A bout nested under an already nested parent was lost in either view, and the page read only the list route's first page of 50, so the oldest episodes dropped off the page, its settings and every picker. Grouping moves into groupEpisodes: each episode nests under the highest ancestor that shares its status, or is a card of its own, and a looping parent chain leaves every member a card. Both views render the nested episodes with their course and end date; the count heading says "flares" only when they all are. useIllnessEpisodes reads page after page until a short one. Refs #1224 --- .../__tests__/illness-view-nesting.test.tsx | 174 ++++++++++++++++++ .../use-illness-episodes-paging.test.ts | 50 +++++ src/components/illness/group-episodes.ts | 62 +++++++ src/components/illness/illness-view.tsx | 110 +++++------ src/components/illness/use-illness.ts | 25 ++- 5 files changed, 361 insertions(+), 60 deletions(-) create mode 100644 src/components/illness/__tests__/illness-view-nesting.test.tsx create mode 100644 src/components/illness/__tests__/use-illness-episodes-paging.test.ts create mode 100644 src/components/illness/group-episodes.ts 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; + } + }, }); } From 0ee7e156d372f84868d05c0e82ada015713bcdf5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 16 Oct 2026 11:00:27 +0200 Subject: [PATCH 2/3] fix(report): print lab values with the precision they were recorded at The doctor report PDF printed lab values and reference ranges with one decimal and the share page with two at most, so a PSA of 0.034 read 0.0 or 0.03 and a TSH window of 0.27 read 0.3, while the labs page kept more. The labs page itself cut values above 1 to two decimals. One rule now decides every recorded number (src/lib/format/recorded-number): a stored value keeps every digit it holds, minus binary float noise, and a value converted from the unit its report printed keeps the precision of the printed figure. The report payload carries sourceValue for that. The labs page, its chart and history, the PDF lab table and footnote, the share page, the day view and the OCR review all print through it, reference bounds included. Doses on the PDF and the share page follow the same rule. Measurement statistics in the PDF and on the share page now share one per-row precision: up to three significant digits but never more decimals than recorded, so a waist-to-height ratio of 0.52 no longer reads 0.5; glucose keeps its unit's precision. Refs #1228 --- .../share-view-leaf-render-guard.test.ts | 1 + .../__tests__/clinician-view.test.tsx | 59 +++++++++- src/components/clinician/clinician-view.tsx | 14 ++- src/components/clinician/history-sections.tsx | 22 ++-- src/components/clinician/report-sections.tsx | 79 ++++++++------ src/components/clinician/therapy-sections.tsx | 14 ++- .../__tests__/lab-converted-note.test.tsx | 4 +- .../lab-reference-range-bar.test.tsx | 4 +- .../labs/__tests__/ocr-row-editor.test.tsx | 3 +- src/components/labs/lab-biomarker-chart.tsx | 10 +- src/components/labs/lab-converted-note.tsx | 3 +- src/components/labs/lab-history-list.tsx | 2 +- src/components/labs/ocr-row-editor.tsx | 5 +- src/components/labs/use-lab-format.ts | 8 +- .../__tests__/doctor-report-pdf-core.test.ts | 100 +++++++++++++++++ src/lib/day/records.ts | 6 +- .../clinical-records-notes-section.ts | 24 ++++- .../measurements-charts-section.ts | 30 ++++-- .../medication-mood-wellness-section.ts | 5 +- src/lib/doctor-report-types.ts | 6 ++ src/lib/doctor-report/clinical-records.ts | 2 + src/lib/doctor-report/stat-display.ts | 48 +++++++++ .../fhir/__tests__/lab-observations.test.ts | 6 ++ src/lib/format/recorded-number.ts | 101 ++++++++++++++++++ src/lib/labs/__tests__/format-value.test.ts | 51 +++++++-- src/lib/labs/format-value.ts | 55 ++++++---- .../lab-source-reference-range.test.ts | 11 ++ 27 files changed, 560 insertions(+), 113 deletions(-) create mode 100644 src/lib/format/recorded-number.ts 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/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; From d2fe55bc9df8318fe5001255c15b6ec5c81b87ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 16 Oct 2026 11:01:44 +0200 Subject: [PATCH 3/3] chore(release): v1.43.1 --- CHANGELOG.md | 14 ++++++++++++++ docs/api/openapi.yaml | 2 +- package.json | 2 +- public/sw.js | 2 +- 4 files changed, 17 insertions(+), 3 deletions(-) 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/*`