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

+2 -1
View File
@@ -8085,7 +8085,8 @@
"bodyToday": "Dein Vorrat an {medName} reicht keinen ganzen Tag mehr ({units} Einheiten übrig).",
"bodyReorder": "Dein Vorrat an {medName} ({units} Einheiten) ist um den {runsOutOn} aufgebraucht — bestelle bis zum {reorderBy} nach.",
"bodyLastDose": "Dein Vorrat an {medName} ({units} Einheiten) reicht noch etwa für die letzte Dosis. Aufgebraucht um den {runsOutOn}.",
"bodyExpired": "{medName}: kein nutzbarer Bestand mehr. {units} Einheiten sind als abgelaufen markiert."
"bodyExpired": "{medName}: kein nutzbarer Bestand mehr. {units} Einheiten sind als abgelaufen markiert.",
"bodyAsNeeded": "Von {medName} sind nur noch {units} Einheiten übrig. Bestelle nach, bevor du die nächste Dosis brauchst."
},
"moodReminders": {
"dailyTitle": "Stimmung erfassen",
+2 -1
View File
@@ -8085,7 +8085,8 @@
"bodyToday": "{medName}: {units} units left — less than one day of supply.",
"bodyReorder": "{medName}: {units} units left. Supply runs out around {runsOutOn} — reorder by {reorderBy}.",
"bodyLastDose": "{medName}: {units} units left — about your final dose. Supply runs out around {runsOutOn}.",
"bodyExpired": "{medName}: no usable supply left. {units} units are marked expired."
"bodyExpired": "{medName}: no usable supply left. {units} units are marked expired.",
"bodyAsNeeded": "{medName}: only {units} units left. Reorder before you need the next dose."
},
"moodReminders": {
"dailyTitle": "Log your mood",
+2 -1
View File
@@ -8085,7 +8085,8 @@
"bodyToday": "{medName}: quedan {units} unidades — menos de un día de suministro.",
"bodyReorder": "{medName}: quedan {units} unidades. Las existencias se agotan hacia el {runsOutOn} — vuelve a pedir antes del {reorderBy}.",
"bodyLastDose": "{medName}: quedan {units} unidades — alrededor de tu última dosis. Las existencias se agotan hacia el {runsOutOn}.",
"bodyExpired": "{medName}: no quedan existencias utilizables. {units} unidades están marcadas como caducadas."
"bodyExpired": "{medName}: no quedan existencias utilizables. {units} unidades están marcadas como caducadas.",
"bodyAsNeeded": "{medName}: solo quedan {units} unidades. Vuelve a pedir antes de necesitar la próxima dosis."
},
"moodReminders": {
"dailyTitle": "Registrar el ánimo",
+2 -1
View File
@@ -8085,7 +8085,8 @@
"bodyToday": "{medName} : {units} unités restantes — moins d’un jour de réserve.",
"bodyReorder": "{medName} : {units} unités restantes. Le stock sera épuisé vers le {runsOutOn} — recommandez avant le {reorderBy}.",
"bodyLastDose": "{medName} : {units} unités restantes — environ votre dernière dose. Le stock sera épuisé vers le {runsOutOn}.",
"bodyExpired": "{medName} : plus de stock utilisable. {units} unités sont marquées périmées."
"bodyExpired": "{medName} : plus de stock utilisable. {units} unités sont marquées périmées.",
"bodyAsNeeded": "{medName} : plus que {units} unités. Recommandez avant d’avoir besoin de la prochaine dose."
},
"moodReminders": {
"dailyTitle": "Enregistrer votre humeur",
+2 -1
View File
@@ -8085,7 +8085,8 @@
"bodyToday": "{medName}: {units} unità rimaste — meno di un giorno di scorta.",
"bodyReorder": "{medName}: {units} unità rimaste. Le scorte finiscono intorno al {runsOutOn} — riordina entro il {reorderBy}.",
"bodyLastDose": "{medName}: {units} unità rimaste — circa la tua ultima dose. Le scorte finiscono intorno al {runsOutOn}.",
"bodyExpired": "{medName}: nessuna scorta utilizzabile. {units} unità sono contrassegnate come scadute."
"bodyExpired": "{medName}: nessuna scorta utilizzabile. {units} unità sono contrassegnate come scadute.",
"bodyAsNeeded": "{medName}: restano solo {units} unità. Riordina prima di aver bisogno della prossima dose."
},
"moodReminders": {
"dailyTitle": "Registra il tuo umore",
+2 -1
View File
@@ -8085,7 +8085,8 @@
"bodyToday": "{medName}: {units}개 남았어요 — 하루치도 안 돼요.",
"bodyReorder": "{medName}: {units}개 남았어요. {runsOutOn}쯤 소진돼요 — {reorderBy}까지 재주문하세요.",
"bodyLastDose": "{medName}: {units}개 남았어요 — 마지막 복용분이에요. {runsOutOn}쯤 소진돼요.",
"bodyExpired": "{medName}: 쓸 수 있는 재고가 없어요. {units}개가 유효기간 만료로 표시돼 있어요."
"bodyExpired": "{medName}: 쓸 수 있는 재고가 없어요. {units}개가 유효기간 만료로 표시돼 있어요.",
"bodyAsNeeded": "{medName}: {units}개만 남았어요. 다음에 필요하기 전에 재주문하세요."
},
"moodReminders": {
"dailyTitle": "기분 기록하기",
+2 -1
View File
@@ -8085,7 +8085,8 @@
"bodyToday": "{medName}: pozostało {units} jednostek — mniej niż jeden dzień zapasu.",
"bodyReorder": "{medName}: pozostało {units} jednostek. Zapas skończy się około {runsOutOn} — zamów ponownie do {reorderBy}.",
"bodyLastDose": "{medName}: pozostało {units} jednostek — to mniej więcej Twoja ostatnia dawka. Zapas skończy się około {runsOutOn}.",
"bodyExpired": "{medName}: brak zapasu do użycia. {units} jednostek oznaczono jako przeterminowane."
"bodyExpired": "{medName}: brak zapasu do użycia. {units} jednostek oznaczono jako przeterminowane.",
"bodyAsNeeded": "{medName}: zostało tylko {units} jednostek. Zamów ponownie, zanim będziesz potrzebować kolejnej dawki."
},
"moodReminders": {
"dailyTitle": "Zapisz nastrój",
@@ -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,