Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
# Changelog

## [1.37.22] — 2026-08-15

### Fixed

- The doctor-report export refuses a selection that includes nothing instead of rendering an empty document. The selection panel already disabled its own button in that state, so this only ever reached the server through a hand-built request, and it spent an export slot on a file with no content in it.
- A lab value in the FHIR export no longer names UCUM as its unit system when it carries no UCUM code. That combination told a receiving system the unit was coded when it was not, which is worse than saying nothing: the unit still travels as a display string, and the coded form appears only when the analyte and unit actually resolve to one.

## [1.37.21] — 2026-08-14

### Fixed
Expand Down
2 changes: 1 addition & 1 deletion docs/api/openapi.yaml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: HealthLog API
version: 1.37.21
version: 1.37.22
description: >-
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "healthlog",
"version": "1.37.21",
"version": "1.37.22",
"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",
Expand Down
2 changes: 1 addition & 1 deletion public/sw.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.37.21";
/* @sw-version-fallback */ "v1.37.22";
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/*`
Expand Down
22 changes: 15 additions & 7 deletions src/app/api/export/health-record/__tests__/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,18 @@ describe("POST /api/export/health-record — validation", () => {
expect(res.status).toBe(422);
});

it("rejects a selection that admits nothing with 422", async () => {
// The panel disables its own submit in that state, so only a hand-built
// request gets here. Rendering an empty report and spending a rate-limit
// slot on it would be the wrong answer; this route has no documents-only
// mode that would make an empty leaf list a legitimate scope.
const { POST } = await import("../route");
const res = await POST(mkReq({ format: "pdf", selection: sel() }));
expect(res.status).toBe(422);
const body = (await res.json()) as { meta?: { errorCode?: string } };
expect(body.meta?.errorCode).toBe("export.selection.empty");
});

it("rejects a userId smuggled into the body with 422", async () => {
const { POST } = await import("../route");
const res = await POST(
Expand Down Expand Up @@ -540,15 +552,11 @@ describe("POST /api/export/health-record — the selection reaches every format"
// leaves: the totals must stay in the database.
expect(await observationCodes(sel("LAB_RESULTS"))).toEqual([]);
// And an empty selection — the "I said nothing" case, which used to mean
// "give me everything".
// "give me everything" and is now refused outright, so the totals cannot
// ride out on a scope nobody chose.
const { POST } = await import("../route");
const res = await POST(mkReq({ format: "fhir", selection: sel() }));
const bundle = (await res.json()) as {
entry: { resource: { resourceType: string } }[];
};
expect(
bundle.entry.filter((e) => e.resource.resourceType === "Observation"),
).toEqual([]);
expect(res.status).toBe(422);

// Asked for by name, they are emitted — the data is withheld, not lost.
const asked = await observationCodes(sel("PHQ9_SCORE", "GAD7_SCORE"));
Expand Down
11 changes: 11 additions & 0 deletions src/app/api/export/health-record/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,17 @@ export const POST = apiHandler(async (request: NextRequest) => {
);
}
const selection = minted.selection;
// A selection that admits nothing would render an empty report and still
// spend a rate-limit slot on it. The panel disables its own submit button
// in that state; a hand-built request has to be refused here, because this
// route has no documents-only mode that would make an empty leaf list a
// legitimate scope (the share-link route, which does, mints EMPTY itself).
if (selection.leaves.length === 0) {
annotate({ meta: { empty_selection: true } });
return apiError("Select at least one section to export", 422, {
errorCode: "export.selection.empty",
});
}

const range = normaliseDateRange(input.range ?? undefined);
const practiceName = sanitisePracticeName(input.practiceName);
Expand Down
36 changes: 29 additions & 7 deletions src/lib/fhir/__tests__/lab-observations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,12 @@ describe("lab-result FHIR Observations", () => {
});

describe("lab-result LOINC + canonical UCUM coding (v1.18.8)", () => {
function labOf(type: string, unit: string, value = 5.4) {
function labOf(
type: string,
unit: string,
value = 5.4,
bounds?: { low: number; high: number },
) {
const bundle = buildFhirDocumentBundle(
makeData({
labResults: [
Expand All @@ -191,8 +196,8 @@ describe("lab-result LOINC + canonical UCUM coding (v1.18.8)", () => {
value,
valueText: null,
unit,
referenceLow: null,
referenceHigh: null,
referenceLow: bounds?.low ?? null,
referenceHigh: bounds?.high ?? null,
catalogReferenceLow: null,
catalogReferenceHigh: null,
sourceReferenceText: null,
Expand Down Expand Up @@ -250,24 +255,41 @@ describe("lab-result LOINC + canonical UCUM coding (v1.18.8)", () => {
expect(tsh?.valueQuantity?.code).toBe("m[IU]/L");
});

it("omits the UCUM code when the unit does not match the mapped canonical", () => {
it("omits the UCUM code AND its system when the unit does not match the mapped canonical", () => {
// HbA1c mapped (LOINC present) but recorded in an mmol/mol unit we don't
// canonicalise → keep the LOINC coding, drop the UCUM code, keep display.
// The system goes with the code: a valueQuantity naming UCUM without a
// code claims a coding the bundle does not carry.
const o = labOf("HbA1c", "mmol/mol", 36);
expect(o?.code.coding?.[0].code).toBe("4548-4");
expect(o?.valueQuantity?.system).toBe("http://unitsofmeasure.org");
expect(o?.valueQuantity?.code).toBeUndefined();
expect(o?.valueQuantity?.system).toBeUndefined();
expect(o?.valueQuantity?.unit).toBe("mmol/mol");
});

it("keeps an unmapped analyte text-only with no fabricated coding", () => {
const o = labOf("Selenium", "ug/L", 95);
expect(o?.code.text).toBe("Selenium");
expect(o?.code.coding).toBeUndefined();
// Display unit stays; no coerced UCUM code is invented.
// Display unit stays; no coerced UCUM code is invented, and no code
// system is named for a code that is not there.
expect(o?.valueQuantity?.unit).toBe("ug/L");
expect(o?.valueQuantity?.code).toBeUndefined();
expect(o?.valueQuantity?.system).toBe("http://unitsofmeasure.org");
expect(o?.valueQuantity?.system).toBeUndefined();
});

it("keeps system and code together on the reference range bounds too", () => {
// The bounds are stated in the SAME unit as the value, so an uncoded
// unit must leave them uncoded as well, on both halves.
const uncoded = labOf("HbA1c", "mmol/mol", 36, { low: 20, high: 42 });
expect(uncoded?.referenceRange?.[0].low?.system).toBeUndefined();
expect(uncoded?.referenceRange?.[0].low?.code).toBeUndefined();
expect(uncoded?.referenceRange?.[0].high?.system).toBeUndefined();
const coded = labOf("HDL", "mg/dL", 55, { low: 40, high: 60 });
expect(coded?.referenceRange?.[0].low?.system).toBe(
"http://unitsofmeasure.org",
);
expect(coded?.referenceRange?.[0].low?.code).toBe("mg/dL");
});
});

Expand Down
7 changes: 5 additions & 2 deletions src/lib/fhir/resources/labs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,11 +80,14 @@ export function labObservations(
// A lab quantity: the recorded `unit` is always the display string, and
// the canonical UCUM `code` is stamped only when the analyte map resolved
// one for exactly that unit (no coerced symbol).
// The `system` rides along WITH the code and never alone: naming UCUM as
// the code system while withholding the code claims a coding the bundle
// does not carry, and leaves a receiver unable to machine-compare the
// value it is attached to.
const quantity = (value: number) => ({
value,
unit: lab.unit,
system: UCUM_SYSTEM,
...(coding?.ucum ? { code: coding.ucum } : {}),
...(coding?.ucum ? { system: UCUM_SYSTEM, code: coding.ucum } : {}),
});
// The bounds are stated in the SAME unit as the value, so they carry the
// SAME coding — a bare unit beside a coded value leaves a receiver unable
Expand Down
Loading