feat: keep Tomorrow plans through offline saves (Closes #1160)
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 committed 2026-08-20 02:10:37 +00:00
1 parent ae632e3562
commit f42f89ca21
4 files changed
+235 -17

No files matched your search

+129 -1
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()