Set Week Ahead capacity from a private calendar import #1207

Merged
rockachopa merged 1 commits from timmy/1206-week-calendar-capacity into main 2026-08-21 03:28:01 +00:00
12 changed files with 336 additions and 6 deletions

View File

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

View File

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

View File

@ -509,11 +509,30 @@
<div class="small">Final planning step</div>
<h2 id="week-review-title">Review &amp; rebalance Week Ahead</h2>
<p class="small muted">Check each days load. Move work instead of copying it, then confirm one executable week.</p>
<button id="open-week-capacity-import" type="button">Set capacity from calendar</button>
<div class="week-review-duplicates" id="week-review-duplicates" role="alert" hidden></div>
<div class="week-review-days" id="week-review-days"></div>
<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-capacity-import" id="week-capacity-import" aria-labelledby="week-capacity-title" hidden>
<header><div><div class="small">Private calendar import</div><h2 id="week-capacity-title">Set Week Ahead capacity</h2></div>
<button id="cancel-week-capacity-import" type="button">Cancel</button></header>
<p class="small muted">Your calendar is read only on this device. Event titles, people, locations, and the file are never saved or uploaded.</p>
<label>Calendar file
<input id="week-capacity-file" type="file" accept=".ics,text/calendar" />
</label>
<div class="week-capacity-hours">
<label>Workday starts <input id="week-capacity-start" type="time" value="09:00" required /></label>
<label>Workday ends <input id="week-capacity-end" type="time" value="17:00" required /></label>
</div>
<div id="week-capacity-status" class="small" role="status" aria-live="assertive"></div>
<section id="week-capacity-review" aria-labelledby="week-capacity-review-title" hidden>
<h3 id="week-capacity-review-title">Review available time</h3>
<div id="week-capacity-days" class="week-capacity-days"></div>
<button id="apply-week-capacities" type="button">Apply seven capacities</button>
</section>
</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>
@ -2028,6 +2047,7 @@
<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-calendar-import.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

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

View File

@ -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&&current?.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)=>'<article class="week-capacity-day"><strong>'+
display[index].label+'</strong><span>'+day.capacity_minutes+' min available</span><small>'+day.busy_minutes+
' min busy during working hours</small></article>').join('');
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;

View File

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

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

View File

@ -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"]
)

View File

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

View File

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

View File

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

View File

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