Recover stale plan drafts and handle local save failures
Test and deploy / test-and-deploy (push) Successful in 1m20s

This commit is contained in:
snowspeeder committed 2026-09-05 16:01:39 -04:00
1 parent 5f3a015ea2
commit 393978195c
2 files changed
+71 -43

No files matched your search

+42 -43
View File
@@ -436,50 +436,59 @@ function setSaveStatus(status) {
{
saving: "Saving locally…",
saved: "Synced",
failed: "Saved locally — sync failed",
failed: "Save failed — retry",
local: "Saved locally — waiting to sync",
}[status] || "Not saved yet";
}
let syncQueue = Promise.resolve();
// Resolves true once the server sync attempt has settled (succeeded, failed, or was correctly
// deferred because we're offline/unauthenticated) — offline is not a failure here, the local
// write it's paired with in flushCurrentPlan already guarantees the draft isn't lost, and
// newPlan()/selectPlan() rely on that to let plan-switching keep working offline. Callers that
// specifically need a confirmed server-side plan id (attaching a lot draw or a cupping session)
// must check remotePlanId themselves afterward, which they already do.
function syncPlan(snapshot = structuredClone(state.plan)) {
let lastSaveError = "";
function persistDraft(snapshot) {
try {
if (!storageKey) return false;
localStorage.setItem(storageKey, JSON.stringify({plan: snapshot, remotePlanId, syncedAt: draftSyncedAt}));
return true;
} catch { return false; }
}
function syncPlan(snapshot = structuredClone(state.plan), locallySaved = persistDraft(snapshot)) {
if (!navigator.onLine || !csrfToken()) {
setSaveStatus("local");
return Promise.resolve(true);
lastSaveError = locallySaved ? "Your draft is saved on this device. Reconnect and sign in to sync it before switching plans." : "The draft could not be saved on this device. Reconnect to save it before leaving.";
setSaveStatus(locallySaved ? "local" : "failed");
return Promise.resolve(false);
}
syncQueue = syncQueue.then(async () => {
try {
const response = await protectedFetch(
remotePlanId ? `/api/plans/${remotePlanId}` : "/api/plans",
{
method: remotePlanId ? "PUT" : "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ plan: snapshot }),
},
);
if (!response.ok) throw new Error("sync_failed");
const body = await response.json();
const send = (id) => protectedFetch(id ? `/api/plans/${id}` : "/api/plans", {
method: id ? "PUT" : "POST",
headers: {"content-type":"application/json"},
body: JSON.stringify({plan: snapshot, ...(id ? {} : {name: currentPlanName})}),
});
let response = await send(remotePlanId);
let body = await response.json().catch(() => ({}));
// A deleted plan may still be the last local draft. Recover it as a new owned
// plan, but only for the API's explicit not_found response, never other failures.
if (remotePlanId && response.status === 404 && body.code === "not_found") {
response = await send(null);
body = await response.json().catch(() => ({}));
}
if (!response.ok) {
const reason = response.status === 401 || body.code === "csrf_failed"
? "Your sign-in session needs refreshing. Reload and sign in again, then retry."
: response.status === 413 ? "This plan exceeds the server's save-size limit."
: `The server rejected the save (HTTP ${response.status}${body.code ? `, ${body.code}` : ""}).`;
throw new Error(reason);
}
if (!body.plan?.id) throw new Error("The server returned an invalid save response.");
remotePlanId = body.plan.id;
draftSyncedAt = body.plan.updated_at;
currentPlanName = body.plan.name ?? currentPlanName;
if (storageKey)
localStorage.setItem(
storageKey,
JSON.stringify({ plan: snapshot, remotePlanId, syncedAt: draftSyncedAt }),
);
history.replaceState(
null,
"",
`/app?plan=${encodeURIComponent(remotePlanId)}`,
);
// A confirmed server save succeeds even when browser storage is full/disabled.
persistDraft(snapshot);
history.replaceState(null, "", `/app?plan=${encodeURIComponent(remotePlanId)}`);
lastSaveError = "";
setSaveStatus("saved");
return true;
} catch {
} catch (error) {
lastSaveError = `${locallySaved ? "Your draft is saved on this device. " : "Your draft has not been saved; keep this page open. "}${error instanceof TypeError ? "Could not reach the server. Check your connection and retry." : error.message}`;
setSaveStatus("failed");
return false;
}
@@ -489,17 +498,7 @@ function syncPlan(snapshot = structuredClone(state.plan)) {
async function flushCurrentPlan() {
clearTimeout(autosave._t);
const snapshot = structuredClone(state.plan);
try {
if (storageKey)
localStorage.setItem(
storageKey,
JSON.stringify({ plan: snapshot, remotePlanId, syncedAt: draftSyncedAt }),
);
} catch {
setSaveStatus("failed");
return false;
}
return await syncPlan(snapshot);
return await syncPlan(snapshot, persistDraft(snapshot));
}
function autosave() {
setSaveStatus("saving");
@@ -726,7 +725,7 @@ function wireDrawers() {
async function leaveForPlans(event) {
event.preventDefault();
if (!(await flushCurrentPlan())) {
showToast("Your changes are saved on this device, but sync failed. Retry before switching plans.", "fail");
showToast(lastSaveError || "The plan could not be saved. Keep this page open and retry.", "fail");
return;
}
location.assign("/plans");