feat: keep Tomorrow plans through offline saves (Closes #1160)
All checks were successful
CI / lint (pull_request) Successful in 3m17s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 4m5s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy 2026-08-20 02:10:37 +00:00
parent ae632e3562
commit f42f89ca21
4 changed files with 235 additions and 17 deletions

View File

@ -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

View File

@ -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());

View File

@ -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;

View File

@ -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()