feat: preserve Search work details offline (Closes #1220)
CI / lint (pull_request) Successful in 3m46s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 5m16s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy committed 2026-08-21 12:03:25 +00:00
1 parent ba50784022
commit 2b874d8427
6 files changed
+151 -20

No files matched your search

+24
View File
@@ -137,6 +137,30 @@ const flow=createWeekBatchPlan({{
]
def test_week_batch_stages_details_only_for_successfully_placed_canonical_items():
script = f"""
const createSearchBatchPlan=require({json.dumps(str(SEARCH_BATCH_PLAN))});
const flow=createSearchBatchPlan.createWeekBatchPlan({{
week:{{load:async()=>{{}},dates:()=>[{{date:'2026-08-21'}}],day:()=>({{ids:[],estimates:{{}}}}),
placement:()=>null,place:id=>!id.endsWith('#2'),
rememberPendingItem:(id,item)=>calls.push(['details',id,item.title]),flush:async()=>calls.push(['flush'])}},
prepare:async item=>({{...item,title:'Canonical '+item.number}}),
identity:item=>item.repository+'#'+item.number,
}});
const calls=[];const items=[
{{repository:'r',number:1,title:'Search one'}},{{repository:'r',number:2,title:'Search two'}}];
(async()=>{{const outcome=await flow.run(items,{{'r#1':{{date:'2026-08-21',estimate:20}},'r#2':{{date:'2026-08-21',estimate:30}}}});
process.stdout.write(JSON.stringify({{outcome,calls}}));}})();
"""
result = run_node(script)
assert result["outcome"] == {
"status": "partial", "planned": 1,
"failed": [{"id": "r#2", "reason": "assigned-not-planned"}],
}
assert result["calls"] == [["details", "r#1", "Canonical 1"], ["flush"]]
def test_mobile_search_exposes_touch_safe_durable_batch_planning_controls():
html = HTML.read_text()
dashboard = DASHBOARD.read_text()
+23
View File
@@ -99,3 +99,26 @@ console.log(JSON.stringify(outcome));
""")
assert result == {"status": "planned-pending", "date": "2026-08-22", "estimate": 45}
def test_search_week_plan_stages_canonical_details_before_flushing():
result = run_planner("""
const calls=[];
const week={load:async()=>({}),dates:()=>[{date:'2026-08-22',label:'Sat'}],
day:date=>({plan_date:date,ids:[],capacity_minutes:120,estimates:{}}),placement:()=>null,
place:(id)=>{calls.push(['place',id]);return true;},
rememberPendingItem:(id,item)=>{calls.push(['details',id,item.title,item.repository,item.number]);return true;},
flush:async()=>{calls.push(['flush']);return {revision:3};}};
const planner=createSearchWeekPlan({week,claim:async item=>({...item,title:'Canonical title'}),
accept:item=>({...item,accepted:true}),identity:item=>`issue:${item.repository}:${item.number}:`});
const outcome=await planner.plan({kind:'issue',state:'open',repository:'stackchain/dashboard',number:42,claimable:true},
{date:'2026-08-22',estimate:45});
console.log(JSON.stringify({outcome,calls}));
""")
assert result["outcome"]["status"] == "planned"
assert result["calls"] == [
["place", "issue:stackchain/dashboard:42:"],
["details", "issue:stackchain/dashboard:42:", "Canonical title", "stackchain/dashboard", 42],
["flush"],
]
+52
View File
@@ -577,6 +577,58 @@ console.log(JSON.stringify({loaded,restored,item:fallback.item('issue:r:42:'),ot
assert "secret" not in result["snapshot"]["items"]["issue:r:42:"]
def test_week_controller_promotes_bounded_pending_work_details_after_matching_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 online=true;const requests=[];
const options={storage,getLogin:()=> 'timmy',now:()=>Date.parse('2026-08-20T12:34:00Z'),
fetchJson:async(_url,request={})=>{
requests.push(request.body?JSON.parse(request.body):null);
if(!online)throw new Error('offline');
if(request.method==='PUT')return {revision:8,timezone:'UTC',days:JSON.parse(request.body).days};
return {revision:7,timezone:'UTC',days:[]};
},localDate:()=> '2026-08-20',timeZone:()=> 'UTC'};
const live=createWeekPlan(options);await live.load();
live.place('issue:r:42:','2026-08-21',45);
const admitted=live.rememberPendingItem('issue:r:42:',{kind:'issue',title:'T'.repeat(600),repository:'r'.repeat(300),
number:42,body:'private body',token:'drop'});
const queued=live.pending();
await live.flush();
const confirmed=JSON.parse(values.get('stackchain.week-confirmed.v1.timmy'));
online=false;const offline=createWeekPlan(options);const restored=await offline.load();
console.log(JSON.stringify({admitted,queued,confirmed,restored,item:offline.item('issue:r:42:'),requests}));
""")
assert result["admitted"] is True
assert result["queued"]["items"]["issue:r:42:"]["title"] == "T" * 500
assert len(result["queued"]["items"]["issue:r:42:"]["repository"]) == 255
assert result["requests"][1].keys() == {"base_revision", "timezone", "days"}
assert result["confirmed"]["week"]["revision"] == 8
assert result["confirmed"]["items"]["issue:r:42:"] == result["item"]
assert result["item"]["number"] == 42
assert "body" not in result["item"] and "token" not in result["item"]
assert result["restored"]["offline_snapshot"] is True
def test_week_controller_keeps_pending_work_details_across_a_cold_offline_reload():
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 options={storage,getLogin:()=> 'timmy',fetchJson:async()=>{throw new Error('offline');},
localDate:()=> '2026-08-20',timeZone:()=> 'UTC'};
const first=createWeekPlan(options);first.adopt({revision:7,timezone:'UTC',days:[]});
first.place('issue:r:42:','2026-08-21',45);
first.rememberPendingItem('issue:r:42:',{kind:'issue',title:'Pending work',repository:'r',number:42});
const restored=createWeekPlan(options);const state=await restored.load();
console.log(JSON.stringify({state,item:restored.item('issue:r:42:'),pending:restored.pending()}));
""")
assert result["state"]["sync_pending"] is True
assert result["item"] == {"kind": "issue", "title": "Pending work", "repository": "r", "number": 42}
assert result["pending"]["items"]["issue:r:42:"] == result["item"]
def test_week_controller_does_not_use_a_confirmed_snapshot_after_authentication_fails():
result = run_controller("""
const values=new Map();