feat: keep Tomorrow plans through offline saves (Closes #1160)
This commit is contained in:
1 parent
ae632e3562
commit
f42f89ca21
4 files changed
+235
-17
No files matched your search
@@ -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()
|
||||
|
||||
Reference in new issue
Block a user