mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 21:32:18 +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
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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