feat: reflow overloaded Week Ahead plans (Closes #1226)
CI / lint (pull_request) Successful in 3m55s
CI / build-release (pull_request) Successful in 8s
CI / browser-journey (pull_request) Successful in 5m47s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy committed 2026-08-21 15:34:31 +00:00
1 parent b7dd9757aa
commit ffba6f1212
6 files changed
+306 -9

No files matched your search

+8
View File
@@ -348,6 +348,14 @@ textarea { resize: vertical; min-height: 120px; }
#confirm-week-plan { width:100%; min-height:48px; position:sticky; bottom:0; }
#edit-week-plan { width:100%; min-height:48px; position:sticky; bottom:0; }
#open-week-capacity-import { width:100%; min-height:44px; margin:8px 0 12px; }
#open-week-reflow { width:100%; min-height:48px; margin:0 0 12px; border-color:#60a5fa; }
.week-reflow-review { box-sizing:border-box; width:100%; margin:0 0 14px; padding:14px; border:1px solid #60a5fa; border-radius:12px; background:#10233d; overflow-x:hidden; }
.week-reflow-review h3 { margin:.25rem 0; }
.week-reflow-days { display:grid; gap:6px; margin:12px 0; }
.week-reflow-day { display:flex; justify-content:space-between; gap:10px; min-width:0; padding:8px; border-radius:8px; background:#0b1526; overflow-wrap:anywhere; }
.week-reflow-unscheduled { min-height:1.4em; color:#fde68a; overflow-wrap:anywhere; }
.week-reflow-actions { display:grid; grid-template-columns:1fr 2fr; gap:8px; margin-top:12px; }
.week-reflow-actions button { min-width:0; min-height:48px; }
.week-capacity-import { position:fixed; z-index:121; inset:0; box-sizing:border-box; width:100%; max-width:560px; margin-inline:auto; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); background:#0b1526; overflow:auto; overflow-x:hidden; }
.week-capacity-import[hidden] { display:none; }
.week-capacity-import > header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; }
+12
View File
@@ -512,6 +512,18 @@
<p class="week-offline-snapshot" id="week-offline-snapshot" role="status" aria-live="polite" hidden></p>
<button id="retry-week-live" type="button" hidden>Retry live Week Ahead</button>
<button id="open-week-capacity-import" type="button">Set capacity from calendar</button>
<button id="open-week-reflow" type="button" hidden>Reflow remaining week</button>
<section class="week-reflow-review" id="week-reflow-review" aria-labelledby="week-reflow-title" hidden>
<div class="small">Capacity-safe preview</div>
<h3 id="week-reflow-title">Reflow remaining week?</h3>
<p class="small muted">Work keeps its current order. Anything that cannot fit stays unscheduled in My Work.</p>
<div class="week-reflow-days" id="week-reflow-days"></div>
<div class="week-reflow-unscheduled" id="week-reflow-unscheduled" role="status"></div>
<div class="week-reflow-actions">
<button id="cancel-week-reflow" type="button">Cancel</button>
<button id="apply-week-reflow" type="button">Apply reflow</button>
</div>
</section>
<div class="week-review-duplicates" id="week-review-duplicates" role="alert" hidden></div>
<div class="week-review-days" id="week-review-days"></div>
<div class="small week-review-status" id="week-review-status" role="status" aria-live="polite"></div>
+82 -2
View File
@@ -193,6 +193,36 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin,now=()=>D
['over-capacity','missing-estimate','missing-capacity'].indexOf(left.type)-['over-capacity','missing-estimate','missing-capacity'].indexOf(right.type));
return {days,duplicates,blockers,can_confirm:duplicates.length===0&&blockers.length===0};
}
function previewReflow() {
const available=dates().map(item=>day(item.date));
const seen=new Set(),work=[];
available.forEach(source=>(source.ids||[]).forEach(id=>{
if(seen.has(id))return;
seen.add(id);
const estimate=Number(source.estimates?.[id]);
work.push({id,estimate:Number.isFinite(estimate)?estimate:null,from_date:source.plan_date});
}));
const blockers=work.filter(item=>!(item.estimate>0)).map(item=>({
type:'missing-estimate',id:item.id,plan_date:item.from_date,
}));
if(blockers.length)return {days:[],blockers,unscheduled:[]};
const days=available.map(source=>({plan_date:source.plan_date,ids:[],
capacity_minutes:source.capacity_minutes??null,estimates:{}}));
const unscheduled=[];let cursor=0;
if(!blockers.length)work.forEach(item=>{
const destination=days.slice(cursor).find(value=>value.ids.length<5&&Number(value.capacity_minutes)>0&&
value.ids.reduce((total,id)=>total+Number(value.estimates[id]),0)+item.estimate<=Number(value.capacity_minutes));
if(!destination){unscheduled.push({...item});return;}
cursor=days.indexOf(destination);
destination.ids.push(item.id);destination.estimates[item.id]=item.estimate;
});
return {days,blockers,unscheduled};
}
function applyReflow() {
const preview=previewReflow();
if(preview.blockers.length||offlineSnapshot)return false;
return stageDays(preview.days);
}
function move(id,toDate) {
if(!dates().some(item=>item.date===toDate)) return false;
const sources=week.days.filter(item=>(item.ids||[]).includes(id));
@@ -435,7 +465,7 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin,now=()=>D
const label=planned.length?`${items} item${items===1?'':'s'} across ${planned.length} day${planned.length===1?'':'s'}`:'Nothing planned';
return label+(pending()?' · sync pending':'');
}
return {adopt,state,dates,day,pass,load,saveDay,stageDay,stageCapacities,review,move,placement,place,pending,flush,conflict,chooseDay,saveMerged,
return {adopt,state,dates,day,pass,load,saveDay,stageDay,stageCapacities,review,previewReflow,applyReflow,move,placement,place,pending,flush,conflict,chooseDay,saveMerged,
keepLocal,useRemote,promote,startEarly,reconcile:reconcilePromotion,summary,rememberItems,rememberPendingItem,
item:id=>pendingItems[id]||confirmedItems[id]||null,
offline:()=>offlineSnapshot};
@@ -530,6 +560,52 @@ function createWeekPlanWorkflow({controller,qs,getItem=()=>null,openItem,openPla
selectedDate=button.dataset.weekPlanDate;renderDates();openPlanner(null,false);
}));
}
function closeReflow() {
const panel=qs('#week-reflow-review');if(panel)panel.hidden=true;
const apply=qs('#apply-week-reflow');if(apply)apply.disabled=false;
return true;
}
function openReflow() {
const preview=controller.previewReflow?.();
if(!preview)return false;
const labels=new Map(controller.dates().map(item=>[item.date,item.label]));
const panel=qs('#week-reflow-review'),days=qs('#week-reflow-days'),unscheduled=qs('#week-reflow-unscheduled');
panel.hidden=false;
if(preview.blockers.length){
days.innerHTML='';
const blocker=preview.blockers[0],item=getItem(blocker.id)||controller.item?.(blocker.id);
unscheduled.textContent='Add an estimate for '+(item?.title||blocker.id)+' before reflowing.';
qs('#apply-week-reflow').disabled=true;
panel.focus?.();return preview;
}
days.innerHTML=preview.days.filter(day=>day.ids.length).map(day=>{
const minutes=day.ids.reduce((total,id)=>total+Number(day.estimates?.[id]||0),0);
return '<div class="week-reflow-day"><strong>'+escapeHtml(labels.get(day.plan_date)||day.plan_date)+
'</strong><span>'+escapeHtml(day.ids.length+' item'+(day.ids.length===1?'':'s')+' · '+minutes+' of '+Number(day.capacity_minutes||0)+' min')+'</span></div>';
}).join('')||'<p class="small muted">No work can be scheduled with the current capacities.</p>';
if(preview.unscheduled.length){
const names=preview.unscheduled.map(value=>(getItem(value.id)||controller.item?.(value.id))?.title||value.id);
unscheduled.textContent=names.length+' item'+(names.length===1?'':'s')+' cannot fit: '+names.join(', ')+'. '+
(names.length===1?'It':'They')+' will remain in My Work.';
} else unscheduled.textContent='All planned work fits within capacity.';
panel.focus?.();return preview;
}
async function applyReflow() {
const button=qs('#apply-week-reflow');button.disabled=true;
const staged=controller.applyReflow?.();
if(!staged){button.disabled=false;return false;}
closeReflow();renderReview();
try {
await controller.flush();renderReview();
qs('#mobile-week-summary').textContent=controller.summary();
qs('#week-review-status').textContent='Week Ahead reflowed and saved.';
return true;
} catch(error) {
renderReview();
qs('#week-review-status').textContent=(error.message||'Week Ahead sync is unavailable.')+' Reflow remains saved on this phone.';
return false;
}
}
function renderReview() {
const value=controller.review(),root=qs('#week-review-days'),duplicates=qs('#week-review-duplicates');
const readOnly=Boolean(controller.offline?.());
@@ -572,6 +648,7 @@ function createWeekPlanWorkflow({controller,qs,getItem=()=>null,openItem,openPla
'Add capacity for '+blocker.plan_date+' before confirming.'
):'Week Ahead is balanced and ready.')));
confirm.hidden=overviewing||readOnly;
const reflow=qs('#open-week-reflow');if(reflow)reflow.hidden=!overviewing||readOnly||pending||!value.days.some(day=>day.overloaded);
const editWeek=qs('#edit-week-plan');if(editWeek){editWeek.hidden=!overviewing||readOnly;editWeek.textContent=nextUp?'Edit week':'Plan Week Ahead';}
root.querySelectorAll('[data-week-move]').forEach(button=>button.addEventListener('click',()=>{
const selector=root.querySelector(`[data-week-move-destination="${button.dataset.weekMove}"]`);
@@ -695,9 +772,12 @@ function createWeekPlanWorkflow({controller,qs,getItem=()=>null,openItem,openPla
if(!reviewing||!value.can_confirm||controller.pending()) return false;
return true;
}
function finish(){reviewing=false;overviewing=false;setReviewMode(false);qs('#week-review').hidden=true;return true;}
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);
qs('#open-week-reflow')?.addEventListener('click',openReflow);
qs('#cancel-week-reflow')?.addEventListener('click',closeReflow);
qs('#apply-week-reflow')?.addEventListener('click',applyReflow);
qs('#retry-week-live')?.addEventListener('click',event=>open(event.currentTarget));
async function promote(plan){
try{await controller.load();const promoted=await controller.promote(plan.revision);if(!promoted)return false;blockedReviewOpen=false;todayWork.replace(promoted.ids);todayWork.replacePlanning({capacity_minutes:promoted.capacity_minutes??null,estimates:promoted.estimates||{}});refresh();warm();qs('#mobile-week-summary').textContent=controller.summary();qs('#my-work-action-status').textContent='Your saved Week Ahead plan is now Today.';return true;}