mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 06:52:04 +00:00
feat(medications): fractional dosing via decimal units per dose (#316)
A dose can now consume a fraction of a unit — a half, third or quarter tablet for a split pill. The wizard offers a curated fraction set beside the whole numbers (the most error-resistant input); units-per-dose and the per-container counts widen from Int to Decimal, so a 30-tablet pack dosed at a half reads 29.5 after one dose and the days-left projection follows. The consumption ledger stamps and refunds the exact fraction, and existing whole-number doses are untouched. OpenAPI regenerated — the three unit fields move from integer to number — and the units-per-dose hint is updated across the six locales. Also anchors a first-use date relative to now in an inventory route test that had aged past its 30-day in-use window.
This commit is contained in:
1 parent
5ce3e81972
commit
f4dec8c28f
30 files changed
+510
-136
No files matched your search
@@ -92,15 +92,15 @@ export const GET = apiHandler(
|
||||
const usable = medication.inventoryItems.filter(
|
||||
(item) =>
|
||||
(item.state === "ACTIVE" || item.state === "IN_USE") &&
|
||||
item.unitsRemaining > 0,
|
||||
Number(item.unitsRemaining) > 0,
|
||||
);
|
||||
const unitsRemaining = usable.reduce(
|
||||
(sum, item) => sum + item.unitsRemaining,
|
||||
(sum, item) => sum + Number(item.unitsRemaining),
|
||||
0,
|
||||
);
|
||||
const pensRemaining = usable.length;
|
||||
const dosesRemaining = Math.floor(
|
||||
unitsRemaining / Math.max(1, medication.unitsPerDose),
|
||||
unitsRemaining / (Number(medication.unitsPerDose) || 1),
|
||||
);
|
||||
const weeksOfSupply = dosesRemaining;
|
||||
const lowStock = dosesRemaining < LOW_STOCK_DOSE_THRESHOLD;
|
||||
|
||||
@@ -27,6 +27,10 @@ vi.mock("@/lib/auth/audit", () => ({
|
||||
vi.mock("@/lib/medications/inventory/service", () => ({
|
||||
computeExpiresAt: vi.fn().mockReturnValue(null),
|
||||
buildPatchInventoryUpdate: vi.fn().mockReturnValue({}),
|
||||
// v1.16.12 — the route serialises its Decimal unit columns to numbers
|
||||
// on the way out; a passthrough keeps these update-logic assertions
|
||||
// focused on the Prisma call, not the response shape.
|
||||
serializeInventoryItem: <T,>(item: T) => item,
|
||||
}));
|
||||
vi.mock("@/lib/medications/route-guards", () => ({
|
||||
assertMedicationOwnership: vi.fn().mockResolvedValue(null),
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
computeExpiresAt,
|
||||
computeInventoryState,
|
||||
} from "@/lib/medications/inventory/state-machine";
|
||||
import { serializeInventoryItem } from "@/lib/medications/inventory/service";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string; itemId: string }> };
|
||||
|
||||
@@ -83,7 +84,10 @@ export const PATCH = apiHandler(
|
||||
// commutative — applying them in any order produces the same row.
|
||||
let nextFirstUseAt = existing.firstUseAt;
|
||||
let nextState = existing.state;
|
||||
let nextUnitsRemaining = existing.unitsRemaining;
|
||||
// v1.16.12 — Decimal column; work in JS numbers locally (the
|
||||
// arithmetic below stays well within double precision) and let
|
||||
// Prisma re-widen on write.
|
||||
let nextUnitsRemaining: number = Number(existing.unitsRemaining);
|
||||
let nextPrintedExpiry = existing.printedExpiry;
|
||||
|
||||
if (markAsFirstUseAt) {
|
||||
@@ -103,7 +107,7 @@ export const PATCH = apiHandler(
|
||||
// state-machine re-run below owns every state consequence (0 ⇒
|
||||
// USED_UP, a raise out of 0 re-evaluates against the expiry
|
||||
// clocks).
|
||||
nextUnitsRemaining = Math.min(unitsRemaining, existing.unitsTotal);
|
||||
nextUnitsRemaining = Math.min(unitsRemaining, Number(existing.unitsTotal));
|
||||
}
|
||||
|
||||
if (markAsUsedUp === true) {
|
||||
@@ -126,7 +130,7 @@ export const PATCH = apiHandler(
|
||||
nextState = computeInventoryState(
|
||||
{
|
||||
state: nextState,
|
||||
unitsTotal: existing.unitsTotal,
|
||||
unitsTotal: Number(existing.unitsTotal),
|
||||
unitsRemaining: nextUnitsRemaining,
|
||||
firstUseAt: nextFirstUseAt,
|
||||
printedExpiry: nextPrintedExpiry,
|
||||
@@ -166,7 +170,7 @@ export const PATCH = apiHandler(
|
||||
meta: { medication_id: id, prev: existing.state, next: updated.state },
|
||||
});
|
||||
|
||||
return apiSuccess(updated);
|
||||
return apiSuccess(serializeInventoryItem(updated));
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -28,7 +28,10 @@ import {
|
||||
} from "@/lib/api-response";
|
||||
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import { createInventoryItemSchema } from "@/lib/validations/medication";
|
||||
import { buildCreateInventoryInput } from "@/lib/medications/inventory/service";
|
||||
import {
|
||||
buildCreateInventoryInput,
|
||||
serializeInventoryItem,
|
||||
} from "@/lib/medications/inventory/service";
|
||||
import { assertMedicationOwnership } from "@/lib/medications/route-guards";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
@@ -54,7 +57,10 @@ export const GET = apiHandler(
|
||||
meta: { medication_id: id, total: items.length },
|
||||
});
|
||||
|
||||
return apiSuccess({ items, meta: { total: items.length } });
|
||||
return apiSuccess({
|
||||
items: items.map(serializeInventoryItem),
|
||||
meta: { total: items.length },
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -128,6 +134,6 @@ export const POST = apiHandler(
|
||||
meta: { medication_id: id },
|
||||
});
|
||||
|
||||
return apiSuccess(created, 201);
|
||||
return apiSuccess(serializeInventoryItem(created), 201);
|
||||
},
|
||||
);
|
||||
@@ -178,6 +178,7 @@ export const GET = apiHandler(
|
||||
|
||||
return apiSuccess({
|
||||
...medication,
|
||||
unitsPerDose: Number(medication.unitsPerDose),
|
||||
category,
|
||||
nextDueAt: display ? display.at.toISOString() : null,
|
||||
nextDueOverdue: display?.overdue ?? false,
|
||||
@@ -710,6 +711,7 @@ export const PUT = apiHandler(
|
||||
|
||||
return apiSuccess({
|
||||
...medication,
|
||||
unitsPerDose: Number(medication.unitsPerDose),
|
||||
category: normalizedCategory,
|
||||
});
|
||||
},
|
||||
|
||||
@@ -185,7 +185,7 @@ async function buildMedicationsList(
|
||||
for (const entry of usableStock) {
|
||||
usableUnitsByMedId.set(
|
||||
entry.medicationId,
|
||||
entry._sum.unitsRemaining ?? 0,
|
||||
Number(entry._sum.unitsRemaining ?? 0),
|
||||
);
|
||||
}
|
||||
const trackedMedIds = new Set(inventoryCounts.map((e) => e.medicationId));
|
||||
@@ -233,9 +233,12 @@ async function buildMedicationsList(
|
||||
const stockDosesRemaining =
|
||||
stockUnitsRemaining === null
|
||||
? null
|
||||
: Math.floor(stockUnitsRemaining / Math.max(1, m.unitsPerDose));
|
||||
: Math.floor(stockUnitsRemaining / (Number(m.unitsPerDose) || 1));
|
||||
return {
|
||||
...m,
|
||||
// v1.16.12 — Decimal → number so the wire stays a JSON number, not
|
||||
// the string Prisma would otherwise serialise a Decimal to.
|
||||
unitsPerDose: Number(m.unitsPerDose),
|
||||
category: categoryMap[m.id] ?? "OTHER",
|
||||
lastTakenAt: lastTakenAtByMedicationId[m.id] ?? null,
|
||||
todayEventCount: todayEventCountByMedId[m.id] ?? 0,
|
||||
@@ -489,6 +492,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
return apiSuccess(
|
||||
{
|
||||
...medication,
|
||||
unitsPerDose: Number(medication.unitsPerDose),
|
||||
category: normalizedCategory,
|
||||
},
|
||||
201,
|
||||
|
||||
Reference in new issue
Block a user