Refresh Week Ahead calendar availability with atomic reflow #1267

Merged
rockachopa merged 1 commits from timmy/1266-refresh-week-calendar-atomic-reflow into main 2026-08-22 14:45:05 +00:00
7 changed files with 134 additions and 21 deletions

View File

@ -193,9 +193,13 @@ work that cannot fit is named and remains in My Work, while missing estimates bl
write, and apply stages one durable whole-week transition through the existing conflict-safe sync path.
Confirmation remains disabled while duplicates exist or the account-bound week is still syncing.
Rapid saves and review moves use one network flight plus a coalesced latest-state delivery, so later staged days
are not stranded behind an earlier request. From the same review, **Set capacity from calendar** reads a local
`.ics` file on-device, unions overlapping busy periods within chosen working hours, and previews seven daily
availability totals before one atomic apply. IANA `TZID` values from Google and Outlook calendars are converted
are not stranded behind an earlier request. From the same review, **Refresh from calendar** reads a local
`.ics` file on-device, unions overlapping busy periods within chosen working hours, and compares every current
capacity with refreshed availability and planned load. If refreshed availability creates an overload, the review
previews the deterministic destination of moved work and reports anything that cannot fit. One explicit
**Apply capacities & reflow** action stages the complete seven-day plan once—there is no transient overloaded
save—and preserves consented free windows alongside the redistributed work. A refresh that already fits keeps
the direct one-write capacity path. IANA `TZID` values from Google and Outlook calendars are converted
to the device's local workday, including daylight-saving transitions. Daily and weekly recurrence (`COUNT`,
`UNTIL`, `INTERVAL`, and weekly `BYDAY`), `RDATE`/`EXDATE`, and all-day events are evaluated with work bounded
to the seven review dates; cancelled and transparent events do not consume capacity. If a time zone is unknown,

View File

@ -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; }

View File

@ -522,7 +522,7 @@
<p class="small muted">See what is next and check each days 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>

View File

@ -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;}

View File

@ -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})};

View File

@ -334,12 +334,56 @@ console.log(JSON.stringify({staged,restored}));
assert "private@example.com" not in json.dumps(result)
def test_calendar_refresh_compares_load_and_applies_overloaded_capacity_with_one_atomic_reflow():
result = run_import("""
const elements=new Map();
const element=(value='')=>({value,checked:false,hidden:false,textContent:'',innerHTML:'',disabled:false,files:[],listeners:{},
addEventListener(name,listener){this.listeners[name]=listener;},focus(){}});
for(const selector of ['#week-capacity-import','#week-capacity-review','#week-capacity-days','#week-capacity-status',
'#week-capacity-file','#week-capacity-start','#week-capacity-end','#keep-week-free-times',
'#apply-week-capacities','#cancel-week-capacity-import']) elements.set(selector,element());
elements.get('#week-capacity-start').value='09:00';elements.get('#week-capacity-end').value='17:00';
elements.get('#keep-week-free-times').checked=true;
const dates=['2026-08-21','2026-08-22','2026-08-23','2026-08-24','2026-08-25','2026-08-26','2026-08-27'];
const current=dates.map((date,index)=>({plan_date:date,capacity_minutes:480,planned_minutes:index===0?90:index===1?30:0,
ids:index===0?['one','two']:index===1?['three']:[],estimates:index===0?{one:45,two:45}:index===1?{three:30}:{}}));
let atomicCalls=0,ordinaryCalls=0,flushes=0,received=null;
const controller={dates:()=>dates.map((date,index)=>({date,label:'Day '+(index+1)})),review:()=>({days:current}),
previewCapacityReflow:values=>({blockers:[],unscheduled:[],days:values.map((value,index)=>({...value,
ids:index===0?['one']:index===1?['two','three']:[],estimates:index===0?{one:45}:index===1?{two:45,three:30}:{}}))}),
applyCapacityReflow:values=>{atomicCalls+=1;received=values;return {sync_pending:true};},
stageCapacities:()=>{ordinaryCalls+=1;return {sync_pending:true};},flush:async()=>{flushes+=1;return {};}};
const workflow=calendarImport.createWorkflow({controller,qs:selector=>elements.get(selector)});
const source=`BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nSUMMARY:Private meeting name\r\nATTENDEE:private@example.com\r\nDTSTART:20260821T090000\r\nDTEND:20260821T163000\r\nEND:VEVENT\r\nEND:VCALENDAR`;
const reviewed=workflow.review(source);const label=elements.get('#apply-week-capacities').textContent;
const markup=elements.get('#week-capacity-days').innerHTML;await workflow.apply();
console.log(JSON.stringify({reviewed,label,markup,atomicCalls,ordinaryCalls,flushes,received}));
""")
assert result["reviewed"][0]["capacity_minutes"] == 30
assert "480 → 30 min available" in result["markup"]
assert "90 min planned" in result["markup"]
assert "60 min over refreshed capacity" in result["markup"]
assert "Moves to Day 2" in result["markup"]
assert result["label"] == "Apply capacities & reflow"
assert result["atomicCalls"] == 1
assert result["ordinaryCalls"] == 0
assert result["flushes"] == 1
assert result["received"][0]["free_windows"] == [
{"start_time": "16:30", "end_time": "17:00"}
]
assert "Private meeting name" not in json.dumps(result)
assert "private@example.com" not in json.dumps(result)
def test_week_ahead_exposes_private_calendar_capacity_import_in_the_packaged_planning_flow():
html = (FRONTEND / "index.html").read_text()
dashboard = (FRONTEND / "dashboard.js").read_text()
css = (FRONTEND / "dashboard.css").read_text()
assert 'id="open-week-capacity-import"' in html
assert '>Refresh from calendar</button>' in html
assert '<h2 id="week-capacity-title">Refresh Week Ahead availability</h2>' in html
assert 'id="week-capacity-import"' in html
assert 'id="week-capacity-file"' in html and 'accept=".ics,text/calendar"' in html
assert 'id="week-capacity-start"' in html and 'id="week-capacity-end"' in html

