mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +00:00
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:
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([]);
|
||||
});
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 (
|
||||
|
||||
Reference in new issue
Block a user