mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 21:32:18 +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
@@ -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