diff --git a/public/js/main.js b/public/js/main.js index 393e732..7a38d8e 100644 --- a/public/js/main.js +++ b/public/js/main.js @@ -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"); diff --git a/test/report-browser.test.js b/test/report-browser.test.js index 62533f8..dd9119c 100644 --- a/test/report-browser.test.js +++ b/test/report-browser.test.js @@ -257,3 +257,32 @@ test( 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'); +});