Merge pull request #1233 from MBombeck/fix/v1431

Release v1.43.1
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-10-16 11:40:20 +02:00
commit bc5ea5a67b
36 files changed
+938 -176

No files matched your search

+14
View File
@@ -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,
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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/*`
@@ -208,6 +208,7 @@ const DISPOSITIONS: Record<StructuredLeafId, Disposition> = {
value: 42,
valueText: null,
unit: "ng/mL",
sourceValue: null,
referenceLow: 30,
referenceHigh: 400,
catalogReferenceLow: 30,
@@ -176,6 +176,7 @@ describe("<ClinicianView>", () => {
value: 5.2,
valueText: null,
unit: "mmol/L",
sourceValue: null,
referenceLow: 3.9,
referenceHigh: 5.4,
catalogReferenceLow: 3.5,
@@ -194,6 +195,55 @@ describe("<ClinicianView>", () => {
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("<ClinicianView>", () => {
},
}),
);
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("<ClinicianView>", () => {
);
// 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");
+10 -4
View File
@@ -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}
/>
<LabResultsSection
t={t}
report={report}
scope={scope}
fmtDate={fmtDate}
fmtNum={fmtNum}
fmtLab={fmtRecorded}
/>
{/* ── medications ──────────────────────────────────────── */}
@@ -242,13 +247,14 @@ export function ClinicianView({
scope={scope}
fmtDate={fmtDate}
fmtNum={fmtNum}
fmtRecorded={fmtRecorded}
/>
<DoseLogSection
t={t}
report={report}
scope={scope}
fmtDateTime={fmtDateTime}
fmtNum={fmtNum}
fmtRecorded={fmtRecorded}
/>
{/* ── history ──────────────────────────────────────────── */}
+16 -6
View File
@@ -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
+48 -31
View File
@@ -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 }) => (
<StatRow
key={type}
label={t(MEASUREMENT_TYPE_LABEL_KEYS[type])}
value={statSummary(t, reportStatUnit(type, glucoseUnit, t), {
latest: show(type, stat!.latest),
avg: show(type, stat!.avg),
min: show(type, stat!.min),
max: show(type, stat!.max),
})}
/>
))}
{group.rows.map(({ type, stat }) => {
const digits = reportStatFractionDigits(type, stat!, glucoseUnit);
return (
<StatRow
key={type}
label={t(MEASUREMENT_TYPE_LABEL_KEYS[type])}
value={statSummary(t, reportStatUnit(type, glucoseUnit, t), {
latest: show(type, stat!.latest, digits),
avg: show(type, stat!.avg, digits),
min: show(type, stat!.min, digits),
max: show(type, stat!.max, digits),
})}
/>
);
})}
</Section>
))}
</>
@@ -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 (
<LeafSection
t={t}
@@ -299,18 +309,25 @@ export function GlucoseSection({
title={t("reportSelection.groupGlucose")}
empty={entries.length === 0}
>
{entries.map(([ctx, s]) => (
<StatRow
key={ctx}
label={t(`clinicianView.glucose.${ctx}`)}
value={statSummary(t, glucoseUnit, {
latest: show(s.latest),
avg: show(s.avg),
min: show(s.min),
max: show(s.max),
})}
/>
))}
{entries.map(([ctx, s]) => {
const digits = reportStatFractionDigits(
"BLOOD_GLUCOSE",
s,
glucoseUnit,
);
return (
<StatRow
key={ctx}
label={t(`clinicianView.glucose.${ctx}`)}
value={statSummary(t, glucoseUnit, {
latest: show(s.latest, digits),
avg: show(s.avg, digits),
min: show(s.min, digits),
max: show(s.max, digits),
})}
/>
);
})}
</LeafSection>
);
}
@@ -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}`
: "") +
@@ -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: <T,>(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>): 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(
<I18nProvider initialLocale="en">
<IllnessView />
</I18nProvider>,
);
}
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");
});
});
@@ -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,
);
});
});
+62
View File
@@ -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<string, IllnessEpisodeDTO[]>;
}
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<string, IllnessEpisodeDTO[]>();
const parentOf = (e: IllnessEpisodeDTO) =>
e.parentConditionId ? byId.get(e.parentConditionId) : undefined;
for (const e of list) {
let root = e;
const seen = new Set<string>([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 };
}
+55 -55
View File
@@ -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 (
<div className="space-y-1 border-l-2 pl-3" data-slot="nested-episodes">
{allFlares ? (
<p className="text-muted-foreground text-xs font-medium">
{count === 1
? t("illness.flares.countOne")
: t("illness.flares.countOther", { count })}
</p>
) : null}
<ul className="space-y-1">
{episodes.map((child) => (
<li key={child.id}>
<Link
href={`/illness/${child.id}`}
className="text-foreground/80 hover:text-foreground flex items-center gap-2 text-xs hover:underline"
>
<span className="truncate">{child.label}</span>
<span className="text-muted-foreground shrink-0">
{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))}`
: ""}
</span>
</Link>
</li>
))}
</ul>
</div>
);
}
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({
</DayLinkStated>
</span>
</div>
{/* The list row carries the nested episodes too: a bout filed
under this condition has no row of its own. */}
<NestedEpisodes episodes={flares} />
</div>
{canManageIllness && (
<div className="flex shrink-0 items-center gap-1">
@@ -202,30 +250,7 @@ function EpisodeCard({
) : null}
</p>
{flareCount > 0 ? (
<div className="space-y-1 border-l-2 pl-3">
<p className="text-muted-foreground text-xs font-medium">
{flareCount === 1
? t("illness.flares.countOne")
: t("illness.flares.countOther", { count: flareCount })}
</p>
<ul className="space-y-1">
{flares!.map((flare) => (
<li key={flare.id}>
<Link
href={`/illness/${flare.id}`}
className="text-foreground/80 hover:text-foreground flex items-center gap-2 text-xs hover:underline"
>
<span className="truncate">{flare.label}</span>
<span className="text-muted-foreground shrink-0">
{fmt.dateShortSmart(new Date(flare.onsetAt))}
</span>
</Link>
</li>
))}
</ul>
</div>
) : null}
<NestedEpisodes episodes={flares} />
{/* 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<string, IllnessEpisodeDTO[]>();
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
+20 -5
View File
@@ -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<IllnessEpisodeDTO[]>(
`/api/illness/episodes?includeResolved=${includeResolved}`,
),
queryFn: async () => {
const all: IllnessEpisodeDTO[] = [];
for (let offset = 0; ; offset += EPISODE_PAGE_SIZE) {
const page = await apiGet<IllnessEpisodeDTO[]>(
`/api/illness/episodes?includeResolved=${includeResolved}&limit=${EPISODE_PAGE_SIZE}&offset=${offset}`,
);
all.push(...page);
if (page.length < EPISODE_PAGE_SIZE) return all;
}
},
});
}
@@ -33,10 +33,10 @@ describe("<LabConvertedNote>", () => {
).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([
@@ -53,8 +53,8 @@ describe("<LabReferenceRangeBar>", () => {
"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/);
});
});
@@ -175,7 +175,8 @@ describe("<OcrRowEditor> 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"');
});
+7 -3
View File
@@ -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,
},
];
+1 -2
View File
@@ -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,
})}
</span>
+1 -1
View File
@@ -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
? {
+4 -1
View File
@@ -206,7 +206,10 @@ export function OcrRowEditor({
<Ruler aria-hidden />
<span className="min-w-0 break-words">
{t("labs.ocr.unitConvertedBadge", {
value: labNumber(conversion.value),
value: labNumber(
conversion.value,
conversion.statedValue,
),
markerUnit: conversion.unit,
statedValue: labNumber(conversion.statedValue),
unit: conversion.statedUnit,
+5 -3
View File
@@ -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],
);
}
@@ -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<NonNullable<DoctorReportData["labResults"]>[number]>,
): NonNullable<DoctorReportData["labResults"]>[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<string> {
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({
+5 -1
View File
@@ -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<SectionPart> {
value: true,
valueText: true,
unit: true,
sourceValue: true,
takenAt: true,
biomarkerId: true,
noteEncrypted: true,
@@ -703,7 +705,9 @@ async function labsPart(frame: DayFrame): Promise<SectionPart> {
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",
@@ -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
? ""
@@ -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),
]);
}
@@ -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
+6
View File
@@ -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.
@@ -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,
+48
View File
@@ -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),
);
}
@@ -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,
+101
View File
@@ -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);
}
+43 -8
View File
@@ -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");
});
});
+33 -22
View File
@@ -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();
}
@@ -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;