View File

@ -311,6 +311,35 @@ console.log(JSON.stringify({before,preview,unchanged,applied,writes,pending:week
assert result["pending"]["days"] == result["preview"]["days"]
def test_week_controller_atomically_reflows_against_refreshed_calendar_capacity_and_keeps_windows():
result = run_controller("""
const values=new Map();let writes=0;
const storage={getItem:key=>values.get(key)||null,setItem:(key,value)=>{writes+=1;values.set(key,value);},removeItem:key=>values.delete(key)};
const week=createWeekPlan({storage,getLogin:()=> 'timmy',fetchJson:async()=>({}),
localDate:()=> '2026-08-20',timeZone:()=> 'UTC'});
week.adopt({revision:6,timezone:'UTC',days:[
{plan_date:'2026-08-21',ids:['one','two'],capacity_minutes:120,estimates:{one:45,two:45},free_windows:[{start_time:'09:00',end_time:'11:00'}]},
{plan_date:'2026-08-22',ids:['three'],capacity_minutes:120,estimates:{three:30}},
{plan_date:'2026-08-23',ids:[],capacity_minutes:120,estimates:{}}
]});
const capacities=week.dates().map((item,index)=>({plan_date:item.date,capacity_minutes:index===0?45:120,
free_windows:index===0?[{start_time:'10:00',end_time:'10:45'}]:[{start_time:'09:00',end_time:'11:00'}]}));
const before=week.state();const preview=week.previewCapacityReflow(capacities);const unchanged=week.state();
const applied=week.applyCapacityReflow(capacities);
console.log(JSON.stringify({before,preview,unchanged,applied,writes,pending:week.pending()}));
""")
assert result["unchanged"] == result["before"]
assert result["writes"] == 1
assert result["applied"]["sync_pending"] is True
assert [day["ids"] for day in result["preview"]["days"][:2]] == [["one"], ["two", "three"]]
assert result["preview"]["days"][0]["free_windows"] == [
{"start_time": "10:00", "end_time": "10:45"}
]
assert result["pending"]["days"] == result["preview"]["days"]
assert [item for day in result["pending"]["days"] for item in day["ids"]] == ["one", "two", "three"]
def test_week_controller_reflow_never_places_more_than_five_items_on_a_day():
result = run_controller("""
const week=createWeekPlan({fetchJson:async()=>({}),localDate:()=> '2026-08-20',timeZone:()=> 'UTC'});