mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 22:26:13 +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
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user