feat: refresh Week capacity with atomic reflow (Closes #1266)
This commit is contained in:
1 parent
3c35aa31c1
commit
24eb4d1e79
7 files changed
+134
-21
No files matched your search
@@ -392,6 +392,8 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.week-capacity-day { display:grid; min-width:0; gap:3px; padding:10px; border:1px solid #31577f; border-radius:10px; background:#10233a; overflow-wrap:anywhere; }
|
||||
.week-capacity-day span { color:#bfdbfe; font-weight:700; }
|
||||
.week-capacity-day small { color:#a9bdd3; }
|
||||
.week-capacity-day.is-overloaded { border-color:#f59e0b; background:#2a1c12; }
|
||||
.week-capacity-day .week-capacity-over { color:#fde68a; font-weight:700; }
|
||||
.week-free-times-consent { display:flex; align-items:flex-start; gap:10px; min-width:0; margin:12px 0; padding:10px; border:1px solid #31577f; border-radius:10px; }
|
||||
.week-free-times-consent input { flex:0 0 44px; width:44px; margin:0; }
|
||||
.week-free-times-consent span { display:grid; min-width:0; gap:4px; overflow-wrap:anywhere; }
|
||||
|
||||
+3
-3
@@ -522,7 +522,7 @@
|
||||
<p class="small muted">See what is next and check each day’s load. Edit only when you are ready.</p>
|
||||
<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-capacity-import" type="button">Refresh from calendar</button>
|
||||
<button id="open-week-availability" type="button">Set weekly availability</button>
|
||||
<section class="week-availability-editor" id="week-availability-editor" aria-labelledby="week-availability-title" hidden>
|
||||
<h3 id="week-availability-title">Weekly availability</h3>
|
||||
@@ -565,9 +565,9 @@
|
||||
<button id="confirm-week-plan" type="button">Confirm Week Ahead</button>
|
||||
</section>
|
||||
<section class="week-capacity-import" id="week-capacity-import" aria-labelledby="week-capacity-title" hidden>
|
||||
<header><div><div class="small">Private calendar import</div><h2 id="week-capacity-title">Set Week Ahead capacity</h2></div>
|
||||
<header><div><div class="small">Private calendar refresh</div><h2 id="week-capacity-title">Refresh Week Ahead availability</h2></div>
|
||||
<button id="cancel-week-capacity-import" type="button">Cancel</button></header>
|
||||
<p class="small muted">Your calendar is read only on this device. Event titles, people, locations, and the file are never saved or uploaded.</p>
|
||||
<p class="small muted">Compare refreshed availability with planned load before anything changes. Your calendar is read only on this device; event titles, people, locations, and the file are never saved or uploaded.</p>
|
||||
<label>Calendar file
|
||||
<input id="week-capacity-file" type="file" accept=".ics,text/calendar" />
|
||||
</label>
|
||||
|
||||
@@ -197,14 +197,15 @@ function createWeekCalendarImport() {
|
||||
return days;
|
||||
}
|
||||
function createWorkflow({controller,qs,onApplied=()=>{}}={}) {
|
||||
let reviewed=null,activeAvailability=null;
|
||||
let reviewed=null,activeAvailability=null,needsReflow=false,reflowPreview=null;
|
||||
const root=()=>qs('#week-capacity-import');
|
||||
function clear() {
|
||||
reviewed=null;
|
||||
reviewed=null;needsReflow=false;reflowPreview=null;
|
||||
qs('#week-capacity-file').value='';
|
||||
qs('#week-capacity-review').hidden=true;
|
||||
qs('#week-capacity-days').innerHTML='';
|
||||
qs('#apply-week-capacities').disabled=false;
|
||||
qs('#apply-week-capacities').textContent='Apply seven capacities';
|
||||
}
|
||||
function open() {
|
||||
clear();root().hidden=false;qs('#week-capacity-status').textContent='';
|
||||
@@ -215,24 +216,45 @@ function createWeekCalendarImport() {
|
||||
const display=controller.dates();
|
||||
reviewed=review(source,{dates:display.map(item=>item.date),workdayStart:qs('#week-capacity-start').value,
|
||||
workdayEnd:qs('#week-capacity-end').value});
|
||||
qs('#week-capacity-days').innerHTML=reviewed.map((day,index)=>'<article class="week-capacity-day"><strong>'+
|
||||
display[index].label+'</strong><span>'+day.capacity_minutes+' min available</span><small>'+day.busy_minutes+
|
||||
' min busy during working hours</small></article>').join('');
|
||||
const current=controller.review?.().days||[];
|
||||
needsReflow=reviewed.some((day,index)=>Number(current[index]?.planned_minutes)>day.capacity_minutes);
|
||||
const previewValues=reviewed.map(day=>({plan_date:day.plan_date,capacity_minutes:day.capacity_minutes,
|
||||
free_windows:day.free_windows.map(window=>({...window}))}));
|
||||
reflowPreview=needsReflow?controller.previewCapacityReflow?.(previewValues):null;
|
||||
const destinations=new Map();
|
||||
(reflowPreview?.days||[]).forEach(day=>(day.ids||[]).forEach(id=>destinations.set(id,day.plan_date)));
|
||||
qs('#week-capacity-days').innerHTML=reviewed.map((day,index)=>{
|
||||
const previous=current[index]||{},planned=Number(previous.planned_minutes)||0,over=Math.max(0,planned-day.capacity_minutes);
|
||||
const moved=[...new Set((previous.ids||[]).map(id=>destinations.get(id)).filter(date=>date&&date!==day.plan_date))]
|
||||
.map(date=>display.find(item=>item.date===date)?.label||date);
|
||||
return '<article class="week-capacity-day'+(over?' is-overloaded':'')+'"><strong>'+display[index].label+'</strong><span>'+
|
||||
(Number.isInteger(previous.capacity_minutes)?previous.capacity_minutes+' → ':'')+day.capacity_minutes+' min available</span><small>'+
|
||||
planned+' min planned · '+day.busy_minutes+' min busy</small>'+(over?'<small class="week-capacity-over">'+over+
|
||||
' min over refreshed capacity</small>':'')+(moved.length?'<small>Moves to '+moved.join(', ')+'</small>':'')+'</article>';
|
||||
}).join('');
|
||||
qs('#week-capacity-review').hidden=false;
|
||||
const unsupported=reviewed.unsupported_count||0;
|
||||
qs('#apply-week-capacities').disabled=unsupported>0;
|
||||
const reflowBlocked=Boolean(reflowPreview?.blockers?.length);
|
||||
qs('#apply-week-capacities').disabled=unsupported>0||reflowBlocked;
|
||||
qs('#apply-week-capacities').textContent=needsReflow?'Apply capacities & reflow':'Apply seven capacities';
|
||||
const kind=reviewed.unsupported_timezone_count?'calendar event':'recurring event';
|
||||
qs('#week-capacity-status').textContent=unsupported?
|
||||
unsupported+' '+kind+(unsupported===1?'':'s')+' could not be counted. Apply is unavailable; export a simpler seven-day calendar and try again.':
|
||||
'Review seven capacity totals. Calendar details stay on this device.';
|
||||
(reflowBlocked?'Reflow needs an estimate for every planned item before refreshed capacity can be applied.':
|
||||
(reflowPreview?.unscheduled?.length?reflowPreview.unscheduled.length+' planned item'+
|
||||
(reflowPreview.unscheduled.length===1?'':'s')+' will return to My Work. Review before applying.':
|
||||
'Review seven capacity totals. Calendar details stay on this device.'));
|
||||
return reviewed.map(day=>({...day}));
|
||||
}
|
||||
async function apply() {
|
||||
if(!reviewed||reviewed.unsupported_count)return false;
|
||||
const keep=Boolean(qs('#keep-week-free-times')?.checked);
|
||||
if(!reviewed||reviewed.unsupported_count||!controller.stageCapacities(reviewed.map(day=>({
|
||||
const values=reviewed.map(day=>({
|
||||
plan_date:day.plan_date,capacity_minutes:day.capacity_minutes,
|
||||
...(keep?{free_windows:day.free_windows.map(window=>({...window}))}:{}),
|
||||
}))))return false;
|
||||
}));
|
||||
const staged=needsReflow?controller.applyCapacityReflow?.(values):controller.stageCapacities(values);
|
||||
if(!staged)return false;
|
||||
qs('#apply-week-capacities').disabled=true;
|
||||
try {await controller.flush();activeAvailability=reviewed.map(day=>({plan_date:day.plan_date,
|
||||
free_windows:day.free_windows.map(window=>({...window}))}));cancel();onApplied();return true;}
|
||||
|
||||
+18
-6
@@ -209,6 +209,10 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin,coordinat
|
||||
}]));
|
||||
}
|
||||
function stageCapacities(values) {
|
||||
const changed=capacityDays(values);
|
||||
return changed&&stageDays(changed);
|
||||
}
|
||||
function capacityDays(values) {
|
||||
const available=dates().map(item=>item.date);
|
||||
if(!Array.isArray(values)||values.length!==available.length)return false;
|
||||
const capacities=new Map(values.map(value=>[value?.plan_date,value]));
|
||||
@@ -216,13 +220,13 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin,coordinat
|
||||
const value=capacities.get(date)?.capacity_minutes;
|
||||
return !Number.isInteger(value)||value<0||value>1440;
|
||||
}))return false;
|
||||
return stageDays(available.map(planDate=>{
|
||||
return available.map(planDate=>{
|
||||
const value=day(planDate),capacity=capacities.get(planDate);
|
||||
value.capacity_minutes=capacity.capacity_minutes;
|
||||
if(Array.isArray(capacity.free_windows))value.free_windows=cloneWindows(capacity.free_windows);
|
||||
else delete value.free_windows;
|
||||
return value;
|
||||
}));
|
||||
});
|
||||
}
|
||||
function stageAvailabilityDefaults(values) {
|
||||
if(!cloneDefaults(values)||values.some(value=>!Number.isInteger(value)||value<0||value>1440)||offlineSnapshot)return false;
|
||||
@@ -263,8 +267,9 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin,coordinat
|
||||
['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));
|
||||
function previewReflow(capacities=null) {
|
||||
const available=capacities?capacityDays(capacities):dates().map(item=>day(item.date));
|
||||
if(!available)return {days:[],blockers:[{type:'invalid-capacities'}],unscheduled:[]};
|
||||
const seen=new Set(),work=[];
|
||||
available.forEach(source=>(source.ids||[]).forEach(id=>{
|
||||
if(seen.has(id))return;
|
||||
@@ -277,7 +282,8 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin,coordinat
|
||||
}));
|
||||
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:{}}));
|
||||
capacity_minutes:source.capacity_minutes??null,estimates:{},
|
||||
...(cloneWindows(source.free_windows)?{free_windows:cloneWindows(source.free_windows)}:{})}));
|
||||
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&&
|
||||
@@ -293,6 +299,12 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin,coordinat
|
||||
if(preview.blockers.length||offlineSnapshot)return false;
|
||||
return stageDays(preview.days);
|
||||
}
|
||||
function previewCapacityReflow(values) { return previewReflow(values); }
|
||||
function applyCapacityReflow(values) {
|
||||
const preview=previewCapacityReflow(values);
|
||||
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));
|
||||
@@ -683,7 +695,7 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin,coordinat
|
||||
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,stageAvailabilityDefaults,disableAvailabilityDefaults,review,previewReflow,applyReflow,move,retire,unplan,restore,placement,place,pending,flush,conflict,chooseDay,saveMerged,
|
||||
return {adopt,state,dates,day,pass,load,saveDay,stageDay,stageCapacities,stageAvailabilityDefaults,disableAvailabilityDefaults,review,previewReflow,applyReflow,previewCapacityReflow,applyCapacityReflow,move,retire,unplan,restore,placement,place,pending,flush,conflict,chooseDay,saveMerged,
|
||||
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})};
|
||||
|
||||
Reference in new issue
Block a user