mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
1 parent
04eb8dd7f8
commit
612eedcbbb
9 files changed
+135
-14
No files matched your search
+2
-1
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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,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,
|
||||
|
||||
Reference in new issue
Block a user