Files
HealthLog/src/components/settings/low-stock-card.tsx
T
Marc-André Bombeck 272c426bea fix(ui): close the holes the audit found in this work's own guards
Every item here is a check that could pass while the thing it checks is
broken, or a margin the sweep's codemod could not reach.

Guards:

- The access-payload matcher accepted `.level` on ANY object. A chart's zoom
  level or a log record's level would have satisfied it and reported a
  consumer that does not exist. It now names the bindings the entry actually
  arrives under; a third binding is a deliberate edit rather than a silent
  widening. Re-falsified: rename both readers, it goes red.
- The `pl-7` sweep skipped any line CONTAINING `//`, so a class string sharing
  a line with a URL was waved through. Only the part after a real line-comment
  marker is exempt now. Proven by putting `pl-7` on a line with an
  `https://` comment: caught.
- `LEGIT_IDENTICAL` in the English-leak guard had no staleness check, unlike
  both of its siblings, and carried two dead keys — one from a removal long
  before this work, one from the duplicated version fields that came off
  `/admin`. Both pruned, the check added, falsified with a made-up key.
- `split-description.mjs` split inside `z. B.`, `d. h.`, `u. a.` and `p. ex.`
  and produced a head ending mid-abbreviation. Both halves have to be caught,
  which is why the rule is a shape (single letter + stop) rather than a list,
  and the merge re-tests its own tail. Exercised against eight cases including
  the ones that used to break.

Margins the codemod could not see, because the child lives in a different
component from the card that spaces it: six form roots under managed record
settings (one of which also carried the banned `space-y-5` step), the
low-stock status line, the avatar card's explainer, and the backups restore
report. Each was adding a margin on top of the card's own gap.

Two facts the text diet cut too close, both restored inside the guideline:
that an excluded injection site wins over a medication that prefers it, and
that the privacy card's second button leads to a full health record.
2026-08-07 23:20:39 +02:00

