diff --git a/README.md b/README.md
index bf716a5..724d58a 100644
--- a/README.md
+++ b/README.md
@@ -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,
diff --git a/frontend/dashboard.css b/frontend/dashboard.css
index 95c9416..e6c4775 100644
--- a/frontend/dashboard.css
+++ b/frontend/dashboard.css
@@ -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; }
diff --git a/frontend/index.html b/frontend/index.html
index b43b620..2bd1f06 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -522,7 +522,7 @@
See what is next and check each day’s load. Edit only when you are ready.
-
+
Weekly availability
@@ -565,9 +565,9 @@
-
Private calendar import
Set Week Ahead capacity
+
Private calendar refresh
Refresh Week Ahead availability
-
Your calendar is read only on this device. Event titles, people, locations, and the file are never saved or uploaded.
+
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.
diff --git a/frontend/week-calendar-import.js b/frontend/week-calendar-import.js
index 32e2cc2..d1bf4b0 100644
--- a/frontend/week-calendar-import.js
+++ b/frontend/week-calendar-import.js
@@ -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)=>''+
- display[index].label+''+day.capacity_minutes+' min available'+day.busy_minutes+
- ' min busy during working hours').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 ''+display[index].label+''+
+ (Number.isInteger(previous.capacity_minutes)?previous.capacity_minutes+' → ':'')+day.capacity_minutes+' min available'+
+ planned+' min planned · '+day.busy_minutes+' min busy'+(over?''+over+
+ ' min over refreshed capacity':'')+(moved.length?'Moves to '+moved.join(', ')+'':'')+'';
+ }).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;}
diff --git a/frontend/week-plan.js b/frontend/week-plan.js
index 9075a51..91e07b6 100644
--- a/frontend/week-plan.js
+++ b/frontend/week-plan.js
@@ -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})};
diff --git a/tests/test_week_calendar_import.py b/tests/test_week_calendar_import.py
index 50ee99d..bff01c6 100644
--- a/tests/test_week_calendar_import.py
+++ b/tests/test_week_calendar_import.py
@@ -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' in html
+ assert '
Refresh Week Ahead availability
' 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
diff --git a/tests/test_week_plan_frontend.py b/tests/test_week_plan_frontend.py
index d39288c..8c23629 100644
--- a/tests/test_week_plan_frontend.py
+++ b/tests/test_week_plan_frontend.py
@@ -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'});