diff --git a/frontend/week-plan.js b/frontend/week-plan.js index 9389294..adb28ad 100644 --- a/frontend/week-plan.js +++ b/frontend/week-plan.js @@ -39,20 +39,33 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin,now=()=>D const login=String(getLogin?.()||'').trim().toLowerCase(); return login?pullPrefix+encodeURIComponent(login):''; } - function pendingPull() { + function pullQueue() { const key=pullKey(); - if(!key||!storage)return null; + if(!key||!storage)return []; try { const value=JSON.parse(storage.getItem(key)||'null'); - return typeof value?.body?.operation_id==='string'&&Array.isArray(value?.today?.ids)&& - Number.isInteger(value?.week?.revision)&&Array.isArray(value?.week?.days)?value:null; - } catch(_error){return null;} + const entries=Array.isArray(value?.entries)?value.entries:(value?[value]:[]); + return entries.filter(entry=>typeof entry?.body?.operation_id==='string'&&Array.isArray(entry?.today?.ids)&& + Number.isInteger(entry?.week?.revision)&&Array.isArray(entry?.week?.days)); + } catch(_error){return [];} + } + function writePullQueue(entries) { + const key=pullKey();if(!key||!storage)return false; + try { + if(entries.length)storage.setItem(key,JSON.stringify({version:2,entries})); + else storage.removeItem(key); + return true; + } catch(_error){return false;} + } + function pendingPull() { + return pullQueue()[0]||null; } function resumePull() { - const value=pendingPull(); + const queue=pullQueue(),conflicted=queue.find(entry=>entry.conflict&&entry.current?.week); + const value=conflicted||queue[queue.length-1]; if(!value)return null; - adopt(value.week); - return {...value,sync_pending:true}; + adopt(conflicted?conflicted.current.week:value.week); + return {...value,...(conflicted?.current||{}),sync_pending:true}; } function readConfirmed() { const key=confirmedKey(); @@ -502,8 +515,8 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin,now=()=>D return {...cloneDay(day),ids:(day.ids||[]).filter(id=>id!==identity),estimates}; })}; const record={body,today:optimisticToday,week:optimisticWeek,queued_at:now()}; - try {storage.setItem(key,JSON.stringify(record));} - catch(_error){throw new Error('Could not save this pull on this device. Nothing changed.');} + const queue=pullQueue(); + if(!writePullQueue([...queue,record]))throw new Error('Could not save this pull on this device. Nothing changed.'); adopt(optimisticWeek); try{return await flushPull();} catch(error){ @@ -519,21 +532,39 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin,now=()=>D const record=pendingPull(),key=pullKey(); if(!record||!key)return Promise.resolve(false); pulling=(async()=>{ - let result; - try { - result=await fetchJson('api/v1/week/pull-item',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(record.body)}); - } catch(error) { - if(error?.status!==409)throw error; - const [today,currentWeek]=await Promise.all([fetchJson('api/v1/today'),fetchJson('api/v1/week')]); - const conflicted={...record,conflict:true,current:{today,week:currentWeek}}; - storage.setItem(key,JSON.stringify(conflicted)); - adopt(currentWeek); - return {today,week:currentWeek,conflict:true,sync_pending:true}; + let result,lastResult=false; + while(pendingPull()){ + const current=pendingPull(); + try { + result=await fetchJson('api/v1/week/pull-item',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(current.body)}); + } catch(error) { + if(error?.status!==409)throw error; + const [today,currentWeek]=await Promise.all([fetchJson('api/v1/today'),fetchJson('api/v1/week')]); + const queue=pullQueue(),conflicted={...current,conflict:true,current:{today,week:currentWeek}}; + if(!writePullQueue([conflicted,...queue.slice(1)]))throw new Error('Could not preserve queued Week Ahead work on this device.'); + adopt(currentWeek); + return {today,week:currentWeek,conflict:true,sync_pending:true}; + } + const queue=pullQueue(); + if(queue[0]?.body?.operation_id!==current.body.operation_id)continue; + const remaining=queue.slice(1).map(entry=>{ + const identity=entry.body.identity; + const source=entry.week.days.find(day=>(day.ids||[]).includes(identity)); + const estimate=Number(entry.today?.estimates?.[identity]??source?.estimates?.[identity]); + const today={...result.today,ids:(result.today.ids||[]).includes(identity)?[...result.today.ids]:[...result.today.ids,identity], + estimates:{...(result.today.estimates||{}),...(Number.isFinite(estimate)?{[identity]:estimate}:{})}}; + const nextWeek={revision:result.week.revision,timezone:result.week.timezone||null,days:result.week.days.map(day=>{ + const estimates={...(day.estimates||{})};delete estimates[identity]; + return {...cloneDay(day),ids:(day.ids||[]).filter(id=>id!==identity),estimates}; + })}; + result={today,week:nextWeek}; + return {...entry,body:{...entry.body,today_revision:today.revision,week_revision:nextWeek.revision},today,week:nextWeek}; + }); + if(!writePullQueue(remaining))throw new Error('Could not update queued Week Ahead work on this device.'); + lastResult=result; } - const current=pendingPull(); - if(current?.body?.operation_id===record.body.operation_id)storage.removeItem(key); - adoptConfirmed(result.week,{...confirmedItems,...pendingItems}); - return {...result,sync_pending:Boolean(pendingPull())}; + adoptConfirmed(lastResult.week,{...confirmedItems,...pendingItems}); + return {...lastResult,sync_pending:false}; })().finally(()=>{pulling=null;}); return pulling; } diff --git a/tests/test_week_plan_frontend.py b/tests/test_week_plan_frontend.py index 00ab6cd..71c763c 100644 --- a/tests/test_week_plan_frontend.py +++ b/tests/test_week_plan_frontend.py @@ -358,6 +358,80 @@ console.log(JSON.stringify({pending,resume,confirmed,requests,keys:[...values.ke assert result["state"]["revision"] == 8 +def test_week_controller_queues_two_offline_pulls_and_replays_them_fifo_after_reload(): + result = run_controller(""" +const values=new Map(),delivered=[]; +const storage={getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)}; +let offline=true; +let serverToday={revision:4,ids:['active'],capacity_minutes:180,estimates:{active:30}}; +let serverWeek={revision:7,timezone:'UTC',days:[{plan_date:'2026-08-21',ids:['first','second','later'],capacity_minutes:150,estimates:{first:35,second:40,later:45}}]}; +const fetchJson=async(url,options={})=>{ + if(offline)throw new Error('connection lost'); + const body=JSON.parse(options.body);delivered.push(body); + if(body.today_revision!==serverToday.revision||body.week_revision!==serverWeek.revision){const error=new Error('changed');error.status=409;throw error;} + serverToday={...serverToday,revision:serverToday.revision+1,ids:[...serverToday.ids,body.identity], + estimates:{...serverToday.estimates,[body.identity]:serverWeek.days[0].estimates[body.identity]}}; + serverWeek={...serverWeek,revision:serverWeek.revision+1,days:serverWeek.days.map(day=>({...day, + ids:day.ids.filter(id=>id!==body.identity),estimates:Object.fromEntries(Object.entries(day.estimates).filter(([id])=>id!==body.identity))}))}; + return {today:serverToday,week:serverWeek}; +}; +const options={storage,getLogin:()=> 'Timmy',fetchJson,localDate:()=> '2026-08-20',timeZone:()=> 'UTC'}; +const first=createWeekPlan(options);first.adopt(serverWeek); +const queuedFirst=await first.pullItem('first',serverToday,'pull-first'); +const queuedSecond=await first.pullItem('second',queuedFirst.today,'pull-second'); +const restored=createWeekPlan(options),resumed=restored.resumePull(); +offline=false; +const confirmed=await restored.flushPull(); +console.log(JSON.stringify({queuedFirst,queuedSecond,resumed,confirmed,delivered,keys:[...values.keys()],state:restored.state()})); +""") + + assert result["queuedFirst"]["today"]["ids"] == ["active", "first"] + assert result["queuedSecond"]["today"]["ids"] == ["active", "first", "second"] + assert result["resumed"]["today"]["ids"] == ["active", "first", "second"] + assert [request["operation_id"] for request in result["delivered"]] == ["pull-first", "pull-second"] + assert result["delivered"][1]["today_revision"] == 5 + assert result["delivered"][1]["week_revision"] == 8 + assert result["confirmed"]["today"]["ids"] == ["active", "first", "second"] + assert result["confirmed"]["week"]["days"][0]["ids"] == ["later"] + assert result["confirmed"]["sync_pending"] is False + assert result["keys"] == ["stackchain.week-confirmed.v1.timmy"] + assert result["state"]["revision"] == 9 + + +def test_week_controller_resumes_server_truth_when_a_fifo_head_conflicts(): + 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 offline=true,posts=0; +const remoteToday={revision:10,ids:['server-today'],capacity_minutes:180,estimates:{'server-today':30}}; +const remoteWeek={revision:12,timezone:'UTC',days:[{plan_date:'2026-08-21',ids:['second','third','server-week'],capacity_minutes:180,estimates:{second:40,third:45,'server-week':25}}]}; +const fetchJson=async(url,options={})=>{ + if(offline)throw new Error('connection lost'); + if(!options.method)return url.endsWith('/today')?remoteToday:remoteWeek; + posts+=1; + if(posts===1)return {today:{revision:5,ids:['active','first'],capacity_minutes:180,estimates:{active:30,first:35}}, + week:{revision:8,timezone:'UTC',days:[{plan_date:'2026-08-21',ids:['second','third'],capacity_minutes:180,estimates:{second:40,third:45}}]}}; + const error=new Error('changed');error.status=409;throw error; +}; +const options={storage,getLogin:()=> 'timmy',fetchJson,localDate:()=> '2026-08-20',timeZone:()=> 'UTC'}; +const first=createWeekPlan(options);first.adopt({revision:7,timezone:'UTC',days:[{plan_date:'2026-08-21',ids:['first','second','third'],capacity_minutes:180,estimates:{first:35,second:40,third:45}}]}); +let today={revision:4,ids:['active'],capacity_minutes:180,estimates:{active:30}}; +today=(await first.pullItem('first',today,'pull-first')).today; +today=(await first.pullItem('second',today,'pull-second')).today; +await first.pullItem('third',today,'pull-third'); +offline=false;const conflicted=await first.flushPull(); +const restored=createWeekPlan(options),resumed=restored.resumePull(); +console.log(JSON.stringify({conflicted,resumed,state:restored.state(),pending:restored.pendingPull()})); +""") + + assert result["conflicted"]["conflict"] is True + assert result["resumed"]["conflict"] is True + assert result["resumed"]["today"]["ids"] == ["server-today"] + assert result["state"]["revision"] == 12 + assert result["state"]["days"][0]["ids"] == ["second", "third", "server-week"] + assert result["pending"]["body"]["operation_id"] == "pull-second" + + def test_week_controller_preserves_a_conflicted_pull_intent_and_adopts_server_truth(): result = run_controller(""" const values=new Map(),requests=[];