From 612eedcbbb17c1bdf71c657b1993896e5361b789 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 26 Sep 2026 22:14:03 +0200 Subject: [PATCH] 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. --- messages/de.json | 3 +- messages/en.json | 3 +- messages/es.json | 3 +- messages/fr.json | 3 +- messages/it.json | 3 +- messages/ko.json | 3 +- messages/pl.json | 3 +- .../__tests__/medication-low-stock.test.ts | 68 +++++++++++++++++++ src/lib/jobs/medication-low-stock.ts | 60 ++++++++++++++-- 9 files changed, 135 insertions(+), 14 deletions(-) diff --git a/messages/de.json b/messages/de.json index 8b6523d6b..3f6b58b75 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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", diff --git a/messages/en.json b/messages/en.json index 8eeaf9ad7..324f73f5e 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", diff --git a/messages/es.json b/messages/es.json index 193dd1fea..e2eecfc72 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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", diff --git a/messages/fr.json b/messages/fr.json index 2fe9be017..c1a90a9fe 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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", diff --git a/messages/it.json b/messages/it.json index 4f527a583..6ec3c1592 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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", diff --git a/messages/ko.json b/messages/ko.json index 5e80dc3b9..afd335255 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -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": "기분 기록하기", diff --git a/messages/pl.json b/messages/pl.json index f04de5400..4d6ab0504 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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", diff --git a/src/lib/jobs/__tests__/medication-low-stock.test.ts b/src/lib/jobs/__tests__/medication-low-stock.test.ts index 77ce5506e..5aa3d9b6c 100644 --- a/src/lib/jobs/__tests__/medication-low-stock.test.ts +++ b/src/lib/jobs/__tests__/medication-low-stock.test.ts @@ -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; + }; + 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({ diff --git a/src/lib/jobs/medication-low-stock.ts b/src/lib/jobs/medication-low-stock.ts index 86df5a22c..ea44a842d 100644 --- a/src/lib/jobs/medication-low-stock.ts +++ b/src/lib/jobs/medication-low-stock.ts @@ -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,