From f42f89ca21fc24f1201271bbf1cf7b4f0ed20698 Mon Sep 17 00:00:00 2001 From: timmy Date: Thu, 20 Aug 2026 02:10:37 +0000 Subject: [PATCH] feat: keep Tomorrow plans through offline saves (Closes #1160) --- README.md | 8 +- frontend/dashboard.js | 40 +++++++-- frontend/tomorrow-plan.js | 74 +++++++++++++-- tests/test_tomorrow_plan_frontend.py | 130 ++++++++++++++++++++++++++- 4 files changed, 235 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index eda1cf9..35166e4 100644 --- a/README.md +++ b/README.md @@ -149,7 +149,13 @@ failure preserves both the reply draft and checkpoint. Finishing or choosing **E unchanged. Another or unconfirmed account cannot see or resume it. Server revisions prevent delayed responses from replacing a newer plan; same-account browser tabs exchange fresh snapshots, and reconnecting or returning to the dashboard refreshes server truth after replaying queued -offline operations. Planning edits can remain offline for up to 30 days. After that, the +offline operations. **Plan Tomorrow** remains independent from active Today work and durably admits the exact +ordered plan, capacity, estimates, next local date, timezone, and server revision to account-bound browser +storage before closing on a phone. The Queues summary marks it **sync pending** while offline; reconnect, +foreground, and midnight lifecycle checks share one delivery flight. A successful account receipt removes +the pending copy, while a revision conflict preserves both the phone plan and fresh server snapshot for +review. Unsynced Tomorrow work is never promoted into Today. +Planning edits can remain offline for up to 30 days. After that, the expired edit is discarded visibly and the account plan is kept rather than replaying stale intent. The server retains no more than 4,096 operation receipts per account and removes receipts older than the same 30-day window; client base revisions keep a pruned replay from diff --git a/frontend/dashboard.js b/frontend/dashboard.js index 84ecea2..ef2db89 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -384,10 +384,28 @@ fetchJson:fetchReviewJson, localDate:todayRollover.localDate, timeZone:todayRollover.timeZone, + storage:localStorage, + getLogin:() => planningOwnerLogin, }); function renderTomorrowQueueSummary(value) { qs('#mobile-tomorrow-summary').textContent = value ? tomorrowPlan.summary(value) : tomorrowPlan.summary(); } + function syncPendingTomorrow() { + if (!tomorrowPlan.pending()) return Promise.resolve(false); + return tomorrowPlan.flush().then(saved => { + renderTomorrowQueueSummary(saved); + qs('#my-work-action-status').textContent = saved.ids.length ? + `Tomorrow saved for ${saved.plan_date} without changing Today.` : + `Tomorrow cleared for ${saved.plan_date}.`; + return saved; + }).catch(error => { + const conflict = tomorrowPlan.conflict(); + qs('#my-work-action-status').textContent = conflict ? + 'Another device changed Tomorrow. Your phone plan is preserved; reopen Plan Tomorrow to review it.' : + `${error.message || 'Tomorrow sync is unavailable.'} Saved on this phone · sync pending.`; + return false; + }); + } async function refreshTomorrowQueueSummary() { try { renderTomorrowQueueSummary(await tomorrowPlan.load()); @@ -494,7 +512,10 @@ tomorrowPlan.startLifecycle({ windowObject:window, documentObject:document, - check:() => latestTodayPlan ? promoteTomorrowIfDue(latestTodayPlan) : false, + check:() => Promise.all([ + syncPendingTomorrow(), + latestTodayPlan ? promoteTomorrowIfDue(latestTodayPlan) : false, + ]), }); const todayHandoff = createTodayHandoff({ storage:localStorage, @@ -2716,15 +2737,15 @@ function saveTomorrowPlan(plan) { const normalized = Array.isArray(plan) ? {ids:plan, capacity_minutes:null, estimates:{}} : plan; - tomorrowPlan.save(normalized).then(saved => { - renderTomorrowQueueSummary(saved); - qs('#my-work-action-status').textContent = saved.ids.length ? - `Tomorrow saved for ${saved.plan_date} without changing Today.` : - `Tomorrow cleared for ${saved.plan_date}.`; - }).catch(error => { + const staged = tomorrowPlan.stage(normalized); + if (!staged) { qs('#my-work-action-status').textContent = - `${error.message || 'Tomorrow could not be saved.'} Reopen Plan Tomorrow to retry.`; - }); + 'Tomorrow could not be saved on this phone. Free browser storage and retry.'; + return false; + } + renderTomorrowQueueSummary(staged); + qs('#my-work-action-status').textContent = 'Tomorrow saved on this phone · sync pending.'; + syncPendingTomorrow(); return true; } @@ -5377,6 +5398,7 @@ planningOwnerLogin = retainedPlanningLogin; updatePlanningAvailability(); if (planningOwnerLogin) { + syncPendingTomorrow(); todaySync.migrate(todayWork.read()); todaySync.flush(); laterSync.migrate(laterWork.read()); diff --git a/frontend/tomorrow-plan.js b/frontend/tomorrow-plan.js index d7280ac..a24f61b 100644 --- a/frontend/tomorrow-plan.js +++ b/frontend/tomorrow-plan.js @@ -1,6 +1,22 @@ -function createTomorrowPlan({fetchJson,localDate,timeZone}={}) { +function createTomorrowPlan({fetchJson,localDate,timeZone,storage,getLogin}={}) { let plan={revision:0,ids:[],capacity_minutes:null,estimates:{}}; + let flushing=null; + let lastConflict=null; + const storagePrefix='stackchain.tomorrow-sync.v1.'; const state=()=>({...plan,ids:[...plan.ids],estimates:{...plan.estimates}}); + function storageKey() { + const login=String(getLogin?.()||'').trim().toLowerCase(); + return login?storagePrefix+encodeURIComponent(login):''; + } + function pending() { + const key=storageKey(); + if(!key||!storage) return false; + try { + const value=JSON.parse(storage.getItem(key)||'null'); + return Number.isInteger(value?.base_revision)&&Array.isArray(value?.ids)? + {...value,ids:[...value.ids],estimates:{...(value.estimates||{})},sync_pending:true}:false; + } catch (_error) { return false; } + } function adopt(value) { if (!Number.isInteger(value?.revision)||!Array.isArray(value?.ids)) return false; plan={revision:value.revision,ids:[...value.ids],capacity_minutes:value.capacity_minutes??null, @@ -11,7 +27,48 @@ function createTomorrowPlan({fetchJson,localDate,timeZone}={}) { const [y,m,d]=localDate().split('-').map(Number); return new Date(Date.UTC(y,m-1,d+1)).toISOString().slice(0,10); } - async function load(){return adopt(await fetchJson('api/v1/tomorrow'));} + async function load(){ + const queued=pending(); + return queued?(plan={...queued},state()):adopt(await fetchJson('api/v1/tomorrow')); + } + function stage(value) { + const key=storageKey(); + if(!key||!storage) return false; + const queued={base_revision:plan.revision,revision:plan.revision,ids:[...(value.ids||[])], + capacity_minutes:value.capacity_minutes??null,estimates:{...(value.estimates||{})}, + plan_date:nextLocalDate(),timezone:timeZone(),sync_pending:true}; + try { storage.setItem(key,JSON.stringify(queued)); } + catch (_error) { return false; } + lastConflict=null; + plan=queued; + return state(); + } + function deliveryBody(value) { + return {base_revision:value.base_revision,ids:[...value.ids], + capacity_minutes:value.capacity_minutes??null,estimates:{...(value.estimates||{})}, + plan_date:value.plan_date,timezone:value.timezone}; + } + function flush() { + if(flushing) return flushing; + const queued=pending(); + const key=storageKey(); + if(!queued||!key) return Promise.resolve(false); + const body=deliveryBody(queued); + flushing=fetchJson('api/v1/tomorrow',{method:'PUT',headers:{'Content-Type':'application/json'}, + body:JSON.stringify(body)}).then(saved=>{ + const current=pending(); + if(current&&JSON.stringify(deliveryBody(current))===JSON.stringify(body)) storage.removeItem(key); + if(!pending()) adopt(saved); + return saved; + }).catch(async error=>{ + if(error?.status===409) { + const remote=await fetchJson('api/v1/tomorrow'); + lastConflict={local:pending(),remote:{...remote,ids:[...remote.ids],estimates:{...(remote.estimates||{})}}}; + } + throw error; + }).finally(()=>{flushing=null;}); + return flushing; + } async function save(value) { const body={base_revision:plan.revision,ids:[...(value.ids||[])], capacity_minutes:value.capacity_minutes??null,estimates:{...(value.estimates||{})}, @@ -19,7 +76,7 @@ function createTomorrowPlan({fetchJson,localDate,timeZone}={}) { return adopt(await fetchJson('api/v1/tomorrow',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)})); } async function promote(today_revision) { - if (!plan.plan_date||plan.plan_date>localDate()||!plan.ids.length) return false; + if (pending()||!plan.plan_date||plan.plan_date>localDate()||!plan.ids.length) return false; const promotion_id=`tomorrow-${plan.plan_date}-r${plan.revision}`; return fetchJson('api/v1/tomorrow/promote',{method:'POST',headers:{'Content-Type':'application/json'}, body:JSON.stringify({promotion_id,tomorrow_revision:plan.revision,today_revision})}); @@ -50,12 +107,13 @@ function createTomorrowPlan({fetchJson,localDate,timeZone}={}) { } function summary(value=plan) { const ids=Array.isArray(value?.ids)?value.ids:[]; - if(!ids.length) return 'Nothing planned'; + const suffix=value?.sync_pending?' · sync pending':''; + if(!ids.length) return 'Nothing planned'+suffix; const label=`${ids.length} planned`; const estimates=value.estimates||{}; const estimated=ids.reduce((total,id)=>total+(Number(estimates[id])||0),0); const capacity=Number(value.capacity_minutes)||0; - return estimated&&capacity?`${label} · ${estimated} of ${capacity} min`:label; + return (estimated&&capacity?`${label} · ${estimated} of ${capacity} min`:label)+suffix; } function startLifecycle({windowObject,documentObject,check,setTimer=setTimeout,clearTimer=clearTimeout, nextDelay=millisecondsUntilNextDay}={}) { @@ -74,6 +132,10 @@ function createTomorrowPlan({fetchJson,localDate,timeZone}={}) { run(); return {run,stop(){if(timer!==null)clearTimer(timer);timer=null;}}; } - return {adopt,load,save,promote,state,summary,nextLocalDate,startLifecycle}; + function conflict() { + return lastConflict&&{local:{...lastConflict.local,ids:[...lastConflict.local.ids],estimates:{...lastConflict.local.estimates}}, + remote:{...lastConflict.remote,ids:[...lastConflict.remote.ids],estimates:{...lastConflict.remote.estimates}}}; + } + return {adopt,load,save,stage,pending,flush,conflict,promote,state,summary,nextLocalDate,startLifecycle}; } if(typeof module!=='undefined'&&module.exports)module.exports=createTomorrowPlan; diff --git a/tests/test_tomorrow_plan_frontend.py b/tests/test_tomorrow_plan_frontend.py index cd71f2c..4a8a3e7 100644 --- a/tests/test_tomorrow_plan_frontend.py +++ b/tests/test_tomorrow_plan_frontend.py @@ -20,6 +20,96 @@ const createTomorrowPlan = require({json.dumps(str(CONTROLLER))}); return json.loads(completed.stdout) +def test_tomorrow_planner_restores_an_account_bound_plan_before_network_delivery(): + result = run_controller(""" +const values=new Map(); +const storage={ + getItem:key=>values.has(key)?values.get(key):null, + setItem:(key,value)=>values.set(key,value), + removeItem:key=>values.delete(key), +}; +let login='Timmy'; +let requests=0; +const options={storage,getLogin:()=>login,fetchJson:async()=>{requests+=1;throw new Error('offline');}, + localDate:()=> '2026-08-19',timeZone:()=> 'America/New_York'}; +const first=createTomorrowPlan(options); +first.adopt({revision:7,ids:[],capacity_minutes:null,estimates:{}}); +const queued=first.stage({ids:['issue:r:3:','issue:r:2:'],capacity_minutes:120, + estimates:{'issue:r:3:':45,'issue:r:2:':30}}); +const restored=await createTomorrowPlan(options).load(); +login='alexander'; +let otherError=''; +try { await createTomorrowPlan(options).load(); } catch(error) { otherError=error.message; } +console.log(JSON.stringify({queued,restored,requests,otherError,keys:[...values.keys()]})); +""") + + assert result["queued"]["sync_pending"] is True + assert result["restored"] == result["queued"] + assert result["requests"] == 1 + assert result["otherError"] == "offline" + assert result["keys"] == ["stackchain.tomorrow-sync.v1.timmy"] + + +def test_tomorrow_planner_flushes_once_and_removes_pending_only_after_server_receipt(): + result = run_controller(""" +const values=new Map(); +const storage={getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)}; +let resolveRequest; +let requests=0; +const fetchJson=async (_url,options={})=>{ + requests+=1; + const body=JSON.parse(options.body); + await new Promise(resolve=>{resolveRequest=()=>resolve({revision:9,...body});}); + return {revision:9,...body}; +}; +const planner=createTomorrowPlan({storage,getLogin:()=> 'timmy',fetchJson, + localDate:()=> '2026-08-19',timeZone:()=> 'UTC'}); +planner.adopt({revision:8,ids:[],capacity_minutes:null,estimates:{}}); +planner.stage({ids:['issue:r:4:'],capacity_minutes:60,estimates:{'issue:r:4:':30}}); +const first=planner.flush(); +const second=planner.flush(); +await Promise.resolve(); +const pendingDuring=planner.pending(); +resolveRequest(); +const [saved,reused]=await Promise.all([first,second]); +console.log(JSON.stringify({requests,pendingDuring,saved,reused,pendingAfter:planner.pending(),keys:[...values.keys()]})); +""") + + assert result["requests"] == 1 + assert result["pendingDuring"]["ids"] == ["issue:r:4:"] + assert result["saved"]["revision"] == 9 + assert result["reused"]["revision"] == 9 + assert result["pendingAfter"] is False + assert result["keys"] == [] + + +def test_tomorrow_planner_preserves_local_and_server_plans_on_revision_conflict(): + result = run_controller(""" +const values=new Map(); +const storage={getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)}; +const requests=[]; +const fetchJson=async (_url,options={})=>{ + requests.push(options.method||'GET'); + if(options.method==='PUT'){const error=new Error('changed elsewhere');error.status=409;throw error;} + return {revision:12,ids:['issue:r:server:'],capacity_minutes:60,estimates:{},plan_date:'2026-08-20',timezone:'UTC'}; +}; +const planner=createTomorrowPlan({storage,getLogin:()=> 'timmy',fetchJson, + localDate:()=> '2026-08-19',timeZone:()=> 'UTC'}); +planner.adopt({revision:11,ids:[],capacity_minutes:null,estimates:{}}); +planner.stage({ids:['issue:r:phone:'],capacity_minutes:90,estimates:{}}); +let message=''; +try { await planner.flush(); } catch(error) { message=error.message; } +console.log(JSON.stringify({requests,message,pending:planner.pending(),conflict:planner.conflict()})); +""") + + assert result["requests"] == ["PUT", "GET"] + assert result["message"] == "changed elsewhere" + assert result["pending"]["ids"] == ["issue:r:phone:"] + assert result["conflict"]["local"]["ids"] == ["issue:r:phone:"] + assert result["conflict"]["remote"]["ids"] == ["issue:r:server:"] + assert result["conflict"]["remote"]["revision"] == 12 + + def test_tomorrow_planner_loads_and_saves_independently_from_today(): result = run_controller(""" const requests=[]; @@ -57,6 +147,26 @@ console.log(JSON.stringify({loaded,saved,requests})); assert all(request["url"] != "api/v1/today" for request in result["requests"]) +def test_pending_tomorrow_plan_is_not_promoted_before_account_sync(): + result = run_controller(""" +const values=new Map(); +const storage={getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)}; +const requests=[]; +let date='2026-08-19'; +const planner=createTomorrowPlan({storage,getLogin:()=> 'timmy',fetchJson:async(url)=>{requests.push(url);return {};}, + localDate:()=> date,timeZone:()=> 'UTC'}); +planner.adopt({revision:3,ids:['issue:r:2:'],capacity_minutes:60,estimates:{},plan_date:'2026-08-20',timezone:'UTC'}); +planner.stage({ids:['issue:r:2:'],capacity_minutes:60,estimates:{}}); +date='2026-08-22'; +const promoted=await planner.promote(7); +console.log(JSON.stringify({promoted,requests,pending:planner.pending()})); +""") + + assert result["promoted"] is False + assert result["requests"] == [] + assert result["pending"]["ids"] == ["issue:r:2:"] + + def test_tomorrow_planner_promotes_overdue_plans_with_a_stable_retry_identity(): result = run_controller(""" const requests=[]; @@ -147,16 +257,34 @@ const planned=planner.summary({ estimates:{'issue:r:1:':30,'issue:r:2:':45,'issue:r:3:':30} }); const unestimated=planner.summary({ids:['issue:r:4:'],capacity_minutes:null,estimates:{}}); -console.log(JSON.stringify({empty,planned,unestimated})); +const pending=planner.summary({ids:['issue:r:5:'],capacity_minutes:60,estimates:{'issue:r:5:':30},sync_pending:true}); +console.log(JSON.stringify({empty,planned,unestimated,pending})); """) assert result == { "empty": "Nothing planned", "planned": "3 planned · 105 of 120 min", "unestimated": "1 planned", + "pending": "1 planned · 30 of 60 min · sync pending", } +def test_mobile_tomorrow_save_is_admitted_before_background_delivery(): + dashboard = (FRONTEND / "dashboard.js").read_text() + + assert "storage:localStorage" in dashboard + assert "getLogin:() => planningOwnerLogin" in dashboard + assert "const staged = tomorrowPlan.stage(normalized);" in dashboard + assert "if (!staged) {" in dashboard + assert "Free browser storage and retry." in dashboard + assert "renderTomorrowQueueSummary(staged);" in dashboard + assert "Tomorrow saved on this phone · sync pending." in dashboard + assert "tomorrowPlan.flush().then" in dashboard + assert "syncPendingTomorrow()" in dashboard + assert dashboard.count("syncPendingTomorrow();") >= 2 + assert "Another device changed Tomorrow" in dashboard + + def test_mobile_queues_expose_tomorrow_and_keep_duplicate_header_actions_hidden(): index = INDEX.read_text() css = CSS.read_text() -- 2.43.0