diff --git a/frontend/dashboard.css b/frontend/dashboard.css
index 6a0d6b7..54fb8bf 100644
--- a/frontend/dashboard.css
+++ b/frontend/dashboard.css
@@ -337,6 +337,19 @@ 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; }
+.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; }
+.week-calendar-day header { display:flex; align-items:center; justify-content:space-between; gap:12px; }
+.week-calendar-day h3 { margin:0; }
+.week-calendar-day input[type="time"] { min-height:44px; font-size:16px; }
+.week-calendar-item { display:grid; grid-template-columns:auto minmax(0,1fr); gap:10px; align-items:center; min-height:44px; padding:8px 0; overflow-wrap:anywhere; }
+.week-calendar-item input { width:22px; height:22px; }
+.week-calendar-item span { display:grid; gap:3px; }
+.week-calendar-item small { color:#a9bdd3; }
+.week-calendar-status { min-height:1.4em; margin:10px 0; }
+.week-calendar-actions { position:sticky; bottom:0; display:grid; grid-template-columns:1fr 2fr; gap:8px; padding-bottom:env(safe-area-inset-bottom); background:#0b1526; }
+.week-calendar-actions button { min-width:0; min-height:48px; }
.plan-today-sheet.week-review-mode .week-plan-dates,
.plan-today-sheet.week-review-mode .mobile-plan-today-nav,
.plan-today-sheet.week-review-mode #plan-today-fit,
diff --git a/frontend/dashboard.js b/frontend/dashboard.js
index 47f4598..5d28da4 100644
--- a/frontend/dashboard.js
+++ b/frontend/dashboard.js
@@ -452,11 +452,15 @@
storage: localStorage,
getLogin: () => planningOwnerLogin,
});
+ const weekItem=identity=>[...todayMyWork,...activeMyWork].find(item=>todayWork.identity(item)===identity);
const weekWorkflow=createWeekPlanWorkflow({controller:weekPlan,qs,getLogin:()=>planningOwnerLogin,
- getItem:identity=>[...todayMyWork,...activeMyWork].find(item=>todayWork.identity(item)===identity),
+ getItem:weekItem,
openPlanner:openPlanToday,setReviewMode:value=>qs('#plan-today-sheet').classList.toggle('week-review-mode',value),
escapeHtml,escapeAttribute:escAttr,todayWork,
refresh:refreshMyWorkView,warm:warmTodayOffline});
+ const weekCalendar=StackchainWeekCalendar.mountWeekCalendarHandoff({qs,getItem:weekItem,escapeHtml,escapeAttribute:escAttr,
+ onDone:()=>{weekWorkflow.finish();taskOverlayHistory.leave();},
+ });
const todaySync = createTodaySync({
storage: localStorage,
getLogin: () => planningOwnerLogin,
@@ -2701,6 +2705,7 @@
}
planToday.cancel();
planningTomorrow = false;
+ weekCalendar.close();
weekWorkflow.clear();
qs('#tomorrow-conflict-review').hidden = true;
qs('#plan-today-sheet').classList.remove('tomorrow-conflict-mode');
@@ -7919,9 +7924,7 @@
qs('#week-review-status').textContent=weekPlan.pending()?'Wait for Week Ahead to finish syncing before confirming.':'Move duplicated work to one date before confirming.';
return;
}
- qs('#mobile-week-summary').textContent=weekPlan.summary();
- qs('#my-work-action-status').textContent='Week Ahead reviewed and confirmed.';
- taskOverlayHistory.leave();
+ weekCalendar.open(weekPlan.state());
});
qs('#plan-today-sheet').addEventListener('click', event => {
if (event.target === qs('#plan-today-sheet')) closePlanToday();
diff --git a/frontend/index.html b/frontend/index.html
index 9e9e7d4..b373e58 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -514,6 +514,17 @@
Confirm Week Ahead
+
+ Calendar handoff
+ Add Week Ahead to calendar
+ Choose each day’s start time and exclude anything private. Titles, references, dates, and times are included in a local calendar file; nothing is uploaded.
+
+
+
+ Back to review
+ Share calendar blocks
+
+
Fit
Today
@@ -2016,6 +2027,7 @@
+
diff --git a/frontend/service-worker.js b/frontend/service-worker.js
index 046c27a..f145340 100644
--- a/frontend/service-worker.js
+++ b/frontend/service-worker.js
@@ -124,6 +124,7 @@ const SHELL = [
BASE + 'static/plan-today-readiness.js',
BASE + 'static/plan-today-preview.js',
BASE + 'static/tomorrow-plan.js',
+ BASE + 'static/week-calendar.js',
BASE + 'static/week-plan.js',
BASE + 'static/search-week-plan.js',
BASE + 'static/today-sync.js',
diff --git a/frontend/week-calendar.js b/frontend/week-calendar.js
new file mode 100644
index 0000000..1fdd86b
--- /dev/null
+++ b/frontend/week-calendar.js
@@ -0,0 +1,109 @@
+(function(root){
+ 'use strict';
+ function escapeText(value){
+ return String(value||'').replace(/\\/g,'\\\\').replace(/\r?\n/g,'\\n').replace(/,/g,'\\,').replace(/;/g,'\\;');
+ }
+ function compact(value){return String(value||'').replace(/[-:]/g,'');}
+ function addMinutes(value,minutes){
+ const [hours,mins]=String(value).split(':').map(Number),total=hours*60+mins+Number(minutes);
+ return String(Math.floor(total/60)%24).padStart(2,'0')+':'+String(total%60).padStart(2,'0');
+ }
+ function stableId(value){return String(value||'work').replace(/[^a-z0-9]+/gi,'-').replace(/^-|-$/g,'').toLowerCase();}
+ function foldLine(line){
+ const encoder=new TextEncoder(),chunks=[];let chunk='',bytes=0,limit=75;
+ for(const character of String(line)){
+ const width=encoder.encode(character).length;
+ if(chunk&&bytes+width>limit){chunks.push(chunk);chunk=character;bytes=width;limit=74;}
+ else {chunk+=character;bytes+=width;}
+ }
+ chunks.push(chunk);return chunks.join('\r\n ');
+ }
+ function buildWeekBlocks(plan,startTimes,getItem,selected){
+ const blocks=[];
+ (plan?.days||[]).slice().sort((a,b)=>a.plan_date.localeCompare(b.plan_date)).forEach(day=>{
+ let cursor=startTimes?.[day.plan_date]||'09:00';
+ (day.ids||[]).forEach(id=>{
+ if(selected&& !selected.has(id))return;
+ const minutes=Number(day.estimates?.[id]),item=getItem?.(id);
+ if(!item||!Number.isFinite(minutes)||minutes<=0)return;
+ const end=addMinutes(cursor,minutes);
+ blocks.push({...item,id,plan_date:day.plan_date,start_time:cursor,end_time:end,minutes});
+ cursor=end;
+ });
+ });
+ return blocks;
+ }
+ function serializeWeekCalendar(blocks,{timezone='UTC',revision=0,generatedAt}={}){
+ const stamp=generatedAt||new Date().toISOString().replace(/[-:]/g,'').replace(/\.\d{3}/,'');
+ const lines=['BEGIN:VCALENDAR','VERSION:2.0','PRODID:-//Stackchain//Week Ahead//EN','CALSCALE:GREGORIAN','METHOD:PUBLISH','X-WR-CALNAME:Stackchain Week Ahead',`X-WR-TIMEZONE:${timezone}`,'BEGIN:VTIMEZONE',`TZID:${timezone}`,'END:VTIMEZONE'];
+ (blocks||[]).forEach(block=>{
+ const day=compact(block.plan_date),reference=block.repository+(block.number!=null?' #'+block.number:'');
+ lines.push('BEGIN:VEVENT',`UID:week-${revision}-${day}-${stableId(block.id)}@stackchain`,`DTSTAMP:${stamp}`,
+ `DTSTART;TZID=${timezone}:${day}T${compact(block.start_time)}00`,`DTEND;TZID=${timezone}:${day}T${compact(block.end_time)}00`,
+ `SUMMARY:${escapeText(block.title||'Untitled work')}`,`DESCRIPTION:${escapeText(reference+' · Stackchain Week Ahead')}`,
+ `URL:${String(block.url||'')}`,'TRANSP:OPAQUE','END:VEVENT');
+ });
+ lines.push('END:VCALENDAR');return lines.map(foldLine).join('\r\n')+'\r\n';
+ }
+ async function deliverWeekCalendar({text,filename,navigator,document,urlApi,FileCtor}){
+ const file=new FileCtor([text],filename,{type:'text/calendar;charset=utf-8'});
+ const payload={files:[file],title:'Stackchain Week Ahead',text:'Timed Week Ahead calendar blocks'};
+ if(typeof navigator?.share==='function'&&typeof navigator?.canShare==='function'&&navigator.canShare(payload)){
+ await navigator.share(payload);return 'shared';
+ }
+ const href=urlApi.createObjectURL(file);
+ try{const anchor=document.createElement('a');anchor.href=href;anchor.download=filename;anchor.click();}
+ finally{urlApi.revokeObjectURL(href);}
+ return 'downloaded';
+ }
+ function mountWeekCalendarHandoff({qs,getItem,escapeHtml,escapeAttribute,onDone,windowObject=root,navigatorObject=root.navigator,
+ documentObject=root.document,urlApi=root.URL,FileCtor=root.File}={}){
+ const handoff=qs('#week-calendar-handoff'),review=qs('#week-review'),daysRoot=qs('#week-calendar-days');
+ let plan=null;
+ const selected=()=>new Set(Array.from(daysRoot.querySelectorAll('[data-week-calendar-item]')).filter(input=>input.checked).map(input=>input.value));
+ const starts=()=>Object.fromEntries(Array.from(daysRoot.querySelectorAll('[data-week-calendar-start]')).map(input=>[input.dataset.weekCalendarStart,input.value]));
+ const blocks=()=>buildWeekBlocks(plan,starts(),getItem,selected());
+ function update(){
+ const current=blocks(),byId=new Map(current.map(block=>[block.id,block]));
+ daysRoot.querySelectorAll('[data-week-calendar-preview]').forEach(node=>{
+ const block=byId.get(node.dataset.weekCalendarPreview);
+ node.textContent=block?block.start_time+'–'+block.end_time+' · '+block.minutes+' min':'Excluded from calendar';
+ });
+ qs('#share-week-calendar').disabled=!current.length;
+ qs('#week-calendar-status').textContent=current.length+' calendar block'+(current.length===1?'':'s')+' selected.';
+ }
+ function close({back=false}={}){
+ handoff.hidden=true;
+ if(back){review.hidden=false;qs('#confirm-week-plan').focus?.();}
+ }
+ function open(value){
+ plan=value;review.hidden=true;handoff.hidden=false;
+ const labels=new Map((value.days||[]).map(day=>[day.plan_date,new Intl.DateTimeFormat('en',{weekday:'short',month:'short',day:'numeric',timeZone:'UTC'}).format(new Date(day.plan_date+'T12:00:00Z'))]));
+ daysRoot.innerHTML=(value.days||[]).filter(day=>day.ids?.length).map(day=>''+
+ day.ids.map(id=>{const item=getItem(id),reference=item?(item.repository+' #'+item.number):String(id).slice(0,96);
+ return ''+escapeHtml(item?.title||'Work details unavailable')+
+ ' '+escapeHtml(reference)+' ';}).join('')+' ').join('');
+ daysRoot.querySelectorAll('input').forEach(input=>input.addEventListener('change',update));
+ update();qs('#back-to-week-review-from-calendar').focus?.();return true;
+ }
+ qs('#back-to-week-review-from-calendar').addEventListener('click',()=>close({back:true}));
+ qs('#share-week-calendar').addEventListener('click',async()=>{
+ const button=qs('#share-week-calendar'),current=blocks();if(!current.length)return;
+ button.disabled=true;qs('#week-calendar-status').textContent='Preparing calendar blocks…';
+ const day=new Date().toISOString().slice(0,10);
+ try{
+ const text=serializeWeekCalendar(current,{timezone:plan.timezone||Intl.DateTimeFormat().resolvedOptions().timeZone,revision:plan.revision});
+ const result=await deliverWeekCalendar({text,filename:'stackchain-week-ahead-'+day+'.ics',navigator:navigatorObject,
+ document:documentObject,urlApi,FileCtor});
+ close();onDone?.(result);
+ }catch(error){
+ qs('#week-calendar-status').textContent=error?.name==='AbortError'?'Share cancelled. Your Week Ahead is still ready.':'Calendar export failed. Retry without leaving Week Ahead.';
+ button.disabled=false;
+ }
+ });
+ return {open,close,blocks};
+ }
+ const api={buildWeekBlocks,deliverWeekCalendar,escapeText,foldLine,mountWeekCalendarHandoff,serializeWeekCalendar};
+ if(typeof module!=='undefined'&&module.exports)module.exports=api;else root.StackchainWeekCalendar=api;
+})(typeof window!=='undefined'?window:globalThis);
diff --git a/frontend/week-plan.js b/frontend/week-plan.js
index 7dea960..a2ed848 100644
--- a/frontend/week-plan.js
+++ b/frontend/week-plan.js
@@ -90,7 +90,16 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin}={}) {
});
const duplicates=[...assigned.entries()].filter(([_id,assignedDates])=>assignedDates.length>1)
.map(([id,assignedDates])=>({id,dates:assignedDates}));
- return {days,duplicates,can_confirm:duplicates.length===0};
+ const blockers=[];
+ days.forEach(value=>{
+ if(value.ids.length&&!Number(value.capacity_minutes))blockers.push({type:'missing-capacity',plan_date:value.plan_date});
+ value.ids.forEach(id=>{if(!(Number(value.estimates?.[id])>0))blockers.push({type:'missing-estimate',plan_date:value.plan_date,id});});
+ if(value.overloaded)blockers.push({type:'over-capacity',plan_date:value.plan_date,
+ minutes:value.planned_minutes-Number(value.capacity_minutes)});
+ });
+ blockers.sort((left,right)=>left.plan_date.localeCompare(right.plan_date)||
+ ['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 move(id,toDate) {
if(!dates().some(item=>item.date===toDate)) return false;
@@ -402,8 +411,13 @@ function createWeekPlanWorkflow({controller,qs,getLogin,getItem=()=>null,openPla
const confirm=qs('#confirm-week-plan');
const pending=Boolean(controller.pending?.());
confirm.disabled=!value.can_confirm||pending;
+ const blocker=value.blockers?.[0];
qs('#week-review-status').textContent=value.duplicates.length?'Duplicate work must be moved to one date.':
- (pending?'Saving the latest week to your account…':'Week Ahead is balanced and ready.');
+ (pending?'Saving the latest week to your account…':blocker?(
+ blocker.type==='over-capacity'?blocker.plan_date+' is '+blocker.minutes+' min over capacity. Edit that day before confirming.':
+ blocker.type==='missing-estimate'?'Add an estimate for work on '+blocker.plan_date+' before confirming.':
+ 'Add capacity for '+blocker.plan_date+' before confirming.'
+ ):'Week Ahead is balanced and ready.');
root.querySelectorAll('[data-week-move]').forEach(button=>button.addEventListener('click',()=>{
const selector=root.querySelector(`[data-week-move-destination="${button.dataset.weekMove}"]`);
if(!controller.move(button.dataset.weekMove,selector?.value))return;
@@ -474,8 +488,9 @@ function createWeekPlanWorkflow({controller,qs,getLogin,getItem=()=>null,openPla
function confirm() {
const value=controller.review();
if(!reviewing||!value.can_confirm||controller.pending()) return false;
- reviewing=false;setReviewMode(false);qs('#week-review').hidden=true;return true;
+ return true;
}
+ function finish(){reviewing=false;setReviewMode(false);qs('#week-review').hidden=true;return true;}
qs('#back-to-week-review')?.addEventListener('click',returnToReview);
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;}
@@ -489,7 +504,7 @@ 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,promote,renderDates,renderConflict,active:()=>Boolean(selectedDate)||reviewing,
+ return {open,save,advance,confirm,finish,promote,renderDates,renderConflict,active:()=>Boolean(selectedDate)||reviewing,
reviewing:()=>reviewing,selectedDate:()=>selectedDate,
day:()=>selectedDate?controller.day(selectedDate):null,
copy:()=>selectedDate?{title:'Plan Week Ahead',heading:selectedDate+', in order',available:'Available this day',build:'Build this day'}:null,
diff --git a/src/frontend_bundle.py b/src/frontend_bundle.py
index 553d389..05067eb 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-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-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 e903c68..8eabb31 100644
--- a/tests/e2e/test_mobile_week_ahead_release.py
+++ b/tests/e2e/test_mobile_week_ahead_release.py
@@ -102,6 +102,20 @@ def test_release_artifact_plans_seven_touch_safe_mobile_dates(
bounds = confirm.bounding_box()
assert bounds and bounds["height"] >= 44
confirm.click()
+ expect(page.locator("#week-calendar-handoff")).to_be_visible()
+ expect(page.locator("#week-review")).to_be_hidden()
+ start = page.locator("[data-week-calendar-start]").first
+ expect(start).to_have_value("09:00")
+ expect(page.locator('[data-week-calendar-preview="issue:acme/mobile:41:"]')).to_have_text("09:00–09:30 · 30 min")
+ for control in (start, page.locator("#back-to-week-review-from-calendar"), page.locator("#share-week-calendar")):
+ bounds = control.bounding_box()
+ assert bounds and bounds["height"] >= 44
+ page.locator("#back-to-week-review-from-calendar").click()
+ expect(page.locator("#week-review")).to_be_visible()
+ confirm.click()
+ with page.expect_download() as download_info:
+ page.locator("#share-week-calendar").click()
+ 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
diff --git a/tests/test_service_worker.py b/tests/test_service_worker.py
index 5137b71..5ca8e5f 100644
--- a/tests/test_service_worker.py
+++ b/tests/test_service_worker.py
@@ -1328,6 +1328,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
"/dashboard/static/plan-today-readiness.js",
"/dashboard/static/plan-today-preview.js",
"/dashboard/static/tomorrow-plan.js",
+ "/dashboard/static/week-calendar.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
new file mode 100644
index 0000000..4135247
--- /dev/null
+++ b/tests/test_week_calendar.py
@@ -0,0 +1,110 @@
+import json
+import subprocess
+from pathlib import Path
+
+
+WEEK_CALENDAR = Path(__file__).parents[1] / "frontend" / "week-calendar.js"
+INDEX = Path(__file__).parents[1] / "frontend" / "index.html"
+DASHBOARD = Path(__file__).parents[1] / "frontend" / "dashboard.js"
+BUNDLE = Path(__file__).parents[1] / "src" / "frontend_bundle.py"
+
+
+def run_node(source: str):
+ completed = subprocess.run(["node", "-e", source], capture_output=True, text=True)
+ assert completed.returncode == 0, completed.stderr
+ return json.loads(completed.stdout)
+
+
+def test_week_calendar_builds_ordered_local_time_blocks_and_serializes_opaque_events():
+ plan = {
+ "timezone": "America/New_York",
+ "revision": 7,
+ "days": [{
+ "plan_date": "2026-08-21",
+ "ids": ["issue:stackchain/dashboard:12:", "pull:stackchain/api:7:"],
+ "capacity_minutes": 120,
+ "estimates": {
+ "issue:stackchain/dashboard:12:": 45,
+ "pull:stackchain/api:7:": 30,
+ },
+ }],
+ }
+ items = {
+ "issue:stackchain/dashboard:12:": {
+ "id": 401,
+ "title": "Plan, ship; verify",
+ "repository": "stackchain/dashboard",
+ "number": 12,
+ "url": "https://forge.alexanderwhitestone.com/git/stackchain/stackchain-dashboard/issues/12",
+ },
+ "pull:stackchain/api:7:": {
+ "title": "API release",
+ "repository": "stackchain/api",
+ "number": 7,
+ "url": "https://forge.alexanderwhitestone.com/git/stackchain/api/pulls/7",
+ },
+ }
+ source = f"""
+const calendar=require({json.dumps(str(WEEK_CALENDAR))});
+const plan={json.dumps(plan)}, items={json.dumps(items)};
+const blocks=calendar.buildWeekBlocks(plan, {{'2026-08-21':'09:00'}}, id=>items[id]);
+const text=calendar.serializeWeekCalendar(blocks, {{timezone:plan.timezone,revision:plan.revision,generatedAt:'20260820T120000Z'}});
+console.log(JSON.stringify({{blocks,text}}));
+"""
+
+ result = run_node(source)
+ assert [(block["start_time"], block["end_time"]) for block in result["blocks"]] == [
+ ("09:00", "09:45"), ("09:45", "10:15")
+ ]
+ text = result["text"]
+ assert "TZID:America/New_York" in text
+ assert "DTSTART;TZID=America/New_York:20260821T090000" in text
+ assert "DTEND;TZID=America/New_York:20260821T094500" in text
+ assert "DTSTART;TZID=America/New_York:20260821T094500" in text
+ assert "DTEND;TZID=America/New_York:20260821T101500" in text
+ assert "TRANSP:OPAQUE" in text
+ assert "UID:week-7-20260821-issue-stackchain-dashboard-12@stackchain" in text
+ assert "SUMMARY:Plan\\, ship\\; verify" in text
+ unfolded = text.replace("\r\n ", "")
+ assert "URL:https://forge.alexanderwhitestone.com/git/stackchain/stackchain-dashboard/issues/12" in unfolded
+
+
+def test_week_calendar_excludes_unselected_items_and_downloads_when_native_share_is_unavailable():
+ source = f"""
+const calendar=require({json.dumps(str(WEEK_CALENDAR))});
+const plan={{timezone:'UTC',revision:2,days:[{{plan_date:'2026-08-21',ids:['one','two'],capacity_minutes:60,estimates:{{one:20,two:25}}}}]}};
+const items={{one:{{title:'Private',repository:'r',number:1,url:'https://forge/r/1'}},two:{{title:'Public',repository:'r',number:2,url:'https://forge/r/2'}}}};
+const blocks=calendar.buildWeekBlocks(plan,{{'2026-08-21':'13:00'}},id=>items[id],new Set(['two']));
+class FakeFile {{constructor(parts,name,options){{this.parts=parts;this.name=name;this.type=options.type;}}}}
+const clicks=[],revoked=[];
+(async()=>{{
+ const result=await calendar.deliverWeekCalendar({{text:'calendar',filename:'week.ics',navigator:{{}},FileCtor:FakeFile,
+ document:{{createElement:()=>({{click(){{clicks.push([this.download,this.href]);}}}})}},
+ urlApi:{{createObjectURL:()=> 'blob:week',revokeObjectURL:value=>revoked.push(value)}}}});
+ console.log(JSON.stringify({{blocks,result,clicks,revoked}}));
+}})();
+"""
+
+ result = run_node(source)
+ assert [block["id"] for block in result["blocks"]] == ["two"]
+ assert result["blocks"][0]["start_time"] == "13:00"
+ assert result["result"] == "downloaded"
+ assert result["clicks"] == [["week.ics", "blob:week"]]
+ assert result["revoked"] == ["blob:week"]
+
+
+def test_week_calendar_handoff_is_wired_into_confirmation_with_mobile_privacy_controls():
+ index = INDEX.read_text()
+ dashboard = DASHBOARD.read_text()
+ bundle = BUNDLE.read_text()
+
+ assert '' in index
+ assert 'id="week-calendar-handoff"' in index
+ assert 'id="week-calendar-days"' in index
+ assert 'id="share-week-calendar"' in index
+ assert 'id="back-to-week-review-from-calendar"' in index
+ assert "Titles, references, dates, and times are included" in index
+ 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
diff --git a/tests/test_week_plan_frontend.py b/tests/test_week_plan_frontend.py
index e0bc5fa..5cef6fc 100644
--- a/tests/test_week_plan_frontend.py
+++ b/tests/test_week_plan_frontend.py
@@ -16,6 +16,25 @@ const createWeekPlan = require({json.dumps(str(CONTROLLER))});
return json.loads(completed.stdout)
+def test_week_controller_requires_executable_days_before_calendar_handoff():
+ result = run_controller("""
+const week=createWeekPlan({fetchJson:async()=>({}),localDate:()=> '2026-08-20',timeZone:()=> 'UTC'});
+week.adopt({revision:7,timezone:'UTC',days:[
+ {plan_date:'2026-08-21',ids:['over'],capacity_minutes:30,estimates:{over:45}},
+ {plan_date:'2026-08-22',ids:['missing-estimate'],capacity_minutes:60,estimates:{}},
+ {plan_date:'2026-08-23',ids:['missing-capacity'],capacity_minutes:null,estimates:{'missing-capacity':20}},
+]});
+console.log(JSON.stringify(week.review()));
+""")
+
+ assert result["can_confirm"] is False
+ assert result["blockers"] == [
+ {"type": "over-capacity", "plan_date": "2026-08-21", "minutes": 15},
+ {"type": "missing-estimate", "plan_date": "2026-08-22", "id": "missing-estimate"},
+ {"type": "missing-capacity", "plan_date": "2026-08-23"},
+ ]
+
+
def test_week_controller_lists_seven_local_dates_and_saves_one_without_changing_other_days():
result = run_controller("""
const requests=[];