mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
Merge pull request #798 from MBombeck/release/v1.37.22
release/v1.37.22
This commit is contained in:
8 files changed
+70
-19
No files matched your search
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
+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.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/*`
|
||||
|
||||
@@ -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(
|
||||
@@ -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"));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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: [
|
||||
@@ -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,
|
||||
@@ -250,13 +255,15 @@ 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");
|
||||
});
|
||||
|
||||
@@ -264,10 +271,25 @@ describe("lab-result LOINC + canonical UCUM coding (v1.18.8)", () => {
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user