mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
feat(medications): pen-inventory state-machine + 30-day clock helpers
This commit is contained in:
1 parent
570b14d83f
commit
34ec6d3bd3
2 files changed
+451
No files matched your search
@@ -0,0 +1,227 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
computeExpiresAt,
|
||||
computeInventoryState,
|
||||
daysRemainingInUse,
|
||||
decrementDose,
|
||||
DEFAULT_IN_USE_WINDOW_DAYS,
|
||||
type InventoryItemView,
|
||||
} from "../state-machine";
|
||||
|
||||
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
||||
const NOW = new Date("2026-05-14T12:00:00Z");
|
||||
const NOW_MS = NOW.getTime();
|
||||
|
||||
function makeItem(overrides: Partial<InventoryItemView> = {}): InventoryItemView {
|
||||
return {
|
||||
state: "ACTIVE",
|
||||
dosesTotal: 4,
|
||||
dosesRemaining: 4,
|
||||
firstUseAt: null,
|
||||
printedExpiry: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("computeInventoryState", () => {
|
||||
it("returns ACTIVE for a fresh refrigerated pen", () => {
|
||||
expect(computeInventoryState(makeItem(), NOW_MS)).toBe("ACTIVE");
|
||||
});
|
||||
|
||||
it("returns IN_USE once firstUseAt is set and within the 30-day window", () => {
|
||||
const item = makeItem({
|
||||
firstUseAt: new Date(NOW_MS - 5 * MS_PER_DAY),
|
||||
});
|
||||
expect(computeInventoryState(item, NOW_MS)).toBe("IN_USE");
|
||||
});
|
||||
|
||||
it("returns EXPIRED when the in-use clock blew past 30 days", () => {
|
||||
const item = makeItem({
|
||||
firstUseAt: new Date(NOW_MS - 31 * MS_PER_DAY),
|
||||
});
|
||||
expect(computeInventoryState(item, NOW_MS)).toBe("EXPIRED");
|
||||
});
|
||||
|
||||
it("returns EXPIRED when printed expiry has lapsed (unopened pen)", () => {
|
||||
const item = makeItem({
|
||||
printedExpiry: new Date(NOW_MS - 1 * MS_PER_DAY),
|
||||
});
|
||||
expect(computeInventoryState(item, NOW_MS)).toBe("EXPIRED");
|
||||
});
|
||||
|
||||
it("returns EXPIRED when printed expiry has lapsed even if in-use clock still valid", () => {
|
||||
const item = makeItem({
|
||||
firstUseAt: new Date(NOW_MS - 5 * MS_PER_DAY),
|
||||
printedExpiry: new Date(NOW_MS - 1 * MS_PER_DAY),
|
||||
});
|
||||
expect(computeInventoryState(item, NOW_MS)).toBe("EXPIRED");
|
||||
});
|
||||
|
||||
it("returns USED_UP when dosesRemaining is zero (terminal — outranks EXPIRED)", () => {
|
||||
const item = makeItem({
|
||||
dosesRemaining: 0,
|
||||
firstUseAt: new Date(NOW_MS - 31 * MS_PER_DAY),
|
||||
});
|
||||
expect(computeInventoryState(item, NOW_MS)).toBe("USED_UP");
|
||||
});
|
||||
|
||||
it("returns USED_UP even when printed expiry has lapsed (dosesRemaining wins)", () => {
|
||||
const item = makeItem({
|
||||
dosesRemaining: 0,
|
||||
printedExpiry: new Date(NOW_MS - 5 * MS_PER_DAY),
|
||||
});
|
||||
expect(computeInventoryState(item, NOW_MS)).toBe("USED_UP");
|
||||
});
|
||||
|
||||
it("respects a custom in-use window override", () => {
|
||||
// Ozempic — 56 days per its EPAR.
|
||||
const item = makeItem({
|
||||
firstUseAt: new Date(NOW_MS - 45 * MS_PER_DAY),
|
||||
});
|
||||
expect(computeInventoryState(item, NOW_MS, 56)).toBe("IN_USE");
|
||||
expect(computeInventoryState(item, NOW_MS, 30)).toBe("EXPIRED");
|
||||
});
|
||||
|
||||
it("treats the exact 30-day boundary as still IN_USE", () => {
|
||||
const item = makeItem({
|
||||
firstUseAt: new Date(NOW_MS - 30 * MS_PER_DAY),
|
||||
});
|
||||
expect(computeInventoryState(item, NOW_MS)).toBe("IN_USE");
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeExpiresAt", () => {
|
||||
it("returns null when neither firstUseAt nor printedExpiry is set", () => {
|
||||
expect(computeExpiresAt(null, null)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns the printed expiry when only printedExpiry is set", () => {
|
||||
const printed = new Date("2027-01-15T00:00:00Z");
|
||||
expect(computeExpiresAt(null, printed)).toEqual(printed);
|
||||
});
|
||||
|
||||
it("returns firstUseAt + 30 days when only firstUseAt is set", () => {
|
||||
const firstUse = new Date("2026-05-01T00:00:00Z");
|
||||
const result = computeExpiresAt(firstUse, null);
|
||||
expect(result?.getTime()).toBe(firstUse.getTime() + 30 * MS_PER_DAY);
|
||||
});
|
||||
|
||||
it("returns the in-use deadline when it lands before printed expiry", () => {
|
||||
const firstUse = new Date("2026-05-01T00:00:00Z");
|
||||
const printed = new Date("2027-01-15T00:00:00Z");
|
||||
const result = computeExpiresAt(firstUse, printed);
|
||||
expect(result?.getTime()).toBe(firstUse.getTime() + 30 * MS_PER_DAY);
|
||||
});
|
||||
|
||||
it("returns the printed expiry when it lands before the in-use deadline", () => {
|
||||
const firstUse = new Date("2026-05-01T00:00:00Z");
|
||||
const printed = new Date("2026-05-10T00:00:00Z");
|
||||
const result = computeExpiresAt(firstUse, printed);
|
||||
expect(result).toEqual(printed);
|
||||
});
|
||||
});
|
||||
|
||||
describe("decrementDose", () => {
|
||||
it("first_use — flips ACTIVE → IN_USE and sets firstUseAt", () => {
|
||||
const item = makeItem();
|
||||
const result = decrementDose(item, NOW);
|
||||
|
||||
expect(result.change).toBe("first_use");
|
||||
expect(result.item.state).toBe("IN_USE");
|
||||
expect(result.item.firstUseAt).toEqual(NOW);
|
||||
expect(result.item.dosesRemaining).toBe(3);
|
||||
expect(result.item.expiresAt?.getTime()).toBe(
|
||||
NOW_MS + DEFAULT_IN_USE_WINDOW_DAYS * MS_PER_DAY,
|
||||
);
|
||||
});
|
||||
|
||||
it("consumed — IN_USE pen with doses left stays IN_USE", () => {
|
||||
const firstUse = new Date(NOW_MS - 3 * MS_PER_DAY);
|
||||
const item = makeItem({
|
||||
state: "IN_USE",
|
||||
dosesRemaining: 3,
|
||||
firstUseAt: firstUse,
|
||||
});
|
||||
const result = decrementDose(item, NOW);
|
||||
|
||||
expect(result.change).toBe("consumed");
|
||||
expect(result.item.state).toBe("IN_USE");
|
||||
expect(result.item.firstUseAt).toEqual(firstUse);
|
||||
expect(result.item.dosesRemaining).toBe(2);
|
||||
});
|
||||
|
||||
it("depleted — final dose flips state to USED_UP", () => {
|
||||
const firstUse = new Date(NOW_MS - 3 * MS_PER_DAY);
|
||||
const item = makeItem({
|
||||
state: "IN_USE",
|
||||
dosesRemaining: 1,
|
||||
firstUseAt: firstUse,
|
||||
});
|
||||
const result = decrementDose(item, NOW);
|
||||
|
||||
expect(result.change).toBe("depleted");
|
||||
expect(result.item.state).toBe("USED_UP");
|
||||
expect(result.item.dosesRemaining).toBe(0);
|
||||
});
|
||||
|
||||
it("does not mutate its input", () => {
|
||||
const firstUse = new Date(NOW_MS - 3 * MS_PER_DAY);
|
||||
const item = makeItem({
|
||||
state: "IN_USE",
|
||||
dosesRemaining: 3,
|
||||
firstUseAt: firstUse,
|
||||
});
|
||||
const snapshot = JSON.stringify(item);
|
||||
decrementDose(item, NOW);
|
||||
expect(JSON.stringify(item)).toBe(snapshot);
|
||||
});
|
||||
|
||||
it("clamps dosesRemaining at zero even if called on an already-empty pen", () => {
|
||||
const item = makeItem({
|
||||
state: "USED_UP",
|
||||
dosesRemaining: 0,
|
||||
firstUseAt: new Date(NOW_MS - 5 * MS_PER_DAY),
|
||||
});
|
||||
const result = decrementDose(item, NOW);
|
||||
|
||||
expect(result.item.dosesRemaining).toBe(0);
|
||||
expect(result.item.state).toBe("USED_UP");
|
||||
});
|
||||
});
|
||||
|
||||
describe("daysRemainingInUse", () => {
|
||||
it("returns null for an ACTIVE (unopened) pen", () => {
|
||||
expect(daysRemainingInUse(makeItem(), NOW_MS)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for an EXPIRED pen (clock blew)", () => {
|
||||
const item = makeItem({
|
||||
firstUseAt: new Date(NOW_MS - 31 * MS_PER_DAY),
|
||||
});
|
||||
expect(daysRemainingInUse(item, NOW_MS)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for a USED_UP pen", () => {
|
||||
const item = makeItem({
|
||||
dosesRemaining: 0,
|
||||
firstUseAt: new Date(NOW_MS - 5 * MS_PER_DAY),
|
||||
});
|
||||
expect(daysRemainingInUse(item, NOW_MS)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns 25 days when the pen was opened 5 days ago", () => {
|
||||
const item = makeItem({
|
||||
firstUseAt: new Date(NOW_MS - 5 * MS_PER_DAY),
|
||||
});
|
||||
expect(daysRemainingInUse(item, NOW_MS)).toBe(25);
|
||||
});
|
||||
|
||||
it("returns 0 on the last day of the window (still IN_USE)", () => {
|
||||
// 29 days + 23h59m elapsed — under one day left, floor → 0.
|
||||
const item = makeItem({
|
||||
firstUseAt: new Date(NOW_MS - 30 * MS_PER_DAY + 60_000),
|
||||
});
|
||||
expect(daysRemainingInUse(item, NOW_MS)).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,224 @@
|
||||
/**
|
||||
* v1.4.25 W19b — pen / vial inventory state machine.
|
||||
*
|
||||
* Pure functions over a minimal `InventoryItemView` shape — no DB
|
||||
* access, no Prisma imports. The route handlers and the daily expire
|
||||
* cron both compose this module with their own persistence layer.
|
||||
*
|
||||
* Decision tree (in evaluation order):
|
||||
* 1. dosesRemaining === 0 → USED_UP (terminal)
|
||||
* 2. printedExpiry && now > expiry → EXPIRED (printed label trumps clock)
|
||||
* 3. firstUseAt && now > firstUseAt + IN_USE_WINDOW_MS → EXPIRED
|
||||
* 4. firstUseAt is set → IN_USE
|
||||
* 5. otherwise → ACTIVE
|
||||
*
|
||||
* The window length is fixed at 30 days per EMA EPAR §6.3 for
|
||||
* Mounjaro KwikPen / Saxenda / Trulicity. Ozempic ships with a 56-day
|
||||
* window per its own EPAR — that drug-specific override is *not*
|
||||
* encoded here yet. The W19b scope deliberately uses the
|
||||
* conservative 30-day default; a follow-up can read the
|
||||
* drug-knowledge layer (`glp1-knowledge.ts`) and parametrise the
|
||||
* window per Glp1DrugId. The state machine itself remains
|
||||
* window-agnostic — it accepts the window length as input.
|
||||
*/
|
||||
|
||||
import type { MedicationInventoryState } from "@/generated/prisma/client";
|
||||
|
||||
/** Default in-use window per EMA EPAR §6.3 for the strictest GLP-1
|
||||
* agonists (Mounjaro, Saxenda, Trulicity). 30 days. */
|
||||
export const DEFAULT_IN_USE_WINDOW_DAYS = 30;
|
||||
|
||||
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* The subset of `MedicationInventoryItem` the state machine needs.
|
||||
* Decoupling here means tests don't have to construct a full Prisma
|
||||
* row, and the helper composes cleanly with the route layer's
|
||||
* partial-update shapes.
|
||||
*/
|
||||
export interface InventoryItemView {
|
||||
state: MedicationInventoryState;
|
||||
dosesTotal: number;
|
||||
dosesRemaining: number;
|
||||
firstUseAt: Date | null;
|
||||
printedExpiry: Date | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the canonical state for a pen / vial given the wall clock.
|
||||
*
|
||||
* The function is pure — given the same input it always returns the
|
||||
* same state — so the caller can re-evaluate as often as it wants
|
||||
* without side-effects. The expire-cron and the intake-hook both use
|
||||
* this same evaluator; the only difference is what they do with the
|
||||
* result.
|
||||
*/
|
||||
export function computeInventoryState(
|
||||
item: InventoryItemView,
|
||||
nowMs: number,
|
||||
inUseWindowDays: number = DEFAULT_IN_USE_WINDOW_DAYS,
|
||||
): MedicationInventoryState {
|
||||
// (1) Terminal — once a pen is used up, it stays used up. The
|
||||
// printed expiry on an empty pen is irrelevant; the EXPIRED state
|
||||
// exists to warn the user not to inject from a pen that's still
|
||||
// got doses but has gone stale, and an empty pen has neither
|
||||
// problem.
|
||||
if (item.dosesRemaining <= 0) {
|
||||
return "USED_UP";
|
||||
}
|
||||
|
||||
// (2) Printed expiry trumps the in-use clock. A pen whose carton
|
||||
// expiry has lapsed is EXPIRED even if it was never opened.
|
||||
if (item.printedExpiry && nowMs > item.printedExpiry.getTime()) {
|
||||
return "EXPIRED";
|
||||
}
|
||||
|
||||
// (3) In-use clock blew. firstUseAt + window < now ⇒ EXPIRED.
|
||||
if (item.firstUseAt) {
|
||||
const inUseDeadlineMs =
|
||||
item.firstUseAt.getTime() + inUseWindowDays * MS_PER_DAY;
|
||||
if (nowMs > inUseDeadlineMs) {
|
||||
return "EXPIRED";
|
||||
}
|
||||
// (4) Has been opened but still inside the window.
|
||||
return "IN_USE";
|
||||
}
|
||||
|
||||
// (5) Refrigerated, unopened.
|
||||
return "ACTIVE";
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the `expiresAt` column. It's the min of the in-use
|
||||
* deadline (firstUseAt + window) and the printed expiry — whichever
|
||||
* lands first. If the pen has never been opened we fall back to the
|
||||
* printed expiry alone (in-use clock hasn't started). If neither is
|
||||
* set we return null so the daily expire-cron has nothing to scan.
|
||||
*/
|
||||
export function computeExpiresAt(
|
||||
firstUseAt: Date | null,
|
||||
printedExpiry: Date | null,
|
||||
inUseWindowDays: number = DEFAULT_IN_USE_WINDOW_DAYS,
|
||||
): Date | null {
|
||||
const inUseDeadline = firstUseAt
|
||||
? new Date(firstUseAt.getTime() + inUseWindowDays * MS_PER_DAY)
|
||||
: null;
|
||||
|
||||
if (inUseDeadline && printedExpiry) {
|
||||
return inUseDeadline.getTime() < printedExpiry.getTime()
|
||||
? inUseDeadline
|
||||
: printedExpiry;
|
||||
}
|
||||
return inUseDeadline ?? printedExpiry ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Outcome of decrementing a dose. `first_use` fires when a pen
|
||||
* transitions from ACTIVE → IN_USE (this intake event was its first
|
||||
* use). `consumed` is the routine case. `depleted` fires on the
|
||||
* intake that brings dosesRemaining to zero.
|
||||
*/
|
||||
export type DecrementOutcome = "first_use" | "consumed" | "depleted";
|
||||
|
||||
/**
|
||||
* Result of `decrementDose`. The returned `item` is a fresh object —
|
||||
* the function does not mutate its input. The caller persists the
|
||||
* returned shape (typically via a `prisma.medicationInventoryItem.update`).
|
||||
*/
|
||||
export interface DecrementResult {
|
||||
item: {
|
||||
state: MedicationInventoryState;
|
||||
dosesRemaining: number;
|
||||
firstUseAt: Date | null;
|
||||
expiresAt: Date | null;
|
||||
};
|
||||
change: DecrementOutcome;
|
||||
}
|
||||
|
||||
/**
|
||||
* Consume one dose from a pen.
|
||||
*
|
||||
* If the pen is ACTIVE (never opened), this intake event sets
|
||||
* `firstUseAt = intakeAt` and starts the 30-day clock. If the pen
|
||||
* was already IN_USE, only `dosesRemaining` decrements. When the
|
||||
* last dose is taken, the state moves to USED_UP and `expiresAt`
|
||||
* is left untouched (the in-use window is no longer meaningful for
|
||||
* an empty pen, but persisting the deadline preserves the audit
|
||||
* trail).
|
||||
*
|
||||
* The function is defensive — if called on an EXPIRED or USED_UP
|
||||
* pen it still decrements (caller's job to gate). The state
|
||||
* recomputation respects whatever the final dose count + clock say.
|
||||
*/
|
||||
export function decrementDose(
|
||||
item: InventoryItemView,
|
||||
intakeAt: Date,
|
||||
inUseWindowDays: number = DEFAULT_IN_USE_WINDOW_DAYS,
|
||||
): DecrementResult {
|
||||
const wasFirstUse = item.firstUseAt === null;
|
||||
const nextFirstUseAt = item.firstUseAt ?? intakeAt;
|
||||
const nextDosesRemaining = Math.max(0, item.dosesRemaining - 1);
|
||||
const nextExpiresAt = computeExpiresAt(
|
||||
nextFirstUseAt,
|
||||
item.printedExpiry,
|
||||
inUseWindowDays,
|
||||
);
|
||||
|
||||
const projected: InventoryItemView = {
|
||||
state: item.state,
|
||||
dosesTotal: item.dosesTotal,
|
||||
dosesRemaining: nextDosesRemaining,
|
||||
firstUseAt: nextFirstUseAt,
|
||||
printedExpiry: item.printedExpiry,
|
||||
};
|
||||
const nextState = computeInventoryState(
|
||||
projected,
|
||||
intakeAt.getTime(),
|
||||
inUseWindowDays,
|
||||
);
|
||||
|
||||
const change: DecrementOutcome =
|
||||
nextDosesRemaining === 0
|
||||
? "depleted"
|
||||
: wasFirstUse
|
||||
? "first_use"
|
||||
: "consumed";
|
||||
|
||||
return {
|
||||
item: {
|
||||
state: nextState,
|
||||
dosesRemaining: nextDosesRemaining,
|
||||
firstUseAt: nextFirstUseAt,
|
||||
expiresAt: nextExpiresAt,
|
||||
},
|
||||
change,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Whole-days remaining in the 30-day in-use window, rounded toward
|
||||
* zero. Returns null when the pen is not currently IN_USE (the UI
|
||||
* surface for ACTIVE / EXPIRED / USED_UP shows a different label).
|
||||
*
|
||||
* The day count is computed against the in-use deadline only —
|
||||
* the printed expiry is its own separate countdown surfaced as the
|
||||
* "Carton expires {date}" label. This split avoids the UI lying to
|
||||
* the user when the printed expiry is more imminent than the
|
||||
* in-use deadline (the EXPIRED transition is still correct via
|
||||
* `computeInventoryState`, but the day-count chip shows the
|
||||
* 30-day-clock number).
|
||||
*/
|
||||
export function daysRemainingInUse(
|
||||
item: InventoryItemView,
|
||||
nowMs: number,
|
||||
inUseWindowDays: number = DEFAULT_IN_USE_WINDOW_DAYS,
|
||||
): number | null {
|
||||
if (!item.firstUseAt) return null;
|
||||
const state = computeInventoryState(item, nowMs, inUseWindowDays);
|
||||
if (state !== "IN_USE") return null;
|
||||
const deadlineMs =
|
||||
item.firstUseAt.getTime() + inUseWindowDays * MS_PER_DAY;
|
||||
const remainingMs = deadlineMs - nowMs;
|
||||
if (remainingMs <= 0) return 0;
|
||||
return Math.floor(remainingMs / MS_PER_DAY);
|
||||
}
|
||||
Reference in new issue
Block a user