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

+64
View File
@@ -5481,6 +5481,12 @@ components:
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.
type: number
reorderLeadDays:
description: 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).
type: integer
minimum: 0
maximum: 60
deliveryForm:
type: string
enum:
@@ -5768,6 +5774,14 @@ components:
/ ¾). The intake consumption hook decrements this many units per taken dose; already-stamped intake events
keep their recorded consumption.
type: number
reorderLeadDays:
description: Per-medication reorder lead time in days (0–60). null clears the override and reverts to the user-level
reorderLeadDays default.
anyOf:
- type: integer
minimum: 0
maximum: 60
- type: "null"
deliveryForm:
type: string
enum:
@@ -6167,6 +6181,10 @@ components:
minimum: 1
maximum: 60
- type: "null"
reorderLeadDays:
type: integer
minimum: 0
maximum: 60
mood:
type: object
properties:
@@ -9707,6 +9725,15 @@ components:
description: 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:
anyOf:
- type: integer
minimum: -9007199254740991
maximum: 9007199254740991
- type: "null"
description: 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:
type: boolean
notificationsEnabled:
@@ -9840,6 +9867,7 @@ components:
- treatmentClass
- dosesPerUnit
- unitsPerDose
- reorderLeadDays
- active
- notificationsEnabled
- liveActivityEnabled
@@ -9925,6 +9953,15 @@ components:
description: 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:
anyOf:
- type: integer
minimum: -9007199254740991
maximum: 9007199254740991
- type: "null"
description: 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:
type: boolean
notificationsEnabled:
@@ -10025,6 +10062,7 @@ components:
- treatmentClass
- dosesPerUnit
- unitsPerDose
- reorderLeadDays
- active
- notificationsEnabled
- liveActivityEnabled
@@ -12891,10 +12929,18 @@ components:
maximum: 60
- type: "null"
description: Low-stock alert threshold as remaining runway days (1–60). null = alert off. Default 7.
reorderLeadDays:
type: integer
minimum: 0
maximum: 60
description: 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.
required:
- clientManaged
- deliveryDefault
- lowStockRunwayDays
- reorderLeadDays
additionalProperties: false
mood:
type: object
@@ -15390,10 +15436,18 @@ components:
- type: "null"
description: 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:
type: integer
minimum: 0
maximum: 60
description: 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.
required:
- lateMinutes
- missedMinutes
- lowStockRunwayDays
- reorderLeadDays
additionalProperties: false
description: "Medication reminder thresholds: the operator-level late/missed minute marks plus the calling user's
low-stock runway threshold (v1.16.11)."
@@ -15796,6 +15850,15 @@ components:
description: 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:
anyOf:
- type: integer
minimum: -9007199254740991
maximum: 9007199254740991
- type: "null"
description: 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:
type: boolean
notificationsEnabled:
@@ -15894,6 +15957,7 @@ components:
- treatmentClass
- dosesPerUnit
- unitsPerDose
- reorderLeadDays
- active
- notificationsEnabled
- liveActivityEnabled
+7 -1
View File
@@ -5519,6 +5519,10 @@
"statusOff": "Aus",
"daysLabel": "Warnen unter (Tage)",
"daysAria": "Vorratsschwelle in Tagen wählen",
"leadLabel": "Vorlaufzeit zum Nachbestellen (Tage)",
"leadAria": "Vorlaufzeit zum Nachbestellen in Tagen",
"leadHelp": "Die Warnung kommt um so viele Tage plus ein Dosisintervall früher, damit der Nachschub vor deiner letzten Dosis eintrifft. Ein einzelnes Medikament kann diesen Wert auf seinem Bestand-Tab überschreiben.",
"leadSavedToast": "Vorlaufzeit gespeichert.",
"savedToast": "Vorratsschwelle gespeichert.",
"enabledToast": "Vorratswarnungen aktiviert.",
"disabledToast": "Vorratswarnungen deaktiviert.",
@@ -5712,7 +5716,9 @@
"lowStockReminders": {
"title": "Vorrat niedrig: {medName}",
"body": "Dein Vorrat an {medName} reicht noch etwa {days} Tage ({units} Einheiten übrig).",
"bodyToday": "Dein Vorrat an {medName} reicht keinen ganzen Tag mehr ({units} Einheiten übrig)."
"bodyToday": "Dein Vorrat an {medName} reicht keinen ganzen Tag mehr ({units} Einheiten übrig).",
"bodyReorder": "Dein Vorrat an {medName} ({units} Einheiten) ist um den {runsOutOn} aufgebraucht — bestelle bis zum {reorderBy} nach.",
"bodyLastDose": "Dein Vorrat an {medName} ({units} Einheiten) reicht noch etwa für die letzte Dosis. Aufgebraucht um den {runsOutOn}."
},
"moodReminders": {
"dailyTitle": "Stimmung erfassen",
+7 -1
View File
@@ -5519,6 +5519,10 @@
"statusOff": "Off",
"daysLabel": "Notify below (days)",
"daysAria": "Select the low-stock threshold in days",
"leadLabel": "Reorder lead time (days)",
"leadAria": "Reorder lead time in days",
"leadHelp": "The alert fires this many days plus one dose-interval earlier so a refill arrives before your last dose. A single medication can override this on its supply tab.",
"leadSavedToast": "Reorder lead time saved.",
"savedToast": "Low-stock threshold saved.",
"enabledToast": "Low-stock alerts enabled.",
"disabledToast": "Low-stock alerts disabled.",
@@ -5712,7 +5716,9 @@
"lowStockReminders": {
"title": "Low supply: {medName}",
"body": "{medName}: {units} units left — enough for about {days} more days.",
"bodyToday": "{medName}: {units} units left — less than one day of supply."
"bodyToday": "{medName}: {units} units left — less than one day of supply.",
"bodyReorder": "{medName}: {units} units left. Supply runs out around {runsOutOn} — reorder by {reorderBy}.",
"bodyLastDose": "{medName}: {units} units left — about your final dose. Supply runs out around {runsOutOn}."
},
"moodReminders": {
"dailyTitle": "Log your mood",
+7 -1
View File
@@ -5519,6 +5519,10 @@
"statusOff": "Desactivado",
"daysLabel": "Avisar por debajo de (días)",
"daysAria": "Seleccionar el umbral de existencias en días",
"leadLabel": "Plazo de reposición (días)",
"leadAria": "Plazo de reposición en días",
"leadHelp": "El aviso se envía estos días más un intervalo de dosis antes, para que la reposición llegue antes de tu última dosis. Un medicamento concreto puede anular este valor en su pestaña de existencias.",
"leadSavedToast": "Plazo de reposición guardado.",
"savedToast": "Umbral de existencias guardado.",
"enabledToast": "Avisos de existencias bajas activados.",
"disabledToast": "Avisos de existencias bajas desactivados.",
@@ -5712,7 +5716,9 @@
"lowStockReminders": {
"title": "Quedan pocas existencias: {medName}",
"body": "{medName}: quedan {units} unidades — para unos {days} días más.",
"bodyToday": "{medName}: quedan {units} unidades — menos de un día de suministro."
"bodyToday": "{medName}: quedan {units} unidades — menos de un día de suministro.",
"bodyReorder": "{medName}: quedan {units} unidades. Las existencias se agotan hacia el {runsOutOn} — vuelve a pedir antes del {reorderBy}.",
"bodyLastDose": "{medName}: quedan {units} unidades — alrededor de tu última dosis. Las existencias se agotan hacia el {runsOutOn}."
},
"moodReminders": {
"dailyTitle": "Registrar el ánimo",
+7 -1
View File
@@ -5519,6 +5519,10 @@
"statusOff": "Désactivé",
"daysLabel": "Prévenir en dessous de (jours)",
"daysAria": "Choisir le seuil de stock en jours",
"leadLabel": "Délai de réapprovisionnement (jours)",
"leadAria": "Délai de réapprovisionnement en jours",
"leadHelp": "L’alerte est envoyée ce nombre de jours plus un intervalle de dose plus tôt, pour qu’un réapprovisionnement arrive avant ta dernière dose. Un médicament donné peut remplacer cette valeur dans son onglet stock.",
"leadSavedToast": "Délai de réapprovisionnement enregistré.",
"savedToast": "Seuil de stock enregistré.",
"enabledToast": "Alertes de stock faible activées.",
"disabledToast": "Alertes de stock faible désactivées.",
@@ -5712,7 +5716,9 @@
"lowStockReminders": {
"title": "Stock faible : {medName}",
"body": "{medName} : {units} unités restantes — environ {days} jours de réserve.",
"bodyToday": "{medName} : {units} unités restantes — moins d’un jour de réserve."
"bodyToday": "{medName} : {units} unités restantes — moins d’un jour de réserve.",
"bodyReorder": "{medName} : {units} unités restantes. Le stock sera épuisé vers le {runsOutOn} — recommandez avant le {reorderBy}.",
"bodyLastDose": "{medName} : {units} unités restantes — environ votre dernière dose. Le stock sera épuisé vers le {runsOutOn}."
},
"moodReminders": {
"dailyTitle": "Enregistrer votre humeur",
+7 -1
View File
@@ -5519,6 +5519,10 @@
"statusOff": "Disattivato",
"daysLabel": "Avvisa sotto (giorni)",
"daysAria": "Seleziona la soglia delle scorte in giorni",
"leadLabel": "Tempo di riordino (giorni)",
"leadAria": "Tempo di riordino in giorni",
"leadHelp": "L’avviso arriva questo numero di giorni più un intervallo di dose in anticipo, così la fornitura arriva prima dell’ultima dose. Un singolo farmaco può sovrascrivere questo valore nella sua scheda scorte.",
"leadSavedToast": "Tempo di riordino salvato.",
"savedToast": "Soglia delle scorte salvata.",
"enabledToast": "Avvisi di scorte basse attivati.",
"disabledToast": "Avvisi di scorte basse disattivati.",
@@ -5712,7 +5716,9 @@
"lowStockReminders": {
"title": "Scorte in esaurimento: {medName}",
"body": "{medName}: {units} unità rimaste — bastano per circa {days} giorni.",
"bodyToday": "{medName}: {units} unità rimaste — meno di un giorno di scorta."
"bodyToday": "{medName}: {units} unità rimaste — meno di un giorno di scorta.",
"bodyReorder": "{medName}: {units} unità rimaste. Le scorte finiscono intorno al {runsOutOn} — riordina entro il {reorderBy}.",
"bodyLastDose": "{medName}: {units} unità rimaste — circa la tua ultima dose. Le scorte finiscono intorno al {runsOutOn}."
},
"moodReminders": {
"dailyTitle": "Registra il tuo umore",
+7 -1
View File
@@ -5519,6 +5519,10 @@
"statusOff": "Wyłączone",
"daysLabel": "Powiadom poniżej (dni)",
"daysAria": "Wybierz próg zapasu w dniach",
"leadLabel": "Czas na ponowne zamówienie (dni)",
"leadAria": "Czas na ponowne zamówienie w dniach",
"leadHelp": "Alert pojawia się o tyle dni plus jeden interwał dawki wcześniej, aby nowy zapas dotarł przed Twoją ostatnią dawką. Pojedynczy lek może nadpisać tę wartość na swojej karcie zapasu.",
"leadSavedToast": "Czas na ponowne zamówienie zapisany.",
"savedToast": "Próg zapasu zapisany.",
"enabledToast": "Alerty niskiego zapasu włączone.",
"disabledToast": "Alerty niskiego zapasu wyłączone.",
@@ -5712,7 +5716,9 @@
"lowStockReminders": {
"title": "Niski zapas: {medName}",
"body": "{medName}: pozostało {units} jednostek — wystarczy na około {days} dni.",
"bodyToday": "{medName}: pozostało {units} jednostek — mniej niż jeden dzień zapasu."
"bodyToday": "{medName}: pozostało {units} jednostek — mniej niż jeden dzień zapasu.",
"bodyReorder": "{medName}: pozostało {units} jednostek. Zapas skończy się około {runsOutOn} — zamów ponownie do {reorderBy}.",
"bodyLastDose": "{medName}: pozostało {units} jednostek — to mniej więcej Twoja ostatnia dawka. Zapas skończy się około {runsOutOn}."
},
"moodReminders": {
"dailyTitle": "Zapisz nastrój",
@@ -0,0 +1,19 @@
-- v1.17.0 — reorder lead time for the medication low-stock alert.
--
-- The low-stock alert previously fired only when the projected runway fell
-- below the user's threshold (default 7 days). For a sparse cadence (e.g. a
-- weekly injection) 7 days is barely one dose-interval, so the warning landed
-- with about one dose left — too late to reorder before the supply ran out.
--
-- `medications.reorder_lead_days` carries an OPTIONAL per-medication reorder
-- lead time (days between placing an order and the new supply arriving). The
-- alert engine adds it, plus one dose-interval, on top of the user threshold
-- so the warning lands before the LAST dose for any cadence. NULL = inherit
-- the per-user `notificationPrefs.medication.reorderLeadDays` default.
--
-- Additive + back-compatible: the column is nullable with no backfill; every
-- existing row keeps NULL and inherits the global default. iOS picks the field
-- up without a migration of its own.
ALTER TABLE "medications"
ADD COLUMN "reorder_lead_days" INTEGER;
+8
View File
@@ -1408,6 +1408,14 @@ model Medication {
/// against. A changed threshold re-arms the medication even while
/// the runway stays low, so the user hears about the new crossing.
lowStockNotifiedThresholdDays Int? @map("low_stock_notified_threshold_days")
/// v1.17.0 — optional per-medication reorder lead time in days (how
/// long between placing a refill order and the new supply arriving).
/// The low-stock alert adds this lead plus one dose-interval on top of
/// the user threshold so the warning lands BEFORE the last dose for any
/// cadence. NULL = inherit the per-user
/// `notificationPrefs.medication.reorderLeadDays` default. Additive; no
/// backfill.
reorderLeadDays Int? @map("reorder_lead_days")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@ -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({
+53 -3
View File
@@ -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,
+4
View File
@@ -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
+4
View File
@@ -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;
+29 -3
View File
@@ -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;
+87 -6
View File
@@ -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();
});
});
+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