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.
This commit is contained in:
Marc-André Bombeck committed 2026-11-10 19:53:47 +01:00
1 parent 69ba8bec91
commit 4dcb041612
6 files changed
+64 -15

No files matched your search

+17 -13
View File
@@ -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
+6 -1
View File
@@ -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" ? (
<LabsCategoryOrderCard onChange={setCategoryOrder} />
<LabsCategoryOrderCard
onChange={(shown) =>
setCategoryOrder(mergeCategoryOrder(shown, prefs.categoryOrder))
}
/>
) : null}
{manual ? (
@@ -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 () => {
+7 -1
View File
@@ -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,
@@ -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"]);
});
});
+18
View File
@@ -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[];