feat: keep Week Ahead plans through offline saves (Closes #1180)
This commit is contained in:
1 parent
99a2f28440
commit
47cad77312
5 files changed
+370
-8
No files matched your search
@@ -67,6 +67,143 @@ console.log(JSON.stringify({conflict:week.conflict(),state:week.state()}));
|
||||
assert result["state"]["revision"] == 7
|
||||
|
||||
|
||||
def test_week_controller_restores_an_account_bound_week_before_network_delivery():
|
||||
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 login='Timmy';
|
||||
let requests=0;
|
||||
const options={storage,getLogin:()=>login,fetchJson:async()=>{requests+=1;throw new Error('offline');},
|
||||
localDate:()=> '2026-08-20',timeZone:()=> 'America/New_York'};
|
||||
const first=createWeekPlan(options);
|
||||
first.adopt({revision:7,timezone:'UTC',days:[{plan_date:'2026-08-21',ids:['old'],capacity_minutes:30,estimates:{old:15}}]});
|
||||
const queued=first.stageDay('2026-08-22',{ids:['issue:r:3:','issue:r:2:'],capacity_minutes:120,
|
||||
estimates:{'issue:r:3:':45,'issue:r:2:':30}});
|
||||
const restored=await createWeekPlan(options).load();
|
||||
login='alexander';
|
||||
let otherError='';
|
||||
try { await createWeekPlan(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["restored"]["timezone"] == "America/New_York"
|
||||
assert [day["plan_date"] for day in result["restored"]["days"]] == ["2026-08-21", "2026-08-22"]
|
||||
assert result["requests"] == 1
|
||||
assert result["otherError"] == "offline"
|
||||
assert result["keys"] == ["stackchain.week-sync.v1.timmy"]
|
||||
|
||||
|
||||
def test_week_controller_flushes_once_and_removes_only_the_matching_pending_week():
|
||||
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 release;
|
||||
let requests=0;
|
||||
const fetchJson=async(_url,options={})=>{
|
||||
requests+=1;const body=JSON.parse(options.body);
|
||||
await new Promise(resolve=>{release=resolve});
|
||||
return {revision:9,timezone:body.timezone,days:body.days};
|
||||
};
|
||||
const week=createWeekPlan({storage,getLogin:()=> 'timmy',fetchJson,localDate:()=> '2026-08-20',timeZone:()=> 'UTC'});
|
||||
week.adopt({revision:8,timezone:'UTC',days:[]});
|
||||
week.stageDay('2026-08-21',{ids:['phone'],capacity_minutes:60,estimates:{phone:30}});
|
||||
const first=week.flush(),second=week.flush();
|
||||
await Promise.resolve();
|
||||
const during=week.pending();
|
||||
release();
|
||||
const [saved,reused]=await Promise.all([first,second]);
|
||||
console.log(JSON.stringify({requests,during,saved,reused,pendingAfter:week.pending(),state:week.state()}));
|
||||
""")
|
||||
|
||||
assert result["requests"] == 1
|
||||
assert result["during"]["days"][0]["ids"] == ["phone"]
|
||||
assert result["saved"]["revision"] == 9
|
||||
assert result["reused"]["revision"] == 9
|
||||
assert result["pendingAfter"] is False
|
||||
assert result["state"]["revision"] == 9
|
||||
|
||||
|
||||
def test_week_controller_keeps_phone_week_by_rebasing_onto_the_latest_revision():
|
||||
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 puts=0;
|
||||
const remote={revision:12,timezone:'UTC',days:[{plan_date:'2026-08-21',ids:['server'],capacity_minutes:60,estimates:{}}]};
|
||||
const fetchJson=async(_url,options={})=>{
|
||||
requests.push({method:options.method||'GET',body:options.body?JSON.parse(options.body):null});
|
||||
if(options.method==='PUT'&&puts++===0){const error=new Error('changed');error.status=409;throw error;}
|
||||
if(options.method==='PUT')return {revision:13,timezone:'UTC',days:JSON.parse(options.body).days};
|
||||
return remote;
|
||||
};
|
||||
const week=createWeekPlan({storage,getLogin:()=> 'timmy',fetchJson,localDate:()=> '2026-08-20',timeZone:()=> 'UTC'});
|
||||
week.adopt({revision:11,timezone:'UTC',days:[]});
|
||||
week.stageDay('2026-08-21',{ids:['phone'],capacity_minutes:90,estimates:{phone:45}});
|
||||
try { await week.flush(); } catch(_error) {}
|
||||
const before=week.conflict();
|
||||
const saved=await week.keepLocal();
|
||||
console.log(JSON.stringify({before,saved,requests,pending:week.pending(),conflict:week.conflict()}));
|
||||
""")
|
||||
|
||||
assert result["before"]["local"]["days"][0]["ids"] == ["phone"]
|
||||
assert result["before"]["remote"]["days"][0]["ids"] == ["server"]
|
||||
assert [request["method"] for request in result["requests"]] == ["PUT", "GET", "PUT"]
|
||||
assert result["requests"][2]["body"]["base_revision"] == 12
|
||||
assert result["saved"]["revision"] == 13
|
||||
assert result["pending"] is False
|
||||
assert result["conflict"] is None
|
||||
|
||||
|
||||
def test_week_controller_refreshes_account_week_after_a_second_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)};
|
||||
let revision=12;
|
||||
const fetchJson=async(_url,options={})=>{
|
||||
if(options.method==='PUT'){const error=new Error('changed again');error.status=409;throw error;}
|
||||
return {revision:revision++,timezone:'UTC',days:[{plan_date:'2026-08-21',ids:['server-'+(revision-1)],capacity_minutes:60,estimates:{}}]};
|
||||
};
|
||||
const week=createWeekPlan({storage,getLogin:()=> 'timmy',fetchJson,localDate:()=> '2026-08-20',timeZone:()=> 'UTC'});
|
||||
week.adopt({revision:11,timezone:'UTC',days:[]});
|
||||
week.stageDay('2026-08-21',{ids:['phone'],capacity_minutes:90,estimates:{}});
|
||||
try { await week.flush(); } catch(_error) {}
|
||||
try { await week.keepLocal(); } catch(_error) {}
|
||||
console.log(JSON.stringify({conflict:week.conflict(),pending:week.pending()}));
|
||||
""")
|
||||
|
||||
assert result["conflict"]["local"]["days"][0]["ids"] == ["phone"]
|
||||
assert result["conflict"]["remote"]["revision"] == 13
|
||||
assert result["conflict"]["remote"]["days"][0]["ids"] == ["server-13"]
|
||||
assert result["pending"]["days"][0]["ids"] == ["phone"]
|
||||
|
||||
|
||||
def test_week_controller_uses_account_week_without_a_second_write():
|
||||
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 remote={revision:12,timezone:'UTC',days:[{plan_date:'2026-08-21',ids:['server'],capacity_minutes:60,estimates:{server:30}}]};
|
||||
const fetchJson=async(_url,options={})=>{
|
||||
requests.push(options.method||'GET');
|
||||
if(options.method==='PUT'){const error=new Error('changed');error.status=409;throw error;}
|
||||
return remote;
|
||||
};
|
||||
const week=createWeekPlan({storage,getLogin:()=> 'timmy',fetchJson,localDate:()=> '2026-08-20',timeZone:()=> 'UTC'});
|
||||
week.adopt({revision:11,timezone:'UTC',days:[]});
|
||||
week.stageDay('2026-08-21',{ids:['phone'],capacity_minutes:90,estimates:{}});
|
||||
try { await week.flush(); } catch(_error) {}
|
||||
const adopted=week.useRemote();
|
||||
console.log(JSON.stringify({adopted,requests,pending:week.pending(),conflict:week.conflict(),state:week.state()}));
|
||||
""")
|
||||
|
||||
assert result["requests"] == ["PUT", "GET"]
|
||||
assert result["adopted"]["days"][0]["ids"] == ["server"]
|
||||
assert result["pending"] is False
|
||||
assert result["conflict"] is None
|
||||
assert result["state"]["revision"] == 12
|
||||
|
||||
|
||||
def test_week_controller_promotes_only_the_earliest_due_plan_with_stable_identity():
|
||||
result = run_controller("""
|
||||
const requests=[];
|
||||
@@ -93,6 +230,22 @@ console.log(JSON.stringify({promoted,requests}));
|
||||
}]
|
||||
|
||||
|
||||
def test_week_controller_does_not_promote_a_pending_week():
|
||||
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 requests=0;
|
||||
const week=createWeekPlan({storage,getLogin:()=> 'timmy',fetchJson:async()=>{requests+=1;return {};},
|
||||
localDate:()=> '2026-08-23',timeZone:()=> 'UTC'});
|
||||
week.adopt({revision:6,timezone:'UTC',days:[]});
|
||||
week.stageDay('2026-08-21',{ids:['oldest'],capacity_minutes:60,estimates:{}});
|
||||
const promoted=await week.promote(3);
|
||||
console.log(JSON.stringify({promoted,requests}));
|
||||
""")
|
||||
|
||||
assert result == {"promoted": False, "requests": 0}
|
||||
|
||||
|
||||
def test_mobile_week_ahead_entry_and_date_strip_are_touch_safe():
|
||||
index = (FRONTEND / "index.html").read_text()
|
||||
css = (FRONTEND / "dashboard.css").read_text()
|
||||
@@ -105,3 +258,18 @@ def test_mobile_week_ahead_entry_and_date_strip_are_touch_safe():
|
||||
assert "weekWorkflow.save" in dashboard
|
||||
assert ".week-plan-dates button { min-height:44px;" in css
|
||||
assert "overflow-x:auto" in css
|
||||
|
||||
|
||||
def test_mobile_week_ahead_exposes_durable_save_and_touch_safe_conflict_choices():
|
||||
index = (FRONTEND / "index.html").read_text()
|
||||
css = (FRONTEND / "dashboard.css").read_text()
|
||||
dashboard = (FRONTEND / "dashboard.js").read_text()
|
||||
|
||||
assert 'id="week-conflict-review"' in index
|
||||
assert 'id="keep-phone-week"' in index
|
||||
assert 'id="use-server-week"' in index
|
||||
assert "storage:localStorage" in dashboard
|
||||
assert "controller.stageDay" in CONTROLLER.read_text()
|
||||
assert "weekPlan.flush" in dashboard
|
||||
assert "week-conflict-mode" in dashboard
|
||||
assert ".week-conflict-actions button { min-height:44px;" in css
|
||||
Reference in new issue
Block a user