feat: reflow overloaded Week Ahead plans (Closes #1226)
This commit is contained in:
1 parent
b7dd9757aa
commit
ffba6f1212
6 files changed
+306
-9
No files matched your search
@@ -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; }
|
||||
|
||||
@@ -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
@@ -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;}
|
||||
|
||||
Reference in new issue
Block a user