diff --git a/src/components/settings/__tests__/pending-reset-guard.test.ts b/src/components/settings/__tests__/pending-reset-guard.test.ts new file mode 100644 index 000000000..cd3f7fc56 --- /dev/null +++ b/src/components/settings/__tests__/pending-reset-guard.test.ts @@ -0,0 +1,163 @@ +/** + * A settings save that raises its own pending flag lowers it in a `finally`. + * + * The cards that hand-roll a `saving` flag around a raw `fetch` set it, await + * the request, and cleared it on the line after. A request that never reached + * the server (offline, a dropped connection, a timeout) rejects instead of + * returning a response, the line after never ran, and the card stayed in its + * saving state for good: the Telegram form, the mood reminder, the low-stock + * threshold, the lock-screen detail switch, the Coach nudges, the profile + * form. Every one of them disabled its own control while "saving", so the + * only way out was a reload. + * + * `useMutation` owns its `isPending` and cannot get stuck this way; this + * guard covers the hand-rolled ones. For every async function under + * `src/components/settings` that sets one of the pending flags below to + * `true` and then awaits, it requires a `try` whose `finally` sets the same + * flag back to `false` — or, the older shape that is equally safe, every + * await inside a `try` that catches, so nothing can skip the reset after it. + * What it cannot see: a flag under another name, or a function that hands + * the await to a helper it does not await itself. + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import ts from "typescript"; +import { describe, expect, it } from "vitest"; + +import { walkSourceFiles } from "@/__tests__/helpers/source-files"; + +const ROOT = join(process.cwd(), "src/components/settings"); +const PENDING_SETTER = + /^set(Saving|Pending|Busy|Loading|Submitting|ActionLoading)$/; + +interface Finding { + where: string; + setter: string; + /** The flag is lowered in a `finally`, or no await can throw past the reset. */ + safe: boolean; +} + +/** Is this await inside the try block of a `try` that has a `catch`? */ +function caught(node: ts.Node, fn: ts.Node): boolean { + for (let p = node.parent; p && p !== fn; p = p.parent) { + if ( + ts.isBlock(p) && + ts.isTryStatement(p.parent) && + p.parent.tryBlock === p && + p.parent.catchClause + ) { + return true; + } + } + return false; +} + +type FunctionLike = + | ts.FunctionDeclaration + | ts.FunctionExpression + | ts.ArrowFunction + | ts.MethodDeclaration; + +function isFunctionLike(node: ts.Node): node is FunctionLike { + return ( + ts.isFunctionDeclaration(node) || + ts.isFunctionExpression(node) || + ts.isArrowFunction(node) || + ts.isMethodDeclaration(node) + ); +} + +/** Walk a function body without descending into nested functions. */ +function ownNodes(body: ts.Node, visit: (node: ts.Node) => void): void { + const step = (node: ts.Node) => { + visit(node); + if (isFunctionLike(node)) return; + ts.forEachChild(node, step); + }; + ts.forEachChild(body, step); +} + +function setterCall(node: ts.Node, sf: ts.SourceFile, value: string) { + return ( + ts.isCallExpression(node) && + ts.isIdentifier(node.expression) && + PENDING_SETTER.test(node.expression.text) && + node.arguments.length === 1 && + node.arguments[0].getText(sf) === value + ); +} + +function scan(): Finding[] { + const findings: Finding[] = []; + for (const rel of walkSourceFiles(ROOT, { floor: 150 })) { + if (rel.includes("__tests__/")) continue; + const text = readFileSync(join(ROOT, rel), "utf8"); + const sf = ts.createSourceFile(rel, text, ts.ScriptTarget.Latest, true); + const visitFn = (node: ts.Node) => { + if ( + isFunctionLike(node) && + node.body && + node.modifiers?.some((m) => m.kind === ts.SyntaxKind.AsyncKeyword) + ) { + const raised = new Set(); + const awaits: ts.Node[] = []; + ownNodes(node.body, (n) => { + if (setterCall(n, sf, "true")) { + raised.add( + ((n as ts.CallExpression).expression as ts.Identifier).text, + ); + } + if (ts.isAwaitExpression(n)) awaits.push(n); + }); + const allCaught = awaits.every((a) => caught(a, node)); + if (awaits.length > 0) { + for (const setter of raised) { + let resetInFinally = false; + ownNodes(node.body, (n) => { + if (!ts.isTryStatement(n) || !n.finallyBlock) return; + ownNodes(n.finallyBlock, (m) => { + if ( + setterCall(m, sf, "false") && + ((m as ts.CallExpression).expression as ts.Identifier) + .text === setter + ) { + resetInFinally = true; + } + }); + }); + const line = + sf.getLineAndCharacterOfPosition(node.getStart(sf)).line + 1; + findings.push({ + where: `${rel}:${line}`, + setter, + safe: resetInFinally || allCaught, + }); + } + } + } + ts.forEachChild(node, visitFn); + }; + visitFn(sf); + } + return findings; +} + +describe("settings saves cannot stay pending after a failed request", () => { + const findings = scan(); + + it("finds the hand-rolled saves it is meant to check", () => { + // A matcher that found nothing would pass on any tree. + expect(findings.length).toBeGreaterThan(10); + }); + + it("every one lowers its flag in a finally", () => { + const stuck = findings + .filter((f) => !f.safe) + .map((f) => `${f.where} (${f.setter})`); + expect( + stuck, + "Move the reset into `finally`, so a request that rejects (offline, timeout) cannot leave the card saving.", + ).toEqual([]); + }); +}); diff --git a/src/components/settings/account-section/index.tsx b/src/components/settings/account-section/index.tsx index 860586e40..68aec1b0b 100644 --- a/src/components/settings/account-section/index.tsx +++ b/src/components/settings/account-section/index.tsx @@ -253,132 +253,143 @@ export function AccountSection() { setSaveMsg(null); setSaveMsgType(null); setFieldErrors({}); - const savedProfile = { - email: email.trim(), - height, - dateOfBirth, - gender, - timezone, - fullName: fullName.trim(), - insurerName: insurerName.trim(), - insuranceNumber: insuranceNumber.trim(), - }; + try { + const savedProfile = { + email: email.trim(), + height, + dateOfBirth, + gender, + timezone, + fullName: fullName.trim(), + insurerName: insurerName.trim(), + insuranceNumber: insuranceNumber.trim(), + }; - // The timezone is owned by a dedicated route (v1.4.25 W7) so the - // resolver cache can be invalidated without piping the flag - // through the bigger profile patch path. Run the two PUTs in - // parallel — they're independent. - const [profileRes, tzRes] = await Promise.all([ - apiFetchRaw("/api/auth/profile", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - email: savedProfile.email || null, - ...(emailChanged && emailPassword - ? { currentPassword: emailPassword } - : {}), - heightCm: heightAdapter.toCanonicalCm(savedProfile.height), - dateOfBirth: savedProfile.dateOfBirth || null, - gender: savedProfile.gender || null, - fullName: savedProfile.fullName || null, - insurerName: savedProfile.insurerName || null, - insuranceNumber: savedProfile.insuranceNumber || null, + // The timezone is owned by a dedicated route (v1.4.25 W7) so the + // resolver cache can be invalidated without piping the flag + // through the bigger profile patch path. Run the two PUTs in + // parallel — they're independent. + const [profileRes, tzRes] = await Promise.all([ + apiFetchRaw("/api/auth/profile", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + email: savedProfile.email || null, + ...(emailChanged && emailPassword + ? { currentPassword: emailPassword } + : {}), + heightCm: heightAdapter.toCanonicalCm(savedProfile.height), + dateOfBirth: savedProfile.dateOfBirth || null, + gender: savedProfile.gender || null, + fullName: savedProfile.fullName || null, + insurerName: savedProfile.insurerName || null, + insuranceNumber: savedProfile.insuranceNumber || null, + }), }), - }), - user && timezone && timezone !== user.timezone - ? apiFetchRaw("/api/auth/me/timezone", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ timezone }), - }) - : Promise.resolve({ ok: true } as Response), - ]); + user && timezone && timezone !== user.timezone + ? apiFetchRaw("/api/auth/me/timezone", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ timezone }), + }) + : Promise.resolve({ ok: true } as Response), + ]); - setEmailPassword(""); + setEmailPassword(""); - if (profileRes.ok && tzRes.ok) { - // Instant same-tab flip of every rendered timestamp (issue #490): - // mirror the accepted zone before the `/me` refetch confirms it — - // the same synchronous-update pattern the time/date-format selects - // use. `refetch()` → `fetchMe` re-writes the mirror authoritatively. - if (user && timezone && timezone !== user.timezone) { - storeTimezone(timezone); - } - const profileJson = (await profileRes.json().catch(() => null)) as { - data?: { rejectedFields?: RejectedProfileField[] }; - } | null; - const rejected = profileJson?.data?.rejectedFields; + if (profileRes.ok && tzRes.ok) { + // Instant same-tab flip of every rendered timestamp (issue #490): + // mirror the accepted zone before the `/me` refetch confirms it — + // the same synchronous-update pattern the time/date-format selects + // use. `refetch()` → `fetchMe` re-writes the mirror authoritatively. + if (user && timezone && timezone !== user.timezone) { + storeTimezone(timezone); + } + const profileJson = (await profileRes.json().catch(() => null)) as { + data?: { rejectedFields?: RejectedProfileField[] }; + } | null; + const rejected = profileJson?.data?.rejectedFields; - if (!afterFactor && needsSecondFactor(rejected, undefined)) { - if (await proveFactorThenSave()) return; - } + if (!afterFactor && needsSecondFactor(rejected, undefined)) { + if (await proveFactorThenSave()) return; + } - if (rejected && rejected.length > 0) { - // The write is field-independent: everything but the rejected - // field(s) landed. Name the field instead of implying full - // success, and reseed the form from what the server actually - // persisted — the client's optimistic value for the rejected - // field was never written, so it must not read as saved. - setSaveMsg({ - key: "settings.profilePartiallySaved", - params: { field: describeRejectedProfileField(rejected, t) ?? "" }, - }); - setSaveMsgType("warning"); - setFieldErrors(describeRejectedProfileFields(rejected, t)); - await refetch(); - setSeededUserId(null); + if (rejected && rejected.length > 0) { + // The write is field-independent: everything but the rejected + // field(s) landed. Name the field instead of implying full + // success, and reseed the form from what the server actually + // persisted — the client's optimistic value for the rejected + // field was never written, so it must not read as saved. + setSaveMsg({ + key: "settings.profilePartiallySaved", + params: { field: describeRejectedProfileField(rejected, t) ?? "" }, + }); + setSaveMsgType("warning"); + setFieldErrors(describeRejectedProfileFields(rejected, t)); + await refetch(); + setSeededUserId(null); + } else { + setSaveMsg({ key: "settings.profileSaved" }); + setSaveMsgType("success"); + setEmail(savedProfile.email); + setFullName(savedProfile.fullName); + setInsurerName(savedProfile.insurerName); + setInsuranceNumber(savedProfile.insuranceNumber); + setProfileSeed(savedProfile); + await refetch(); + } + } else if (!tzRes.ok) { + // The dedicated tz endpoint owns the IANA validation error + // text. Surface its message verbatim so the user sees + // "Not a valid IANA timezone." instead of the generic save + // failure copy. + try { + const json = (await (tzRes as Response).json()) as { error?: string }; + setSaveMsg( + json.error + ? { text: json.error } + : { key: "settings.timezoneInvalid" }, + ); + } catch { + setSaveMsg({ key: "settings.timezoneInvalid" }); + } + setSaveMsgType("error"); } else { - setSaveMsg({ key: "settings.profileSaved" }); - setSaveMsgType("success"); - setEmail(savedProfile.email); - setFullName(savedProfile.fullName); - setInsurerName(savedProfile.insurerName); - setInsuranceNumber(savedProfile.insuranceNumber); - setProfileSeed(savedProfile); - await refetch(); - } - } else if (!tzRes.ok) { - // The dedicated tz endpoint owns the IANA validation error - // text. Surface its message verbatim so the user sees - // "Not a valid IANA timezone." instead of the generic save - // failure copy. - try { - const json = (await (tzRes as Response).json()) as { error?: string }; - setSaveMsg( - json.error - ? { text: json.error } - : { key: "settings.timezoneInvalid" }, - ); - } catch { - setSaveMsg({ key: "settings.timezoneInvalid" }); - } - setSaveMsgType("error"); - } else { - // The server's top-level `error` string is intentionally generic - // (never a raw validator message); resolve the specific, localized - // reason through `meta.errorCode` instead of echoing it verbatim. - const json = (await profileRes.json().catch(() => null)) as { - meta?: { errorCode?: string }; - details?: { issues?: RejectedProfileField[] }; - } | null; - const errorCode = json?.meta?.errorCode; - if (!afterFactor && needsSecondFactor(undefined, errorCode)) { - if (await proveFactorThenSave()) return; - } - const field = describeRejectedProfileField(json?.details?.issues, t); - setFieldErrors(describeRejectedProfileFields(json?.details?.issues, t)); - if (errorCode === "profile.update.nothingSaved" && field) { - setSaveMsg({ key: "settings.profileNothingSaved", params: { field } }); - } else if (translateCodeOrNull(t, "apiErrors", errorCode) !== null) { - // Only a code with a sentence of its own: every refusal carries a - // code now, and the generic `http.*` family has no translation. - setSaveMsg({ key: `apiErrors.${errorCode}` }); - } else { - setSaveMsg({ key: "settings.savingError" }); + // The server's top-level `error` string is intentionally generic + // (never a raw validator message); resolve the specific, localized + // reason through `meta.errorCode` instead of echoing it verbatim. + const json = (await profileRes.json().catch(() => null)) as { + meta?: { errorCode?: string }; + details?: { issues?: RejectedProfileField[] }; + } | null; + const errorCode = json?.meta?.errorCode; + if (!afterFactor && needsSecondFactor(undefined, errorCode)) { + if (await proveFactorThenSave()) return; + } + const field = describeRejectedProfileField(json?.details?.issues, t); + setFieldErrors(describeRejectedProfileFields(json?.details?.issues, t)); + if (errorCode === "profile.update.nothingSaved" && field) { + setSaveMsg({ + key: "settings.profileNothingSaved", + params: { field }, + }); + } else if (translateCodeOrNull(t, "apiErrors", errorCode) !== null) { + // Only a code with a sentence of its own: every refusal carries a + // code now, and the generic `http.*` family has no translation. + setSaveMsg({ key: `apiErrors.${errorCode}` }); + } else { + setSaveMsg({ key: "settings.savingError" }); + } + setSaveMsgType("error"); } + } catch { + // The request never reached the server (offline, a dropped + // connection): a failed save, said as one, never a card left saving. + setSaveMsg({ key: "settings.savingError" }); setSaveMsgType("error"); + } finally { + setSaving(false); } - setSaving(false); } if (!mounted || isLoading) { diff --git a/src/components/settings/coach-nudge-card.tsx b/src/components/settings/coach-nudge-card.tsx index 86731b033..6362e5a44 100644 --- a/src/components/settings/coach-nudge-card.tsx +++ b/src/components/settings/coach-nudge-card.tsx @@ -145,45 +145,51 @@ export function CoachNudgeCard({ // v1.32.22 — 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( - { coach: partial }, - readUpdatedAtToken(queryClient, queryKeys.authNotificationPrefs()), + 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( + { coach: partial }, + readUpdatedAtToken(queryClient, queryKeys.authNotificationPrefs()), + ), ), - ), - }); + }).catch(() => null); - if (res.ok) { - if (toast) { - setMsg(toast); - setMsgType("success"); + if (res?.ok) { + if (toast) { + setMsg(toast); + setMsgType("success"); + scheduleClear(); + } + await queryClient.invalidateQueries({ + queryKey: queryKeys.authNotificationPrefs(), + }); + setOptimistic({}); + } else if (res?.status === 409) { + // The prefs advanced since they were loaded. Revert the optimistic knob + // and refetch (the active query reseeds a fresh token), then nudge — the + // change did not persist, so ask the user to re-apply it. + setOptimistic({}); + await queryClient.invalidateQueries({ + queryKey: queryKeys.authNotificationPrefs(), + }); + setMsg(t("common.conflictReloaded")); + setMsgType("error"); + scheduleClear(); + } else { + setOptimistic({}); + setMsg(t("notifications.coachNudge.saveError")); + setMsgType("error"); scheduleClear(); } - await queryClient.invalidateQueries({ - queryKey: queryKeys.authNotificationPrefs(), - }); - setOptimistic({}); - } else if (res.status === 409) { - // The prefs advanced since they were loaded. Revert the optimistic knob - // and refetch (the active query reseeds a fresh token), then nudge — the - // change did not persist, so ask the user to re-apply it. - setOptimistic({}); - await queryClient.invalidateQueries({ - queryKey: queryKeys.authNotificationPrefs(), - }); - setMsg(t("common.conflictReloaded")); - setMsgType("error"); - scheduleClear(); - } else { - setOptimistic({}); - setMsg(t("notifications.coachNudge.saveError")); - setMsgType("error"); - scheduleClear(); + } finally { + setSaving(false); } - setSaving(false); } async function handleToggle(next: boolean) { diff --git a/src/components/settings/low-stock-card.tsx b/src/components/settings/low-stock-card.tsx index caa967fb7..7619c6dea 100644 --- a/src/components/settings/low-stock-card.tsx +++ b/src/components/settings/low-stock-card.tsx @@ -124,44 +124,52 @@ export function LowStockCard({ // v1.32.22 — 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()), + let saved = false; + 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( + body, + readUpdatedAtToken(queryClient, queryKeys.authNotificationPrefs()), + ), ), - ), - }); + }).catch(() => null); - if (res.ok) { - setMsg(successMsg); - setMsgType("success"); - await Promise.all([ - queryClient.invalidateQueries({ + saved = res?.ok === true; + 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(), - }), - 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"); + }); + setMsg(t("common.conflictReloaded")); + setMsgType("error"); + } else { + setMsg(t("notifications.lowStock.saveError")); + setMsgType("error"); + } + scheduleClear(); + } finally { + setSaving(false); } - scheduleClear(); - setSaving(false); - return res.ok; + return saved; } async function persist(next: number | null, successMsg: string) { diff --git a/src/components/settings/mood-reminder-card.tsx b/src/components/settings/mood-reminder-card.tsx index 99694e083..04a9844fd 100644 --- a/src/components/settings/mood-reminder-card.tsx +++ b/src/components/settings/mood-reminder-card.tsx @@ -99,38 +99,44 @@ export function MoodReminderCard({ clearTimerRef.current = null; } - const res = await apiFetchRaw("/api/user/profile", { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ moodReminderEnabled: next }), - }); + 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/user/profile", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ moodReminderEnabled: next }), + }).catch(() => null); - if (res.ok) { - setMsg( - next - ? t("notifications.moodReminder.enabledToast") - : t("notifications.moodReminder.disabledToast"), - ); - setMsgType("success"); - await Promise.all([ - queryClient.invalidateQueries({ - queryKey: queryKeys.userProfile(), - }), - // The /notifications matrix shows the reminder's channel switches - // from the server's `eventDefaults`, which follow this card. - queryClient.invalidateQueries({ - queryKey: queryKeys.notificationsPreferences(), - }), - ]); - setOptimistic(null); - scheduleClear(); - } else { - setOptimistic(null); - setMsg(t("notifications.moodReminder.saveError")); - setMsgType("error"); - scheduleClear(); + if (res?.ok) { + setMsg( + next + ? t("notifications.moodReminder.enabledToast") + : t("notifications.moodReminder.disabledToast"), + ); + setMsgType("success"); + await Promise.all([ + queryClient.invalidateQueries({ + queryKey: queryKeys.userProfile(), + }), + // The /notifications matrix shows the reminder's channel switches + // from the server's `eventDefaults`, which follow this card. + queryClient.invalidateQueries({ + queryKey: queryKeys.notificationsPreferences(), + }), + ]); + setOptimistic(null); + scheduleClear(); + } else { + setOptimistic(null); + setMsg(t("notifications.moodReminder.saveError")); + setMsgType("error"); + scheduleClear(); + } + } finally { + setSaving(false); } - setSaving(false); } async function handleHourChange(next: number) { @@ -146,43 +152,49 @@ export function MoodReminderCard({ // v1.32.22 — echo the base token so a concurrent notification-prefs // write (the iOS client's `clientManaged` flag, another web card) 409s // instead of silently reverting a sibling category through the deep-merge. - const res = await apiFetchRaw("/api/auth/me/notification-prefs", { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify( - withBaseToken( - { mood: { reminderHour: next } }, - readUpdatedAtToken(queryClient, queryKeys.authNotificationPrefs()), + 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( + { mood: { reminderHour: next } }, + readUpdatedAtToken(queryClient, queryKeys.authNotificationPrefs()), + ), ), - ), - }); + }).catch(() => null); - if (res.ok) { - setMsg(t("notifications.moodReminder.hourSavedToast")); - setMsgType("success"); - await queryClient.invalidateQueries({ - queryKey: queryKeys.authNotificationPrefs(), - }); - setOptimisticHour(null); - scheduleClear(); - } else if (res.status === 409) { - // The prefs advanced since they were loaded. Revert the optimistic hour - // and refetch (the active query reseeds a fresh token), then nudge — the - // change did not persist, so ask the user to re-apply it. - setOptimisticHour(null); - await queryClient.invalidateQueries({ - queryKey: queryKeys.authNotificationPrefs(), - }); - setMsg(t("common.conflictReloaded")); - setMsgType("error"); - scheduleClear(); - } else { - setOptimisticHour(null); - setMsg(t("notifications.moodReminder.saveError")); - setMsgType("error"); - scheduleClear(); + if (res?.ok) { + setMsg(t("notifications.moodReminder.hourSavedToast")); + setMsgType("success"); + await queryClient.invalidateQueries({ + queryKey: queryKeys.authNotificationPrefs(), + }); + setOptimisticHour(null); + scheduleClear(); + } else if (res?.status === 409) { + // The prefs advanced since they were loaded. Revert the optimistic hour + // and refetch (the active query reseeds a fresh token), then nudge — the + // change did not persist, so ask the user to re-apply it. + setOptimisticHour(null); + await queryClient.invalidateQueries({ + queryKey: queryKeys.authNotificationPrefs(), + }); + setMsg(t("common.conflictReloaded")); + setMsgType("error"); + scheduleClear(); + } else { + setOptimisticHour(null); + setMsg(t("notifications.moodReminder.saveError")); + setMsgType("error"); + scheduleClear(); + } + } finally { + setSaving(false); } - setSaving(false); } return ( diff --git a/src/components/settings/notification-content-card.tsx b/src/components/settings/notification-content-card.tsx index 91dfc2f2b..f64b06256 100644 --- a/src/components/settings/notification-content-card.tsx +++ b/src/components/settings/notification-content-card.tsx @@ -69,37 +69,43 @@ export function NotificationContentCard({ setMsg(null); setMsgType(null); - 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()), + 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"); + 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); } - scheduleClear(); - setSaving(false); } return ( diff --git a/src/components/settings/telegram-card.tsx b/src/components/settings/telegram-card.tsx index a0870d6ff..3c685ce6b 100644 --- a/src/components/settings/telegram-card.tsx +++ b/src/components/settings/telegram-card.tsx @@ -116,23 +116,30 @@ export function TelegramCard({ if (botToken.trim()) body.botToken = botToken.trim(); if (chatId !== (settings?.chatId ?? "")) body.chatId = chatId; - const res = await apiFetchRaw("/api/settings/telegram", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(body), - }); + try { + // A request that never reaches the server (offline, a dropped + // connection) is a failed save like any other, not a stuck one. + const res = await apiFetchRaw("/api/settings/telegram", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }).catch(() => null); - if (res.ok) { - setMsg(t("settings.saved")); - setMsgType("success"); - setBotToken(""); - queryClient.invalidateQueries({ queryKey: queryKeys.telegram() }); - } else { - const json = await res.json(); - setMsg(json.error || t("settings.savingError")); - setMsgType("error"); + if (res?.ok) { + setMsg(t("settings.saved")); + setMsgType("success"); + setBotToken(""); + queryClient.invalidateQueries({ queryKey: queryKeys.telegram() }); + } else { + const json = (await res?.json().catch(() => null)) as { + error?: string; + } | null; + setMsg(json?.error || t("settings.savingError")); + setMsgType("error"); + } + } finally { + setSaving(false); } - setSaving(false); } return (