mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
fix(inventory): serialise unknown units as null, not 0 (iOS shows unknown, no phantom negatives)
This commit is contained in:
commit
fcd498d252
12 files changed
+146
-30
No files matched your search
+11
-4
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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 })
|
||||
|
||||
Reference in new issue
Block a user