mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
36 files changed
+938
-176
No files matched your search
@@ -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,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
@@ -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
@@ -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");
|
||||
|
||||
@@ -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 ──────────────────────────────────────────── */}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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"');
|
||||
});
|
||||
|
||||
@@ -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,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>
|
||||
|
||||
@@ -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
|
||||
? {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user