mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +00:00
feat(medications): make low-stock alert reorder-lead-time aware
The low-stock alert fired when the projected runway dropped below the user threshold (default 7 days). For a sparse cadence — a weekly injection's 7-day floor is barely one dose-interval — the warning landed with about one dose left, too late to reorder before the supply ran out. Add an optional per-medication reorder lead time plus a user-level default (0–60 days, default 10) and widen the alert trigger to max(lowStockRunwayDays, leadDays + cadenceIntervalDays), so the warning lands before the last dose for any cadence. Because the trigger is a max() over the user floor it never shrinks anyone's current threshold; a daily med with no lead keeps its prior boundary. The push body and the card notice surface concrete dates — supply runs out around <date>, reorder by <date> — with a last-dose informational state and the days/units fallback when supply data is thin. Retire the stale fixed 4-dose count threshold on the GLP-1 details endpoint and route its card lowStock flag through the same runway evaluation so the card and the daily cron agree. Surface the lead default in the low-stock settings card with progressive disclosure; a single medication can override it. Additive schema: nullable Medication.reorderLeadDays (migration 0161, no backfill) and notificationPrefs.medication.reorderLeadDays. OpenAPI regenerated.
This commit is contained in:
1 parent
52bf985734
commit
08aa655be4
29 files changed
+1060
-97
No files matched your search
@@ -92,6 +92,7 @@ describe("GET /api/auth/me/notification-prefs", () => {
|
||||
clientManaged: false,
|
||||
deliveryDefault: "server",
|
||||
lowStockRunwayDays: 7,
|
||||
reorderLeadDays: 10,
|
||||
},
|
||||
mood: { reminderHour: 22 },
|
||||
cycle: { clientManaged: false },
|
||||
@@ -123,6 +124,7 @@ describe("GET /api/auth/me/notification-prefs", () => {
|
||||
clientManaged: true,
|
||||
deliveryDefault: "server",
|
||||
lowStockRunwayDays: 7,
|
||||
reorderLeadDays: 10,
|
||||
},
|
||||
mood: { reminderHour: 22 },
|
||||
cycle: { clientManaged: false },
|
||||
@@ -156,6 +158,7 @@ describe("GET /api/auth/me/notification-prefs", () => {
|
||||
clientManaged: false,
|
||||
deliveryDefault: "server",
|
||||
lowStockRunwayDays: 7,
|
||||
reorderLeadDays: 10,
|
||||
},
|
||||
mood: { reminderHour: 22 },
|
||||
cycle: { clientManaged: false },
|
||||
@@ -200,6 +203,7 @@ describe("PATCH /api/auth/me/notification-prefs", () => {
|
||||
clientManaged: true,
|
||||
deliveryDefault: "server",
|
||||
lowStockRunwayDays: 7,
|
||||
reorderLeadDays: 10,
|
||||
},
|
||||
mood: { reminderHour: 22 },
|
||||
cycle: { clientManaged: false },
|
||||
@@ -220,6 +224,7 @@ describe("PATCH /api/auth/me/notification-prefs", () => {
|
||||
clientManaged: true,
|
||||
deliveryDefault: "server",
|
||||
lowStockRunwayDays: 7,
|
||||
reorderLeadDays: 10,
|
||||
},
|
||||
mood: { reminderHour: 22 },
|
||||
cycle: { clientManaged: false },
|
||||
@@ -244,6 +249,7 @@ describe("PATCH /api/auth/me/notification-prefs", () => {
|
||||
clientManaged: false,
|
||||
deliveryDefault: "server",
|
||||
lowStockRunwayDays: 7,
|
||||
reorderLeadDays: 10,
|
||||
},
|
||||
mood: { reminderHour: 22 },
|
||||
cycle: { clientManaged: false },
|
||||
@@ -260,6 +266,7 @@ describe("PATCH /api/auth/me/notification-prefs", () => {
|
||||
clientManaged: true,
|
||||
deliveryDefault: "server",
|
||||
lowStockRunwayDays: 7,
|
||||
reorderLeadDays: 10,
|
||||
},
|
||||
mood: { reminderHour: 22 },
|
||||
cycle: { clientManaged: false },
|
||||
@@ -329,6 +336,7 @@ describe("PATCH /api/auth/me/notification-prefs", () => {
|
||||
clientManaged: true,
|
||||
deliveryDefault: "server",
|
||||
lowStockRunwayDays: 7,
|
||||
reorderLeadDays: 10,
|
||||
},
|
||||
mood: { reminderHour: 22 },
|
||||
cycle: { clientManaged: false },
|
||||
@@ -363,6 +371,7 @@ describe("PATCH /api/auth/me/notification-prefs", () => {
|
||||
clientManaged: true,
|
||||
deliveryDefault: "server",
|
||||
lowStockRunwayDays: 7,
|
||||
reorderLeadDays: 10,
|
||||
},
|
||||
mood: { reminderHour: 22 },
|
||||
cycle: { clientManaged: false },
|
||||
@@ -383,6 +392,7 @@ describe("PATCH /api/auth/me/notification-prefs", () => {
|
||||
clientManaged: true,
|
||||
deliveryDefault: "server",
|
||||
lowStockRunwayDays: 7,
|
||||
reorderLeadDays: 10,
|
||||
},
|
||||
mood: { reminderHour: 22 },
|
||||
cycle: { clientManaged: false },
|
||||
@@ -422,6 +432,7 @@ describe("PATCH /api/auth/me/notification-prefs", () => {
|
||||
clientManaged: false,
|
||||
deliveryDefault: "server",
|
||||
lowStockRunwayDays: 7,
|
||||
reorderLeadDays: 10,
|
||||
},
|
||||
mood: { reminderHour: 9 },
|
||||
cycle: { clientManaged: false },
|
||||
@@ -471,6 +482,7 @@ describe("PATCH /api/auth/me/notification-prefs", () => {
|
||||
clientManaged: false,
|
||||
deliveryDefault: "server",
|
||||
lowStockRunwayDays: 14,
|
||||
reorderLeadDays: 10,
|
||||
},
|
||||
}),
|
||||
},
|
||||
|
||||
@@ -14,6 +14,10 @@ import { NextRequest } from "next/server";
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
medication: { findUnique: vi.fn() },
|
||||
// v1.17.0 — the GET reads the caller's notificationPrefs to resolve
|
||||
// the reorder-lead-aware low-stock trigger. Default null = the
|
||||
// documented defaults (alert on, 7-day floor, 10-day lead).
|
||||
user: { findUnique: vi.fn().mockResolvedValue({ notificationPrefs: null }) },
|
||||
medicationDoseChange: { create: vi.fn() },
|
||||
medicationInventoryEvent: { create: vi.fn() },
|
||||
},
|
||||
@@ -443,7 +447,19 @@ describe("GET /api/medications/[id]/glp1 — v1.16.10 item-backed inventory", ()
|
||||
userId: "user-1",
|
||||
doseChanges: [],
|
||||
intakeEvents: [],
|
||||
schedules: [],
|
||||
// Weekly cadence — the canonical GLP-1 case. 3 doses ≈ 21 days of
|
||||
// runway, comfortably above the reorder-lead-aware trigger
|
||||
// (max(7, 10 + 7) = 17), so the flag is FALSE.
|
||||
schedules: [
|
||||
{
|
||||
windowStart: "08:00",
|
||||
daysOfWeek: "6",
|
||||
timesOfDay: ["08:00"],
|
||||
rrule: null,
|
||||
rollingIntervalDays: null,
|
||||
},
|
||||
],
|
||||
reorderLeadDays: null,
|
||||
dosesPerUnit: 4,
|
||||
unitsPerDose: 2,
|
||||
inventoryItems: [
|
||||
@@ -476,14 +492,90 @@ describe("GET /api/medications/[id]/glp1 — v1.16.10 item-backed inventory", ()
|
||||
};
|
||||
};
|
||||
// 2 usable containers; 7 pooled units / 2 units per dose = 3 doses.
|
||||
// v1.17.0 — lowStock now rides the runway model: 3 weekly doses ≈ 21
|
||||
// days > trigger 17 ⇒ false.
|
||||
expect(body.data.inventory).toEqual({
|
||||
pensRemaining: 2,
|
||||
dosesRemaining: 3,
|
||||
weeksOfSupply: 3,
|
||||
lowStock: true,
|
||||
lowStock: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("v1.17.0 — lowStock flag matches the reorder-lead-aware runway decision (fires before the last weekly dose)", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.medication.findUnique).mockResolvedValue({
|
||||
id: "med-1",
|
||||
userId: "user-1",
|
||||
doseChanges: [],
|
||||
intakeEvents: [],
|
||||
// Weekly cadence, ONE dose left ≈ 7 days of runway. With the default
|
||||
// 10-day reorder lead the trigger is max(7, 10 + 7) = 17, so runway
|
||||
// 7 ≤ 17 ⇒ the card flag lights with reorder headroom — exactly the
|
||||
// cron's decision (no longer "at the last dose").
|
||||
schedules: [
|
||||
{
|
||||
windowStart: "08:00",
|
||||
daysOfWeek: "6",
|
||||
timesOfDay: ["08:00"],
|
||||
rrule: null,
|
||||
rollingIntervalDays: null,
|
||||
},
|
||||
],
|
||||
reorderLeadDays: null,
|
||||
dosesPerUnit: 1,
|
||||
unitsPerDose: 1,
|
||||
inventoryItems: [{ state: "ACTIVE", unitsTotal: 4, unitsRemaining: 1 }],
|
||||
inventoryEvents: [],
|
||||
} as never);
|
||||
|
||||
const res = await GET(getReq(), {
|
||||
params: Promise.resolve({ id: "med-1" }),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: { inventory: { dosesRemaining: number; lowStock: boolean } };
|
||||
};
|
||||
expect(body.data.inventory.dosesRemaining).toBe(1);
|
||||
expect(body.data.inventory.lowStock).toBe(true);
|
||||
});
|
||||
|
||||
it("v1.17.0 — lowStock is false when the alert is switched off (null threshold)", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
|
||||
notificationPrefs: { medication: { lowStockRunwayDays: null } },
|
||||
} as never);
|
||||
vi.mocked(prisma.medication.findUnique).mockResolvedValue({
|
||||
id: "med-1",
|
||||
userId: "user-1",
|
||||
doseChanges: [],
|
||||
intakeEvents: [],
|
||||
schedules: [
|
||||
{
|
||||
windowStart: "08:00",
|
||||
daysOfWeek: "6",
|
||||
timesOfDay: ["08:00"],
|
||||
rrule: null,
|
||||
rollingIntervalDays: null,
|
||||
},
|
||||
],
|
||||
reorderLeadDays: null,
|
||||
dosesPerUnit: 1,
|
||||
unitsPerDose: 1,
|
||||
inventoryItems: [{ state: "ACTIVE", unitsTotal: 4, unitsRemaining: 1 }],
|
||||
inventoryEvents: [],
|
||||
} as never);
|
||||
|
||||
const res = await GET(getReq(), {
|
||||
params: Promise.resolve({ id: "med-1" }),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: { inventory: { lowStock: boolean } };
|
||||
};
|
||||
expect(body.data.inventory.lowStock).toBe(false);
|
||||
});
|
||||
|
||||
it("returns a null inventory block when no items and no ledger rows exist", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.medication.findUnique).mockResolvedValue({
|
||||
|
||||
@@ -28,11 +28,20 @@ import {
|
||||
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import { glp1PostBodySchema } from "@/lib/validations/medication";
|
||||
import { assertMedicationOwnership } from "@/lib/medications/route-guards";
|
||||
import {
|
||||
estimateDailyDoseCount,
|
||||
estimateRunwayDays,
|
||||
lowStockTriggerDays,
|
||||
type RunwaySchedule,
|
||||
} from "@/components/medications/detail/supply-runway";
|
||||
import {
|
||||
resolveLowStockRunwayDays,
|
||||
resolveReorderLeadDays,
|
||||
} from "@/lib/validations/notification-prefs";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
const LOW_STOCK_DOSE_THRESHOLD = 4;
|
||||
const POST_RATE_LIMIT = 30;
|
||||
const POST_WINDOW_MS = 60_000;
|
||||
|
||||
@@ -76,6 +85,47 @@ export const GET = apiHandler(
|
||||
return apiError("Medication not found", 404);
|
||||
}
|
||||
|
||||
// v1.17.0 — the card `lowStock` flag rides the SAME reorder-lead-aware
|
||||
// runway evaluation as the daily low-stock cron, so the GLP-1 card and
|
||||
// the notification can never disagree. The legacy fixed 4-dose count
|
||||
// threshold (`LOW_STOCK_DOSE_THRESHOLD`) is retired: a weekly cadence's
|
||||
// "4 doses" was ~28 days — far too eager — while a daily med at 4 doses
|
||||
// was ~4 days — far too late. Runway days ≤ the effective trigger
|
||||
// (`max(lowStockRunwayDays, leadDays + cadenceIntervalDays)`) is the one
|
||||
// truth. `lowStockRunwayDays === null` (alert off) ⇒ flag never lights.
|
||||
const prefsRow = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { notificationPrefs: true },
|
||||
});
|
||||
const runwayFloor = resolveLowStockRunwayDays(
|
||||
prefsRow?.notificationPrefs ?? null,
|
||||
);
|
||||
const leadDays = resolveReorderLeadDays(
|
||||
prefsRow?.notificationPrefs ?? null,
|
||||
medication.reorderLeadDays,
|
||||
);
|
||||
const schedules: RunwaySchedule[] = medication.schedules;
|
||||
/** runway ≤ effective trigger AND the alert is enabled. */
|
||||
const isLowStock = (dosesRemaining: number): boolean => {
|
||||
if (runwayFloor === null) return false;
|
||||
// No consuming schedule ⇒ no runway ⇒ never low (matches the cron's
|
||||
// `evaluateMedicationRunway` null branch).
|
||||
if (estimateDailyDoseCount(schedules) <= 0) return false;
|
||||
const triggerDays = lowStockTriggerDays({
|
||||
lowStockRunwayDays: runwayFloor,
|
||||
leadDays,
|
||||
schedules,
|
||||
});
|
||||
// Exhausted supply with a consuming schedule is runway 0 (below
|
||||
// every trigger); otherwise the cadence-aware days of supply. Same
|
||||
// semantics the cron's `evaluateMedicationRunway` applies.
|
||||
const runwayDays =
|
||||
dosesRemaining > 0
|
||||
? (estimateRunwayDays(dosesRemaining, schedules) ?? 0)
|
||||
: 0;
|
||||
return runwayDays <= triggerDays;
|
||||
};
|
||||
|
||||
// Inventory math over the per-item entities. The response shape is
|
||||
// locked for the iOS client: `pensRemaining` = count of usable
|
||||
// containers (ACTIVE / IN_USE with units left), `dosesRemaining` =
|
||||
@@ -103,7 +153,7 @@ export const GET = apiHandler(
|
||||
unitsRemaining / (Number(medication.unitsPerDose) || 1),
|
||||
);
|
||||
const weeksOfSupply = dosesRemaining;
|
||||
const lowStock = dosesRemaining < LOW_STOCK_DOSE_THRESHOLD;
|
||||
const lowStock = isLowStock(dosesRemaining);
|
||||
inventory = {
|
||||
pensRemaining,
|
||||
dosesRemaining,
|
||||
@@ -127,7 +177,7 @@ export const GET = apiHandler(
|
||||
const pensRemaining = Math.max(0, pens);
|
||||
const dosesRemaining = pensRemaining * medication.dosesPerUnit;
|
||||
const weeksOfSupply = dosesRemaining;
|
||||
const lowStock = dosesRemaining < LOW_STOCK_DOSE_THRESHOLD;
|
||||
const lowStock = isLowStock(dosesRemaining);
|
||||
inventory = {
|
||||
pensRemaining,
|
||||
dosesRemaining,
|
||||
|
||||
@@ -232,6 +232,7 @@ export const PUT = apiHandler(
|
||||
treatmentClass,
|
||||
dosesPerUnit,
|
||||
unitsPerDose,
|
||||
reorderLeadDays,
|
||||
deliveryForm,
|
||||
trackInjectionSites,
|
||||
allowedInjectionSites,
|
||||
@@ -598,6 +599,9 @@ export const PUT = apiHandler(
|
||||
// v1.16.10 — units consumed per dose. Field-by-field; the stamp on
|
||||
// already-taken intake events freezes their recorded consumption.
|
||||
...(unitsPerDose !== undefined && { unitsPerDose }),
|
||||
// v1.17.0 — reorder lead override. Field-by-field; `null` is a
|
||||
// valid explicit value (clears the override → user-level default).
|
||||
...(reorderLeadDays !== undefined && { reorderLeadDays }),
|
||||
...(deliveryForm !== undefined && { deliveryForm }),
|
||||
// v1.8.5 — injection-site tracking opt-in + per-medication allowed
|
||||
// sites. Field-by-field; `false` / `[]` are valid explicit values
|
||||
|
||||
@@ -300,6 +300,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
treatmentClass,
|
||||
dosesPerUnit,
|
||||
unitsPerDose,
|
||||
reorderLeadDays,
|
||||
deliveryForm,
|
||||
trackInjectionSites,
|
||||
allowedInjectionSites,
|
||||
@@ -379,6 +380,9 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
...(dosesPerUnit !== undefined && { dosesPerUnit }),
|
||||
// v1.16.10 — units consumed per dose; Prisma defaults to 1 when omitted.
|
||||
...(unitsPerDose !== undefined && { unitsPerDose }),
|
||||
// v1.17.0 — optional reorder lead override; null/omitted = inherit
|
||||
// the user-level default in the low-stock alert.
|
||||
...(reorderLeadDays !== undefined && { reorderLeadDays }),
|
||||
// v1.6.0 — route of administration; Prisma defaults to ORAL when omitted.
|
||||
...(deliveryForm !== undefined && { deliveryForm }),
|
||||
// v1.8.5 — injection-site tracking opt-in + per-medication allowed
|
||||
|
||||
@@ -77,12 +77,15 @@ describe("GET /api/settings/reminder-thresholds", () => {
|
||||
lateMinutes: number;
|
||||
missedMinutes: number;
|
||||
lowStockRunwayDays: number | null;
|
||||
reorderLeadDays: number;
|
||||
};
|
||||
};
|
||||
expect(env.data).toEqual({
|
||||
lateMinutes: 120,
|
||||
missedMinutes: 240,
|
||||
lowStockRunwayDays: 7,
|
||||
// v1.17.0 — the per-user reorder lead default rides along.
|
||||
reorderLeadDays: 10,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { getReminderThresholds } from "@/lib/app-settings";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { resolveLowStockRunwayDays } from "@/lib/validations/notification-prefs";
|
||||
import { parseNotificationPrefs } from "@/lib/validations/notification-prefs";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -22,11 +22,15 @@ export const GET = apiHandler(async () => {
|
||||
where: { id: user.id },
|
||||
select: { notificationPrefs: true },
|
||||
});
|
||||
const lowStockRunwayDays = resolveLowStockRunwayDays(
|
||||
row?.notificationPrefs ?? null,
|
||||
);
|
||||
const prefs = parseNotificationPrefs(row?.notificationPrefs ?? null);
|
||||
const lowStockRunwayDays = prefs.medication.lowStockRunwayDays;
|
||||
// v1.17.0 — the user-level reorder lead default rides along so the
|
||||
// medication cards can derive the same reorder-lead-aware trigger the
|
||||
// daily cron uses (per-medication overrides come from the list payload's
|
||||
// own `reorderLeadDays`).
|
||||
const reorderLeadDays = prefs.medication.reorderLeadDays;
|
||||
|
||||
annotate({ action: { name: "settings.reminder-thresholds.get" } });
|
||||
|
||||
return apiSuccess({ ...thresholds, lowStockRunwayDays });
|
||||
return apiSuccess({ ...thresholds, lowStockRunwayDays, reorderLeadDays });
|
||||
});
|
||||
Reference in new issue
Block a user