Keep Week-to-Today pulls through offline reloads #1247
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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'}:
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user