mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +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 });
|
||||
});
|
||||
@@ -6,8 +6,12 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import {
|
||||
cadenceIntervalDays,
|
||||
classifyLowStockState,
|
||||
estimateDailyDoseCount,
|
||||
estimateRunwayDays,
|
||||
lowStockTriggerDays,
|
||||
supplyRunwayDates,
|
||||
type RunwaySchedule,
|
||||
} from "@/components/medications/detail/supply-runway";
|
||||
|
||||
@@ -130,3 +134,96 @@ describe("estimateRunwayDays — v1.16.10 multi-unit doses", () => {
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("cadenceIntervalDays — v1.17.0", () => {
|
||||
it("is ≈1 for a daily med", () => {
|
||||
expect(cadenceIntervalDays([schedule({ timesOfDay: ["08:00"] })])).toBe(1);
|
||||
});
|
||||
|
||||
it("is ≈7 for a weekly injection", () => {
|
||||
expect(
|
||||
cadenceIntervalDays([
|
||||
schedule({ timesOfDay: ["09:00"], rollingIntervalDays: 7 }),
|
||||
]),
|
||||
).toBe(7);
|
||||
});
|
||||
|
||||
it("is null for a schedule-less (as-needed) medication", () => {
|
||||
expect(cadenceIntervalDays([])).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("lowStockTriggerDays — v1.17.0", () => {
|
||||
const daily = [schedule({ timesOfDay: ["08:00"] })];
|
||||
const weekly = [schedule({ timesOfDay: ["09:00"], rollingIntervalDays: 7 })];
|
||||
|
||||
it("keeps a daily med with no lead at the bare floor (never shrinks)", () => {
|
||||
expect(
|
||||
lowStockTriggerDays({ lowStockRunwayDays: 7, leadDays: 0, schedules: daily }),
|
||||
).toBe(7);
|
||||
});
|
||||
|
||||
it("widens a weekly med to cover the reorder lead PLUS one dose-interval", () => {
|
||||
// max(7, 10 lead + 7 interval) = 17 → fires ~10 days before the last dose.
|
||||
expect(
|
||||
lowStockTriggerDays({ lowStockRunwayDays: 7, leadDays: 10, schedules: weekly }),
|
||||
).toBe(17);
|
||||
});
|
||||
|
||||
it("never drops below the user floor even when lead + cadence are small", () => {
|
||||
expect(
|
||||
lowStockTriggerDays({ lowStockRunwayDays: 14, leadDays: 2, schedules: daily }),
|
||||
).toBe(14);
|
||||
});
|
||||
|
||||
it("falls back to the bare floor for a schedule-less medication", () => {
|
||||
expect(
|
||||
lowStockTriggerDays({ lowStockRunwayDays: 7, leadDays: 10, schedules: [] }),
|
||||
).toBe(7);
|
||||
});
|
||||
});
|
||||
|
||||
describe("classifyLowStockState — v1.17.0", () => {
|
||||
const weekly = [schedule({ timesOfDay: ["09:00"], rollingIntervalDays: 7 })];
|
||||
|
||||
it("returns null when comfortably above the trigger", () => {
|
||||
expect(
|
||||
classifyLowStockState({ runwayDays: 20, triggerDays: 17, schedules: weekly }),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("flags running_low within the trigger but above one cadence interval", () => {
|
||||
expect(
|
||||
classifyLowStockState({ runwayDays: 14, triggerDays: 17, schedules: weekly }),
|
||||
).toBe("running_low");
|
||||
});
|
||||
|
||||
it("flags last_dose at one cadence interval", () => {
|
||||
expect(
|
||||
classifyLowStockState({ runwayDays: 7, triggerDays: 17, schedules: weekly }),
|
||||
).toBe("last_dose");
|
||||
});
|
||||
});
|
||||
|
||||
describe("supplyRunwayDates — v1.17.0", () => {
|
||||
const today = new Date(Date.UTC(2026, 5, 1)); // 1 Jun 2026
|
||||
|
||||
it("computes runsOutOn = today + runway and reorderBy = runsOutOn − lead", () => {
|
||||
const { runsOutOn, reorderBy } = supplyRunwayDates({
|
||||
today,
|
||||
runwayDays: 14,
|
||||
leadDays: 10,
|
||||
});
|
||||
expect(runsOutOn.toISOString().slice(0, 10)).toBe("2026-06-15");
|
||||
expect(reorderBy.toISOString().slice(0, 10)).toBe("2026-06-05");
|
||||
});
|
||||
|
||||
it("clamps reorderBy to today when the lead pushes it into the past", () => {
|
||||
const { reorderBy } = supplyRunwayDates({
|
||||
today,
|
||||
runwayDays: 3,
|
||||
leadDays: 10,
|
||||
});
|
||||
expect(reorderBy.toISOString().slice(0, 10)).toBe("2026-06-01");
|
||||
});
|
||||
});
|
||||
@@ -67,3 +67,90 @@ export function estimateRunwayDays(
|
||||
if (perDay <= 0) return null;
|
||||
return Math.floor(dosesRemaining / perDay);
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.17.0 — one dose-interval in whole days for a cadence: the inverse
|
||||
* of the daily-dose rate, rounded up. ≈1 for a daily med, ≈7 for a
|
||||
* weekly injection, ≈30 for a monthly RRULE. Returns `null` for a
|
||||
* schedule with no derivable consumption (as-needed / empty).
|
||||
*/
|
||||
export function cadenceIntervalDays(schedules: RunwaySchedule[]): number | null {
|
||||
const perDay = estimateDailyDoseCount(schedules);
|
||||
if (perDay <= 0) return null;
|
||||
return Math.ceil(1 / perDay);
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.17.0 — reorder-lead-aware low-stock trigger threshold in days.
|
||||
*
|
||||
* The bare `lowStockRunwayDays` floor fired the alert only when the
|
||||
* runway dropped below it; for a sparse cadence (a weekly injection's
|
||||
* 7-day floor ≈ one dose-interval) that warning landed with ~1 dose
|
||||
* left — too late to reorder. The effective trigger widens the floor to
|
||||
* cover the reorder lead PLUS one dose-interval:
|
||||
*
|
||||
* trigger = max(lowStockRunwayDays, leadDays + cadenceIntervalDays)
|
||||
*
|
||||
* so the alert lands before the LAST dose for any cadence. Because the
|
||||
* result is `max(...)` over the user's own floor, it NEVER shrinks
|
||||
* anyone's current threshold — only widens it when the lead + cadence
|
||||
* demand more headroom. A schedule with no derivable cadence falls back
|
||||
* to the bare floor (it has no runway anyway, so it never fires).
|
||||
*/
|
||||
export function lowStockTriggerDays(input: {
|
||||
lowStockRunwayDays: number;
|
||||
leadDays: number;
|
||||
schedules: RunwaySchedule[];
|
||||
}): number {
|
||||
const interval = cadenceIntervalDays(input.schedules);
|
||||
if (interval === null) return input.lowStockRunwayDays;
|
||||
return Math.max(input.lowStockRunwayDays, input.leadDays + interval);
|
||||
}
|
||||
|
||||
/** v1.17.0 — informational vs actionable low-stock presentation state. */
|
||||
export type LowStockState = "running_low" | "last_dose";
|
||||
|
||||
/**
|
||||
* v1.17.0 — classify a runway against its cadence + trigger for the
|
||||
* card / push copy. Returns `null` when the supply is comfortably above
|
||||
* the trigger (no notice). `"last_dose"` when the runway is down to one
|
||||
* dose-interval (informational — about to take the final dose);
|
||||
* `"running_low"` otherwise within the trigger (actionable — reorder
|
||||
* now). `last_dose` wins when both apply so the final-dose case reads
|
||||
* as the calmer, more specific line.
|
||||
*/
|
||||
export function classifyLowStockState(input: {
|
||||
runwayDays: number;
|
||||
triggerDays: number;
|
||||
schedules: RunwaySchedule[];
|
||||
}): LowStockState | null {
|
||||
if (input.runwayDays > input.triggerDays) return null;
|
||||
const interval = cadenceIntervalDays(input.schedules);
|
||||
if (interval !== null && input.runwayDays <= interval) return "last_dose";
|
||||
return "running_low";
|
||||
}
|
||||
|
||||
/** A calendar date as a UTC-midnight `Date` — the copy renders day-only. */
|
||||
function addDaysUtc(base: Date, days: number): Date {
|
||||
const d = new Date(base);
|
||||
d.setUTCDate(d.getUTCDate() + days);
|
||||
return d;
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.17.0 — the two concrete dates the low-stock copy surfaces:
|
||||
* runsOutOn = today + runwayDays (supply depletes)
|
||||
* reorderBy = runsOutOn − leadDays (order by this date to avoid a gap)
|
||||
* Both are clamped to never precede `today` (a deep low-stock state can
|
||||
* push `reorderBy` into the past — surface today instead of a past date).
|
||||
*/
|
||||
export function supplyRunwayDates(input: {
|
||||
today: Date;
|
||||
runwayDays: number;
|
||||
leadDays: number;
|
||||
}): { runsOutOn: Date; reorderBy: Date } {
|
||||
const runsOutOn = addDaysUtc(input.today, Math.max(0, input.runwayDays));
|
||||
const reorderRaw = addDaysUtc(runsOutOn, -input.leadDays);
|
||||
const reorderBy = reorderRaw < input.today ? new Date(input.today) : reorderRaw;
|
||||
return { runsOutOn, reorderBy };
|
||||
}
|
||||
@@ -29,6 +29,7 @@ import { resolveDisplayedSlotInstant } from "@/components/medications/card-parts
|
||||
import {
|
||||
estimateDailyDoseCount,
|
||||
estimateRunwayDays,
|
||||
lowStockTriggerDays,
|
||||
} from "@/components/medications/detail/supply-runway";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useMedicationComplianceSummary } from "@/lib/queries/use-medication-compliance-summary";
|
||||
@@ -117,6 +118,8 @@ export interface Glp1Medication {
|
||||
nextDueOverdue?: boolean;
|
||||
/** v1.16.10 — dose-derived stock from the list payload; null = inventory tracking off. */
|
||||
stockDosesRemaining?: number | null;
|
||||
/** v1.17.0 — per-medication reorder lead override (days); null = inherit the user default. */
|
||||
reorderLeadDays?: number | null;
|
||||
schedules: ScheduleLite[];
|
||||
}
|
||||
|
||||
@@ -225,6 +228,7 @@ export function Glp1MedicationCard({
|
||||
lateMinutes: number;
|
||||
missedMinutes: number;
|
||||
lowStockRunwayDays: number | null;
|
||||
reorderLeadDays?: number;
|
||||
}>("/api/settings/reminder-thresholds");
|
||||
} catch {
|
||||
return null;
|
||||
@@ -339,13 +343,18 @@ export function Glp1MedicationCard({
|
||||
timeZone: userTz,
|
||||
});
|
||||
|
||||
// v1.16.11 — low-stock card context, identical to the generic card:
|
||||
// runway days from the list payload's dose-derived stock, surfaced
|
||||
// ONLY below the user's runway threshold (`lowStockRunwayDays`,
|
||||
// default 7, null = alert off → no card line). Stock 0 with a
|
||||
// consuming schedule is runway 0 (mirrors `evaluateMedicationRunway`).
|
||||
const lowStockThreshold =
|
||||
// v1.16.11 / v1.17.0 — low-stock card context, identical to the generic
|
||||
// card: runway days from the list payload's dose-derived stock, surfaced
|
||||
// ONLY at runway ≤ the reorder-lead-aware trigger
|
||||
// (max(lowStockRunwayDays, lead + cadenceIntervalDays)), so the weekly
|
||||
// GLP-1 cadence is warned before its last dose. Stock 0 with a consuming
|
||||
// schedule is runway 0 (mirrors `evaluateMedicationRunway`).
|
||||
const lowStockFloor =
|
||||
thresholds == null ? 7 : thresholds.lowStockRunwayDays;
|
||||
const leadDays =
|
||||
medication.reorderLeadDays != null
|
||||
? medication.reorderLeadDays
|
||||
: (thresholds?.reorderLeadDays ?? 10);
|
||||
const stockRunwayDays =
|
||||
medication.stockDosesRemaining == null
|
||||
? null
|
||||
@@ -357,10 +366,18 @@ export function Glp1MedicationCard({
|
||||
: estimateDailyDoseCount(medication.schedules) > 0
|
||||
? 0
|
||||
: null;
|
||||
const lowStockTrigger =
|
||||
lowStockFloor === null
|
||||
? null
|
||||
: lowStockTriggerDays({
|
||||
lowStockRunwayDays: lowStockFloor,
|
||||
leadDays,
|
||||
schedules: medication.schedules,
|
||||
});
|
||||
const lowStockRunwayDays =
|
||||
stockRunwayDays !== null &&
|
||||
lowStockThreshold !== null &&
|
||||
stockRunwayDays < lowStockThreshold
|
||||
lowStockTrigger !== null &&
|
||||
stockRunwayDays <= lowStockTrigger
|
||||
? stockRunwayDays
|
||||
: null;
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ import { resolveDisplayedSlotInstant } from "@/components/medications/card-parts
|
||||
import {
|
||||
estimateDailyDoseCount,
|
||||
estimateRunwayDays,
|
||||
lowStockTriggerDays,
|
||||
} from "@/components/medications/detail/supply-runway";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
import {
|
||||
@@ -101,6 +102,12 @@ interface Medication {
|
||||
asNeeded?: boolean;
|
||||
/** v1.16.10 — dose-derived stock from the list payload; null = inventory tracking off. */
|
||||
stockDosesRemaining?: number | null;
|
||||
/**
|
||||
* v1.17.0 — optional per-medication reorder lead override (days); null /
|
||||
* absent = inherit the user-level reorderLeadDays default. Widens the
|
||||
* low-stock trigger so the notice lands before the last dose.
|
||||
*/
|
||||
reorderLeadDays?: number | null;
|
||||
schedules: Schedule[];
|
||||
}
|
||||
|
||||
@@ -171,6 +178,7 @@ export function MedicationCard({
|
||||
lateMinutes: number;
|
||||
missedMinutes: number;
|
||||
lowStockRunwayDays: number | null;
|
||||
reorderLeadDays?: number;
|
||||
}>("/api/settings/reminder-thresholds");
|
||||
} catch {
|
||||
return null;
|
||||
@@ -282,8 +290,18 @@ export function MedicationCard({
|
||||
// alert off → no card line either). Stock 0 with a consuming schedule
|
||||
// is runway 0 (mirrors `evaluateMedicationRunway`); as-needed has no
|
||||
// runway, ever.
|
||||
const lowStockThreshold =
|
||||
// v1.17.0 — the trigger is reorder-lead-aware: max(floor, lead +
|
||||
// cadenceIntervalDays). The bare floor (`lowStockRunwayDays`) and the
|
||||
// user-level lead default come from the thresholds endpoint; a per-med
|
||||
// `reorderLeadDays` overrides the default. The notice lights at runway
|
||||
// ≤ trigger (matching the daily cron), so a weekly med is warned before
|
||||
// its last dose, not at it.
|
||||
const lowStockFloor =
|
||||
thresholds == null ? 7 : thresholds.lowStockRunwayDays;
|
||||
const leadDays =
|
||||
medication.reorderLeadDays != null
|
||||
? medication.reorderLeadDays
|
||||
: (thresholds?.reorderLeadDays ?? 10);
|
||||
const stockRunwayDays =
|
||||
medication.asNeeded || medication.stockDosesRemaining == null
|
||||
? null
|
||||
@@ -295,10 +313,18 @@ export function MedicationCard({
|
||||
: estimateDailyDoseCount(medication.schedules) > 0
|
||||
? 0
|
||||
: null;
|
||||
const lowStockTrigger =
|
||||
lowStockFloor === null
|
||||
? null
|
||||
: lowStockTriggerDays({
|
||||
lowStockRunwayDays: lowStockFloor,
|
||||
leadDays,
|
||||
schedules: medication.schedules,
|
||||
});
|
||||
const lowStockRunwayDays =
|
||||
stockRunwayDays !== null &&
|
||||
lowStockThreshold !== null &&
|
||||
stockRunwayDays < lowStockThreshold
|
||||
lowStockTrigger !== null &&
|
||||
stockRunwayDays <= lowStockTrigger
|
||||
? stockRunwayDays
|
||||
: null;
|
||||
|
||||
|
||||
@@ -24,12 +24,17 @@ import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
|
||||
*/
|
||||
|
||||
interface NotificationPrefsShape {
|
||||
medication: { lowStockRunwayDays: number | null };
|
||||
medication: { lowStockRunwayDays: number | null; reorderLeadDays?: number };
|
||||
}
|
||||
|
||||
const DEFAULT_DAYS = 7;
|
||||
const MIN_DAYS = 1;
|
||||
const MAX_DAYS = 60;
|
||||
// v1.17.0 — reorder lead time. The alert widens its trigger by this lead
|
||||
// plus one dose-interval so a refill arrives before the last dose.
|
||||
const DEFAULT_LEAD = 10;
|
||||
const MIN_LEAD = 0;
|
||||
const MAX_LEAD = 60;
|
||||
|
||||
export function LowStockCard({
|
||||
isAuthenticated,
|
||||
@@ -42,6 +47,10 @@ export function LowStockCard({
|
||||
undefined,
|
||||
);
|
||||
const [draft, setDraft] = useState<string | null>(null);
|
||||
const [leadDraft, setLeadDraft] = useState<string | null>(null);
|
||||
const [leadOptimistic, setLeadOptimistic] = useState<number | undefined>(
|
||||
undefined,
|
||||
);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [msg, setMsg] = useState<string | null>(null);
|
||||
const [msgType, setMsgType] = useState<"success" | "error" | null>(null);
|
||||
@@ -87,8 +96,16 @@ export function LowStockCard({
|
||||
const enabled = current !== null;
|
||||
const days = current ?? DEFAULT_DAYS;
|
||||
|
||||
async function persist(next: number | null, successMsg: string) {
|
||||
setOptimistic(next);
|
||||
const persistedLead =
|
||||
prefs?.medication?.reorderLeadDays === undefined
|
||||
? DEFAULT_LEAD
|
||||
: prefs.medication.reorderLeadDays;
|
||||
const leadDays = leadOptimistic === undefined ? persistedLead : leadOptimistic;
|
||||
|
||||
async function patchPrefs(
|
||||
body: Record<string, unknown>,
|
||||
successMsg: string,
|
||||
): Promise<boolean> {
|
||||
setSaving(true);
|
||||
setMsg(null);
|
||||
setMsgType(null);
|
||||
@@ -100,7 +117,7 @@ export function LowStockCard({
|
||||
const res = await apiFetchRaw("/api/auth/me/notification-prefs", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ medication: { lowStockRunwayDays: next } }),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
@@ -114,14 +131,33 @@ export function LowStockCard({
|
||||
queryKey: queryKeys.settingsReminderThresholds(),
|
||||
}),
|
||||
]);
|
||||
setOptimistic(undefined);
|
||||
} else {
|
||||
setOptimistic(undefined);
|
||||
setMsg(t("notifications.lowStock.saveError"));
|
||||
setMsgType("error");
|
||||
}
|
||||
scheduleClear();
|
||||
setSaving(false);
|
||||
return res.ok;
|
||||
}
|
||||
|
||||
async function persist(next: number | null, successMsg: string) {
|
||||
setOptimistic(next);
|
||||
const ok = await patchPrefs(
|
||||
{ medication: { lowStockRunwayDays: next } },
|
||||
successMsg,
|
||||
);
|
||||
setOptimistic(undefined);
|
||||
void ok;
|
||||
}
|
||||
|
||||
async function persistLead(next: number, successMsg: string) {
|
||||
setLeadOptimistic(next);
|
||||
const ok = await patchPrefs(
|
||||
{ medication: { reorderLeadDays: next } },
|
||||
successMsg,
|
||||
);
|
||||
setLeadOptimistic(undefined);
|
||||
void ok;
|
||||
}
|
||||
|
||||
async function handleToggle(next: boolean) {
|
||||
@@ -143,6 +179,16 @@ export function LowStockCard({
|
||||
void persist(clamped, t("notifications.lowStock.savedToast"));
|
||||
}
|
||||
|
||||
function commitLeadDraft() {
|
||||
if (leadDraft === null) return;
|
||||
const parsed = Number(leadDraft);
|
||||
setLeadDraft(null);
|
||||
if (!Number.isInteger(parsed)) return;
|
||||
const clamped = Math.min(MAX_LEAD, Math.max(MIN_LEAD, parsed));
|
||||
if (clamped === leadDays) return;
|
||||
void persistLead(clamped, t("notifications.lowStock.leadSavedToast"));
|
||||
}
|
||||
|
||||
return (
|
||||
<section
|
||||
// `id` is the anchor target of the supply tab's cross-link
|
||||
@@ -198,6 +244,41 @@ export function LowStockCard({
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* v1.17.0 — reorder lead time. Progressive disclosure: shown only
|
||||
while the alert is enabled, with a sensible default (10) most
|
||||
users never touch. A single medication can override this on its
|
||||
own supply tab. */}
|
||||
{enabled && (
|
||||
<div className="mt-4 pl-7">
|
||||
<div className="flex min-h-11 items-center gap-3">
|
||||
<label htmlFor="low-stock-lead" className="text-sm font-medium">
|
||||
{t("notifications.lowStock.leadLabel")}
|
||||
</label>
|
||||
<Input
|
||||
id="low-stock-lead"
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={MIN_LEAD}
|
||||
max={MAX_LEAD}
|
||||
className="w-24"
|
||||
value={leadDraft ?? String(leadDays)}
|
||||
disabled={!isAuthenticated || saving}
|
||||
onChange={(e) => setLeadDraft(e.target.value)}
|
||||
onBlur={commitLeadDraft}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") commitLeadDraft();
|
||||
}}
|
||||
aria-label={t("notifications.lowStock.leadAria")}
|
||||
autoComplete="off"
|
||||
data-lpignore="true"
|
||||
data-1p-ignore="true"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-1.5 max-w-prose text-xs">
|
||||
{t("notifications.lowStock.leadHelp")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{msg && (
|
||||
<p
|
||||
role="status"
|
||||
|
||||
@@ -80,21 +80,21 @@ describe("evaluateMedicationRunway", () => {
|
||||
describe("decideLowStockAction — boundary / dedupe / re-arm", () => {
|
||||
const armed = { notifiedAt: null, notifiedThresholdDays: null };
|
||||
|
||||
it("notifies just below the threshold", () => {
|
||||
it("notifies just below the trigger", () => {
|
||||
expect(
|
||||
decideLowStockAction({ runwayDays: 6, thresholdDays: 7, ...armed }),
|
||||
decideLowStockAction({ runwayDays: 6, triggerDays: 7, ...armed }),
|
||||
).toBe("notify");
|
||||
});
|
||||
|
||||
it("stays silent AT the threshold (strictly-below contract)", () => {
|
||||
it("notifies AT the trigger (v1.17.0 ≤ boundary — lands before the last dose)", () => {
|
||||
expect(
|
||||
decideLowStockAction({ runwayDays: 7, thresholdDays: 7, ...armed }),
|
||||
).toBe("skip_above_threshold");
|
||||
decideLowStockAction({ runwayDays: 7, triggerDays: 7, ...armed }),
|
||||
).toBe("notify");
|
||||
});
|
||||
|
||||
it("stays silent above the threshold", () => {
|
||||
it("stays silent above the trigger", () => {
|
||||
expect(
|
||||
decideLowStockAction({ runwayDays: 8, thresholdDays: 7, ...armed }),
|
||||
decideLowStockAction({ runwayDays: 8, triggerDays: 7, ...armed }),
|
||||
).toBe("skip_above_threshold");
|
||||
});
|
||||
|
||||
@@ -102,29 +102,29 @@ describe("decideLowStockAction — boundary / dedupe / re-arm", () => {
|
||||
expect(
|
||||
decideLowStockAction({
|
||||
runwayDays: 5,
|
||||
thresholdDays: 7,
|
||||
triggerDays: 7,
|
||||
notifiedAt: daysAgo(1),
|
||||
notifiedThresholdDays: 7,
|
||||
}),
|
||||
).toBe("skip_already_notified");
|
||||
});
|
||||
|
||||
it("re-arms when the runway rises back above the threshold (refill)", () => {
|
||||
it("re-arms when the runway rises back above the trigger (refill)", () => {
|
||||
expect(
|
||||
decideLowStockAction({
|
||||
runwayDays: 30,
|
||||
thresholdDays: 7,
|
||||
triggerDays: 7,
|
||||
notifiedAt: daysAgo(2),
|
||||
notifiedThresholdDays: 7,
|
||||
}),
|
||||
).toBe("rearm");
|
||||
});
|
||||
|
||||
it("a changed threshold re-arms even while the runway stays low", () => {
|
||||
it("a changed trigger (threshold OR lead) re-arms even while the runway stays low", () => {
|
||||
expect(
|
||||
decideLowStockAction({
|
||||
runwayDays: 10,
|
||||
thresholdDays: 14,
|
||||
triggerDays: 14,
|
||||
notifiedAt: daysAgo(3),
|
||||
notifiedThresholdDays: 7,
|
||||
}),
|
||||
@@ -133,33 +133,58 @@ describe("decideLowStockAction — boundary / dedupe / re-arm", () => {
|
||||
|
||||
it("never notifies when no runway is derivable", () => {
|
||||
expect(
|
||||
decideLowStockAction({ runwayDays: null, thresholdDays: 7, ...armed }),
|
||||
decideLowStockAction({ runwayDays: null, triggerDays: 7, ...armed }),
|
||||
).toBe("skip_no_runway");
|
||||
});
|
||||
});
|
||||
|
||||
const payloadArgs = (over: Partial<Parameters<typeof buildLowStockPayload>[0]>) =>
|
||||
buildLowStockPayload({
|
||||
locale: "en",
|
||||
medName: "Metformin",
|
||||
runwayDays: 5,
|
||||
unitsRemaining: 10,
|
||||
leadDays: 0,
|
||||
triggerDays: 7,
|
||||
schedules: [dailySchedule],
|
||||
today: new Date(Date.UTC(2026, 5, 1)),
|
||||
...over,
|
||||
});
|
||||
|
||||
describe("buildLowStockPayload", () => {
|
||||
it("names the medication and the remaining days / units (en)", () => {
|
||||
const out = buildLowStockPayload("en", "Metformin", 5, 10);
|
||||
it("names the medication and reorder dates when supply is datable (en)", () => {
|
||||
const out = payloadArgs({ runwayDays: 5, leadDays: 3, triggerDays: 9 });
|
||||
expect(out.title).toContain("Metformin");
|
||||
expect(out.body).toContain("Metformin");
|
||||
expect(out.body).toContain("5");
|
||||
expect(out.body).toContain("10");
|
||||
// runsOutOn = 1 Jun + 5 = 6 Jun; reorderBy = 6 Jun − 3 = 3 Jun.
|
||||
// The "en" bundle resolves to the en-US date style ("Jun 6, 2026").
|
||||
expect(out.body).toContain("Jun 6, 2026");
|
||||
expect(out.body).toContain("Jun 3, 2026");
|
||||
});
|
||||
|
||||
it("uses the du-Form German copy", () => {
|
||||
const out = buildLowStockPayload("de", "Metformin", 5, 10);
|
||||
const out = payloadArgs({ locale: "de", leadDays: 3, triggerDays: 9 });
|
||||
expect(out.body).toContain("Dein Vorrat an Metformin");
|
||||
expect(out.body).toContain("5");
|
||||
});
|
||||
|
||||
it("switches to the depleted line at runway 0", () => {
|
||||
const out = buildLowStockPayload("en", "Metformin", 0, 1);
|
||||
const out = payloadArgs({ runwayDays: 0, unitsRemaining: 1 });
|
||||
expect(out.body).toContain("less than one day");
|
||||
});
|
||||
|
||||
it("renders the last-dose line when the runway is one cadence interval", () => {
|
||||
// Weekly cadence, runway 7 ≈ one dose-interval → last-dose state.
|
||||
const out = payloadArgs({
|
||||
runwayDays: 7,
|
||||
leadDays: 10,
|
||||
triggerDays: 17,
|
||||
schedules: [weeklySchedule],
|
||||
});
|
||||
expect(out.body).toContain("final dose");
|
||||
});
|
||||
|
||||
it("falls back to the app default locale for unknown locales", () => {
|
||||
const out = buildLowStockPayload("xx", "Metformin", 5, 10);
|
||||
const out = payloadArgs({ locale: "xx" });
|
||||
expect(out.title).toBe("Low supply: Metformin");
|
||||
});
|
||||
});
|
||||
@@ -170,6 +195,11 @@ describe("runMedicationLowStockTick", () => {
|
||||
id: "med-1",
|
||||
name: "Metformin",
|
||||
unitsPerDose: 1,
|
||||
// v1.17.0 — daily fixtures pin lead 0 so the effective trigger stays
|
||||
// the bare 7-day floor (max(7, 0 + 1 dose-interval) = 7); a daily med
|
||||
// with no reorder lead is UNCHANGED from the pre-v1.17.0 behaviour.
|
||||
// The weekly + lead scenarios opt into a non-zero lead explicitly.
|
||||
reorderLeadDays: 0,
|
||||
lowStockNotifiedAt: null,
|
||||
lowStockNotifiedThresholdDays: null,
|
||||
inventoryItems: [item(5)],
|
||||
@@ -426,4 +456,86 @@ describe("runMedicationLowStockTick", () => {
|
||||
expect(summary.notified).toBe(0);
|
||||
expect(prisma.medication.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("a daily med with no reorder lead is UNCHANGED (trigger stays the 7-day floor)", async () => {
|
||||
const dispatch = okDispatch();
|
||||
// Runway 8 (8 daily doses) sits above the bare floor of 7; with lead 0
|
||||
// and a 1-day cadence the trigger is max(7, 0 + 1) = 7, so 8 > 7 stays
|
||||
// silent — exactly the pre-v1.17.0 boundary.
|
||||
const prisma = prismaMock({
|
||||
users: [userRow()],
|
||||
medications: [medRow({ inventoryItems: [item(8)], reorderLeadDays: 0 })],
|
||||
});
|
||||
|
||||
const summary = await runMedicationLowStockTick(
|
||||
prisma as unknown as PrismaClient,
|
||||
now,
|
||||
{ dispatch },
|
||||
);
|
||||
|
||||
expect(summary.skippedAboveThreshold).toBe(1);
|
||||
expect(summary.notified).toBe(0);
|
||||
expect(dispatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("a WEEKLY med with one dose left now fires with reorder headroom (the maintainer's scenario)", async () => {
|
||||
const dispatch = okDispatch();
|
||||
// One weekly dose left ≈ 7 days of runway. Under the old strict-below
|
||||
// 7-day floor this fired only AT the last dose; with the default lead
|
||||
// (10) the trigger is max(7, 10 + 7) = 17, so runway 7 ≤ 17 fires with
|
||||
// ~10 days of reorder headroom before the supply runs out.
|
||||
const prisma = prismaMock({
|
||||
users: [userRow()],
|
||||
medications: [
|
||||
medRow({
|
||||
schedules: [weeklySchedule],
|
||||
inventoryItems: [item(1)],
|
||||
reorderLeadDays: null, // inherit the user-level default (10)
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const summary = await runMedicationLowStockTick(
|
||||
prisma as unknown as PrismaClient,
|
||||
now,
|
||||
{ dispatch },
|
||||
);
|
||||
|
||||
expect(summary.notified).toBe(1);
|
||||
expect(dispatch).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
metadata: expect.objectContaining({
|
||||
runwayDays: 7,
|
||||
triggerDays: 17,
|
||||
leadDays: 10,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
// The stamp records the EFFECTIVE trigger, not the bare floor.
|
||||
expect(prisma.medication.update).toHaveBeenCalledWith({
|
||||
where: { id: "med-1" },
|
||||
data: { lowStockNotifiedAt: now, lowStockNotifiedThresholdDays: 17 },
|
||||
});
|
||||
});
|
||||
|
||||
it("a per-med reorder-lead override beats the user-level default", async () => {
|
||||
const dispatch = okDispatch();
|
||||
// User default lead 10; this med overrides it with 0. Daily cadence →
|
||||
// trigger max(7, 0 + 1) = 7. Runway 9 (9 daily doses) > 7 stays silent,
|
||||
// proving the per-med 0 override displaced the user's 10 (which would
|
||||
// have given trigger 11 and fired).
|
||||
const prisma = prismaMock({
|
||||
users: [userRow()],
|
||||
medications: [medRow({ inventoryItems: [item(9)], reorderLeadDays: 0 })],
|
||||
});
|
||||
|
||||
const summary = await runMedicationLowStockTick(
|
||||
prisma as unknown as PrismaClient,
|
||||
now,
|
||||
{ dispatch },
|
||||
);
|
||||
|
||||
expect(summary.skippedAboveThreshold).toBe(1);
|
||||
expect(dispatch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -27,6 +27,16 @@
|
||||
* `GET /api/settings/reminder-thresholds`, written through
|
||||
* `PATCH /api/auth/me/notification-prefs`.
|
||||
*
|
||||
* v1.17.0 — reorder lead time. The bare runway floor fired a sparse
|
||||
* cadence (e.g. a weekly injection) only when ~1 dose was left — too
|
||||
* late to reorder. The effective trigger widens the floor to
|
||||
* `max(lowStockRunwayDays, leadDays + cadenceIntervalDays)` so the
|
||||
* warning lands BEFORE the last dose for any cadence. `leadDays` is the
|
||||
* per-medication `Medication.reorderLeadDays` override, else the
|
||||
* user-level `notificationPrefs.medication.reorderLeadDays` default
|
||||
* (0–60, default 10). Because the trigger is a `max(...)` over the
|
||||
* user's floor it never shrinks anyone's current threshold.
|
||||
*
|
||||
* Anti-spam: notify ONCE per threshold crossing. The stamp lives on
|
||||
* the medication row (`lowStockNotifiedAt` +
|
||||
* `lowStockNotifiedThresholdDays`); the pass clears it (re-arms) when
|
||||
@@ -46,8 +56,11 @@
|
||||
*/
|
||||
import type { PrismaClient } from "@/generated/prisma/client";
|
||||
import {
|
||||
classifyLowStockState,
|
||||
estimateDailyDoseCount,
|
||||
estimateRunwayDays,
|
||||
lowStockTriggerDays,
|
||||
supplyRunwayDates,
|
||||
type RunwaySchedule,
|
||||
} from "@/components/medications/detail/supply-runway";
|
||||
import {
|
||||
@@ -55,7 +68,10 @@ import {
|
||||
type SupplyItem,
|
||||
} from "@/lib/medications/inventory/summary";
|
||||
import { dispatchNotification } from "@/lib/notifications/dispatcher";
|
||||
import { resolveLowStockRunwayDays } from "@/lib/validations/notification-prefs";
|
||||
import {
|
||||
resolveLowStockRunwayDays,
|
||||
resolveReorderLeadDays,
|
||||
} from "@/lib/validations/notification-prefs";
|
||||
import { getServerTranslator } from "@/lib/i18n/server-translator";
|
||||
import type { Locale } from "@/lib/i18n/config";
|
||||
import { defaultLocale, locales } from "@/lib/i18n/config";
|
||||
@@ -132,28 +148,48 @@ export type LowStockDecision =
|
||||
* Pure crossing logic — exported for the unit tests so the boundary /
|
||||
* dedupe / re-arm semantics stay pinned without a DB.
|
||||
*
|
||||
* runway < threshold → notify, unless the stamp already records THIS
|
||||
* threshold's crossing (`skip_already_notified`).
|
||||
* A stamp written against a DIFFERENT threshold
|
||||
* counts as re-armed and notifies again.
|
||||
* runway ≥ threshold → re-arm (clear the stamp) when one is set;
|
||||
* otherwise nothing to do.
|
||||
* runway null → no runway derivable (schedule-less) — never
|
||||
* notifies, stamp untouched.
|
||||
* v1.17.0 — the comparison is against the EFFECTIVE trigger
|
||||
* (`triggerDays`), which the caller derives via `lowStockTriggerDays`
|
||||
* from the user's `lowStockRunwayDays` floor, the reorder lead time, and
|
||||
* the cadence interval. The boundary is now `runway ≤ triggerDays` (was
|
||||
* strictly-below the bare floor): a weekly cadence whose runway equals
|
||||
* one dose-interval still fires with reorder headroom.
|
||||
*
|
||||
* Every cadence WIDENS by the lead (the default lead is 10, so a daily
|
||||
* med's trigger becomes max(7, 10 + 1) = 11 and it now alerts ~4 days
|
||||
* earlier than the pre-v1.17.0 `< 7`). The boundary only ever moves the
|
||||
* alert EARLIER, never later — `triggerDays` is `max(floor, …)`, so it
|
||||
* can never drop below the user's floor and no one's current threshold
|
||||
* shrinks. A daily med keeps the old `< 7` boundary ONLY at lead 0
|
||||
* (trigger stays 7); the "unchanged daily" case in the tests pins that
|
||||
* lead-0 corner, not the default.
|
||||
*
|
||||
* runway ≤ trigger → notify, unless the stamp already records THIS
|
||||
* trigger's crossing (`skip_already_notified`).
|
||||
* A stamp written against a DIFFERENT trigger
|
||||
* counts as re-armed and notifies again.
|
||||
* runway > trigger → re-arm (clear the stamp) when one is set;
|
||||
* otherwise nothing to do.
|
||||
* runway null → no runway derivable (schedule-less) — never
|
||||
* notifies, stamp untouched.
|
||||
*
|
||||
* The re-arm stamp records `triggerDays`, not the bare floor, so a
|
||||
* change to EITHER the user threshold or the reorder lead re-arms the
|
||||
* medication and the user hears the next crossing.
|
||||
*/
|
||||
export function decideLowStockAction(input: {
|
||||
runwayDays: number | null;
|
||||
thresholdDays: number;
|
||||
triggerDays: number;
|
||||
notifiedAt: Date | null;
|
||||
notifiedThresholdDays: number | null;
|
||||
}): LowStockDecision {
|
||||
if (input.runwayDays === null) return "skip_no_runway";
|
||||
if (input.runwayDays >= input.thresholdDays) {
|
||||
if (input.runwayDays > input.triggerDays) {
|
||||
return input.notifiedAt !== null ? "rearm" : "skip_above_threshold";
|
||||
}
|
||||
if (
|
||||
input.notifiedAt !== null &&
|
||||
input.notifiedThresholdDays === input.thresholdDays
|
||||
input.notifiedThresholdDays === input.triggerDays
|
||||
) {
|
||||
return "skip_already_notified";
|
||||
}
|
||||
@@ -167,30 +203,80 @@ function resolveLocale(locale: string | null | undefined): Locale {
|
||||
}
|
||||
|
||||
/**
|
||||
* Localised push copy: factual, names the medication and the remaining
|
||||
* days / units. Runway 0 gets its own line — "about 0 more days" reads
|
||||
* broken in every locale.
|
||||
* v1.17.0 — localised push copy. When the supply data is rich enough to
|
||||
* date (`runwayDays >= 1` with a derivable cadence) the body names the
|
||||
* two concrete dates — "Supply runs out ~<date> — reorder by <date>" —
|
||||
* so the user can act without doing the arithmetic. The `last_dose`
|
||||
* state gets its own calmer line (the final dose is imminent; the
|
||||
* reorder-by date has already passed). Runway 0 keeps the depleted line;
|
||||
* a thin / undatable supply keeps the days/units fallback so the push
|
||||
* never reads broken.
|
||||
*/
|
||||
export function buildLowStockPayload(
|
||||
locale: string | null | undefined,
|
||||
medName: string,
|
||||
runwayDays: number,
|
||||
unitsRemaining: number,
|
||||
): { title: string; body: string } {
|
||||
const t = getServerTranslator(resolveLocale(locale)).t;
|
||||
export function buildLowStockPayload(input: {
|
||||
locale: string | null | undefined;
|
||||
medName: string;
|
||||
runwayDays: number;
|
||||
unitsRemaining: number;
|
||||
leadDays: number;
|
||||
triggerDays: number;
|
||||
schedules: RunwaySchedule[];
|
||||
today: Date;
|
||||
}): { title: string; body: string } {
|
||||
const t = getServerTranslator(resolveLocale(input.locale)).t;
|
||||
const title = t("lowStockReminders.title", { medName: input.medName });
|
||||
|
||||
if (input.runwayDays < 1) {
|
||||
return {
|
||||
title,
|
||||
body: t("lowStockReminders.bodyToday", {
|
||||
medName: input.medName,
|
||||
units: input.unitsRemaining,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
const state = classifyLowStockState({
|
||||
runwayDays: input.runwayDays,
|
||||
triggerDays: input.triggerDays,
|
||||
schedules: input.schedules,
|
||||
});
|
||||
const { runsOutOn, reorderBy } = supplyRunwayDates({
|
||||
today: input.today,
|
||||
runwayDays: input.runwayDays,
|
||||
leadDays: input.leadDays,
|
||||
});
|
||||
// Day-only, UTC: the runway dates are UTC-midnight calendar days, so a
|
||||
// UTC formatter renders the intended day in every locale. A medium
|
||||
// style stays compact and unambiguous for a push body.
|
||||
const dateFmt = new Intl.DateTimeFormat(resolveLocale(input.locale), {
|
||||
timeZone: "UTC",
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
});
|
||||
const fmt = (d: Date) => dateFmt.format(d);
|
||||
|
||||
if (state === "last_dose") {
|
||||
// Final dose imminent — informational. No "reorder by" date: the
|
||||
// lead window has lapsed, so the line names only the run-out date.
|
||||
return {
|
||||
title,
|
||||
body: t("lowStockReminders.bodyLastDose", {
|
||||
medName: input.medName,
|
||||
units: input.unitsRemaining,
|
||||
runsOutOn: fmt(runsOutOn),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
title: t("lowStockReminders.title", { medName }),
|
||||
body:
|
||||
runwayDays >= 1
|
||||
? t("lowStockReminders.body", {
|
||||
medName,
|
||||
days: runwayDays,
|
||||
units: unitsRemaining,
|
||||
})
|
||||
: t("lowStockReminders.bodyToday", {
|
||||
medName,
|
||||
units: unitsRemaining,
|
||||
}),
|
||||
title,
|
||||
body: t("lowStockReminders.bodyReorder", {
|
||||
medName: input.medName,
|
||||
units: input.unitsRemaining,
|
||||
runsOutOn: fmt(runsOutOn),
|
||||
reorderBy: fmt(reorderBy),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -269,6 +355,9 @@ export async function runMedicationLowStockTick(
|
||||
id: true,
|
||||
name: true,
|
||||
unitsPerDose: true,
|
||||
// v1.17.0 — per-medication reorder lead override (null =
|
||||
// inherit the user-level default).
|
||||
reorderLeadDays: true,
|
||||
lowStockNotifiedAt: true,
|
||||
lowStockNotifiedThresholdDays: true,
|
||||
inventoryItems: {
|
||||
@@ -299,9 +388,24 @@ export async function runMedicationLowStockTick(
|
||||
Number(med.unitsPerDose),
|
||||
med.schedules,
|
||||
);
|
||||
|
||||
// v1.17.0 — effective reorder lead (per-med override beats the
|
||||
// user default) widens the bare runway floor by lead + one
|
||||
// dose-interval so the alert lands before the last dose. The
|
||||
// trigger never shrinks below the user's `lowStockRunwayDays`.
|
||||
const leadDays = resolveReorderLeadDays(
|
||||
user.notificationPrefs,
|
||||
med.reorderLeadDays,
|
||||
);
|
||||
const triggerDays = lowStockTriggerDays({
|
||||
lowStockRunwayDays: thresholdDays,
|
||||
leadDays,
|
||||
schedules: med.schedules,
|
||||
});
|
||||
|
||||
const decision = decideLowStockAction({
|
||||
runwayDays: evaluation.runwayDays,
|
||||
thresholdDays,
|
||||
triggerDays,
|
||||
notifiedAt: med.lowStockNotifiedAt,
|
||||
notifiedThresholdDays: med.lowStockNotifiedThresholdDays,
|
||||
});
|
||||
@@ -310,7 +414,9 @@ export async function runMedicationLowStockTick(
|
||||
user_id: user.id,
|
||||
medication_id: med.id,
|
||||
runway_days: evaluation.runwayDays,
|
||||
threshold_days: thresholdDays,
|
||||
// The effective trigger (post lead + cadence widening), so the
|
||||
// wide event reflects the threshold the crossing fired against.
|
||||
threshold_days: triggerDays,
|
||||
doses_remaining: evaluation.dosesRemaining,
|
||||
units_remaining: evaluation.unitsRemaining,
|
||||
};
|
||||
@@ -338,12 +444,16 @@ export async function runMedicationLowStockTick(
|
||||
summary.rearmed += 1;
|
||||
break;
|
||||
case "notify": {
|
||||
const { title, body } = buildLowStockPayload(
|
||||
user.locale,
|
||||
med.name,
|
||||
evaluation.runwayDays ?? 0,
|
||||
evaluation.unitsRemaining,
|
||||
);
|
||||
const { title, body } = buildLowStockPayload({
|
||||
locale: user.locale,
|
||||
medName: med.name,
|
||||
runwayDays: evaluation.runwayDays ?? 0,
|
||||
unitsRemaining: evaluation.unitsRemaining,
|
||||
leadDays,
|
||||
triggerDays,
|
||||
schedules: med.schedules,
|
||||
today: now,
|
||||
});
|
||||
const outcome = await dispatchImpl({
|
||||
eventType: "MEDICATION_LOW_STOCK",
|
||||
userId: user.id,
|
||||
@@ -354,6 +464,10 @@ export async function runMedicationLowStockTick(
|
||||
medicationId: med.id,
|
||||
runwayDays: evaluation.runwayDays,
|
||||
thresholdDays,
|
||||
// v1.17.0 — the effective trigger + reorder lead so the
|
||||
// client can render the same dates the push body names.
|
||||
triggerDays,
|
||||
leadDays,
|
||||
unitsRemaining: evaluation.unitsRemaining,
|
||||
dosesRemaining: evaluation.dosesRemaining,
|
||||
// Deep link — the web-push sender threads this into
|
||||
@@ -374,7 +488,10 @@ export async function runMedicationLowStockTick(
|
||||
where: { id: med.id },
|
||||
data: {
|
||||
lowStockNotifiedAt: now,
|
||||
lowStockNotifiedThresholdDays: thresholdDays,
|
||||
// v1.17.0 — stamp the EFFECTIVE trigger so a later change
|
||||
// to the user threshold OR the reorder lead re-arms the
|
||||
// medication (the decision compares against this value).
|
||||
lowStockNotifiedThresholdDays: triggerDays,
|
||||
},
|
||||
});
|
||||
summary.notified += 1;
|
||||
|
||||
@@ -152,6 +152,13 @@ export const medicationResource = z
|
||||
.describe(
|
||||
"v1.16.10 — inventory units one dose consumes (e.g. 2 tablets of 2 mg for a 4 mg dose). v1.16.12 — may be a split-pill fraction (¼ / ⅓ / ½ / ⅔ / ¾); thirds carry as ≈0.3333 / 0.6667. Default 1. The intake consumption hook decrements this many units per taken dose; dose-derived readouts divide unit counts by it.",
|
||||
),
|
||||
reorderLeadDays: z
|
||||
.number()
|
||||
.int()
|
||||
.nullable()
|
||||
.describe(
|
||||
"v1.17.0 — optional per-medication reorder lead time in days (0–60). The low-stock alert widens its trigger by this lead plus one dose-interval so a refill arrives before the last dose. null = inherit the user-level notificationPrefs.medication.reorderLeadDays default (10).",
|
||||
),
|
||||
active: z.boolean(),
|
||||
notificationsEnabled: z.boolean(),
|
||||
liveActivityEnabled: z
|
||||
|
||||
@@ -62,6 +62,14 @@ const notificationPrefsResolved = z
|
||||
.describe(
|
||||
"Low-stock alert threshold as remaining runway days (1–60). null = alert off. Default 7.",
|
||||
),
|
||||
reorderLeadDays: z
|
||||
.number()
|
||||
.int()
|
||||
.min(0)
|
||||
.max(60)
|
||||
.describe(
|
||||
"Reorder lead time in days (0–60) the low-stock alert assumes. The alert fires this lead plus one dose-interval before the supply runs out so a refill arrives before the last dose. Default 10; a per-medication reorderLeadDays overrides it.",
|
||||
),
|
||||
}),
|
||||
mood: z.object({
|
||||
reminderHour: z
|
||||
|
||||
@@ -37,6 +37,14 @@ const reminderThresholdsResponse = z
|
||||
.describe(
|
||||
"Per-user low-stock alert threshold as remaining runway days (1–60). null = the alert is off. Default 7. Written via PATCH /api/auth/me/notification-prefs.",
|
||||
),
|
||||
reorderLeadDays: z
|
||||
.number()
|
||||
.int()
|
||||
.min(0)
|
||||
.max(60)
|
||||
.describe(
|
||||
"v1.17.0 — per-user reorder lead default in days (0–60, default 10). The low-stock alert fires this lead plus one dose-interval before the supply runs out so a refill arrives before the last dose; a per-medication reorderLeadDays overrides it. Written via PATCH /api/auth/me/notification-prefs.",
|
||||
),
|
||||
})
|
||||
.meta({
|
||||
id: "ReminderThresholdsResponse",
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
DEFAULT_LOW_STOCK_RUNWAY_DAYS,
|
||||
DEFAULT_MOOD_REMINDER_HOUR,
|
||||
DEFAULT_NOTIFICATION_PREFS,
|
||||
DEFAULT_REORDER_LEAD_DAYS,
|
||||
isCycleReminderClientManaged,
|
||||
isMedicationReminderClientManaged,
|
||||
notificationPrefsSchema,
|
||||
@@ -13,6 +14,7 @@ import {
|
||||
resolveLowStockRunwayDays,
|
||||
resolveMoodReminderHour,
|
||||
resolveNotificationPrefs,
|
||||
resolveReorderLeadDays,
|
||||
} from "../notification-prefs";
|
||||
|
||||
/**
|
||||
@@ -96,6 +98,7 @@ describe("parseNotificationPrefs", () => {
|
||||
clientManaged: true,
|
||||
deliveryDefault: "server",
|
||||
lowStockRunwayDays: 7,
|
||||
reorderLeadDays: 10,
|
||||
},
|
||||
mood: { reminderHour: DEFAULT_MOOD_REMINDER_HOUR },
|
||||
cycle: { clientManaged: false },
|
||||
@@ -115,6 +118,7 @@ describe("parseNotificationPrefs", () => {
|
||||
clientManaged: false,
|
||||
deliveryDefault: "server",
|
||||
lowStockRunwayDays: 7,
|
||||
reorderLeadDays: 10,
|
||||
},
|
||||
mood: { reminderHour: DEFAULT_MOOD_REMINDER_HOUR },
|
||||
cycle: { clientManaged: false },
|
||||
@@ -136,6 +140,7 @@ describe("parseNotificationPrefs", () => {
|
||||
clientManaged: true,
|
||||
deliveryDefault: "client",
|
||||
lowStockRunwayDays: 7,
|
||||
reorderLeadDays: 10,
|
||||
},
|
||||
mood: { reminderHour: DEFAULT_MOOD_REMINDER_HOUR },
|
||||
cycle: { clientManaged: false },
|
||||
@@ -173,6 +178,7 @@ describe("resolveNotificationPrefs (deep-merge)", () => {
|
||||
clientManaged: true,
|
||||
deliveryDefault: "server",
|
||||
lowStockRunwayDays: 7,
|
||||
reorderLeadDays: 10,
|
||||
},
|
||||
mood: { reminderHour: DEFAULT_MOOD_REMINDER_HOUR },
|
||||
cycle: { clientManaged: false },
|
||||
@@ -198,6 +204,7 @@ describe("resolveNotificationPrefs (deep-merge)", () => {
|
||||
clientManaged: true,
|
||||
deliveryDefault: "server",
|
||||
lowStockRunwayDays: 7,
|
||||
reorderLeadDays: 10,
|
||||
},
|
||||
mood: { reminderHour: DEFAULT_MOOD_REMINDER_HOUR },
|
||||
cycle: { clientManaged: false },
|
||||
@@ -220,6 +227,7 @@ describe("resolveNotificationPrefs (deep-merge)", () => {
|
||||
clientManaged: true,
|
||||
deliveryDefault: "server",
|
||||
lowStockRunwayDays: 7,
|
||||
reorderLeadDays: 10,
|
||||
},
|
||||
mood: { reminderHour: DEFAULT_MOOD_REMINDER_HOUR },
|
||||
cycle: { clientManaged: false },
|
||||
@@ -242,6 +250,7 @@ describe("resolveNotificationPrefs (deep-merge)", () => {
|
||||
clientManaged: true,
|
||||
deliveryDefault: "client",
|
||||
lowStockRunwayDays: 7,
|
||||
reorderLeadDays: 10,
|
||||
},
|
||||
mood: { reminderHour: DEFAULT_MOOD_REMINDER_HOUR },
|
||||
cycle: { clientManaged: false },
|
||||
@@ -265,6 +274,7 @@ describe("resolveNotificationPrefs (deep-merge)", () => {
|
||||
clientManaged: true,
|
||||
deliveryDefault: "server",
|
||||
lowStockRunwayDays: 7,
|
||||
reorderLeadDays: 10,
|
||||
},
|
||||
mood: { reminderHour: 8 },
|
||||
cycle: { clientManaged: false },
|
||||
@@ -515,6 +525,29 @@ describe("lowStockRunwayDays — v1.16.11 low-stock threshold", () => {
|
||||
).toBe(14);
|
||||
});
|
||||
|
||||
it("resolves the reorder lead default (10) for a null / drifted row", () => {
|
||||
expect(resolveReorderLeadDays(null, null)).toBe(DEFAULT_REORDER_LEAD_DAYS);
|
||||
expect(resolveReorderLeadDays({ unknown: "shape" }, undefined)).toBe(
|
||||
DEFAULT_REORDER_LEAD_DAYS,
|
||||
);
|
||||
});
|
||||
|
||||
it("reads the persisted user-level reorder lead when no per-med override", () => {
|
||||
expect(
|
||||
resolveReorderLeadDays({ medication: { reorderLeadDays: 21 } }, null),
|
||||
).toBe(21);
|
||||
});
|
||||
|
||||
it("a per-medication reorder-lead override beats the user default", () => {
|
||||
// User default 21, per-med 0 → the per-med value wins (incl. 0).
|
||||
expect(
|
||||
resolveReorderLeadDays({ medication: { reorderLeadDays: 21 } }, 0),
|
||||
).toBe(0);
|
||||
expect(
|
||||
resolveReorderLeadDays({ medication: { reorderLeadDays: 21 } }, 5),
|
||||
).toBe(5);
|
||||
});
|
||||
|
||||
it("returns null when the user switched the alert off", () => {
|
||||
expect(
|
||||
resolveLowStockRunwayDays({ medication: { lowStockRunwayDays: null } }),
|
||||
@@ -532,6 +565,7 @@ describe("lowStockRunwayDays — v1.16.11 low-stock threshold", () => {
|
||||
clientManaged: true,
|
||||
deliveryDefault: "server",
|
||||
lowStockRunwayDays: 21,
|
||||
reorderLeadDays: 10,
|
||||
});
|
||||
// ...and the next PATCH (threshold off) keeps the sibling again.
|
||||
const off = resolveNotificationPrefs(merged, {
|
||||
@@ -541,6 +575,7 @@ describe("lowStockRunwayDays — v1.16.11 low-stock threshold", () => {
|
||||
clientManaged: true,
|
||||
deliveryDefault: "server",
|
||||
lowStockRunwayDays: null,
|
||||
reorderLeadDays: 10,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -530,6 +530,22 @@ export const createMedicationSchema = z
|
||||
.describe(
|
||||
"Inventory units consumed per dose. A whole number 1–100 (e.g. 2 tablets of 2 mg for a 4 mg dose) or a supported fraction for a split pill (¼ / ⅓ / ½ / ⅔ / ¾). Default 1. The intake consumption hook decrements this many units per taken dose; dose-derived readouts divide unit counts by it.",
|
||||
),
|
||||
/**
|
||||
* v1.17.0 — optional per-medication reorder lead time in days. The
|
||||
* low-stock alert widens its trigger by this lead plus one
|
||||
* dose-interval so a sparse cadence is warned before its last dose.
|
||||
* Omitted = inherit the user-level
|
||||
* `notificationPrefs.medication.reorderLeadDays` default.
|
||||
*/
|
||||
reorderLeadDays: z
|
||||
.number()
|
||||
.int()
|
||||
.min(0)
|
||||
.max(60)
|
||||
.optional()
|
||||
.describe(
|
||||
"Per-medication reorder lead time in days (0–60). The low-stock alert fires the supply early enough to reorder before the last dose. Omitted = inherit the user-level reorderLeadDays default (10).",
|
||||
),
|
||||
/** v1.6.0 — route of administration (ORAL | INJECTION | OTHER). */
|
||||
deliveryForm: z.enum(MEDICATION_DELIVERY_FORM_VALUES).optional(),
|
||||
/**
|
||||
@@ -635,6 +651,20 @@ export const updateMedicationSchema = z
|
||||
.describe(
|
||||
"Inventory units consumed per dose — a whole number 1–100 or a supported split-pill fraction (¼ / ⅓ / ½ / ⅔ / ¾). The intake consumption hook decrements this many units per taken dose; already-stamped intake events keep their recorded consumption.",
|
||||
),
|
||||
/**
|
||||
* v1.17.0 — per-medication reorder lead time in days (0–60). `null`
|
||||
* clears the override and reverts to the user-level default.
|
||||
*/
|
||||
reorderLeadDays: z
|
||||
.number()
|
||||
.int()
|
||||
.min(0)
|
||||
.max(60)
|
||||
.nullable()
|
||||
.optional()
|
||||
.describe(
|
||||
"Per-medication reorder lead time in days (0–60). null clears the override and reverts to the user-level reorderLeadDays default.",
|
||||
),
|
||||
/** v1.6.0 — route of administration (ORAL | INJECTION | OTHER). */
|
||||
deliveryForm: z.enum(MEDICATION_DELIVERY_FORM_VALUES).optional(),
|
||||
/** v1.8.5 — per-medication injection-site tracking opt-in. */
|
||||
|
||||
@@ -48,6 +48,17 @@ const medicationPrefsSchema = z
|
||||
* the medication-low-stock cron.
|
||||
*/
|
||||
lowStockRunwayDays: z.number().int().min(1).max(60).nullable(),
|
||||
/**
|
||||
* v1.17.0 — reorder lead time (days) the low-stock alert assumes
|
||||
* between placing a refill order and the new supply arriving. The
|
||||
* engine fires the alert when the projected runway drops to or below
|
||||
* `max(lowStockRunwayDays, reorderLeadDays + one dose-interval)`, so
|
||||
* the warning lands BEFORE the last dose for any cadence (a weekly
|
||||
* injection no longer alerts only when ~1 dose is left). 0–60;
|
||||
* default 10. A per-medication `Medication.reorderLeadDays` overrides
|
||||
* this user default when set.
|
||||
*/
|
||||
reorderLeadDays: z.number().int().min(0).max(60),
|
||||
})
|
||||
.partial();
|
||||
|
||||
@@ -143,6 +154,11 @@ export interface NotificationPrefs {
|
||||
* when the alert is off. Default 7.
|
||||
*/
|
||||
lowStockRunwayDays: number | null;
|
||||
/**
|
||||
* v1.17.0 — reorder lead time (days) the low-stock alert assumes.
|
||||
* 0–60; default 10. Per-medication `reorderLeadDays` overrides it.
|
||||
*/
|
||||
reorderLeadDays: number;
|
||||
};
|
||||
mood: {
|
||||
/** v1.7.0 — local-time hour (0–23) for the daily mood reminder. */
|
||||
@@ -185,6 +201,14 @@ export const DEFAULT_MOOD_REMINDER_HOUR = 22;
|
||||
*/
|
||||
export const DEFAULT_LOW_STOCK_RUNWAY_DAYS = 7;
|
||||
|
||||
/**
|
||||
* v1.17.0 — default reorder lead time: assume 10 days between ordering a
|
||||
* refill and the new supply arriving. The alert engine widens the
|
||||
* trigger by this lead plus one dose-interval so a sparse cadence is
|
||||
* warned before its last dose.
|
||||
*/
|
||||
export const DEFAULT_REORDER_LEAD_DAYS = 10;
|
||||
|
||||
/**
|
||||
* Safe defaults. Every category is off so the server reminders flow
|
||||
* unchanged for any user the iOS app has not explicitly opted in.
|
||||
@@ -196,6 +220,7 @@ export const DEFAULT_NOTIFICATION_PREFS: NotificationPrefs = {
|
||||
clientManaged: false,
|
||||
deliveryDefault: "server",
|
||||
lowStockRunwayDays: DEFAULT_LOW_STOCK_RUNWAY_DAYS,
|
||||
reorderLeadDays: DEFAULT_REORDER_LEAD_DAYS,
|
||||
},
|
||||
mood: {
|
||||
reminderHour: DEFAULT_MOOD_REMINDER_HOUR,
|
||||
@@ -348,6 +373,23 @@ export function resolveLowStockRunwayDays(raw: unknown): number | null {
|
||||
return parseNotificationPrefs(raw).medication.lowStockRunwayDays;
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.17.0 — resolve the EFFECTIVE reorder lead time (days) for one
|
||||
* medication: the per-medication override when set, otherwise the
|
||||
* user-level default from the prefs blob (a null / drifted row resolves
|
||||
* to the documented 10-day default). The low-stock alert adds this lead
|
||||
* plus one dose-interval on top of the runway threshold.
|
||||
*/
|
||||
export function resolveReorderLeadDays(
|
||||
raw: unknown,
|
||||
medicationOverride: number | null | undefined,
|
||||
): number {
|
||||
if (typeof medicationOverride === "number" && medicationOverride >= 0) {
|
||||
return medicationOverride;
|
||||
}
|
||||
return parseNotificationPrefs(raw).medication.reorderLeadDays;
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.16.5 — fully-resolved Coach-nudge view for the cron: the master
|
||||
* switch, the three per-group toggles, and the frequency cap mapped to
|
||||
|
||||
Reference in new issue
Block a user