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.
This commit is contained in:
Marc-André Bombeck committed 2026-10-21 12:36:14 +02:00
1 parent 6a9543b4f8
commit b6e3b47bec
7 files changed
+504 -291

No files matched your search

@@ -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<string>();
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([]);
});
});
+127 -116
View File
@@ -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) {
+40 -34
View File
@@ -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) {
+42 -34
View File
@@ -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) {
+76 -64
View File
@@ -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 (
@@ -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 (
+22 -15
View File
@@ -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 (