mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 22:50:51 +00:00
feat(medications): make low-stock alert reorder-lead-time aware
The low-stock alert fired when the projected runway dropped below the user threshold (default 7 days). For a sparse cadence — a weekly injection's 7-day floor is barely one dose-interval — the warning landed with about one dose left, too late to reorder before the supply ran out. Add an optional per-medication reorder lead time plus a user-level default (0–60 days, default 10) and widen the alert trigger to max(lowStockRunwayDays, leadDays + cadenceIntervalDays), so the warning lands before the last dose for any cadence. Because the trigger is a max() over the user floor it never shrinks anyone's current threshold; a daily med with no lead keeps its prior boundary. The push body and the card notice surface concrete dates — supply runs out around <date>, reorder by <date> — with a last-dose informational state and the days/units fallback when supply data is thin. Retire the stale fixed 4-dose count threshold on the GLP-1 details endpoint and route its card lowStock flag through the same runway evaluation so the card and the daily cron agree. Surface the lead default in the low-stock settings card with progressive disclosure; a single medication can override it. Additive schema: nullable Medication.reorderLeadDays (migration 0161, no backfill) and notificationPrefs.medication.reorderLeadDays. OpenAPI regenerated.
This commit is contained in:
1 parent
52bf985734
commit
08aa655be4
29 files changed
+1060
-97
No files matched your search
@@ -4,6 +4,7 @@ import {
|
||||
DEFAULT_LOW_STOCK_RUNWAY_DAYS,
|
||||
DEFAULT_MOOD_REMINDER_HOUR,
|
||||
DEFAULT_NOTIFICATION_PREFS,
|
||||
DEFAULT_REORDER_LEAD_DAYS,
|
||||
isCycleReminderClientManaged,
|
||||
isMedicationReminderClientManaged,
|
||||
notificationPrefsSchema,
|
||||
@@ -13,6 +14,7 @@ import {
|
||||
resolveLowStockRunwayDays,
|
||||
resolveMoodReminderHour,
|
||||
resolveNotificationPrefs,
|
||||
resolveReorderLeadDays,
|
||||
} from "../notification-prefs";
|
||||
|
||||
/**
|
||||
@@ -96,6 +98,7 @@ describe("parseNotificationPrefs", () => {
|
||||
clientManaged: true,
|
||||
deliveryDefault: "server",
|
||||
lowStockRunwayDays: 7,
|
||||
reorderLeadDays: 10,
|
||||
},
|
||||
mood: { reminderHour: DEFAULT_MOOD_REMINDER_HOUR },
|
||||
cycle: { clientManaged: false },
|
||||
@@ -115,6 +118,7 @@ describe("parseNotificationPrefs", () => {
|
||||
clientManaged: false,
|
||||
deliveryDefault: "server",
|
||||
lowStockRunwayDays: 7,
|
||||
reorderLeadDays: 10,
|
||||
},
|
||||
mood: { reminderHour: DEFAULT_MOOD_REMINDER_HOUR },
|
||||
cycle: { clientManaged: false },
|
||||
@@ -136,6 +140,7 @@ describe("parseNotificationPrefs", () => {
|
||||
clientManaged: true,
|
||||
deliveryDefault: "client",
|
||||
lowStockRunwayDays: 7,
|
||||
reorderLeadDays: 10,
|
||||
},
|
||||
mood: { reminderHour: DEFAULT_MOOD_REMINDER_HOUR },
|
||||
cycle: { clientManaged: false },
|
||||
@@ -173,6 +178,7 @@ describe("resolveNotificationPrefs (deep-merge)", () => {
|
||||
clientManaged: true,
|
||||
deliveryDefault: "server",
|
||||
lowStockRunwayDays: 7,
|
||||
reorderLeadDays: 10,
|
||||
},
|
||||
mood: { reminderHour: DEFAULT_MOOD_REMINDER_HOUR },
|
||||
cycle: { clientManaged: false },
|
||||
@@ -198,6 +204,7 @@ describe("resolveNotificationPrefs (deep-merge)", () => {
|
||||
clientManaged: true,
|
||||
deliveryDefault: "server",
|
||||
lowStockRunwayDays: 7,
|
||||
reorderLeadDays: 10,
|
||||
},
|
||||
mood: { reminderHour: DEFAULT_MOOD_REMINDER_HOUR },
|
||||
cycle: { clientManaged: false },
|
||||
@@ -220,6 +227,7 @@ describe("resolveNotificationPrefs (deep-merge)", () => {
|
||||
clientManaged: true,
|
||||
deliveryDefault: "server",
|
||||
lowStockRunwayDays: 7,
|
||||
reorderLeadDays: 10,
|
||||
},
|
||||
mood: { reminderHour: DEFAULT_MOOD_REMINDER_HOUR },
|
||||
cycle: { clientManaged: false },
|
||||
@@ -242,6 +250,7 @@ describe("resolveNotificationPrefs (deep-merge)", () => {
|
||||
clientManaged: true,
|
||||
deliveryDefault: "client",
|
||||
lowStockRunwayDays: 7,
|
||||
reorderLeadDays: 10,
|
||||
},
|
||||
mood: { reminderHour: DEFAULT_MOOD_REMINDER_HOUR },
|
||||
cycle: { clientManaged: false },
|
||||
@@ -265,6 +274,7 @@ describe("resolveNotificationPrefs (deep-merge)", () => {
|
||||
clientManaged: true,
|
||||
deliveryDefault: "server",
|
||||
lowStockRunwayDays: 7,
|
||||
reorderLeadDays: 10,
|
||||
},
|
||||
mood: { reminderHour: 8 },
|
||||
cycle: { clientManaged: false },
|
||||
@@ -515,6 +525,29 @@ describe("lowStockRunwayDays — v1.16.11 low-stock threshold", () => {
|
||||
).toBe(14);
|
||||
});
|
||||
|
||||
it("resolves the reorder lead default (10) for a null / drifted row", () => {
|
||||
expect(resolveReorderLeadDays(null, null)).toBe(DEFAULT_REORDER_LEAD_DAYS);
|
||||
expect(resolveReorderLeadDays({ unknown: "shape" }, undefined)).toBe(
|
||||
DEFAULT_REORDER_LEAD_DAYS,
|
||||
);
|
||||
});
|
||||
|
||||
it("reads the persisted user-level reorder lead when no per-med override", () => {
|
||||
expect(
|
||||
resolveReorderLeadDays({ medication: { reorderLeadDays: 21 } }, null),
|
||||
).toBe(21);
|
||||
});
|
||||
|
||||
it("a per-medication reorder-lead override beats the user default", () => {
|
||||
// User default 21, per-med 0 → the per-med value wins (incl. 0).
|
||||
expect(
|
||||
resolveReorderLeadDays({ medication: { reorderLeadDays: 21 } }, 0),
|
||||
).toBe(0);
|
||||
expect(
|
||||
resolveReorderLeadDays({ medication: { reorderLeadDays: 21 } }, 5),
|
||||
).toBe(5);
|
||||
});
|
||||
|
||||
it("returns null when the user switched the alert off", () => {
|
||||
expect(
|
||||
resolveLowStockRunwayDays({ medication: { lowStockRunwayDays: null } }),
|
||||
@@ -532,6 +565,7 @@ describe("lowStockRunwayDays — v1.16.11 low-stock threshold", () => {
|
||||
clientManaged: true,
|
||||
deliveryDefault: "server",
|
||||
lowStockRunwayDays: 21,
|
||||
reorderLeadDays: 10,
|
||||
});
|
||||
// ...and the next PATCH (threshold off) keeps the sibling again.
|
||||
const off = resolveNotificationPrefs(merged, {
|
||||
@@ -541,6 +575,7 @@ describe("lowStockRunwayDays — v1.16.11 low-stock threshold", () => {
|
||||
clientManaged: true,
|
||||
deliveryDefault: "server",
|
||||
lowStockRunwayDays: null,
|
||||
reorderLeadDays: 10,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -530,6 +530,22 @@ export const createMedicationSchema = z
|
||||
.describe(
|
||||
"Inventory units consumed per dose. A whole number 1–100 (e.g. 2 tablets of 2 mg for a 4 mg dose) or a supported fraction for a split pill (¼ / ⅓ / ½ / ⅔ / ¾). Default 1. The intake consumption hook decrements this many units per taken dose; dose-derived readouts divide unit counts by it.",
|
||||
),
|
||||
/**
|
||||
* v1.17.0 — optional per-medication reorder lead time in days. The
|
||||
* low-stock alert widens its trigger by this lead plus one
|
||||
* dose-interval so a sparse cadence is warned before its last dose.
|
||||
* Omitted = inherit the user-level
|
||||
* `notificationPrefs.medication.reorderLeadDays` default.
|
||||
*/
|
||||
reorderLeadDays: z
|
||||
.number()
|
||||
.int()
|
||||
.min(0)
|
||||
.max(60)
|
||||
.optional()
|
||||
.describe(
|
||||
"Per-medication reorder lead time in days (0–60). The low-stock alert fires the supply early enough to reorder before the last dose. Omitted = inherit the user-level reorderLeadDays default (10).",
|
||||
),
|
||||
/** v1.6.0 — route of administration (ORAL | INJECTION | OTHER). */
|
||||
deliveryForm: z.enum(MEDICATION_DELIVERY_FORM_VALUES).optional(),
|
||||
/**
|
||||
@@ -635,6 +651,20 @@ export const updateMedicationSchema = z
|
||||
.describe(
|
||||
"Inventory units consumed per dose — a whole number 1–100 or a supported split-pill fraction (¼ / ⅓ / ½ / ⅔ / ¾). The intake consumption hook decrements this many units per taken dose; already-stamped intake events keep their recorded consumption.",
|
||||
),
|
||||
/**
|
||||
* v1.17.0 — per-medication reorder lead time in days (0–60). `null`
|
||||
* clears the override and reverts to the user-level default.
|
||||
*/
|
||||
reorderLeadDays: z
|
||||
.number()
|
||||
.int()
|
||||
.min(0)
|
||||
.max(60)
|
||||
.nullable()
|
||||
.optional()
|
||||
.describe(
|
||||
"Per-medication reorder lead time in days (0–60). null clears the override and reverts to the user-level reorderLeadDays default.",
|
||||
),
|
||||
/** v1.6.0 — route of administration (ORAL | INJECTION | OTHER). */
|
||||
deliveryForm: z.enum(MEDICATION_DELIVERY_FORM_VALUES).optional(),
|
||||
/** v1.8.5 — per-medication injection-site tracking opt-in. */
|
||||
|
||||
@@ -48,6 +48,17 @@ const medicationPrefsSchema = z
|
||||
* the medication-low-stock cron.
|
||||
*/
|
||||
lowStockRunwayDays: z.number().int().min(1).max(60).nullable(),
|
||||
/**
|
||||
* v1.17.0 — reorder lead time (days) the low-stock alert assumes
|
||||
* between placing a refill order and the new supply arriving. The
|
||||
* engine fires the alert when the projected runway drops to or below
|
||||
* `max(lowStockRunwayDays, reorderLeadDays + one dose-interval)`, so
|
||||
* the warning lands BEFORE the last dose for any cadence (a weekly
|
||||
* injection no longer alerts only when ~1 dose is left). 0–60;
|
||||
* default 10. A per-medication `Medication.reorderLeadDays` overrides
|
||||
* this user default when set.
|
||||
*/
|
||||
reorderLeadDays: z.number().int().min(0).max(60),
|
||||
})
|
||||
.partial();
|
||||
|
||||
@@ -143,6 +154,11 @@ export interface NotificationPrefs {
|
||||
* when the alert is off. Default 7.
|
||||
*/
|
||||
lowStockRunwayDays: number | null;
|
||||
/**
|
||||
* v1.17.0 — reorder lead time (days) the low-stock alert assumes.
|
||||
* 0–60; default 10. Per-medication `reorderLeadDays` overrides it.
|
||||
*/
|
||||
reorderLeadDays: number;
|
||||
};
|
||||
mood: {
|
||||
/** v1.7.0 — local-time hour (0–23) for the daily mood reminder. */
|
||||
@@ -185,6 +201,14 @@ export const DEFAULT_MOOD_REMINDER_HOUR = 22;
|
||||
*/
|
||||
export const DEFAULT_LOW_STOCK_RUNWAY_DAYS = 7;
|
||||
|
||||
/**
|
||||
* v1.17.0 — default reorder lead time: assume 10 days between ordering a
|
||||
* refill and the new supply arriving. The alert engine widens the
|
||||
* trigger by this lead plus one dose-interval so a sparse cadence is
|
||||
* warned before its last dose.
|
||||
*/
|
||||
export const DEFAULT_REORDER_LEAD_DAYS = 10;
|
||||
|
||||
/**
|
||||
* Safe defaults. Every category is off so the server reminders flow
|
||||
* unchanged for any user the iOS app has not explicitly opted in.
|
||||
@@ -196,6 +220,7 @@ export const DEFAULT_NOTIFICATION_PREFS: NotificationPrefs = {
|
||||
clientManaged: false,
|
||||
deliveryDefault: "server",
|
||||
lowStockRunwayDays: DEFAULT_LOW_STOCK_RUNWAY_DAYS,
|
||||
reorderLeadDays: DEFAULT_REORDER_LEAD_DAYS,
|
||||
},
|
||||
mood: {
|
||||
reminderHour: DEFAULT_MOOD_REMINDER_HOUR,
|
||||
@@ -348,6 +373,23 @@ export function resolveLowStockRunwayDays(raw: unknown): number | null {
|
||||
return parseNotificationPrefs(raw).medication.lowStockRunwayDays;
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.17.0 — resolve the EFFECTIVE reorder lead time (days) for one
|
||||
* medication: the per-medication override when set, otherwise the
|
||||
* user-level default from the prefs blob (a null / drifted row resolves
|
||||
* to the documented 10-day default). The low-stock alert adds this lead
|
||||
* plus one dose-interval on top of the runway threshold.
|
||||
*/
|
||||
export function resolveReorderLeadDays(
|
||||
raw: unknown,
|
||||
medicationOverride: number | null | undefined,
|
||||
): number {
|
||||
if (typeof medicationOverride === "number" && medicationOverride >= 0) {
|
||||
return medicationOverride;
|
||||
}
|
||||
return parseNotificationPrefs(raw).medication.reorderLeadDays;
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.16.5 — fully-resolved Coach-nudge view for the cron: the master
|
||||
* switch, the three per-group toggles, and the frequency cap mapped to
|
||||
|
||||
Reference in new issue
Block a user