Recover stale plan drafts and handle local save failures
Test and deploy / test-and-deploy (push) Successful in 1m20s
Test and deploy / test-and-deploy (push) Successful in 1m20s
This commit is contained in:
1 parent
5f3a015ea2
commit
393978195c
2 files changed
+70
-42
No files matched your search
+41
-42
@@ -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",
|
||||
const send = (id) => protectedFetch(id ? `/api/plans/${id}` : "/api/plans", {
|
||||
method: id ? "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();
|
||||
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");
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
Reference in new issue
Block a user