Export Week Ahead as timed calendar blocks #1199

Merged
rockachopa merged 1 commits from timmy/1198-week-calendar-blocks into main 2026-08-20 23:24:37 +00:00
11 changed files with 306 additions and 9 deletions

View File

@ -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,

View File

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

View File

@ -514,6 +514,17 @@
<div class="small week-review-status" id="week-review-status" role="status" aria-live="polite"></div>
<button id="confirm-week-plan" type="button">Confirm Week Ahead</button>
</section>
<section class="week-calendar-handoff" id="week-calendar-handoff" aria-labelledby="week-calendar-title" hidden>
<div class="small">Calendar handoff</div>
<h2 id="week-calendar-title">Add Week Ahead to calendar</h2>
<p class="small muted">Choose each days start time and exclude anything private. Titles, references, dates, and times are included in a local calendar file; nothing is uploaded.</p>
<div class="week-calendar-days" id="week-calendar-days"></div>
<div class="small week-calendar-status" id="week-calendar-status" role="status" aria-live="assertive"></div>
<div class="week-calendar-actions">
<button id="back-to-week-review-from-calendar" type="button">Back to review</button>
<button id="share-week-calendar" type="button">Share calendar blocks</button>
</div>
</section>
<nav class="mobile-plan-today-nav" aria-label="Plan Today sections">
<button type="button" data-plan-today-section="fit" aria-current="location">Fit</button>
<button type="button" data-plan-today-section="today">Today</button>
@ -2016,6 +2027,7 @@
<script src="static/plan-today-readiness.js"></script>
<script src="static/plan-today-preview.js"></script>
<script src="static/tomorrow-plan.js"></script>
<script src="static/week-calendar.js"></script>
<script src="static/week-plan.js"></script>
<script src="static/search-week-plan.js"></script>
<script src="static/today-sync.js"></script>

View File

@ -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',

109
frontend/week-calendar.js Normal file
View File

@ -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=>'<article class="week-calendar-day"><header><h3>'+escapeHtml(labels.get(day.plan_date)||day.plan_date)+
'</h3><label>Start <input type="time" value="09:00" data-week-calendar-start="'+escapeAttribute(day.plan_date)+'"></label></header>'+
day.ids.map(id=>{const item=getItem(id),reference=item?(item.repository+' #'+item.number):String(id).slice(0,96);
return '<label class="week-calendar-item"><input type="checkbox" data-week-calendar-item value="'+escapeAttribute(id)+'" checked><span><strong>'+escapeHtml(item?.title||'Work details unavailable')+
'</strong><small>'+escapeHtml(reference)+'</small><small data-week-calendar-preview="'+escapeAttribute(id)+'"></small></span></label>';}).join('')+'</article>').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);

View File

@ -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,

View File

@ -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",

View File

@ -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:0009: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

View File

@ -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",

110
tests/test_week_calendar.py Normal file
View File

@ -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 '<script src="static/week-calendar.js"></script>' 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

View File

@ -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=[];