fix(medications): warn when an as-needed medication runs low

Since the supply runway stopped counting an as-needed schedule as a daily
dose, a medication taken only as needed had no runway and never raised a
low-supply notice. It now warns once when two doses or fewer are left,
with its own line naming what is left, and re-arms after a refill like any
other medication.
This commit is contained in:
Marc-André Bombeck committed 2026-09-26 22:14:03 +02:00
1 parent 04eb8dd7f8
commit 612eedcbbb
9 files changed
+135 -14

No files matched your search

@@ -49,6 +49,7 @@ describe("evaluateMedicationRunway", () => {
dosesRemaining: 6,
unitsRemaining: 6,
expiredUnits: 0,
asNeeded: false,
});
});
@@ -444,6 +445,73 @@ describe("runMedicationLowStockTick", () => {
expect(prisma.medication.update).not.toHaveBeenCalled();
});
it("an as-needed medication warns once when two doses or fewer are left", async () => {
const prn = { ...dailySchedule, scheduleType: "PRN" };
const dispatch = okDispatch();
// Three doses left: no cadence, so no runway, and above the dose floor.
const above = prismaMock({
users: [userRow()],
medications: [medRow({ schedules: [prn], inventoryItems: [item(3)] })],
});
const quiet = await runMedicationLowStockTick(
above as unknown as PrismaClient,
now,
{ dispatch },
);
expect(quiet.skippedAboveThreshold).toBe(1);
expect(dispatch).not.toHaveBeenCalled();
// Two left: warns, names what is left, and stamps the dose floor.
const low = prismaMock({
users: [userRow()],
medications: [medRow({ schedules: [prn], inventoryItems: [item(2)] })],
});
const summary = await runMedicationLowStockTick(
low as unknown as PrismaClient,
now,
{ dispatch },
);
expect(summary.notified).toBe(1);
const payload = dispatch.mock.calls[0]?.[0] as unknown as {
message: string;
metadata: Record<string, unknown>;
};
expect(payload.message).toBe(
"Metformin: only 2 units left. Reorder before you need the next dose.",
);
expect(payload.metadata).toMatchObject({
runwayDays: null,
triggerDays: null,
asNeeded: true,
dosesRemaining: 2,
});
expect(low.medication.update).toHaveBeenCalledWith({
where: { id: "med-1" },
data: { lowStockNotifiedAt: now, lowStockNotifiedThresholdDays: 2 },
});
// Still two left the next day: no repeat.
const again = prismaMock({
users: [userRow()],
medications: [
medRow({
schedules: [prn],
inventoryItems: [item(1)],
lowStockNotifiedAt: daysAgo(1),
lowStockNotifiedThresholdDays: 2,
}),
],
});
const repeat = await runMedicationLowStockTick(
again as unknown as PrismaClient,
now,
{ dispatch },
);
expect(repeat.skippedAlreadyNotified).toBe(1);
expect(dispatch).toHaveBeenCalledTimes(1);
});
it("an exhausted supply (runway 0) notifies with the depleted copy", async () => {
const dispatch = okDispatch();
const prisma = prismaMock({
+53 -7
View File
@@ -53,6 +53,12 @@
*
* Inactive / paused medications are skipped: nothing consumes their
* stock, so their runway is not falling.
*
* As-needed (PRN) medications have no cadence and so no runway in days.
* Once the runway math stopped counting a PRN schedule as a daily dose,
* a medication taken only as needed never warned at all. It now warns on
* an absolute floor instead: `AS_NEEDED_LOW_STOCK_DOSES` doses or fewer
* left, once per crossing, with the same stamp and re-arm rule.
*/
import type { PrismaClient } from "@/generated/prisma/client";
import {
@@ -89,6 +95,12 @@ export const MEDICATION_LOW_STOCK_QUEUE = "medication-low-stock";
*/
export const MEDICATION_LOW_STOCK_CRON = "0 9 * * *";
/**
* An as-needed medication warns when this many doses or fewer are left: no
* cadence says how many days that is, so the floor is a dose count.
*/
export const AS_NEEDED_LOW_STOCK_DOSES = 2;
export interface MedicationLowStockSummary {
usersScanned: number;
skippedThresholdOff: number;
@@ -114,6 +126,11 @@ export interface LowStockEvaluation {
* able to tell them apart.
*/
expiredUnits: number;
/**
* Taken only as needed: no schedule consumes on a cadence, and at least
* one is PRN. Such a medication warns on the dose floor, not on days.
*/
asNeeded: boolean;
}
/**
@@ -156,6 +173,7 @@ export function evaluateMedicationRunway(
dosesRemaining: supply.dosesRemaining,
unitsRemaining: supply.unitsRemaining,
expiredUnits: supply.expiredUnits,
asNeeded: schedules.some((sched) => sched.scheduleType === "PRN"),
};
}
const runwayDays =
@@ -167,6 +185,7 @@ export function evaluateMedicationRunway(
dosesRemaining: supply.dosesRemaining,
unitsRemaining: supply.unitsRemaining,
expiredUnits: supply.expiredUnits,
asNeeded: false,
};
}
@@ -249,10 +268,25 @@ export function buildLowStockPayload(input: {
triggerDays: number;
schedules: RunwaySchedule[];
today: Date;
/** Taken as needed: no dates to name, only what is left. */
asNeeded?: boolean;
}): { title: string; body: string } {
const t = getServerTranslator(coerceLocale(input.locale)).t;
const title = t("lowStockReminders.title", { medName: input.medName });
if (
input.asNeeded &&
!(input.unitsRemaining <= 0 && input.expiredUnits > 0)
) {
return {
title,
body: t("lowStockReminders.bodyAsNeeded", {
medName: input.medName,
units: input.unitsRemaining,
}),
};
}
if (input.runwayDays < 1) {
// Nothing usable left. Whether that means the packs are empty or
// that every remaining unit was written off is the difference
@@ -464,14 +498,21 @@ export async function runMedicationLowStockTick(
user.notificationPrefs,
med.reorderLeadDays,
);
const triggerDays = lowStockTriggerDays({
lowStockRunwayDays: thresholdDays,
leadDays,
schedules: runwaySchedules,
});
// An as-needed medication has no runway in days: it crosses on the
// dose floor, and the same stamp records that floor.
const asNeeded = evaluation.runwayDays === null && evaluation.asNeeded;
const triggerDays = asNeeded
? AS_NEEDED_LOW_STOCK_DOSES
: lowStockTriggerDays({
lowStockRunwayDays: thresholdDays,
leadDays,
schedules: runwaySchedules,
});
const decision = decideLowStockAction({
runwayDays: evaluation.runwayDays,
runwayDays: asNeeded
? evaluation.dosesRemaining
: evaluation.runwayDays,
triggerDays,
notifiedAt: med.lowStockNotifiedAt,
notifiedThresholdDays: med.lowStockNotifiedThresholdDays,
@@ -522,6 +563,7 @@ export async function runMedicationLowStockTick(
triggerDays,
schedules: runwaySchedules,
today: now,
asNeeded,
});
const renderForRecipient = (locale: Locale) => {
const rendered = buildLowStockPayload({
@@ -534,6 +576,7 @@ export async function runMedicationLowStockTick(
triggerDays,
schedules: runwaySchedules,
today: now,
asNeeded,
});
return { title: rendered.title, message: rendered.body };
};
@@ -550,7 +593,10 @@ export async function runMedicationLowStockTick(
thresholdDays,
// v1.17.0 — the effective trigger + reorder lead so the
// client can render the same dates the push body names.
triggerDays,
// An as-needed medication has no trigger in days; it
// crossed the dose floor instead.
triggerDays: asNeeded ? null : triggerDays,
asNeeded,
leadDays,
unitsRemaining: evaluation.unitsRemaining,
dosesRemaining: evaluation.dosesRemaining,