Files
HealthLog/src/components/settings/notification-content-card.tsx
T
Marc-André Bombeck b6e3b47bec fix(settings): never leave a card saving after a failed request
Six settings cards raised their own saving flag, awaited a raw fetch and
lowered the flag on the next line. A request that never reached the
server rejected instead, the line never ran, and the card kept its
control disabled until a reload: the Telegram form, the mood reminder,
the low-stock threshold, the lock-screen detail switch, the Coach nudges
and the profile form.

A network failure now falls through to the card's own error line, and the
flag is lowered in a finally. A guard holds every hand-rolled pending
flag under settings to that shape.
2026-10-21 12:36:14 +02:00

154 lines
4.9 KiB
TypeScript

"use client";
import { useEffect, useRef, useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { LockKeyhole } from "lucide-react";
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";
interface NotificationPrefsShape {
content?: { detail: "generic" | "detailed" };
updatedAt?: string;
}
/**
* Settings → Notifications: what a notification may show on a lock screen.
*
* Off (the default) keeps every notification to what it is about ("Your
* morning briefing"); on adds the health figures a notification has, today
* the morning score. Stored as `notificationPrefs.content.detail` through
* the same PATCH the iOS app uses, with the optimistic-concurrency token, so
* a stale web write cannot revert a sibling category.
*/
export function NotificationContentCard({
isAuthenticated,
}: {
isAuthenticated: boolean;
}) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const [optimistic, setOptimistic] = useState<boolean | null>(null);
const [saving, setSaving] = useState(false);
const [msg, setMsg] = useState<string | null>(null);
const [msgType, setMsgType] = useState<"success" | "error" | null>(null);
const clearTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
return () => {
if (clearTimerRef.current !== null) clearTimeout(clearTimerRef.current);
};
}, []);
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 () =>
apiGet<NotificationPrefsShape>("/api/auth/me/notification-prefs"),
enabled: isAuthenticated,
});
const detailed = optimistic ?? prefs?.content?.detail === "detailed";
async function handleToggle(next: boolean) {
setOptimistic(next);
setSaving(true);
setMsg(null);
setMsgType(null);
try {
// A request that never reaches the server (offline, a dropped
// connection) is a failed save like any other, not a stuck one: it
// falls through to the error branch, and `finally` lowers the flag.
const res = await apiFetchRaw("/api/auth/me/notification-prefs", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(
withBaseToken(
{ content: { detail: next ? "detailed" : "generic" } },
readUpdatedAtToken(queryClient, queryKeys.authNotificationPrefs()),
),
),
}).catch(() => null);
await queryClient.invalidateQueries({
queryKey: queryKeys.authNotificationPrefs(),
});
setOptimistic(null);
if (res?.ok) {
setMsg(
next
? t("notifications.content.detailedToast")
: t("notifications.content.genericToast"),
);
setMsgType("success");
} else if (res?.status === 409) {
setMsg(t("common.conflictReloaded"));
setMsgType("error");
} else {
setMsg(t("notifications.content.saveError"));
setMsgType("error");
}
scheduleClear();
} finally {
setSaving(false);
}
}
return (
<SettingsCard
as="section"
aria-labelledby="settings-notification-content-title"
>
<SettingsCardHeader
icon={LockKeyhole}
title={t("notifications.content.title")}
titleId="settings-notification-content-title"
description={t("notifications.content.description")}
status={
<label className="flex min-h-11 items-center gap-3">
<Switch
checked={detailed}
onCheckedChange={handleToggle}
disabled={!isAuthenticated || saving}
aria-label={t("notifications.content.toggleAria")}
/>
<span className="text-muted-foreground text-xs">
{detailed
? t("notifications.content.statusDetailed")
: t("notifications.content.statusGeneric")}
</span>
</label>
}
/>
<p className="text-sm">{t("notifications.content.detail")}</p>
{msg && (
<p
role="status"
aria-live="polite"
className={
msgType === "error"
? "text-destructive text-sm"
: "text-muted-foreground text-sm"
}
>
{msg}
</p>
)}
</SettingsCard>
);
}