diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 14045abca..110824a5d 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -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 diff --git a/messages/de.json b/messages/de.json index eedfbf516..ed1b0fdaf 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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", diff --git a/messages/en.json b/messages/en.json index ffd41caf9..d1d9fc79d 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", diff --git a/messages/es.json b/messages/es.json index 7615b36a6..5ac283fed 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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", diff --git a/messages/fr.json b/messages/fr.json index 22e9be210..a87db4ac7 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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", diff --git a/messages/it.json b/messages/it.json index 71f2f9cb5..f7f87e6a6 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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", diff --git a/messages/pl.json b/messages/pl.json index b0204d0ff..1bf402a8b 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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", diff --git a/prisma/migrations/0161_v1170_reorder_lead_days/migration.sql b/prisma/migrations/0161_v1170_reorder_lead_days/migration.sql new file mode 100644 index 000000000..ac0db91e6 --- /dev/null +++ b/prisma/migrations/0161_v1170_reorder_lead_days/migration.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 313ef0ff4..38c126279 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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") diff --git a/src/app/api/auth/me/notification-prefs/__tests__/route.test.ts b/src/app/api/auth/me/notification-prefs/__tests__/route.test.ts index afae11aea..ae16929e3 100644 --- a/src/app/api/auth/me/notification-prefs/__tests__/route.test.ts +++ b/src/app/api/auth/me/notification-prefs/__tests__/route.test.ts @@ -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, }, }), }, diff --git a/src/app/api/medications/[id]/glp1/__tests__/route.test.ts b/src/app/api/medications/[id]/glp1/__tests__/route.test.ts index 6f74d2026..6c2393d1d 100644 --- a/src/app/api/medications/[id]/glp1/__tests__/route.test.ts +++ b/src/app/api/medications/[id]/glp1/__tests__/route.test.ts @@ -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({ diff --git a/src/app/api/medications/[id]/glp1/route.ts b/src/app/api/medications/[id]/glp1/route.ts index 0fe699b56..b6523afd9 100644 --- a/src/app/api/medications/[id]/glp1/route.ts +++ b/src/app/api/medications/[id]/glp1/route.ts @@ -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, diff --git a/src/app/api/medications/[id]/route.ts b/src/app/api/medications/[id]/route.ts index 054eaacb0..1edbdcd69 100644 --- a/src/app/api/medications/[id]/route.ts +++ b/src/app/api/medications/[id]/route.ts @@ -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 diff --git a/src/app/api/medications/route.ts b/src/app/api/medications/route.ts index 258cda7d0..a71a017a0 100644 --- a/src/app/api/medications/route.ts +++ b/src/app/api/medications/route.ts @@ -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 diff --git a/src/app/api/settings/reminder-thresholds/__tests__/route.test.ts b/src/app/api/settings/reminder-thresholds/__tests__/route.test.ts index a9fb93cab..b2f000a05 100644 --- a/src/app/api/settings/reminder-thresholds/__tests__/route.test.ts +++ b/src/app/api/settings/reminder-thresholds/__tests__/route.test.ts @@ -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, }); }); diff --git a/src/app/api/settings/reminder-thresholds/route.ts b/src/app/api/settings/reminder-thresholds/route.ts index 8432fc3db..b966ea189 100644 --- a/src/app/api/settings/reminder-thresholds/route.ts +++ b/src/app/api/settings/reminder-thresholds/route.ts @@ -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 }); }); diff --git a/src/components/medications/__tests__/supply-runway.test.ts b/src/components/medications/__tests__/supply-runway.test.ts index 727cb9f6e..e30ead1f3 100644 --- a/src/components/medications/__tests__/supply-runway.test.ts +++ b/src/components/medications/__tests__/supply-runway.test.ts @@ -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"); + }); +}); diff --git a/src/components/medications/detail/supply-runway.ts b/src/components/medications/detail/supply-runway.ts index 0c31761f1..f9b29f0cf 100644 --- a/src/components/medications/detail/supply-runway.ts +++ b/src/components/medications/detail/supply-runway.ts @@ -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 }; +} diff --git a/src/components/medications/glp1-medication-card.tsx b/src/components/medications/glp1-medication-card.tsx index 015d49e3d..9678ce3bd 100644 --- a/src/components/medications/glp1-medication-card.tsx +++ b/src/components/medications/glp1-medication-card.tsx @@ -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; diff --git a/src/components/medications/medication-card.tsx b/src/components/medications/medication-card.tsx index de2b4fc22..3cf0bb210 100644 --- a/src/components/medications/medication-card.tsx +++ b/src/components/medications/medication-card.tsx @@ -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; diff --git a/src/components/settings/low-stock-card.tsx b/src/components/settings/low-stock-card.tsx index c4eff0be4..d25019d79 100644 --- a/src/components/settings/low-stock-card.tsx +++ b/src/components/settings/low-stock-card.tsx @@ -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(null); + const [leadDraft, setLeadDraft] = useState(null); + const [leadOptimistic, setLeadOptimistic] = useState( + undefined, + ); const [saving, setSaving] = useState(false); const [msg, setMsg] = useState(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, + successMsg: string, + ): Promise { 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 (
)} + {/* 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 && ( +
+
+ + 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" + /> +
+

+ {t("notifications.lowStock.leadHelp")} +

+
+ )} {msg && (

{ 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[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(); + }); }); diff --git a/src/lib/jobs/medication-low-stock.ts b/src/lib/jobs/medication-low-stock.ts index 7647c3ac6..e922491e7 100644 --- a/src/lib/jobs/medication-low-stock.ts +++ b/src/lib/jobs/medication-low-stock.ts @@ -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 ~ — reorder by " — + * 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; diff --git a/src/lib/openapi/routes/medications.ts b/src/lib/openapi/routes/medications.ts index 9d7f00707..396d48b0c 100644 --- a/src/lib/openapi/routes/medications.ts +++ b/src/lib/openapi/routes/medications.ts @@ -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 diff --git a/src/lib/openapi/routes/profile.ts b/src/lib/openapi/routes/profile.ts index ea303ff85..3a6f4224c 100644 --- a/src/lib/openapi/routes/profile.ts +++ b/src/lib/openapi/routes/profile.ts @@ -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 diff --git a/src/lib/openapi/routes/settings.ts b/src/lib/openapi/routes/settings.ts index db22300a6..927554db9 100644 --- a/src/lib/openapi/routes/settings.ts +++ b/src/lib/openapi/routes/settings.ts @@ -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", diff --git a/src/lib/validations/__tests__/notification-prefs.test.ts b/src/lib/validations/__tests__/notification-prefs.test.ts index 874cc6b26..eef97b83d 100644 --- a/src/lib/validations/__tests__/notification-prefs.test.ts +++ b/src/lib/validations/__tests__/notification-prefs.test.ts @@ -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, }); }); }); diff --git a/src/lib/validations/medication.ts b/src/lib/validations/medication.ts index fb2748da4..dc238c443 100644 --- a/src/lib/validations/medication.ts +++ b/src/lib/validations/medication.ts @@ -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. */ diff --git a/src/lib/validations/notification-prefs.ts b/src/lib/validations/notification-prefs.ts index 4df4b52ba..92e00d7f1 100644 --- a/src/lib/validations/notification-prefs.ts +++ b/src/lib/validations/notification-prefs.ts @@ -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