mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
Add the healthlog/no-raw-palette-color rule so UI code routes color through the semantic tokens instead of Tailwind palette shades, and sweep the remaining offenders (password-strength scale, injection-site accents, redundant dark overrides). Add guard tests for width stability, the single page-header vocabulary, and motion-reduce coverage on indefinite animations.
413 lines
14 KiB
TypeScript
413 lines
14 KiB
TypeScript
"use client";
|
|
|
|
import { useState } from "react";
|
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
|
import {
|
|
Activity,
|
|
Bell,
|
|
CheckCircle2,
|
|
HeartPulse,
|
|
Loader2,
|
|
XCircle,
|
|
} from "lucide-react";
|
|
import { toast } from "sonner";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
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 { useAdminSettings, useUpdateSettings } from "./_shared";
|
|
import { apiGet, apiPost } from "@/lib/api/api-fetch";
|
|
|
|
interface NotificationHealth {
|
|
windowHours: number;
|
|
since: string;
|
|
channels: Array<{
|
|
channel: string;
|
|
ok: number;
|
|
error: number;
|
|
skipped: number;
|
|
total: number;
|
|
}>;
|
|
autoDisabledChannels: Array<{ type: string; count: number }>;
|
|
}
|
|
|
|
/**
|
|
* v1.17.1 — operator-wide notification delivery-health panel. A single
|
|
* `groupBy(channel, result)` over the trailing 24h of `push_attempts` plus the
|
|
* count of auto-disabled channels across all users. Lets the operator answer
|
|
* "are anyone's pushes failing?" without DB shell.
|
|
*/
|
|
function NotificationHealthPanel() {
|
|
const { t } = useTranslations();
|
|
const windowHours = 24;
|
|
|
|
const { data, isLoading } = useQuery({
|
|
queryKey: queryKeys.adminNotificationHealth(windowHours),
|
|
queryFn: async () =>
|
|
apiGet<NotificationHealth>(
|
|
`/api/admin/notifications/health?hours=${windowHours}`,
|
|
),
|
|
});
|
|
|
|
return (
|
|
<SettingsCard className="mt-6">
|
|
<SettingsCardHeader
|
|
icon={HeartPulse}
|
|
title={t("admin.notificationHealth.title")}
|
|
description={t("admin.notificationHealth.description")}
|
|
/>
|
|
|
|
<div className="mt-4 pl-7">
|
|
{isLoading && (
|
|
<Loader2 className="text-muted-foreground h-4 w-4 animate-spin motion-reduce:animate-none" />
|
|
)}
|
|
|
|
{data && data.channels.length === 0 && (
|
|
<p className="text-muted-foreground text-sm">
|
|
{t("admin.notificationHealth.empty")}
|
|
</p>
|
|
)}
|
|
|
|
{data && data.channels.length > 0 && (
|
|
<div className="space-y-2">
|
|
{data.channels.map((c) => (
|
|
<div
|
|
key={c.channel}
|
|
className="flex items-center justify-between text-sm"
|
|
>
|
|
<span className="font-medium">{c.channel}</span>
|
|
<span className="flex items-center gap-3 text-xs">
|
|
<span className="text-success">
|
|
{t("admin.notificationHealth.ok")}: {c.ok}
|
|
</span>
|
|
<span className="text-destructive">
|
|
{t("admin.notificationHealth.error")}: {c.error}
|
|
</span>
|
|
<span className="text-muted-foreground">
|
|
{t("admin.notificationHealth.skipped")}: {c.skipped}
|
|
</span>
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{data && data.autoDisabledChannels.length > 0 && (
|
|
<div className="mt-4 space-y-1">
|
|
<h2 className="text-sm font-medium">
|
|
{t("admin.notificationHealth.autoDisabled")}
|
|
</h2>
|
|
<div className="flex flex-wrap gap-2">
|
|
{data.autoDisabledChannels.map((d) => (
|
|
<Badge key={d.type} variant="secondary">
|
|
{d.type} ({d.count})
|
|
</Badge>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</SettingsCard>
|
|
);
|
|
}
|
|
|
|
export function RemindersSection() {
|
|
const { t } = useTranslations();
|
|
const { data: settings } = useAdminSettings();
|
|
const updateSettings = useUpdateSettings();
|
|
const [reminderLateDraft, setReminderLateDraft] = useState<number | null>(
|
|
null,
|
|
);
|
|
const [reminderMissedDraft, setReminderMissedDraft] = useState<number | null>(
|
|
null,
|
|
);
|
|
|
|
const testNotification = useMutation({
|
|
mutationFn: async () => {
|
|
return apiPost<
|
|
| {
|
|
message?: string;
|
|
results?: Array<{
|
|
channel: string;
|
|
success: boolean;
|
|
error?: string;
|
|
}>;
|
|
}
|
|
| undefined
|
|
>("/api/admin/notifications/test");
|
|
},
|
|
onSuccess: (data) => {
|
|
const hasFailures = data?.results?.some((r) => !r.success);
|
|
if (hasFailures) {
|
|
toast.error(data?.message ?? t("admin.notificationTestFailed"));
|
|
} else {
|
|
toast.success(data?.message ?? t("admin.notificationTestSuccess"));
|
|
}
|
|
},
|
|
onError: (error) => {
|
|
toast.error(
|
|
error instanceof Error
|
|
? error.message
|
|
: t("admin.notificationTestFailed"),
|
|
);
|
|
},
|
|
});
|
|
|
|
const reminderCheck = useMutation({
|
|
mutationFn: async () => {
|
|
return apiPost<
|
|
| {
|
|
message?: string;
|
|
medications?: Array<{
|
|
name: string;
|
|
dose: string;
|
|
user: string;
|
|
localTime: string;
|
|
dayOfWeek: string;
|
|
notificationsEnabled: boolean;
|
|
schedules: Array<{
|
|
window: string;
|
|
days: string;
|
|
status: string;
|
|
label: string;
|
|
notificationSent?: boolean;
|
|
}>;
|
|
eventsToday: number;
|
|
}>;
|
|
notificationsSent?: number;
|
|
}
|
|
| undefined
|
|
>("/api/admin/notifications/reminder-check");
|
|
},
|
|
onSuccess: (data) => {
|
|
toast.success(data?.message ?? t("admin.reminderCheckSuccess"));
|
|
},
|
|
onError: (error) => {
|
|
toast.error(
|
|
error instanceof Error ? error.message : t("admin.reminderCheckFailed"),
|
|
);
|
|
},
|
|
});
|
|
|
|
return (
|
|
<>
|
|
<SettingsCard>
|
|
{/* v1.4.22 D / F-58 — icon swap so the section header matches
|
|
the admin-shell nav tile (Bell). Clock now lives on the
|
|
timing chips inline instead of competing as the page icon. */}
|
|
<SettingsCardHeader
|
|
icon={Bell}
|
|
title={t("admin.medicationReminders")}
|
|
description={t("admin.medicationRemindersDescription")}
|
|
/>
|
|
|
|
<div className="mt-4 space-y-3 pl-7">
|
|
<div className="grid gap-3 sm:grid-cols-2">
|
|
<div className="space-y-1.5">
|
|
<Label htmlFor="admin-reminder-late" className="text-xs">
|
|
{t("admin.reminderLateMinutes")}
|
|
</Label>
|
|
<p className="text-muted-foreground text-xs">
|
|
{t("admin.reminderLateMinutesDescription")}
|
|
</p>
|
|
<Input
|
|
id="admin-reminder-late"
|
|
type="number"
|
|
inputMode="numeric"
|
|
enterKeyHint="next"
|
|
min={15}
|
|
max={480}
|
|
value={
|
|
reminderLateDraft ?? settings?.reminderLateMinutes ?? 120
|
|
}
|
|
onChange={(e) => setReminderLateDraft(Number(e.target.value))}
|
|
autoComplete="off"
|
|
data-lpignore="true"
|
|
data-1p-ignore="true"
|
|
disabled={updateSettings.isPending}
|
|
className="w-32"
|
|
/>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<Label htmlFor="admin-reminder-missed" className="text-xs">
|
|
{t("admin.reminderMissedMinutes")}
|
|
</Label>
|
|
<p className="text-muted-foreground text-xs">
|
|
{t("admin.reminderMissedMinutesDescription")}
|
|
</p>
|
|
<Input
|
|
id="admin-reminder-missed"
|
|
type="number"
|
|
inputMode="numeric"
|
|
enterKeyHint="done"
|
|
min={30}
|
|
max={720}
|
|
value={
|
|
reminderMissedDraft ?? settings?.reminderMissedMinutes ?? 240
|
|
}
|
|
onChange={(e) => setReminderMissedDraft(Number(e.target.value))}
|
|
autoComplete="off"
|
|
data-lpignore="true"
|
|
data-1p-ignore="true"
|
|
disabled={updateSettings.isPending}
|
|
className="w-32"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mt-4 flex justify-end gap-2">
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={() => testNotification.mutate()}
|
|
disabled={testNotification.isPending}
|
|
>
|
|
{testNotification.isPending && (
|
|
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
|
)}
|
|
<Bell className="h-3.5 w-3.5" />
|
|
{t("admin.notificationTestSend")}
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={() => reminderCheck.mutate()}
|
|
disabled={reminderCheck.isPending}
|
|
>
|
|
{reminderCheck.isPending && (
|
|
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
|
)}
|
|
<Activity className="h-3.5 w-3.5" />
|
|
{t("admin.reminderCheckRun")}
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
onClick={() => {
|
|
updateSettings.mutate(
|
|
{
|
|
...(reminderLateDraft != null && {
|
|
reminderLateMinutes: reminderLateDraft,
|
|
}),
|
|
...(reminderMissedDraft != null && {
|
|
reminderMissedMinutes: reminderMissedDraft,
|
|
}),
|
|
},
|
|
{
|
|
onSuccess: () => {
|
|
setReminderLateDraft(null);
|
|
setReminderMissedDraft(null);
|
|
},
|
|
},
|
|
);
|
|
}}
|
|
disabled={
|
|
updateSettings.isPending ||
|
|
(reminderLateDraft == null && reminderMissedDraft == null)
|
|
}
|
|
>
|
|
{updateSettings.isPending && (
|
|
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
|
)}
|
|
{t("common.save")}
|
|
</Button>
|
|
</div>
|
|
|
|
{testNotification.data?.results &&
|
|
testNotification.data.results.length > 0 && (
|
|
<div className="mt-4 space-y-1">
|
|
{testNotification.data.results.map((r, i) => (
|
|
<div key={i} className="flex items-center gap-1.5 text-xs">
|
|
{r.success ? (
|
|
<CheckCircle2 className="text-success h-3.5 w-3.5 shrink-0" />
|
|
) : (
|
|
<XCircle className="text-destructive h-3.5 w-3.5 shrink-0" />
|
|
)}
|
|
<span className="font-medium">{r.channel}</span>
|
|
{r.error && (
|
|
<span className="text-muted-foreground">— {r.error}</span>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{reminderCheck.data?.medications &&
|
|
reminderCheck.data.medications.length > 0 && (
|
|
<div className="mt-4 space-y-2">
|
|
{/* v1.4.33 IW9 — h4 -> h2 so the page outline stays
|
|
sequential (parent admin SectionFrame ships <h1>). */}
|
|
<h2 className="text-sm font-medium">
|
|
{t("admin.reminderCheckResults")}
|
|
</h2>
|
|
<div className="space-y-2">
|
|
{reminderCheck.data.medications.map((med, i) => (
|
|
<div
|
|
key={i}
|
|
className="bg-muted/50 space-y-1.5 rounded-lg p-3"
|
|
>
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-sm font-medium">
|
|
{med.name} ({med.dose})
|
|
</span>
|
|
<Badge
|
|
variant={
|
|
med.notificationsEnabled ? "default" : "secondary"
|
|
}
|
|
>
|
|
{med.notificationsEnabled
|
|
? t("admin.reminderCheckNotifOn")
|
|
: t("admin.reminderCheckNotifOff")}
|
|
</Badge>
|
|
</div>
|
|
<p className="text-muted-foreground text-xs">
|
|
{med.user} — {med.dayOfWeek} {med.localTime} —{" "}
|
|
{t("admin.reminderCheckEventsToday")}: {med.eventsToday}
|
|
</p>
|
|
{med.schedules.map((sched, j) => {
|
|
const statusColor =
|
|
sched.status === "open"
|
|
? "text-success"
|
|
: sched.status === "threshold"
|
|
? "text-warning"
|
|
: sched.status === "missed"
|
|
? "text-destructive"
|
|
: sched.status === "skipped"
|
|
? "text-muted-foreground"
|
|
: "";
|
|
return (
|
|
<div
|
|
key={j}
|
|
className="flex items-start gap-1.5 text-xs"
|
|
>
|
|
<span className="text-muted-foreground shrink-0">
|
|
{sched.window}
|
|
</span>
|
|
<span className="text-muted-foreground shrink-0">
|
|
[{sched.days}]
|
|
</span>
|
|
<span className={statusColor}>{sched.label}</span>
|
|
{sched.notificationSent && (
|
|
<span className="text-success flex shrink-0 items-center gap-0.5">
|
|
<Bell className="h-3 w-3" />
|
|
{t("admin.reminderCheckNotifSent")}
|
|
</span>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</SettingsCard>
|
|
<NotificationHealthPanel />
|
|
</>
|
|
);
|
|
}
|