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…", saving: "Saving locally…",
saved: "Synced", saved: "Synced",
failed: "Saved locally — sync failed", failed: "Save failed — retry",
local: "Saved locally — waiting to sync", local: "Saved locally — waiting to sync",
}[status] || "Not saved yet"; }[status] || "Not saved yet";
} }
let syncQueue = Promise.resolve(); let syncQueue = Promise.resolve();
// Resolves true once the server sync attempt has settled (succeeded, failed, or was correctly let lastSaveError = "";
// deferred because we're offline/unauthenticated) — offline is not a failure here, the local function persistDraft(snapshot) {
// write it's paired with in flushCurrentPlan already guarantees the draft isn't lost, and try {
// newPlan()/selectPlan() rely on that to let plan-switching keep working offline. Callers that if (!storageKey) return false;
// specifically need a confirmed server-side plan id (attaching a lot draw or a cupping session) localStorage.setItem(storageKey, JSON.stringify({plan: snapshot, remotePlanId, syncedAt: draftSyncedAt}));
// must check remotePlanId themselves afterward, which they already do. return true;
function syncPlan(snapshot = structuredClone(state.plan)) { } catch { return false; }
}
function syncPlan(snapshot = structuredClone(state.plan), locallySaved = persistDraft(snapshot)) {
if (!navigator.onLine || !csrfToken()) { if (!navigator.onLine || !csrfToken()) {
setSaveStatus("local"); 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.";
return Promise.resolve(true); setSaveStatus(locallySaved ? "local" : "failed");
return Promise.resolve(false);
} }
syncQueue = syncQueue.then(async () => { syncQueue = syncQueue.then(async () => {
try { try {
const response = await protectedFetch( const send = (id) => protectedFetch(id ? `/api/plans/${id}` : "/api/plans", {
remotePlanId ? `/api/plans/${remotePlanId}` : "/api/plans", method: id ? "PUT" : "POST",
{ headers: {"content-type":"application/json"},
method: remotePlanId ? "PUT" : "POST", body: JSON.stringify({plan: snapshot, ...(id ? {} : {name: currentPlanName})}),
headers: { "content-type": "application/json" }, });
body: JSON.stringify({ plan: snapshot }), 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
if (!response.ok) throw new Error("sync_failed"); // plan, but only for the API's explicit not_found response, never other failures.
const body = await response.json(); 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; remotePlanId = body.plan.id;
draftSyncedAt = body.plan.updated_at; draftSyncedAt = body.plan.updated_at;
currentPlanName = body.plan.name ?? currentPlanName; currentPlanName = body.plan.name ?? currentPlanName;
if (storageKey) // A confirmed server save succeeds even when browser storage is full/disabled.
localStorage.setItem( persistDraft(snapshot);
storageKey, history.replaceState(null, "", `/app?plan=${encodeURIComponent(remotePlanId)}`);
JSON.stringify({ plan: snapshot, remotePlanId, syncedAt: draftSyncedAt }), lastSaveError = "";
);
history.replaceState(
null,
"",
`/app?plan=${encodeURIComponent(remotePlanId)}`,
);
setSaveStatus("saved"); setSaveStatus("saved");
return true; 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"); setSaveStatus("failed");
return false; return false;
} }
@@ -489,17 +498,7 @@ function syncPlan(snapshot = structuredClone(state.plan)) {
async function flushCurrentPlan() { async function flushCurrentPlan() {
clearTimeout(autosave._t); clearTimeout(autosave._t);
const snapshot = structuredClone(state.plan); const snapshot = structuredClone(state.plan);
try { return await syncPlan(snapshot, persistDraft(snapshot));
if (storageKey)
localStorage.setItem(
storageKey,
JSON.stringify({ plan: snapshot, remotePlanId, syncedAt: draftSyncedAt }),
);
} catch {
setSaveStatus("failed");
return false;
}
return await syncPlan(snapshot);
} }
function autosave() { function autosave() {
setSaveStatus("saving"); setSaveStatus("saving");
@@ -726,7 +725,7 @@ function wireDrawers() {
async function leaveForPlans(event) { async function leaveForPlans(event) {
event.preventDefault(); event.preventDefault();
if (!(await flushCurrentPlan())) { 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; return;
} }
location.assign("/plans"); location.assign("/plans");
+29
View File
@@ -257,3 +257,32 @@ test(
assert.deepEqual(errors, []); assert.deepEqual(errors, []);
}, },
); );
test('browser: Your Plans recovers a deleted remote draft without losing edits', browserOptions, async t => {
const {page,base,create,db,errors}=await session(t);
const saved=await create(warningPlan(),'Recovered fruit');
await page.goto(base+'/app?plan='+saved.id);
await page.locator('#plan-form input[name="0.4"]').waitFor();
await db.query('DELETE FROM roast_plans WHERE id=$1',[saved.id]);
await page.locator('#plan-form input[name="0.4"]').fill('530');
await page.getByRole('link',{name:'▤ Your plans',exact:true}).click();
await page.waitForURL('**/plans*');
await page.getByRole('link',{name:'Recovered fruit',exact:true}).waitFor();
const rows=(await db.query('SELECT id,plan FROM roast_plans')).rows;
assert.equal(rows.length,1);assert.notEqual(rows[0].id,saved.id);assert.equal(rows[0].plan.fields['0.4'],'530');assert.deepEqual(errors,[]);
});
test('browser: Your Plans saves to server when browser draft storage is full', browserOptions, async t => {
const {page,base,create,db,errors}=await session(t);const saved=await create(warningPlan());
await page.goto(base+'/app?plan='+saved.id);await page.locator('#plan-form input[name="0.4"]').waitFor();
await page.evaluate(()=>{const original=Storage.prototype.setItem;Storage.prototype.setItem=function(key,value){if(key.startsWith('roastPlannerPlan.v2:'))throw new DOMException('Storage full','QuotaExceededError');return original.call(this,key,value);};});
await page.locator('#plan-form input[name="0.4"]').fill('540');await page.getByRole('link',{name:'▤ Your plans',exact:true}).click();await page.waitForURL('**/plans*');
assert.equal((await db.query('SELECT plan FROM roast_plans WHERE id=$1',[saved.id])).rows[0].plan.fields['0.4'],'540');assert.deepEqual(errors,[]);
});
test('browser: rejected sync shows the cause and keeps the edited plan open', browserOptions, async t => {
const {page,base,create,db}=await session(t);const saved=await create(warningPlan());await page.goto(base+'/app?plan='+saved.id);await page.locator('#plan-form input[name="0.4"]').waitFor();
await page.route('**/api/plans/'+saved.id,async route=>route.request().method()==='PUT'?route.fulfill({status:403,contentType:'application/json',body:JSON.stringify({ok:false,code:'csrf_failed'})}):route.continue());
await page.locator('#plan-form input[name="0.4"]').fill('550');await page.getByRole('link',{name:'▤ Your plans',exact:true}).click();await page.getByText(/Your sign-in session needs refreshing/).waitFor();assert.ok(page.url().includes('/app?plan='+saved.id));assert.equal((await db.query('SELECT COUNT(*) AS n FROM roast_plans')).rows[0].n,1);
const draft=await page.evaluate(()=>JSON.parse(localStorage.getItem(Object.keys(localStorage).find(k=>k.startsWith('roastPlannerPlan.v2:')&&!k.endsWith(':last-user')))));assert.equal(draft.plan.fields['0.4'],'550');
});