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:
Marc-André Bombeck committed 2026-06-14 20:39:06 +02:00
1 parent 52bf985734
commit 08aa655be4
29 files changed
+1060 -97

No files matched your search

@@ -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();
});
});
+160 -43
View File
@@ -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;
+7
View File
@@ -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
+8
View File
@@ -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
+8
View File
@@ -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,
});
});
});
+30
View File
@@ -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. */
+42
View File
@@ -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