diff --git a/README.md b/README.md
index 83b7204..4846453 100644
--- a/README.md
+++ b/README.md
@@ -173,7 +173,10 @@ and flags work assigned to more than one date. Operators can move an item to ano
the estimate follows the item and the server rejects duplicate cross-day assignments without advancing the
week revision. 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.
+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. Raw calendar data and event metadata are never persisted or sent;
+only the reviewed capacity-minute totals use the existing encrypted, account-bound Week Ahead sync.
Planning edits can remain offline for up to 30 days. After that, the
expired edit is discarded visibly and the account plan is kept rather than replaying stale
intent. The server retains no more than 4,096 operation receipts per account and removes
diff --git a/frontend/dashboard.css b/frontend/dashboard.css
index fbebfe3..d7521d3 100644
--- a/frontend/dashboard.css
+++ b/frontend/dashboard.css
@@ -337,6 +337,20 @@ textarea { resize: vertical; min-height: 120px; }
.week-review-duplicates { margin:12px 0; padding:12px; border:1px solid #f59e0b; border-radius:10px; background:#2a1c12; }
.week-review-status { min-height:1.4em; margin:10px 0; }
#confirm-week-plan { width:100%; min-height:48px; position:sticky; bottom:0; }
+#open-week-capacity-import { width:100%; min-height:44px; margin:8px 0 12px; }
+.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; }
+.week-capacity-import h2 { margin:.2rem 0; }
+.week-capacity-import > label, .week-capacity-hours label { display:grid; min-width:0; gap:6px; margin:12px 0; }
+.week-capacity-import input, .week-capacity-import button { box-sizing:border-box; min-width:0; min-height:44px; max-width:100%; }
+.week-capacity-hours { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:10px; }
+.week-capacity-days { display:grid; gap:8px; margin:10px 0 14px; }
+.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; }
+#apply-week-capacities { width:100%; min-height:48px; position:sticky; bottom:0; }
+@media (max-width:359px) { .week-capacity-hours { grid-template-columns:1fr; } }
.week-calendar-handoff h2 { margin-bottom:6px; }
.week-calendar-days { display:grid; gap:12px; margin:14px 0; }
.week-calendar-day { min-width:0; padding:12px; border:1px solid #31577f; border-radius:12px; background:#10233a; }
diff --git a/frontend/index.html b/frontend/index.html
index b373e58..b64beb3 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -509,11 +509,30 @@
Final planning step
Review & rebalance Week Ahead
Check each day’s load. Move work instead of copying it, then confirm one executable week.
+
+
+
Private calendar import
Set Week Ahead capacity
+
+
Your calendar is read only on this device. Event titles, people, locations, and the file are never saved or uploaded.
+
+
+
+
+
+
+
+
Review available time
+
+
+
+
Calendar handoff
Add Week Ahead to calendar
@@ -2028,6 +2047,7 @@
+
diff --git a/frontend/service-worker.js b/frontend/service-worker.js
index f145340..cd66125 100644
--- a/frontend/service-worker.js
+++ b/frontend/service-worker.js
@@ -125,6 +125,7 @@ const SHELL = [
BASE + 'static/plan-today-preview.js',
BASE + 'static/tomorrow-plan.js',
BASE + 'static/week-calendar.js',
+ BASE + 'static/week-calendar-import.js',
BASE + 'static/week-plan.js',
BASE + 'static/search-week-plan.js',
BASE + 'static/today-sync.js',
diff --git a/frontend/week-calendar-import.js b/frontend/week-calendar-import.js
new file mode 100644
index 0000000..4fe7f2f
--- /dev/null
+++ b/frontend/week-calendar-import.js
@@ -0,0 +1,111 @@
+function createWeekCalendarImport() {
+ const maxBytes=1024*1024;
+ function clock(value) {
+ const match=/^(\d{2}):(\d{2})$/.exec(String(value||''));
+ if(!match)return null;
+ const minutes=Number(match[1])*60+Number(match[2]);
+ return Number(match[1])<24&&Number(match[2])<60?minutes:null;
+ }
+ function instant(value) {
+ const match=/^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})(Z?)$/.exec(value||'');
+ if(!match)return null;
+ const parts=match.slice(1,7).map(Number);
+ const milliseconds=match[7]?Date.UTC(parts[0],parts[1]-1,parts[2],parts[3],parts[4],parts[5]):
+ new Date(parts[0],parts[1]-1,parts[2],parts[3],parts[4],parts[5]).getTime();
+ return Number.isFinite(milliseconds)?milliseconds:null;
+ }
+ function events(source) {
+ if(typeof source!=='string'||!source.includes('BEGIN:VCALENDAR'))throw new Error('Choose a valid .ics calendar file.');
+ if(new TextEncoder().encode(source).length>maxBytes)throw new Error('Calendar files must be 1 MB or smaller.');
+ const lines=source.replace(/\r\n[ \t]/g,'').split(/\r?\n/);
+ const result=[];let current=null;
+ lines.forEach(line=>{
+ if(line==='BEGIN:VEVENT'){current={};return;}
+ if(line==='END:VEVENT'){
+ if(current?.start!=null&¤t?.end>current.start)result.push(current);
+ current=null;return;
+ }
+ if(!current)return;
+ const separator=line.indexOf(':');if(separator<0)return;
+ const name=line.slice(0,separator).split(';')[0],value=line.slice(separator+1);
+ if(name==='DTSTART')current.start=instant(value);
+ if(name==='DTEND')current.end=instant(value);
+ });
+ return result;
+ }
+ function dayBoundary(date,minutes) {
+ const [year,month,day]=date.split('-').map(Number);
+ return new Date(year,month-1,day,Math.floor(minutes/60),minutes%60).getTime();
+ }
+ function review(source,{dates,workdayStart='09:00',workdayEnd='17:00'}={}) {
+ if(!Array.isArray(dates)||dates.length!==7)throw new Error('Week Ahead must contain seven dates.');
+ const startMinute=clock(workdayStart),endMinute=clock(workdayEnd);
+ if(startMinute==null||endMinute==null||endMinute<=startMinute)throw new Error('Working hours must end after they start.');
+ const calendarEvents=events(source);
+ return dates.map(plan_date=>{
+ const start=dayBoundary(plan_date,startMinute),end=dayBoundary(plan_date,endMinute);
+ const ranges=calendarEvents.map(event=>[Math.max(start,event.start),Math.min(end,event.end)])
+ .filter(range=>range[1]>range[0]).sort((left,right)=>left[0]-right[0]);
+ const merged=[];
+ ranges.forEach(range=>{
+ const previous=merged[merged.length-1];
+ if(previous&&range[0]<=previous[1])previous[1]=Math.max(previous[1],range[1]);
+ else merged.push([...range]);
+ });
+ const busy_minutes=Math.round(merged.reduce((total,range)=>total+range[1]-range[0],0)/60000);
+ return {plan_date,busy_minutes,capacity_minutes:endMinute-startMinute-busy_minutes};
+ });
+ }
+ function createWorkflow({controller,qs,onApplied=()=>{}}={}) {
+ let reviewed=null;
+ const root=()=>qs('#week-capacity-import');
+ function clear() {
+ reviewed=null;
+ qs('#week-capacity-file').value='';
+ qs('#week-capacity-review').hidden=true;
+ qs('#week-capacity-days').innerHTML='';
+ }
+ function open() {
+ clear();root().hidden=false;qs('#week-capacity-status').textContent='';
+ qs('#week-capacity-file').focus?.();return true;
+ }
+ function cancel() {clear();root().hidden=true;return true;}
+ function reviewSource(source) {
+ 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('');
+ qs('#week-capacity-review').hidden=false;
+ qs('#week-capacity-status').textContent='Review seven capacity totals. Calendar details stay on this device.';
+ return reviewed.map(day=>({...day}));
+ }
+ async function apply() {
+ if(!reviewed||!controller.stageCapacities(reviewed))return false;
+ qs('#apply-week-capacities').disabled=true;
+ try {await controller.flush();cancel();onApplied();return true;}
+ finally {qs('#apply-week-capacities').disabled=false;}
+ }
+ qs('#week-capacity-file')?.addEventListener('change',async event=>{
+ const file=event.currentTarget.files?.[0];if(!file)return;
+ try {
+ if(file.size>maxBytes)throw new Error('Calendar files must be 1 MB or smaller.');
+ reviewSource(await file.text());
+ } catch(error) {clear();qs('#week-capacity-status').textContent=error.message||'Calendar could not be read.';}
+ });
+ qs('#cancel-week-capacity-import')?.addEventListener('click',cancel);
+ qs('#apply-week-capacities')?.addEventListener('click',()=>apply().catch(error=>{
+ qs('#week-capacity-status').textContent=(error.message||'Week Ahead sync is unavailable.')+' Capacities remain saved on this phone.';
+ }));
+ return {open,cancel,review:reviewSource,apply,state:()=>reviewed?reviewed.map(day=>({...day})):null};
+ }
+ function mount(controller,weekWorkflow,qs) {
+ const workflow=createWorkflow({controller,qs,onApplied:weekWorkflow.renderReview});
+ qs('#open-week-capacity-import').addEventListener('click',workflow.open);
+ return workflow;
+ }
+ return {review,createWorkflow,mount};
+}
+const weekCalendarImport=createWeekCalendarImport();
+if(typeof module!=='undefined'&&module.exports)module.exports=weekCalendarImport;
diff --git a/frontend/week-plan.js b/frontend/week-plan.js
index 1ec5e0d..6f8772a 100644
--- a/frontend/week-plan.js
+++ b/frontend/week-plan.js
@@ -78,6 +78,17 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin}={}) {
estimates:{...(value.estimates||{})},
}]));
}
+ function stageCapacities(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?.capacity_minutes]));
+ if(capacities.size!==available.length||available.some(date=>{
+ const value=capacities.get(date);return !Number.isInteger(value)||value<0||value>1440;
+ }))return false;
+ return stageDays(available.map(planDate=>{
+ const value=day(planDate);value.capacity_minutes=capacities.get(planDate);return value;
+ }));
+ }
function review() {
const assigned=new Map();
const days=dates().map(item=>{
@@ -330,7 +341,7 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin}={}) {
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,review,move,placement,place,pending,flush,conflict,chooseDay,saveMerged,
+ return {adopt,state,dates,day,pass,load,saveDay,stageDay,stageCapacities,review,move,placement,place,pending,flush,conflict,chooseDay,saveMerged,
keepLocal,useRemote,promote,reconcile:reconcilePromotion,summary};
}
function createWeekPlanWorkflow({controller,qs,getLogin,getItem=()=>null,openPlanner,setReviewMode=()=>{},escapeHtml,escapeAttribute,
@@ -538,13 +549,15 @@ function createWeekPlanWorkflow({controller,qs,getLogin,getItem=()=>null,openPla
qs('#my-work-action-status').textContent=(error.message||'Week Ahead needs review before promotion.')+' Open Week Ahead to review.';return false;
}
}
- return {open,save,advance,confirm,finish,promote,renderDates,renderConflict,active:()=>Boolean(selectedDate)||reviewing||Boolean(reconciliation),
+ const workflow={open,save,advance,confirm,finish,promote,renderDates,renderReview,renderConflict,active:()=>Boolean(selectedDate)||reviewing||Boolean(reconciliation),
reviewing:()=>reviewing,selectedDate:()=>selectedDate,
day:()=>reconciliationDay()||(selectedDate?controller.day(selectedDate):null),
copy:()=>reconciliation?{title:"Start today's plan",heading:'Unfinished Today + due Week Ahead',available:'Available today',build:'Build combined Today'}:
(selectedDate?{title:'Plan Week Ahead',heading:selectedDate+', in order',available:'Available this day',build:'Build this day'}:null),
clear(){selectedDate=null;reviewing=false;editingFromReview=false;reconciliation=null;setReviewMode(false);qs('#week-review').hidden=true;
const back=qs('#back-to-week-review');if(back)back.hidden=true;renderDates();}};
+ if(typeof weekCalendarImport!=='undefined')weekCalendarImport.mount(controller,workflow,qs);
+ return workflow;
}
if(typeof module!=='undefined'&&module.exports){
module.exports=createWeekPlan;
diff --git a/src/frontend_bundle.py b/src/frontend_bundle.py
index 05067eb..f863a99 100644
--- a/src/frontend_bundle.py
+++ b/src/frontend_bundle.py
@@ -36,7 +36,7 @@ FEATURE_SOURCES = {
"planning": (
"static/plan-today.js", "static/plan-today-readiness.js",
"static/plan-today-preview.js", "static/today-rollover.js", "static/today-readiness.js",
- "static/tomorrow-plan.js", "static/week-calendar.js", "static/week-plan.js", "static/search-week-plan.js", "static/search-batch-plan.js", "static/agenda-session-launcher.js", "static/mobile-plan-today-nav.js",
+ "static/tomorrow-plan.js", "static/week-calendar.js", "static/week-calendar-import.js", "static/week-plan.js", "static/search-week-plan.js", "static/search-batch-plan.js", "static/agenda-session-launcher.js", "static/mobile-plan-today-nav.js",
),
"today-timer": (
"static/conversation.js", "static/widgets.js", "static/voice-transcript-store.js", "static/voice-conversation-capture.js", "static/mobile-launch.js", "static/mobile-insights.js", "static/mobile-app-shortcuts.js", "static/mobile-find-work-nav.js", "static/mobile-pull-refresh.js", "static/live-data-status.js",
diff --git a/tests/e2e/test_mobile_week_ahead_release.py b/tests/e2e/test_mobile_week_ahead_release.py
index 8eabb31..9b63519 100644
--- a/tests/e2e/test_mobile_week_ahead_release.py
+++ b/tests/e2e/test_mobile_week_ahead_release.py
@@ -97,6 +97,40 @@ def test_release_artifact_plans_seven_touch_safe_mobile_dates(
page.locator("#back-to-week-review").click()
expect(page.locator("#week-review")).to_be_visible()
expect(first_day).to_contain_text("Ship mobile capture")
+
+ page.locator("#open-week-capacity-import").click()
+ capacity_import = page.locator("#week-capacity-import")
+ expect(capacity_import).to_be_visible()
+ private_title = "Private customer planning"
+ tomorrow = (date.today() + timedelta(days=1)).strftime("%Y%m%d")
+ calendar = (
+ "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\n"
+ f"SUMMARY:{private_title}\r\nDTSTART:{tomorrow}T100000\r\n"
+ f"DTEND:{tomorrow}T110000\r\nEND:VEVENT\r\nEND:VCALENDAR"
+ )
+ page.locator("#week-capacity-file").set_input_files({
+ "name": "availability.ics", "mimeType": "text/calendar", "buffer": calendar.encode()
+ })
+ expect(page.locator("#week-capacity-days .week-capacity-day")).to_have_count(7)
+ expect(page.locator("#week-capacity-days .week-capacity-day").first).to_contain_text(
+ "420 min available"
+ )
+ expect(capacity_import).not_to_contain_text(private_title)
+ for control in (
+ page.locator("#cancel-week-capacity-import"),
+ page.locator("#week-capacity-file"),
+ page.locator("#week-capacity-start"),
+ page.locator("#week-capacity-end"),
+ page.locator("#apply-week-capacities"),
+ ):
+ bounds = control.bounding_box()
+ assert bounds and bounds["height"] >= 44
+ assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
+ page.locator("#apply-week-capacities").click()
+ expect(capacity_import).to_be_hidden()
+ expect(page.locator("#week-review")).to_be_visible()
+ expect(first_day).to_contain_text("30 of 420 min")
+
page.wait_for_function("() => !document.querySelector('#confirm-week-plan').disabled")
confirm = page.locator("#confirm-week-plan")
bounds = confirm.bounding_box()
@@ -118,7 +152,7 @@ def test_release_artifact_plans_seven_touch_safe_mobile_dates(
assert download_info.value.suggested_filename.startswith("stackchain-week-ahead-")
expect(page.locator("#plan-today-sheet")).to_be_hidden()
page.wait_for_function("() => document.querySelector('#mobile-week-summary').textContent !== 'Loading Week Ahead…'")
- assert saved and len(saved[-1]["days"]) == 3
+ assert saved and len(saved[-1]["days"]) == 7
assert [day["plan_date"] for day in saved[-1]["days"]] == sorted(
day["plan_date"] for day in saved[-1]["days"]
)
diff --git a/tests/test_service_worker.py b/tests/test_service_worker.py
index 5ca8e5f..b1b5631 100644
--- a/tests/test_service_worker.py
+++ b/tests/test_service_worker.py
@@ -1329,6 +1329,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
"/dashboard/static/plan-today-preview.js",
"/dashboard/static/tomorrow-plan.js",
"/dashboard/static/week-calendar.js",
+ "/dashboard/static/week-calendar-import.js",
"/dashboard/static/week-plan.js",
"/dashboard/static/search-week-plan.js",
"/dashboard/static/today-sync.js",
diff --git a/tests/test_week_calendar.py b/tests/test_week_calendar.py
index 4135247..d89dac3 100644
--- a/tests/test_week_calendar.py
+++ b/tests/test_week_calendar.py
@@ -107,4 +107,4 @@ def test_week_calendar_handoff_is_wired_into_confirmation_with_mobile_privacy_co
assert "mountWeekCalendarHandoff" in WEEK_CALENDAR.read_text()
assert "weekCalendar.open(weekPlan.state())" in dashboard
assert "weekCalendar.close()" in dashboard
- assert '"static/week-calendar.js", "static/week-plan.js"' in bundle
+ assert '"static/week-calendar.js", "static/week-calendar-import.js", "static/week-plan.js"' in bundle
diff --git a/tests/test_week_calendar_import.py b/tests/test_week_calendar_import.py
new file mode 100644
index 0000000..77a3ac4
--- /dev/null
+++ b/tests/test_week_calendar_import.py
@@ -0,0 +1,108 @@
+import json
+import subprocess
+from pathlib import Path
+
+
+MODULE = Path(__file__).parents[1] / "frontend" / "week-calendar-import.js"
+FRONTEND = MODULE.parent
+
+
+def run_import(scenario: str) -> dict:
+ harness = f"""
+const calendarImport = require({json.dumps(str(MODULE))});
+(async() => {{ {scenario} }})().catch(error=>{{console.error(error);process.exit(1);}})
+"""
+ completed = subprocess.run(
+ ["node", "-e", harness], check=True, capture_output=True, text=True
+ )
+ return json.loads(completed.stdout)
+
+
+def test_calendar_import_unions_busy_time_within_working_hours_across_local_days():
+ result = run_import("""
+const source=`BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nUID:one\r\nSUMMARY:Private planning\r\nDTSTART:20260821T100000\r\nDTEND:20260821T110000\r\nEND:VEVENT\r\nBEGIN:VEVENT\r\nUID:two\r\nSUMMARY:Secret customer call\r\nATTENDEE:mailto:private@example.com\r\nDTSTART:20260821T103000\r\nDTEND:20260821T120000\r\nEND:VEVENT\r\nBEGIN:VEVENT\r\nUID:outside\r\nDTSTART:20260821T070000\r\nDTEND:20260821T080000\r\nEND:VEVENT\r\nBEGIN:VEVENT\r\nUID:overnight\r\nLOCATION:Private office\r\nDTSTART:20260821T163000\r\nDTEND:20260822T100000\r\nEND:VEVENT\r\nEND:VCALENDAR`;
+const dates=['2026-08-21','2026-08-22','2026-08-23','2026-08-24','2026-08-25','2026-08-26','2026-08-27'];
+const days=calendarImport.review(source,{dates,workdayStart:'09:00',workdayEnd:'17:00'});
+console.log(JSON.stringify({days,serialized:JSON.stringify(days)}));
+""")
+
+ assert result["days"][0] == {
+ "plan_date": "2026-08-21", "busy_minutes": 150, "capacity_minutes": 330
+ }
+ assert result["days"][1] == {
+ "plan_date": "2026-08-22", "busy_minutes": 60, "capacity_minutes": 420
+ }
+ assert [day["capacity_minutes"] for day in result["days"][2:]] == [480] * 5
+ for private_value in ("Private planning", "Secret customer call", "private@example.com", "Private office"):
+ assert private_value not in result["serialized"]
+
+
+def test_calendar_import_rejects_invalid_and_oversized_files_without_returning_capacity():
+ result = run_import("""
+const errors=[];
+for(const source of ['not a calendar','BEGIN:VCALENDAR\\r\\n'+('X'.repeat(1024*1024))+'\\r\\nEND:VCALENDAR']) {
+ try { calendarImport.review(source,{dates:['2026-08-21','2026-08-22','2026-08-23','2026-08-24','2026-08-25','2026-08-26','2026-08-27']}); }
+ catch(error) { errors.push(error.message); }
+}
+console.log(JSON.stringify({errors}));
+""")
+
+ assert result["errors"] == [
+ "Choose a valid .ics calendar file.",
+ "Calendar files must be 1 MB or smaller.",
+ ]
+
+
+def test_calendar_import_workflow_reviews_then_applies_once_without_persisting_calendar_text():
+ result = run_import("""
+const elements=new Map();
+const element=(value='')=>({value,hidden:false,textContent:'',innerHTML:'',disabled:false,files:[],listeners:{},
+ addEventListener(name,listener){this.listeners[name]=listener;},focus(){this.focused=true;}});
+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','#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';
+const dates=['2026-08-21','2026-08-22','2026-08-23','2026-08-24','2026-08-25','2026-08-26','2026-08-27'];
+let staged=[],flushes=0,appliedCalls=0;
+const controller={dates:()=>dates.map((date,index)=>({date,label:'Day '+(index+1)})),
+ stageCapacities:value=>{staged.push(value);return {sync_pending:true};},flush:async()=>{flushes+=1;}};
+const workflow=calendarImport.createWorkflow({controller,qs:selector=>elements.get(selector),onApplied:()=>{appliedCalls+=1;}});
+workflow.open();
+const source=`BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nSUMMARY:Do not retain me\r\nDTSTART:20260821T100000\r\nDTEND:20260821T110000\r\nEND:VEVENT\r\nEND:VCALENDAR`;
+const reviewed=workflow.review(source);
+await workflow.apply();
+console.log(JSON.stringify({reviewed,staged,flushes,appliedCalls,rootHidden:elements.get('#week-capacity-import').hidden,
+ status:elements.get('#week-capacity-status').textContent,markup:elements.get('#week-capacity-days').innerHTML,
+ fileValue:elements.get('#week-capacity-file').value,workflowState:workflow.state()}));
+""")
+
+ assert result["reviewed"][0]["capacity_minutes"] == 420
+ assert len(result["staged"]) == 1
+ assert len(result["staged"][0]) == 7
+ assert result["flushes"] == 1
+ assert result["appliedCalls"] == 1
+ assert result["rootHidden"] is True
+ assert result["fileValue"] == ""
+ assert result["workflowState"] is None
+ assert "Do not retain me" 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 '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
+ assert 'id="week-capacity-review"' in html
+ assert 'id="apply-week-capacities"' in html
+ assert '' in html
+ assert html.index('static/week-calendar-import.js') < html.index('static/week-plan.js')
+ week_plan = (FRONTEND / "week-plan.js").read_text()
+ assert "weekCalendarImport.mount" in week_plan
+ assert "weekCalendarImport.mount" not in dashboard
+ assert "open-week-capacity-import" in MODULE.read_text()
+ assert ".week-capacity-import" in css
+ assert "overflow-x:hidden" in css
diff --git a/tests/test_week_plan_frontend.py b/tests/test_week_plan_frontend.py
index b7657c6..1a994fc 100644
--- a/tests/test_week_plan_frontend.py
+++ b/tests/test_week_plan_frontend.py
@@ -114,6 +114,31 @@ console.log(JSON.stringify({before,moved,after,pending:week.pending()}));
assert result["pending"]["days"][2]["estimates"] == {"shared": 30}
+def test_week_controller_applies_seven_imported_capacities_in_one_local_transition():
+ 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'],capacity_minutes:60,estimates:{one:30}},
+ {plan_date:'2026-08-23',ids:['three'],capacity_minutes:90,estimates:{three:45}}
+]});
+const capacities=week.dates().map((item,index)=>({plan_date:item.date,capacity_minutes:300+index*15}));
+const applied=week.stageCapacities(capacities);
+console.log(JSON.stringify({applied,writes,state:week.state(),pending:week.pending()}));
+""")
+
+ assert result["writes"] == 1
+ assert result["applied"]["sync_pending"] is True
+ assert [day["capacity_minutes"] for day in result["state"]["days"]] == [
+ 300, 315, 330, 345, 360, 375, 390
+ ]
+ assert result["state"]["days"][0]["ids"] == ["one"]
+ assert result["state"]["days"][2]["ids"] == ["three"]
+ assert result["pending"]["days"] == result["state"]["days"]
+
+
def test_week_controller_places_new_work_or_explicitly_moves_existing_work_without_duplicates():
result = run_controller("""
const values=new Map();