Files
HealthLog/src/components/settings/notification-status-card.tsx
T
Marc-André Bombeck a31711699c fix(settings): stop the integrations notification card hydrating from null
NotificationStatusCard read useAuth().isAuthenticated, which is false
during SSR and true on the client, so the card rendered null on the
server and a loading card on first client paint — a tree-shape change
React reported as a hydration mismatch on every authenticated load of
the integrations page, regenerating the subtree and dropping the
channel controls' handlers.

Gate the card behind the shared useMounted() hydration probe so the
server and the first client paint agree (both render null); the real
status loads as an ordinary post-hydration update. Fold the settings
shell's own inline copy of the probe onto the same hook.
2026-06-30 13:02:07 +02:00

355 lines
11 KiB
TypeScript

"use client";
/**
* Per-channel reliability status surface (v1.4.15 Phase B3).
*
* Shows for each configured Telegram / ntfy / Web Push channel:
* - State badge (Active / Auto-disabled / Sending paused / Manually off)
* - Last successful send timestamp
* - Last failure + reason
* - Consecutive failure counter
* - "Re-enable" button when auto-disabled
* - "Send test" button (delegates to per-channel test endpoints that
* already exist for Telegram, ntfy, Web Push).
*
* Pulls data from `GET /api/notifications/status`. Re-enable hits
* `POST /api/notifications/status`, which clears `disabledReason` +
* `consecutiveFailures`, then the user can fire a test send via the
* channel's existing test endpoint.
*/
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
AlertCircle,
Bell,
CheckCircle2,
Clock,
Loader2,
RotateCw,
Send,
} from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { useAuth } from "@/hooks/use-auth";
import { useMounted } from "@/hooks/use-mounted";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { apiGet, apiPost } from "@/lib/api/api-fetch";
type ChannelType = "TELEGRAM" | "NTFY" | "WEB_PUSH" | "APNS";
type ChannelState =
"active" | "auto_disabled" | "manually_disabled" | "sending_paused";
interface ChannelStatus {
id: string;
type: ChannelType;
label: string;
enabled: boolean;
state: ChannelState;
disabledReason: string | null;
consecutiveFailures: number;
lastSuccessAt: string | null;
lastFailureAt: string | null;
lastFailureReason: string | null;
nextRetryAt: string | null;
}
const TEST_ENDPOINTS: Record<ChannelType, string> = {
TELEGRAM: "/api/settings/telegram/test",
NTFY: "/api/settings/ntfy/test",
WEB_PUSH: "/api/notifications/web-push/test",
APNS: "/api/notifications/apns/test",
};
export function NotificationStatusCard() {
const hydrated = useMounted();
const { isAuthenticated } = useAuth();
const { t } = useTranslations();
const fmt = useFormatters();
const queryClient = useQueryClient();
const { data, isLoading } = useQuery({
queryKey: queryKeys.notificationsStatus(),
queryFn: async () => {
const data = await apiGet<{ channels: ChannelStatus[] }>(
"/api/notifications/status",
);
return data.channels;
},
enabled: isAuthenticated,
refetchInterval: 30_000,
});
const reEnable = useMutation({
mutationFn: async (channelId: string) => {
await apiPost("/api/notifications/status", { channelId });
},
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: queryKeys.notificationsStatus(),
});
},
});
const test = useMutation({
mutationFn: async (channel: ChannelStatus) => {
const res = await fetch(TEST_ENDPOINTS[channel.type], { method: "POST" });
if (!res.ok) {
const json = await res.json().catch(() => ({}));
throw new Error((json as { error?: string }).error ?? "test_failed");
}
},
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: queryKeys.notificationsStatus(),
});
},
});
// SSR and the first client paint must render the SAME tree shape. `useAuth`
// reads a client TanStack query, so `isAuthenticated` is `false` on the
// server (renders `null`) but `true` on the hydrating client (would render
// the loading card) — different shapes → React #418 hydration mismatch.
// Gate every auth/loading-dependent branch behind the post-mount probe so
// both passes render `null`; the real content lands as an ordinary
// post-hydration client update, not a hydration-time branch.
if (!hydrated || !isAuthenticated) return null;
if (isLoading) {
return (
<SettingsCard>
<Loader2 className="text-muted-foreground h-4 w-4 animate-spin motion-reduce:animate-none" />
</SettingsCard>
);
}
// Defensive: the status endpoint returns `{ channels, events }` where
// `events` is an object map, not an array. A stale or shape-drifted
// payload (or a cache read that kept the whole object instead of the
// `.channels` slice) must never reach `.map` on a non-array — guard the
// type here rather than white-screening the whole notifications panel.
const channels = Array.isArray(data) ? data : [];
if (channels.length === 0) {
return (
<SettingsCard data-testid="notification-status-empty">
<SettingsCardHeader
icon={Bell}
title={t("settings.notificationStatus.title")}
description={t("settings.notificationStatus.emptyDescription")}
/>
</SettingsCard>
);
}
return (
<SettingsCard>
<SettingsCardHeader
icon={Bell}
title={t("settings.notificationStatus.title")}
description={t("settings.notificationStatus.description")}
/>
<ul
className="divide-border/60 mt-4 divide-y pl-7"
data-testid="notification-status-list"
>
{channels.map((ch) => (
<ChannelRow
key={ch.id}
channel={ch}
formatDateTime={(iso) => fmt.dateTime(iso)}
t={t}
onReEnable={() => reEnable.mutate(ch.id)}
onTest={() => test.mutate(ch)}
reEnablePending={reEnable.isPending}
testPending={test.isPending}
/>
))}
</ul>
</SettingsCard>
);
}
function ChannelRow({
channel,
formatDateTime,
t,
onReEnable,
onTest,
reEnablePending,
testPending,
}: {
channel: ChannelStatus;
formatDateTime: (iso: string | Date) => string;
t: (key: string, vars?: Record<string, string | number>) => string;
onReEnable: () => void;
onTest: () => void;
reEnablePending: boolean;
testPending: boolean;
}) {
const stateBadge = stateBadgeFor(channel.state, t);
return (
<li
className="flex flex-col gap-2 py-4 first:pt-0 last:pb-0 sm:flex-row sm:items-start sm:justify-between"
data-testid={`notification-status-row-${channel.type}`}
data-state={channel.state}
>
<div className="space-y-1">
<div className="flex items-center gap-2">
<span className="font-medium">{channel.label}</span>
<Badge
variant={stateBadge.variant}
className={stateBadge.className}
data-testid={`notification-status-badge-${channel.type}`}
>
{stateBadge.icon}
<span className="ml-1">{stateBadge.label}</span>
</Badge>
</div>
<dl className="text-muted-foreground space-y-0.5 text-xs">
{channel.lastSuccessAt && (
<div>
<dt className="inline">
{t("settings.notificationStatus.lastSuccess")}:
</dt>{" "}
<dd className="inline" data-testid="last-success">
{formatDateTime(channel.lastSuccessAt)}
</dd>
</div>
)}
{channel.lastFailureAt && (
<div>
<dt className="inline">
{t("settings.notificationStatus.lastFailure")}:
</dt>{" "}
<dd className="inline" data-testid="last-failure">
{formatDateTime(channel.lastFailureAt)}
{channel.lastFailureReason
? ` · ${channel.lastFailureReason}`
: ""}
</dd>
</div>
)}
{channel.consecutiveFailures > 0 && (
<div>
<dt className="inline">
{t("settings.notificationStatus.consecutiveFailures")}:
</dt>{" "}
<dd className="inline" data-testid="consecutive-failures">
{channel.consecutiveFailures}
</dd>
</div>
)}
{channel.disabledReason && (
<div>
<dt className="inline">
{t("settings.notificationStatus.disabledReason")}:
</dt>{" "}
<dd className="inline" data-testid="disabled-reason">
{channel.disabledReason}
</dd>
</div>
)}
{channel.state === "sending_paused" && channel.nextRetryAt && (
<div>
<dt className="inline">
{t("settings.notificationStatus.nextRetry")}:
</dt>{" "}
<dd className="inline" data-testid="next-retry">
{formatDateTime(channel.nextRetryAt)}
</dd>
</div>
)}
</dl>
</div>
{/* v1.4.15 H4 design: action buttons hit the WCAG 2.5.5 44 px
floor on mobile via `min-h-11`. Settings is the most-touched
mobile surface; `size="sm"` here clips to ~32 px which the maintainer
flagged as too small on the iPad/iPhone PWA shell. */}
<div className="flex flex-wrap gap-2">
{channel.state === "auto_disabled" && (
<Button
type="button"
variant="outline"
size="sm"
onClick={onReEnable}
disabled={reEnablePending}
data-testid={`re-enable-${channel.type}`}
className="min-h-11"
>
{reEnablePending ? (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<RotateCw className="h-3.5 w-3.5" />
)}
{t("settings.notificationStatus.reEnable")}
</Button>
)}
<Button
type="button"
variant="ghost"
size="sm"
onClick={onTest}
disabled={testPending || channel.state === "auto_disabled"}
data-testid={`send-test-${channel.type}`}
className="min-h-11"
>
{testPending ? (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<Send className="h-3.5 w-3.5" />
)}
{t("settings.notificationStatus.sendTest")}
</Button>
</div>
</li>
);
}
function stateBadgeFor(
state: ChannelState,
t: (key: string) => string,
): {
label: string;
variant: "default" | "outline" | "destructive" | "secondary";
className: string;
icon: React.ReactNode;
} {
switch (state) {
case "active":
return {
label: t("settings.notificationStatus.stateActive"),
variant: "default",
className: "border-success/30 bg-success/15 text-success",
icon: <CheckCircle2 className="h-3 w-3" aria-hidden />,
};
case "auto_disabled":
return {
label: t("settings.notificationStatus.stateAutoDisabled"),
variant: "destructive",
className: "",
icon: <AlertCircle className="h-3 w-3" aria-hidden />,
};
case "sending_paused":
return {
label: t("settings.notificationStatus.stateSendingPaused"),
variant: "outline",
className: "border-warning/40 text-warning",
icon: <Clock className="h-3 w-3" aria-hidden />,
};
case "manually_disabled":
return {
label: t("settings.notificationStatus.stateManuallyDisabled"),
variant: "secondary",
className: "",
icon: <Clock className="h-3 w-3" aria-hidden />,
};
}
}