fix(inventory): serialise unknown units as null, not 0 (iOS shows unknown, no phantom negatives)

This commit is contained in:
Marc-André Bombeck committed 2026-06-17 14:09:42 +02:00
commit fcd498d252
12 files changed
+146 -30

No files matched your search

+11 -4
View File
@@ -11992,14 +11992,21 @@ components:
description: Kind of physical container (PEN / AMPOULE / BLISTER / INHALER / BOTTLE / OTHER). Display-level
classification; defaults to OTHER.
unitsTotal:
type: number
anyOf:
- type: number
- type: "null"
description: Units the container shipped with (tablets / ampoules / puffs; 1–1000). v1.16.12 — fractional, so a
split-pill remainder reads cleanly. Dose-derived readouts divide by the medication's `unitsPerDose`.
split-pill remainder reads cleanly. Dose-derived readouts divide by the medication's `unitsPerDose`. v1.18.3
(iOS#31) — NULL when the unit count is unknown (a corrupt or legacy row); the client renders "unknown",
never a fabricated 0.
unitsRemaining:
type: number
anyOf:
- type: number
- type: "null"
description: Units left in the container. v1.16.12 — fractional (a ½-tablet dose leaves 29.5 of 30). Decremented by the
intake consumption hook (FEFO with spillover across containers); refunded when a taken dose is skipped,
edited away, or deleted.
edited away, or deleted. v1.18.3 (iOS#31) — NULL when the count is unknown (a corrupt or legacy row); the
client renders "unknown", never a fabricated 0 it could decrement into negatives.
firstUseAt:
anyOf:
- type: string
+1
View File
@@ -1667,6 +1667,7 @@
"summary": "{remaining} von {total} Dosen übrig",
"itemsTitle": "Packungen & Behälter",
"doses": "{remaining} / {total} Dosen",
"unknown": "Unbekannt",
"state": {
"ACTIVE": "Ungeöffnet",
"IN_USE": "In Gebrauch",
+1
View File
@@ -1667,6 +1667,7 @@
"summary": "{remaining} of {total} doses remaining",
"itemsTitle": "Packs & containers",
"doses": "{remaining} / {total} doses",
"unknown": "Unknown",
"state": {
"ACTIVE": "Unopened",
"IN_USE": "In use",
+1
View File
@@ -1667,6 +1667,7 @@
"summary": "{remaining} de {total} dosis restantes",
"itemsTitle": "Envases y recipientes",
"doses": "{remaining} / {total} dosis",
"unknown": "Desconocido",
"state": {
"ACTIVE": "Sin abrir",
"IN_USE": "En uso",
+1
View File
@@ -1667,6 +1667,7 @@
"summary": "{remaining} doses restantes sur {total}",
"itemsTitle": "Boîtes et contenants",
"doses": "{remaining} / {total} doses",
"unknown": "Inconnu",
"state": {
"ACTIVE": "Non ouvert",
"IN_USE": "En cours",
+1
View File
@@ -1667,6 +1667,7 @@
"summary": "{remaining} dosi rimanenti su {total}",
"itemsTitle": "Confezioni e contenitori",
"doses": "{remaining} / {total} dosi",
"unknown": "Sconosciuto",
"state": {
"ACTIVE": "Non aperto",
"IN_USE": "In uso",
+1
View File
@@ -1667,6 +1667,7 @@
"summary": "Pozostało {remaining} z {total} dawek",
"itemsTitle": "Opakowania i pojemniki",
"doses": "{remaining} / {total} dawek",
"unknown": "Nieznane",
"state": {
"ACTIVE": "Nieotwarte",
"IN_USE": "W użyciu",
@@ -90,8 +90,10 @@ interface InventoryItem {
id: string;
state: InventoryState;
containerType: ContainerType;
unitsTotal: number;
unitsRemaining: number;
// v1.18.3 (iOS#31) — null = unknown unit count (corrupt / legacy row);
// the UI renders "—" instead of a fabricated 0.
unitsTotal: number | null;
unitsRemaining: number | null;
}
interface InventoryResponse {
@@ -203,13 +205,23 @@ export function InventorySection({
// payload and the GLP-1 endpoint on one predicate. Expired stock is
// never available; it renders as a separate muted suffix.
// Dose-derived headline (floor — a partial dose is not a dose).
// v1.18.3 (iOS#31) — an unknown-units row (null) contributes nothing to
// the available-supply math; it shows "—" in its own row but cannot pad
// the pooled headline. Coalesce null → 0 only for the summary inputs.
const {
unitsRemaining: remainingUnits,
unitsTotal: totalUnits,
dosesRemaining: remaining,
dosesTotal: total,
expiredUnits,
} = summariseSupply(items, perDose);
} = summariseSupply(
items.map((item) => ({
state: item.state,
unitsTotal: item.unitsTotal ?? 0,
unitsRemaining: item.unitsRemaining ?? 0,
})),
perDose,
);
const dialogs = (
<>
@@ -370,19 +382,25 @@ export function InventorySection({
inline at meta-text size — read-only, never a control. */}
<span className="text-muted-foreground flex flex-wrap items-center gap-x-1.5 gap-y-0.5 text-xs">
<span>
{t("medications.detail.bestand.doses", {
remaining: Math.floor(item.unitsRemaining / perDose),
total: Math.floor(item.unitsTotal / perDose),
})}
{perDose !== 1 && (
<>
{" · "}
{t("medications.detail.bestand.unitsDetail", {
remaining: formatUnitCount(item.unitsRemaining),
total: formatUnitCount(item.unitsTotal),
{/* v1.18.3 (iOS#31) — an unknown unit count (null) renders
"—" rather than a fabricated 0-dose figure. */}
{item.unitsRemaining == null || item.unitsTotal == null
? t("medications.detail.bestand.unknown")
: t("medications.detail.bestand.doses", {
remaining: Math.floor(item.unitsRemaining / perDose),
total: Math.floor(item.unitsTotal / perDose),
})}
</>
)}
{perDose !== 1 &&
item.unitsRemaining != null &&
item.unitsTotal != null && (
<>
{" · "}
{t("medications.detail.bestand.unitsDetail", {
remaining: formatUnitCount(item.unitsRemaining),
total: formatUnitCount(item.unitsTotal),
})}
</>
)}
</span>
<Badge
variant={STATE_BADGE[item.state]}
@@ -651,13 +669,21 @@ function AdjustInventoryDialog({
}) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const [value, setValue] = useState(String(item.unitsRemaining));
// v1.18.3 (iOS#31) — an unknown remaining count (null) pre-fills empty,
// not the literal "null"; the operator types the corrected figure.
const [value, setValue] = useState(
item.unitsRemaining == null ? "" : String(item.unitsRemaining),
);
const [busy, setBusy] = useState(false);
const parsed = Number(value);
// v1.16.12 — fractional remaining allowed (a ½-tablet dose leaves 29.5).
// v1.18.3 — when capacity is unknown (null) the only ceiling is finiteness.
const valid =
Number.isFinite(parsed) && parsed >= 0 && parsed <= item.unitsTotal;
value.trim() !== "" &&
Number.isFinite(parsed) &&
parsed >= 0 &&
(item.unitsTotal == null || parsed <= item.unitsTotal);
async function submit(e: FormEvent) {
e.preventDefault();
@@ -704,7 +730,7 @@ function AdjustInventoryDialog({
type="number"
inputMode="decimal"
min={0}
max={item.unitsTotal}
max={item.unitsTotal ?? undefined}
step="any"
required
autoComplete="off"
@@ -717,7 +743,9 @@ function AdjustInventoryDialog({
className="text-muted-foreground text-xs"
>
{t("medications.detail.bestand.adjustHelper", {
total: item.unitsTotal,
total:
item.unitsTotal ??
t("medications.detail.bestand.unknown"),
})}
</p>
</div>
@@ -15,6 +15,7 @@ import { prisma } from "@/lib/db";
import {
buildCreateInventoryInput,
expireStaleInUseItems,
serializeInventoryItem,
} from "../service";
const NOW = new Date("2026-05-14T12:00:00Z");
@@ -104,3 +105,55 @@ describe("buildCreateInventoryInput", () => {
expect(input.expiresAt).toBeNull();
});
});
describe("serializeInventoryItem (iOS#31)", () => {
it("serialises a Decimal-as-string unit count to a JSON number", () => {
const out = serializeInventoryItem({
id: "i1",
unitsTotal: "30",
unitsRemaining: "29.5",
});
expect(out.unitsTotal).toBe(30);
expect(out.unitsRemaining).toBe(29.5);
});
it("keeps a genuine tracked zero as 0, not null", () => {
const out = serializeInventoryItem({
id: "i2",
unitsTotal: 4,
unitsRemaining: 0,
});
expect(out.unitsRemaining).toBe(0);
expect(out.unitsTotal).toBe(4);
});
it("serialises an unknown (null) unit count as null, not a fabricated 0", () => {
const out = serializeInventoryItem({
id: "i3",
unitsTotal: null,
unitsRemaining: null,
});
expect(out.unitsTotal).toBeNull();
expect(out.unitsRemaining).toBeNull();
});
it("rejects non-finite corrupt values (NaN / Infinity) to null", () => {
const out = serializeInventoryItem({
id: "i4",
unitsTotal: "not-a-number",
unitsRemaining: Infinity,
});
expect(out.unitsTotal).toBeNull();
expect(out.unitsRemaining).toBeNull();
});
it("preserves every other field unchanged", () => {
const out = serializeInventoryItem({
id: "i5",
state: "ACTIVE",
unitsTotal: "10",
unitsRemaining: "10",
});
expect(out).toMatchObject({ id: "i5", state: "ACTIVE" });
});
});
@@ -108,7 +108,10 @@ describe("supply surfaces — both detail-page readouts ride the shared helper (
"src/components/medications/sections/inventory-section.tsx",
);
expect(src).toContain('from "@/lib/medications/inventory/summary"');
expect(src).toMatch(/summariseSupply\(items, perDose\)/);
// v1.18.3 (iOS#31) — the items are mapped through a nullable→0 coalesce
// (an unknown-units row contributes nothing to the available headline)
// before the shared helper, but the summary still rides summariseSupply.
expect(src).toMatch(/summariseSupply\(\s*items\.map\(/);
expect(src).not.toContain('state !== "USED_UP"');
expect(src).toContain("medications.detail.bestand.expiredSuffix");
});
+21 -4
View File
@@ -56,19 +56,36 @@ export async function expireStaleInUseItems(input: {
* `unitsTotal` / `unitsRemaining` as numbers. Convert at every response
* boundary that returns an item, so a fractional remainder (29.5 after a
* half-tablet dose) lands as a JSON number, never `"29.5"`.
*
* v1.18.3 (iOS#31) — serialise a genuinely-unknown unit count as `null`,
* not `0`. A well-formed row always carries both Decimals, but a corrupt
* or legacy row could hold null / NaN / Infinity; `Number(null) === 0`
* (and `Number("x") === NaN`) would otherwise fabricate a misleading `0`
* that the client decrements into negatives. `null` lets the client
* render "unbekannt" instead. A real tracked `0` stays `0`.
*/
function toFiniteUnit(value: unknown): number | null {
// `Number(null) === 0` and `Number("") === 0`, so reject the empty /
// nullish cases up front — a genuinely-absent count must read as null,
// not a fabricated 0. Anything else goes through the finiteness gate
// (NaN / Infinity from a corrupt string also fall to null).
if (value === null || value === undefined || value === "") return null;
const n = Number(value);
return Number.isFinite(n) ? n : null;
}
export function serializeInventoryItem<
T extends { unitsTotal: unknown; unitsRemaining: unknown },
>(
item: T,
): Omit<T, "unitsTotal" | "unitsRemaining"> & {
unitsTotal: number;
unitsRemaining: number;
unitsTotal: number | null;
unitsRemaining: number | null;
} {
return {
...item,
unitsTotal: Number(item.unitsTotal),
unitsRemaining: Number(item.unitsRemaining),
unitsTotal: toFiniteUnit(item.unitsTotal),
unitsRemaining: toFiniteUnit(item.unitsRemaining),
};
}
+4 -2
View File
@@ -296,13 +296,15 @@ const medicationInventoryItemResource = z
),
unitsTotal: z
.number()
.nullable()
.describe(
"Units the container shipped with (tablets / ampoules / puffs; 1–1000). v1.16.12 — fractional, so a split-pill remainder reads cleanly. Dose-derived readouts divide by the medication's `unitsPerDose`.",
"Units the container shipped with (tablets / ampoules / puffs; 1–1000). v1.16.12 — fractional, so a split-pill remainder reads cleanly. Dose-derived readouts divide by the medication's `unitsPerDose`. v1.18.3 (iOS#31) — NULL when the unit count is unknown (a corrupt or legacy row); the client renders \"unknown\", never a fabricated 0.",
),
unitsRemaining: z
.number()
.nullable()
.describe(
"Units left in the container. v1.16.12 — fractional (a ½-tablet dose leaves 29.5 of 30). Decremented by the intake consumption hook (FEFO with spillover across containers); refunded when a taken dose is skipped, edited away, or deleted.",
"Units left in the container. v1.16.12 — fractional (a ½-tablet dose leaves 29.5 of 30). Decremented by the intake consumption hook (FEFO with spillover across containers); refunded when a taken dose is skipped, edited away, or deleted. v1.18.3 (iOS#31) — NULL when the count is unknown (a corrupt or legacy row); the client renders \"unknown\", never a fabricated 0 it could decrement into negatives.",
),
firstUseAt: z.iso
.datetime({ offset: true })