feat: recover conflicted Week-to-Today transfers (Closes #1254)
CI / lint (pull_request) Successful in 3m45s
CI / build-release (pull_request) Successful in 8s
CI / browser-journey (pull_request) Successful in 5m22s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy committed 2026-08-22 07:06:33 +00:00
1 parent 24cadf88f8
commit 6e72f59fcf
4 files changed
+244 -5

No files matched your search

+6
View File
@@ -329,6 +329,12 @@ textarea { resize: vertical; min-height: 120px; }
.week-conflict-plans { grid-template-columns:1fr; }
.week-conflict-actions { flex-direction:column; }
}
.week-pull-conflict-review { min-width:0; margin-top:14px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); overflow-wrap:anywhere; }
.week-pull-conflict-item { margin-bottom:6px; color:#eff6ff; font-weight:700; }
.week-pull-conflict-actions { display:flex; gap:8px; margin-top:14px; }
.week-pull-conflict-actions button { min-height:44px; flex:1 1 0; }
.week-pull-conflict-status { min-height:1.4em; margin-top:8px; }
@media (max-width:480px) { .week-pull-conflict-actions { flex-direction:column; } }
.week-review { margin-top:14px; }
.week-review h2 { margin-bottom:6px; }
.week-review-days { display:grid; gap:12px; }
+11
View File
@@ -505,6 +505,17 @@
</div>
<div class="small week-conflict-status" id="week-conflict-status" role="status" aria-live="assertive"></div>
</section>
<section class="week-pull-conflict-review" id="week-pull-conflict-review" aria-labelledby="week-pull-conflict-title" tabindex="-1" hidden>
<div class="small">Saved transfer needs review</div>
<h3 id="week-pull-conflict-title">Resolve Today transfer</h3>
<p id="week-pull-conflict-item" class="week-pull-conflict-item"></p>
<p id="week-pull-conflict-context" class="small muted"></p>
<div class="week-pull-conflict-actions">
<button id="rebase-week-pull" type="button">Add using current plans</button>
<button id="keep-week-pull" type="button">Keep in Week Ahead</button>
</div>
<div class="small week-pull-conflict-status" id="week-pull-conflict-status" role="status" aria-live="assertive"></div>
</section>
<section class="week-review" id="week-review" aria-labelledby="week-review-title" hidden>
<div class="small">Seven-day overview</div>
<h2 id="week-review-title">Week Ahead</h2>
+89 -5
View File
@@ -527,6 +527,46 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin,coordinat
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 pullConflict() {
const queue=pullQueue(),entry=queue[0];
if(!entry?.conflict||!entry.current)return null;
const identity=entry.body.identity,today=entry.current.today;
const source=(entry.current.week.days||[]).find(day=>(day.ids||[]).includes(identity));
return {identity,title:(pendingItems[identity]||confirmedItems[identity])?.title||identity,
source_date:source?.plan_date||null,today_items:(today.ids||[]).length,
today_minutes:(today.ids||[]).reduce((total,id)=>total+(Number(today.estimates?.[id])||0),0),
today_capacity:Number(today.capacity_minutes)||0,operation_id:entry.body.operation_id,queue_length:queue.length};
}
async function resolvePullConflict(action) {
const queue=pullQueue(),current=queue[0];
if(!current?.conflict||!current.current||!['add','keep'].includes(action))return false;
const latest=current.current,identity=current.body.identity;
const alreadyToday=(latest.today.ids||[]).includes(identity);
const stillInWeek=(latest.week.days||[]).some(day=>(day.ids||[]).includes(identity));
let remaining=queue.slice(1);
if(action==='add'&&!alreadyToday&&stillInWeek){
if((latest.today.ids||[]).length>=5){const error=new Error('Today is full.');error.code='today_full';throw error;}
const source=latest.week.days.find(day=>(day.ids||[]).includes(identity));
const estimate=Number(source?.estimates?.[identity])||0;
const minutes=(latest.today.ids||[]).reduce((total,id)=>total+(Number(latest.today.estimates?.[id])||0),0);
const capacity=Number(latest.today.capacity_minutes)||0;
if(capacity&&minutes+estimate>capacity&&!current.body.allow_over_capacity){
const error=new Error('Today would exceed its current capacity.');error.code='today_over_capacity';throw error;
}
const rebased={...current,conflict:false,current:undefined,body:{...current.body,
today_revision:latest.today.revision,week_revision:latest.week.revision}};
remaining=[rebased,...remaining];
}
if(remaining.length){
const head=remaining[0];
remaining[0]={...head,conflict:false,current:undefined,body:{...head.body,
today_revision:latest.today.revision,week_revision:latest.week.revision}};
}
if(!writePullQueue(remaining))throw new Error('Could not update queued Week Ahead work on this device.');
adopt(latest.week);
if(!remaining.length)return {...latest,sync_pending:false,resolved:alreadyToday?'already-moved':(!stillInWeek?'no-longer-planned':'kept')};
return flushPull();
}
function flushPull() {
if(pulling)return pulling;
if(!pendingPull()||!pullKey())return Promise.resolve(false);
@@ -599,7 +639,7 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin,coordinat
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,pendingPull,resumePull,flushPull,startEarly,reconcile:reconcilePromotion,summary,rememberItems,rememberPendingItem,
keepLocal,useRemote,promote,pullItem,pendingPull,resumePull,pullConflict,resolvePullConflict,flushPull,startEarly,reconcile:reconcilePromotion,summary,rememberItems,rememberPendingItem,
item:id=>pendingItems[id]||confirmedItems[id]||null,
offline:()=>offlineSnapshot,request:fetchJson,reschedule:()=>({storage,getLogin})};
}
@@ -1013,19 +1053,63 @@ function createWeekPlanWorkflow({controller,qs,getItem=()=>null,openItem,openPla
qs('#my-work-action-status').textContent=message;
return value;
}
function renderPullConflict({open=false}={}) {
const detail=controller.pullConflict?.(),root=qs('#week-pull-conflict-review');
if(!root)return false;
root.hidden=!detail;
if(!detail)return false;
const item=qs('#week-pull-conflict-item'),context=qs('#week-pull-conflict-context');
if(item)item.textContent=detail.title;
if(context)context.textContent=(detail.source_date?'Planned for '+detail.source_date+' · ':'')+
'Today has '+detail.today_items+' item'+(detail.today_items===1?'':'s')+' · '+detail.today_minutes+
(detail.today_capacity?' of '+detail.today_capacity:'')+' min'+(detail.queue_length>1?' · '+detail.queue_length+' saved transfers waiting':'')+'.';
if(open){openPlanner(null,false);const title=qs('#plan-today-title');if(title)title.textContent='Resolve Today transfer';}
root.focus?.();
return detail;
}
async function resolvePullConflict(action) {
const status=qs('#week-pull-conflict-status'),add=qs('#rebase-week-pull'),keep=qs('#keep-week-pull');
if(add)add.disabled=true;if(keep)keep.disabled=true;
if(status)status.textContent=action==='add'?'Checking current plans…':'Keeping this work in Week Ahead…';
try{
const result=await controller.resolvePullConflict?.(action);
if(!result)return false;
adoptPull(result,action==='add'?'Saved transfer added using current plans.':'Work kept in Week Ahead.');
if(controller.pullConflict?.())return renderPullConflict();
const root=qs('#week-pull-conflict-review');if(root)root.hidden=true;
overviewing=true;reviewing=true;setReviewMode(true);
const reviewRoot=qs('#week-review');if(reviewRoot){reviewRoot.hidden=false;renderReview();}
return result;
}catch(error){
if(status)status.textContent=error?.code==='today_full'?'Today is full. Finish or move work, then retry.':
(error?.code==='today_over_capacity'?'Today would exceed its current capacity. Reopen Week Ahead and review Today first.':
(error.message||'Current plans could not be checked.')+' Your saved transfer is still waiting.');
return false;
}finally{if(add)add.disabled=false;if(keep)keep.disabled=false;}
}
qs('#rebase-week-pull')?.addEventListener('click',()=>controller.resolvePullConflict&&resolvePullConflict('add'));
qs('#keep-week-pull')?.addEventListener('click',()=>controller.resolvePullConflict&&resolvePullConflict('keep'));
function resumePull() {
const restored=controller.resumePull?.();
return restored?adoptPull(restored,'Week-to-Today pull saved on this phone · sync pending.'):false;
if(!restored)return false;
const adopted=adoptPull(restored,restored.conflict?'Today or Week Ahead changed. Choose how to finish the saved transfer.':
'Week-to-Today pull saved on this phone · sync pending.');
if(restored.conflict)renderPullConflict({open:true});
return adopted;
}
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.');
if(pendingPull.conflict){
const restored=adoptPull(pendingPull.current,'Today or Week Ahead changed on another device. Choose how to finish the saved transfer.');
renderPullConflict({open:true});return restored;
}
try {
const result=await controller.flushPull();
return adoptPull(result,result.conflict?'Today or Week Ahead changed on another device. Saved pull needs review.':
const adopted=adoptPull(result,result.conflict?'Today or Week Ahead changed on another device. Choose how to finish the saved transfer.':
'Week Ahead work added to Today.');
if(result.conflict)renderPullConflict({open:true});
return adopted;
} catch(error) {
qs('#my-work-action-status').textContent=(error.message||'Week-to-Today sync is unavailable.')+' Saved on this phone · sync pending.';
return false;