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

+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"