feat: keep Week-to-Today pulls through reloads (Closes #1246)
CI / lint (pull_request) Successful in 3m7s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 5m2s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy committed 2026-08-22 02:41:29 +00:00
1 parent a13a13d90c
commit c7d38593dd
3 files changed
+179 -10

No files matched your search

+3 -1
View File
@@ -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 };
+100 -9
View File
@@ -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'}: