From 4a6a8294017019d7c0d286d7413850c97cc4c4d6 Mon Sep 17 00:00:00 2001 From: timmy Date: Sat, 22 Aug 2026 04:25:01 +0000 Subject: [PATCH] fix: serialize Week-to-Today replay across tabs --- frontend/dashboard.js | 2 +- frontend/week-plan.js | 8 ++++--- tests/test_week_plan_frontend.py | 37 ++++++++++++++++++++++++++++++++ 3 files changed, 43 insertions(+), 4 deletions(-) diff --git a/frontend/dashboard.js b/frontend/dashboard.js index c50e320..c62144b 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -391,7 +391,7 @@ }); const weekPlan = createWeekPlan({ fetchJson:fetchReviewJson,localDate:todayRollover.localDate,timeZone:todayRollover.timeZone, - storage:localStorage,getLogin:() => planningOwnerLogin, + storage:localStorage,getLogin:() => planningOwnerLogin,coordinator:outboxCoordinator, }); function openWeekPlanner(trigger) { planningTomorrow=false; return weekFlow.open(trigger); } function renderTomorrowQueueSummary(value) { diff --git a/frontend/week-plan.js b/frontend/week-plan.js index c083c89..0887abe 100644 --- a/frontend/week-plan.js +++ b/frontend/week-plan.js @@ -1,4 +1,4 @@ -function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin,now=()=>Date.now()}={}) { +function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin,coordinator,now=()=>Date.now()}={}) { let week={revision:0,timezone:null,days:[]}; let flushing=null; let lastConflict=null; @@ -530,7 +530,7 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin,now=()=>D function flushPull() { if(pulling)return pulling; if(!pendingPull()||!pullKey())return Promise.resolve(false); - pulling=(async()=>{ + const run=async()=>{ let result,lastResult=false; while(pendingPull()){ const current=pendingPull(); @@ -564,7 +564,9 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin,now=()=>D } adoptConfirmed(lastResult.week,{...confirmedItems,...pendingItems}); return {...lastResult,sync_pending:false}; - })().finally(()=>{pulling=null;}); + }; + const delivery=coordinator?coordinator.runExclusive('week',run):run(); + pulling=Promise.resolve(delivery).finally(()=>{pulling=null;}); return pulling; } async function startEarly(planDate,todayRevision) { diff --git a/tests/test_week_plan_frontend.py b/tests/test_week_plan_frontend.py index 71c763c..a08068a 100644 --- a/tests/test_week_plan_frontend.py +++ b/tests/test_week_plan_frontend.py @@ -398,6 +398,43 @@ console.log(JSON.stringify({queuedFirst,queuedSecond,resumed,confirmed,delivered assert result["state"]["revision"] == 9 +def test_week_controller_allows_only_one_tab_to_replay_the_shared_pull_queue(): + result = run_controller(""" +const values=new Map(),held=new Set(); +const storage={getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)}; +const coordinator={runExclusive:async(queue,work)=>{ + if(held.has(queue))return {lease_skipped:true}; + held.add(queue);try{return await work();}finally{held.delete(queue);} +}}; +let offline=true,posts=0,release; +const gate=new Promise(resolve=>release=resolve); +const fetchJson=async(_url,options={})=>{ + if(offline)throw new Error('connection lost'); + posts+=1;await gate; + const body=JSON.parse(options.body); + return {today:{revision:5,ids:['active',body.identity],capacity_minutes:180,estimates:{active:30,[body.identity]:35}}, + week:{revision:8,timezone:'UTC',days:[{plan_date:'2026-08-21',ids:[],capacity_minutes:150,estimates:{}}]}}; +}; +const options={storage,coordinator,getLogin:()=> 'timmy',fetchJson,localDate:()=> '2026-08-20',timeZone:()=> 'UTC'}; +const source=createWeekPlan(options);source.adopt({revision:7,timezone:'UTC',days:[{plan_date:'2026-08-21',ids:['first'],capacity_minutes:150,estimates:{first:35}}]}); +await source.pullItem('first',{revision:4,ids:['active'],capacity_minutes:180,estimates:{active:30}},'pull-first'); +offline=false; +const first=createWeekPlan(options),second=createWeekPlan(options); +const firstFlush=first.flushPull(); +await Promise.resolve(); +setImmediate(release); +const skipped=await second.flushPull(); +release(); +const confirmed=await firstFlush; +console.log(JSON.stringify({posts,skipped,confirmed,pending:first.pendingPull()})); +""") + + assert result["posts"] == 1 + assert result["skipped"]["lease_skipped"] is True + assert result["confirmed"]["sync_pending"] is False + assert result["pending"] is None + + def test_week_controller_resumes_server_truth_when_a_fifo_head_conflicts(): result = run_controller(""" const values=new Map();