From c7d38593ddc6c07bf2a7bef7d2039485c42a00eb Mon Sep 17 00:00:00 2001 From: timmy Date: Sat, 22 Aug 2026 02:41:29 +0000 Subject: [PATCH] feat: keep Week-to-Today pulls through reloads (Closes #1246) --- frontend/dashboard.js | 4 +- frontend/week-plan.js | 109 ++++++++++++++++++++++++++++--- tests/test_week_plan_frontend.py | 76 +++++++++++++++++++++ 3 files changed, 179 insertions(+), 10 deletions(-) diff --git a/frontend/dashboard.js b/frontend/dashboard.js index b20b762..c50e320 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -398,6 +398,7 @@ qs('#mobile-tomorrow-summary').textContent = value ? tomorrowPlan.summary(value) : tomorrowPlan.summary(); } function syncPendingTomorrow() { + weekFlow.resumePull()&&weekFlow.flushPull(); if (!tomorrowPlan.pending()) return Promise.resolve(false); return tomorrowPlan.flush().then(saved => { renderTomorrowQueueSummary(saved); @@ -410,7 +411,7 @@ if (conflict) renderTomorrowQueueSummary({ids:[],sync_pending:false,conflict:true}); qs('#mobile-tomorrow-summary').textContent = conflict ? 'Conflict · review required' : qs('#mobile-tomorrow-summary').textContent; qs('#my-work-action-status').textContent = conflict ? - 'Another device changed Tomorrow. Both plans are preserved; open Tomorrow to choose one.' : + 'Another device changed Tomorrow. Review it.' : `${error.message || 'Tomorrow sync is unavailable.'} Saved on this phone · sync pending.`; return false; }); @@ -7733,6 +7734,7 @@ planningOwnerLogin = confirmedOwnerLogin; interruptionPrompt.restore(); updatePlanningAvailability(); + syncPendingTomorrow(); saved.notifications = notificationReadOutbox.suppress(saved.notifications || []); lastNotifications = saved.notifications; notificationPagination = saved.notification_pagination || { page:1, total:lastNotifications.length, has_more:false }; diff --git a/frontend/week-plan.js b/frontend/week-plan.js index e2f41a1..9389294 100644 --- a/frontend/week-plan.js +++ b/frontend/week-plan.js @@ -6,8 +6,10 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin,now=()=>D let refreshedAt=null; let confirmedItems={}; let pendingItems={}; + let pulling=null; const storagePrefix='stackchain.week-sync.v1.'; const confirmedPrefix='stackchain.week-confirmed.v1.'; + const pullPrefix='stackchain.week-today-pull.v1.'; const cloneDay=day=>({ plan_date:day.plan_date,ids:[...(day.ids||[])],capacity_minutes:day.capacity_minutes??null, estimates:{...(day.estimates||{})}, @@ -33,6 +35,25 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin,now=()=>D const login=String(getLogin?.()||'').trim().toLowerCase(); return login?confirmedPrefix+encodeURIComponent(login):''; } + function pullKey() { + const login=String(getLogin?.()||'').trim().toLowerCase(); + return login?pullPrefix+encodeURIComponent(login):''; + } + function pendingPull() { + const key=pullKey(); + if(!key||!storage)return null; + 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;} + } + function resumePull() { + const value=pendingPull(); + if(!value)return null; + adopt(value.week); + return {...value,sync_pending:true}; + } function readConfirmed() { const key=confirmedKey(); if(!key||!storage)return null; @@ -467,12 +488,54 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin,now=()=>D async function pullItem(identity,today,operationId,allowOverCapacity=false) { if(offlineSnapshot||pending()||conflict()||!identity||!operationId|| !Number.isInteger(today?.revision)||!(today.ids||[]).length)return false; - const result=await fetchJson('api/v1/week/pull-item',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({ - operation_id:operationId,identity,today_revision:today.revision,week_revision:week.revision, - allow_over_capacity:Boolean(allowOverCapacity), - })}); - adoptConfirmed(result.week,{...confirmedItems,...pendingItems}); - return result; + const body={operation_id:operationId,identity,today_revision:today.revision,week_revision:week.revision, + allow_over_capacity:Boolean(allowOverCapacity)}; + const key=pullKey(); + if(key&&storage){ + const source=week.days.find(day=>(day.ids||[]).includes(identity)); + if(!source)return false; + const estimate=Number(source.estimates?.[identity]); + const optimisticToday={...today,ids:[...(today.ids||[]),identity],estimates:{...(today.estimates||{}), + ...(Number.isFinite(estimate)?{[identity]:estimate}:{})}}; + const optimisticWeek={revision:week.revision,timezone:week.timezone,days:week.days.map(day=>{ + const estimates={...(day.estimates||{})};delete estimates[identity]; + 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.');} + adopt(optimisticWeek); + try{return await flushPull();} + catch(error){ + if(error?.status===401||error?.status===403)throw error; + return {...record,sync_pending:true}; + } + } + const result=await fetchJson('api/v1/week/pull-item',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)}); + adoptConfirmed(result.week,{...confirmedItems,...pendingItems});return result; + } + function flushPull() { + if(pulling)return pulling; + 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}; + } + 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())}; + })().finally(()=>{pulling=null;}); + return pulling; } async function startEarly(planDate,todayRevision) { if(offlineSnapshot||pending()||conflict()||!Number.isInteger(todayRevision))return false; @@ -504,7 +567,7 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin,now=()=>D return label+(pending()?' · sync pending':''); } return {adopt,state,dates,day,pass,load,saveDay,stageDay,stageCapacities,review,previewReflow,applyReflow,move,retire,unplan,restore,placement,place,pending,flush,conflict,chooseDay,saveMerged, - keepLocal,useRemote,promote,pullItem,startEarly,reconcile:reconcilePromotion,summary,rememberItems,rememberPendingItem, + keepLocal,useRemote,promote,pullItem,pendingPull,resumePull,flushPull,startEarly,reconcile:reconcilePromotion,summary,rememberItems,rememberPendingItem, item:id=>pendingItems[id]||confirmedItems[id]||null, offline:()=>offlineSnapshot,request:fetchJson,reschedule:()=>({storage,getLogin})}; } @@ -768,7 +831,8 @@ function createWeekPlanWorkflow({controller,qs,getItem=()=>null,openItem,openPla todayWork.replacePlanning({capacity_minutes:result.today.capacity_minutes??null,estimates:result.today.estimates||{}}); refresh();warm();renderReview(); qs('#mobile-week-summary').textContent=controller.summary(); - qs('#week-review-status').textContent=String(item?.title||'Work')+' added to Today.'; + qs('#week-review-status').textContent=result.sync_pending?String(item?.title||'Work')+' added to Today · sync pending.': + String(item?.title||'Work')+' added to Today.'; }catch(error){ renderReview(); qs('#week-review-status').textContent=error?.code==='today_full'?'Today is full. Finish or move work before adding more.': @@ -908,6 +972,33 @@ function createWeekPlanWorkflow({controller,qs,getItem=()=>null,openItem,openPla return 'pending'; } } + function adoptPull(value,message) { + if(!value?.today)return false; + t(value.today);todayWork.replace(value.today.ids); + todayWork.replacePlanning({capacity_minutes:value.today.capacity_minutes??null,estimates:value.today.estimates||{}}); + refresh();warm(); + qs('#mobile-week-summary').textContent=controller.summary(); + qs('#my-work-action-status').textContent=message; + return value; + } + function resumePull() { + const restored=controller.resumePull?.(); + return restored?adoptPull(restored,'Week-to-Today pull saved on this phone · sync pending.'):false; + } + async function flushPull() { + const pendingPull=controller.pendingPull?.(); + if(!pendingPull)return false; + if(pendingPull.conflict)return adoptPull(pendingPull.current, + 'Today or Week Ahead changed on another device. Saved pull needs review.'); + try { + const result=await controller.flushPull(); + return adoptPull(result,result.conflict?'Today or Week Ahead changed on another device. Saved pull needs review.': + 'Week Ahead work added to Today.'); + } catch(error) { + qs('#my-work-action-status').textContent=(error.message||'Week-to-Today sync is unavailable.')+' Saved on this phone · sync pending.'; + return false; + } + } function finish(){reviewing=false;overviewing=false;setReviewMode(false);closeReflow();qs('#week-review').hidden=true;return true;} qs('#back-to-week-review')?.addEventListener('click',returnToReview); qs('#edit-week-plan')?.addEventListener('click',editWeek); @@ -928,7 +1019,7 @@ function createWeekPlanWorkflow({controller,qs,getItem=()=>null,openItem,openPla qs('#my-work-action-status').textContent=(error.message||'Week Ahead needs review before promotion.')+' Open Week Ahead to review.';return false; } } - const workflow={open,save,advance,confirm,finish,promote,retire,editWeek,renderDates,renderReview,renderConflict,active:()=>Boolean(selectedDate)||reviewing||Boolean(reconciliation), + const workflow={open,save,advance,confirm,finish,promote,retire,resumePull,flushPull,editWeek,renderDates,renderReview,renderConflict,active:()=>Boolean(selectedDate)||reviewing||Boolean(reconciliation), reviewing:()=>reviewing,overviewing:()=>overviewing,selectedDate:()=>selectedDate, day:()=>reconciliationDay()||(selectedDate?controller.day(selectedDate):(overviewing?{ids:[]}:null)), copy:()=>reconciliation?{title:"Start today's plan",heading:'Unfinished Today + due Week Ahead',available:'Available today',build:'Build combined Today'}: diff --git a/tests/test_week_plan_frontend.py b/tests/test_week_plan_frontend.py index 2936616..00ab6cd 100644 --- a/tests/test_week_plan_frontend.py +++ b/tests/test_week_plan_frontend.py @@ -326,6 +326,68 @@ console.log(JSON.stringify({pulled,requests,state:week.state()})); assert result["state"]["days"][0]["ids"] == ["sibling"] +def test_week_controller_replays_a_lost_pull_receipt_with_the_same_operation_after_reload(): + result = run_controller(""" +const values=new Map(),requests=[]; +const storage={getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)}; +let loseResponse=true; +const fetchJson=async(url,options={})=>{ + const body=JSON.parse(options.body);requests.push(body); + if(loseResponse){loseResponse=false;throw new Error('connection lost');} + return {today:{revision:5,ids:['active',body.identity],capacity_minutes:120,estimates:{active:30,[body.identity]:45}}, + week:{revision:8,timezone:'UTC',days:[{plan_date:'2026-08-21',ids:['sibling'],capacity_minutes:90,estimates:{sibling:30}}]}}; +}; +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:['pull','sibling'],capacity_minutes:90,estimates:{pull:45,sibling:30}}]}); +const pending=await first.pullItem('pull',{revision:4,ids:['active'],capacity_minutes:120,estimates:{active:30}},'stable-pull'); +const restored=createWeekPlan(options),resume=restored.resumePull(); +const confirmed=await restored.flushPull(); +console.log(JSON.stringify({pending,resume,confirmed,requests,keys:[...values.keys()],state:restored.state()})); +""") + + assert result["pending"]["sync_pending"] is True + assert result["pending"]["today"]["ids"] == ["active", "pull"] + assert result["pending"]["week"]["days"][0]["ids"] == ["sibling"] + assert result["resume"]["sync_pending"] is True + assert result["resume"]["today"] == result["pending"]["today"] + assert [request["operation_id"] for request in result["requests"]] == ["stable-pull", "stable-pull"] + assert result["confirmed"]["sync_pending"] is False + assert result["confirmed"]["today"]["revision"] == 5 + assert result["keys"] == ["stackchain.week-confirmed.v1.timmy"] + assert result["state"]["revision"] == 8 + + +def test_week_controller_preserves_a_conflicted_pull_intent_and_adopts_server_truth(): + result = run_controller(""" +const values=new Map(),requests=[]; +const storage={getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)}; +const remoteToday={revision:9,ids:['newer'],capacity_minutes:90,estimates:{newer:30}}; +const remoteWeek={revision:12,timezone:'UTC',days:[{plan_date:'2026-08-21',ids:['pull','server'],capacity_minutes:90,estimates:{pull:45,server:30}}]}; +const fetchJson=async(url,options={})=>{ + requests.push({url,method:options.method||'GET'}); + if(options.method){const error=new Error('changed');error.status=409;throw error;} + return url.endsWith('/today')?remoteToday:remoteWeek; +}; +const week=createWeekPlan({storage,getLogin:()=> 'timmy',fetchJson,localDate:()=> '2026-08-20',timeZone:()=> 'UTC'}); +week.adopt({revision:7,timezone:'UTC',days:[{plan_date:'2026-08-21',ids:['pull'],capacity_minutes:90,estimates:{pull:45}}]}); +const outcome=await week.pullItem('pull',{revision:4,ids:['active'],capacity_minutes:90,estimates:{active:30}},'conflicted-pull'); +console.log(JSON.stringify({outcome,state:week.state(),pending:week.pendingPull(),requests})); +""") + + assert result["outcome"]["conflict"] is True + assert result["outcome"]["today"]["ids"] == ["newer"] + assert result["state"]["revision"] == 12 + assert result["state"]["days"][0]["ids"] == ["pull", "server"] + assert result["pending"]["body"]["operation_id"] == "conflicted-pull" + assert result["pending"]["conflict"] is True + assert result["requests"] == [ + {"url": "api/v1/week/pull-item", "method": "POST"}, + {"url": "api/v1/today", "method": "GET"}, + {"url": "api/v1/week", "method": "GET"}, + ] + + def test_week_controller_retires_completed_work_from_every_day_in_one_durable_transition(): result = run_controller(""" const values=new Map();let writes=0; @@ -761,6 +823,20 @@ def test_dashboard_routes_week_overview_controls_through_existing_detail_flow_wi assert "width:100%" in pull_rule +def test_dashboard_restores_and_retries_a_saved_week_to_today_pull_on_mobile_lifecycle(): + dashboard = (FRONTEND / "dashboard.js").read_text() + week_plan = (FRONTEND / "week-plan.js").read_text() + + recovery = dashboard.split(" function syncPendingTomorrow()", 1)[1].split("\n }", 1)[0] + assert "weekFlow.resumePull()" in recovery + assert "weekFlow.flushPull()" in recovery + + lifecycle = dashboard.split("tomorrowPlan.startLifecycle({", 1)[1].split(" });", 1)[0] + assert "syncPendingTomorrow()" in lifecycle + assert dashboard.count("syncPendingTomorrow();") >= 2 + assert "result.sync_pending?String(item?.title||'Work')+' added to Today · sync pending.'" in week_plan + + def test_successful_issue_close_notifies_week_ahead_only_after_upstream_confirmation(): issue_sheet = (FRONTEND / "issue-sheet.js").read_text() week_plan = (FRONTEND / "week-plan.js").read_text() -- 2.43.0