From 4dcb041612c6d3d0d3d63b53a5f6356b324a360e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 10 Nov 2026 19:53:47 +0100 Subject: [PATCH] fix(labs): review a unit that was only a calc note, keep unseen category places A document unit that held only "(calc)" read as no unit and went straight to the record; it now goes to review. Saving a category order keeps the saved keys not on screen after the shown ones. The changelog says what each fix does and no more. --- CHANGELOG.md | 30 +++++++++++-------- src/components/labs/labs-settings.tsx | 7 ++++- src/lib/documents/__tests__/extract.test.ts | 3 ++ src/lib/documents/extract.ts | 8 ++++- .../__tests__/module-list-layout.test.ts | 13 ++++++++ src/lib/settings/module-list-layout.ts | 18 +++++++++++ 6 files changed, 64 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cba2145eb..270544dd6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,18 +31,20 @@ (built-in categories in a fixed order, your own panels alphabetically, Other last). A category that turns up later joins the end of your order, and Other stays last if you left it there. The order is saved - with your account and applied by the server, so the app shows the same - order. The labs list layout accepts `categoryOrder`, and + with your account and applied by the server, so every device that + groups the list shows the same order. The labs list layout accepts + `categoryOrder`, and `GET /api/labs/overview` answers `categories` in that order. (#1349) - **Blood ketones, and the glucose-ketone index worked out for you.** Ketones (beta-hydroxybutyrate, mmol/L) are a measurement of their own now, so a ketogenic diet no longer has to live in a custom metric with the index typed in by hand beside it. Choose "Blood ketones" when you - add a measurement and type the glucose from the same fingerstick into - the field below: both readings are saved together with one time, or - neither is. The glucose-ketone index is glucose in mmol/L divided by - ketones, taken from the glucose reading at the same time or, failing - that, the nearest one within 15 minutes. It is never stored, so it + add a measurement and, if the meter gave one, type the glucose from the + same fingerstick into the field below: both readings are saved together + with one time, or neither is. The glucose-ketone index is glucose in + mmol/L divided by ketones, for every ketone reading above zero, taken + from the glucose reading at the same time or, failing that, the nearest + one within 15 minutes. It is never stored, so it follows any reading you edit or delete. It shows under the chart on the new Ketones page, in the day view, and beside the ketones in the doctor report and the clinician share; the FHIR export carries the ketone @@ -58,10 +60,12 @@ could copy it into the unit, so a marker was created in "g/dL (calc)" or just "(calc)" and the next report's plain "g/dL" was refused as a different unit. The read now drops that note (also "calculated", - "computed" and the German "berechnet"), a unit that was only the note - counts as no unit, and a marker already saved with the note accepts - the plain unit. Any other text in brackets still counts as part of - the unit. (#1313) + "computed" and the German "berechnet"), a marker already saved in + "g/dL (calc)" accepts the plain "g/dL", and a unit that was only the + note counts as no unit and goes to review, so a person decides whether + the value really has none. A marker saved in just "(calc)" keeps that + unit until you change it on the marker. Any other text in brackets + still counts as part of the unit. (#1313) - **The sync feed no longer loses a reading written during a long import, and no longer starts over for a quiet account.** A Garmin or Withings import writes a batch in one transaction and stamps its @@ -80,8 +84,8 @@ - **Changing the calendar subscription while its link is being renewed.** A subscription turned off at the same moment its link was renewed could come back under the new link, and an option changed at that moment - could be lost to the new link. Turning it off now wins, and the change - lands on the link that stays live. + could be lost to the new link. Turning it off now wins whichever comes + first, and the change lands on the link that stays live. ## [1.47.1] — 2026-11-07 diff --git a/src/components/labs/labs-settings.tsx b/src/components/labs/labs-settings.tsx index a5723137f..371760653 100644 --- a/src/components/labs/labs-settings.tsx +++ b/src/components/labs/labs-settings.tsx @@ -49,6 +49,7 @@ import { type ModuleGroupsOpen, type ModuleSortDir, } from "@/lib/module-list-prefs"; +import { mergeCategoryOrder } from "@/lib/settings/module-list-layout"; import { BiomarkerManager } from "./biomarker-manager"; import type { @@ -134,7 +135,11 @@ export function LabsSettings() { /> {prefs.groupBy === "category" ? ( - + + setCategoryOrder(mergeCategoryOrder(shown, prefs.categoryOrder)) + } + /> ) : null} {manual ? ( diff --git a/src/lib/documents/__tests__/extract.test.ts b/src/lib/documents/__tests__/extract.test.ts index af441a5ba..e18ee8f8c 100644 --- a/src/lib/documents/__tests__/extract.test.ts +++ b/src/lib/documents/__tests__/extract.test.ts @@ -221,6 +221,9 @@ describe("runInboundExtraction — stated-status-only mapping", () => { expect( result.facts.map((f) => (f.data as ObservationFactData).unit), ).toEqual(["g/dL", null]); + // A unit that was only the note goes to review: no unit is a choice a + // person makes, not one a read makes for them. + expect(result.facts.map((f) => f.needsReview)).toEqual([false, true]); }); it("stores the document's real span, not the model's echo of it", async () => { diff --git a/src/lib/documents/extract.ts b/src/lib/documents/extract.ts index d1e6bc8bc..54000da5f 100644 --- a/src/lib/documents/extract.ts +++ b/src/lib/documents/extract.ts @@ -399,9 +399,15 @@ export async function runInboundExtraction( // A unit of "1" is the user's explicit no-unit choice. A read that // states it itself goes to review rather than straight to the record, // so a dropped unit never passes as a dimensionless value. + // So does a unit column that held only a "(calc)" note: it reads as no + // unit, and a person decides whether the value really has none. needsReview: confidence < INBOUND_CONFIDENCE_FLOOR || - (raw.type === "OBSERVATION" && isDimensionlessUnit(raw.unit)), + (raw.type === "OBSERVATION" && + (isDimensionlessUnit(raw.unit) || + (raw.unit !== null && + raw.unit.trim() !== "" && + observationUnit(raw.unit) === null))), data, provenance: { sourceText: anchor.sourceText, diff --git a/src/lib/settings/__tests__/module-list-layout.test.ts b/src/lib/settings/__tests__/module-list-layout.test.ts index b5081cdef..08affe190 100644 --- a/src/lib/settings/__tests__/module-list-layout.test.ts +++ b/src/lib/settings/__tests__/module-list-layout.test.ts @@ -6,6 +6,7 @@ import { normaliseModuleListOrder, resolveModuleListLayout, storedModuleListLayout, + mergeCategoryOrder, } from "../module-list-layout"; describe("resolveModuleListLayout", () => { @@ -143,3 +144,15 @@ describe("storedModuleListLayout", () => { ).toEqual({ labs: { sortDir: "manual" } }); }); }); + +describe("mergeCategoryOrder", () => { + it("keeps the place of a saved category that is not on screen", () => { + expect( + mergeCategoryOrder(["lipids", "liver"], ["liver", "thyroid", "lipids"]), + ).toEqual(["lipids", "liver", "thyroid"]); + }); + + it("is the shown order when nothing else was saved", () => { + expect(mergeCategoryOrder(["b", "a"], [])).toEqual(["b", "a"]); + }); +}); diff --git a/src/lib/settings/module-list-layout.ts b/src/lib/settings/module-list-layout.ts index f57bc607d..8a0d3c0a7 100644 --- a/src/lib/settings/module-list-layout.ts +++ b/src/lib/settings/module-list-layout.ts @@ -62,6 +62,24 @@ export const MODULE_LIST_ID_MAX = 64; */ export const MODULE_LIST_CATEGORY_ORDER_MAX = 200; +/** + * The category order to save after a move: the categories on screen in + * their new order, then every saved key that is not on screen now, in its + * saved order. A category without readings at the moment (or one of another + * record a guardian arranged) keeps its place in the list instead of being + * dropped by a move that never showed it. + */ +export function mergeCategoryOrder( + shown: readonly string[], + saved: readonly string[], +): string[] { + const onScreen = new Set(shown); + return [...shown, ...saved.filter((key) => !onScreen.has(key))].slice( + 0, + MODULE_LIST_CATEGORY_ORDER_MAX, + ); +} + export interface ModuleListPrefs { view: ModuleListView; order: string[];