335 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useEffect, useRef, useState } from "react";
import Link from "next/link";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { PackageOpen } from "lucide-react";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
import { readUpdatedAtToken, withBaseToken } from "@/lib/api/optimistic-token";
/**
* v1.16.11 — medication low-stock alert threshold (Settings →
* Notifications). The threshold is REMAINING RUNWAY: notify when a
* tracked medication's supply covers fewer than N days (1–60, default
* 7); the switch off persists `null` (alert off). Reads the same
* per-user prefs blob the mood / coach cards use and writes through
* `PATCH /api/auth/me/notification-prefs`; the value also surfaces on
* `GET /api/settings/reminder-thresholds` for API consumers, so the
* save invalidates both keys.
*/
interface NotificationPrefsShape {
medication: { lowStockRunwayDays: number | null; reorderLeadDays?: number };
// v1.32.22 (R5b) — optimistic-concurrency token, a sibling key on the
// resolved prefs response, echoed on every PATCH from this card.
updatedAt?: string;
}
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,
}: {
isAuthenticated: boolean;
}) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const [optimistic, setOptimistic] = useState<number | null | undefined>(
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);
// Auto-clear the inline status line 3 s after the mutation settles —
// the MoodReminderCard pattern, including the unmount / re-toggle
// timer hygiene.
const clearTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
return () => {
if (clearTimerRef.current !== null) {
clearTimeout(clearTimerRef.current);
clearTimerRef.current = null;
}
};
}, []);
function scheduleClear() {
if (clearTimerRef.current !== null) {
clearTimeout(clearTimerRef.current);
}
clearTimerRef.current = setTimeout(() => {
clearTimerRef.current = null;
setMsg(null);
setMsgType(null);
}, 3000);
}
const { data: prefs } = useQuery({
queryKey: queryKeys.authNotificationPrefs(),
queryFn: async () => {
return apiGet<NotificationPrefsShape>("/api/auth/me/notification-prefs");
},
enabled: isAuthenticated,
});
const persisted =
prefs?.medication?.lowStockRunwayDays === undefined
? DEFAULT_DAYS
: prefs.medication.lowStockRunwayDays;
const current = optimistic === undefined ? persisted : optimistic;
const enabled = current !== null;
const days = current ?? DEFAULT_DAYS;
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);
if (clearTimerRef.current !== null) {
clearTimeout(clearTimerRef.current);
clearTimerRef.current = null;
}
// v1.32.22 (R5b) — echo the base token so a concurrent notification-prefs
// write (the iOS client, another web card) 409s instead of reverting a
// sibling category through the server-side deep-merge.
const res = await apiFetchRaw("/api/auth/me/notification-prefs", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(
withBaseToken(
body,
readUpdatedAtToken(queryClient, queryKeys.authNotificationPrefs()),
),
),
});
if (res.ok) {
setMsg(successMsg);
setMsgType("success");
await Promise.all([
queryClient.invalidateQueries({
queryKey: queryKeys.authNotificationPrefs(),
}),
queryClient.invalidateQueries({
queryKey: queryKeys.settingsReminderThresholds(),
}),
]);
} else if (res.status === 409) {
// The prefs advanced since they were loaded. Refetch (the active query
// reseeds a fresh token) and nudge — the caller reverts its optimistic
// value, so the card snaps back to server truth for a re-apply.
await queryClient.invalidateQueries({
queryKey: queryKeys.authNotificationPrefs(),
});
setMsg(t("common.conflictReloaded"));
setMsgType("error");
} else {
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) {
await persist(
next ? days : null,
next
? t("notifications.lowStock.enabledToast")
: t("notifications.lowStock.disabledToast"),
);
}
function commitDraft() {
if (draft === null) return;
const parsed = Number(draft);
setDraft(null);
if (!Number.isInteger(parsed)) return;
const clamped = Math.min(MAX_DAYS, Math.max(MIN_DAYS, parsed));
if (clamped === days) return;
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 (
<SettingsCard
as="section"
// `id` is the anchor target of the supply tab's cross-link
// (`/settings/notifications#low-stock`) — keep it stable.
id="low-stock"
aria-labelledby="settings-low-stock-title"
className="scroll-mt-28"
>
<SettingsCardHeader
icon={PackageOpen}
title={t("notifications.lowStock.title")}
titleId="settings-low-stock-title"
description={t("notifications.lowStock.description")}
status={
<label className="flex min-h-11 items-center gap-3">
<Switch
checked={enabled}
onCheckedChange={handleToggle}
disabled={!isAuthenticated || saving}
aria-label={t("notifications.lowStock.toggleAria")}
/>
<p className="text-sm">{t("notifications.lowStock.detail")}</p>
<span className="text-muted-foreground text-xs">
{enabled
? t("notifications.lowStock.statusOn")
: t("notifications.lowStock.statusOff")}
</span>
</label>
}
/>
{enabled && (
<div className="flex min-h-11 items-center gap-3">
<label htmlFor="low-stock-days" className="text-sm font-medium">
{t("notifications.lowStock.daysLabel")}
</label>
<Input
id="low-stock-days"
type="number"
inputMode="numeric"
min={MIN_DAYS}
max={MAX_DAYS}
className="w-auto"
value={draft ?? String(days)}
disabled={!isAuthenticated || saving}
onChange={(e) => setDraft(e.target.value)}
onBlur={commitDraft}
onKeyDown={(e) => {
if (e.key === "Enter") commitDraft();
}}
aria-label={t("notifications.lowStock.daysAria")}
autoComplete="off"
data-lpignore="true"
data-1p-ignore="true"
/>
</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>
<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-auto"
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"
aria-live="polite"
className={
msgType === "error"
? "text-destructive text-sm"
: "text-muted-foreground text-sm"
}
>
{msg}
</p>
)}
{/* v1.16.11 — back-link to the medications list, the surface the
alert is about (the supply tab links here the same way). */}
<p>
<Link
href="/medications"
className="text-muted-foreground hover:text-foreground text-xs underline-offset-2 hover:underline"
data-slot="low-stock-medications-link"
>
{t("notifications.lowStock.medicationsLink")}
</Link>
</p>
</SettingsCard>
);
}