From 5fb4da30a45e6b24fe07037101c46b795df0f205 Mon Sep 17 00:00:00 2001 From: timmy Date: Sat, 22 Aug 2026 16:37:25 +0000 Subject: [PATCH] feat: persist exact Week Ahead task times (Closes #1270) --- frontend/dashboard.css | 12 +++-- frontend/dashboard.js | 1 + frontend/index.html | 1 + frontend/week-calendar.js | 54 ++++++++++++++++----- frontend/week-plan.js | 13 ++++- src/main.py | 1 + src/today_store.py | 33 +++++++++++++ tests/e2e/test_mobile_week_ahead_release.py | 11 ++++- tests/test_week_calendar.py | 25 ++++++++++ tests/test_week_plan.py | 24 +++++++++ tests/test_week_plan_frontend.py | 23 +++++++++ 11 files changed, 180 insertions(+), 18 deletions(-) diff --git a/frontend/dashboard.css b/frontend/dashboard.css index e6c4775..bdfdae0 100644 --- a/frontend/dashboard.css +++ b/frontend/dashboard.css @@ -415,13 +415,17 @@ textarea { resize: vertical; min-height: 120px; } .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 { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:10px; align-items:center; min-width:0; padding:8px 0; overflow-wrap:anywhere; } +.week-calendar-choice { display:grid; grid-template-columns:auto minmax(0,1fr); gap:10px; align-items:center; min-height:44px; } +.week-calendar-choice input { width:22px; height:22px; } +.week-calendar-choice span { display:grid; gap:3px; } +.week-calendar-time { display:grid; gap:2px; font-size:12px; font-weight:700; } +.week-calendar-time input { box-sizing:border-box; width:7.5rem; min-height:44px; font-size:16px; } .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 { position:sticky; bottom:0; display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:8px; padding-bottom:env(safe-area-inset-bottom); background:#0b1526; } .week-calendar-actions button { min-width:0; min-height:48px; } +@media(max-width:359px) { .week-calendar-item, .week-calendar-actions { grid-template-columns:1fr; } .week-calendar-time input { width:100%; } } .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 2871056..794ebb0 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -461,6 +461,7 @@ t:v=>v?latestTodayPlan=v:latestTodayPlan,r:refreshMyWorkView,w:warmTodayOffline, x:currentTodayProgressTarget}); const wc=StackchainWeekCalendar.mountWeekCalendarHandoff({qs,getItem:weekFlow.i,escapeHtml,escapeAttribute:escAttr, + onSave:async byDate=>{Object.entries(byDate).forEach(([planDate,startTimes])=>weekPlan.stageStartTimes(planDate,startTimes));await weekPlan.flush();}, onDone:()=>{weekFlow.finish();taskOverlayHistory.leave();}}); const todaySync=createTodaySync({storage:localStorage,getLogin: () => planningOwnerLogin,fetchJson:fetchReviewJson, coordinator:outboxCoordinator, diff --git a/frontend/index.html b/frontend/index.html index 2bd1f06..aabc1be 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -593,6 +593,7 @@
+
diff --git a/frontend/week-calendar.js b/frontend/week-calendar.js index 9950725..30cf25d 100644 --- a/frontend/week-calendar.js +++ b/frontend/week-calendar.js @@ -24,22 +24,39 @@ const blocks=[],blockers=[],availableByDate=new Map((availability||[]).map(day=>[day.plan_date,day.free_windows||[]])); (plan?.days||[]).slice().sort((a,b)=>a.plan_date.localeCompare(b.plan_date)).forEach(day=>{ let cursor=startTimes?.[day.plan_date]||'09:00'; + const windows=availableByDate.has(day.plan_date)?availableByDate.get(day.plan_date):(day.free_windows||null); + const candidates=[]; (day.ids||[]).forEach(id=>{ - if(selected&& !selected.has(id))return; + if(selected&&!selected.has(id))return; const minutes=Number(day.estimates?.[id]),item=getItem?.(id); if(!item||!Number.isFinite(minutes)||minutes<=0)return; - const windows=availableByDate.get(day.plan_date); - if(windows){ + const exact=startTimes?.[id]||day.start_times?.[id]; + let start=exact||cursor; + if(!exact&&windows){ const fit=windows.find(window=>{ const candidate=Math.max(clockMinutes(cursor),clockMinutes(window.start_time)); if(candidate+minutes>clockMinutes(window.end_time))return false; - cursor=addMinutes('00:00',candidate);return true; + start=addMinutes('00:00',candidate);return true; }); if(!fit){blockers.push({id,title:item.title||'Untitled work',plan_date:day.plan_date,minutes});return;} } - const end=addMinutes(cursor,minutes); - blocks.push({...item,id,plan_date:day.plan_date,start_time:cursor,end_time:end,minutes}); - cursor=end; + const startMinute=clockMinutes(start),endMinute=startMinute+minutes; + candidates.push({item,id,minutes,start,startMinute,endMinute,exact:Boolean(exact)}); + if(!exact)cursor=addMinutes('00:00',endMinute); + }); + const invalid=new Set(); + candidates.forEach(candidate=>{ + if(candidate.endMinute>1440||windows&&!windows.some(window=>clockMinutes(window.start_time)<=candidate.startMinute&&candidate.endMinute<=clockMinutes(window.end_time)))invalid.add(candidate.id); + }); + candidates.forEach((left,index)=>candidates.slice(index+1).forEach(right=>{ + if(left.startMinuteright.startMinute){invalid.add(left.id);invalid.add(right.id);} + })); + candidates.forEach(candidate=>{ + if(invalid.has(candidate.id)){ + blockers.push({id:candidate.id,title:candidate.item.title||'Untitled work',plan_date:day.plan_date,minutes:candidate.minutes,reason:'invalid-exact-time'});return; + } + blocks.push({...candidate.item,id:candidate.id,plan_date:day.plan_date,start_time:candidate.start, + end_time:addMinutes('00:00',candidate.endMinute),minutes:candidate.minutes}); }); }); return {blocks,blockers}; @@ -70,12 +87,15 @@ finally{urlApi.revokeObjectURL(href);} return 'downloaded'; } - function mountWeekCalendarHandoff({qs,getItem,getAvailability=availabilityProvider,escapeHtml,escapeAttribute,onDone,windowObject=root,navigatorObject=root.navigator, + function mountWeekCalendarHandoff({qs,getItem,getAvailability=availabilityProvider,escapeHtml,escapeAttribute,onSave,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,focusReturn=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 starts=()=>Object.fromEntries([ + ...Array.from(daysRoot.querySelectorAll('[data-week-calendar-start]')).map(input=>[input.dataset.weekCalendarStart,input.value]), + ...Array.from(daysRoot.querySelectorAll('[data-week-calendar-exact]')).filter(input=>input.value).map(input=>[input.dataset.weekCalendarExact,input.value]), + ]); const schedule=()=>buildWeekSchedule(plan,starts(),getItem,selected(),getAvailability?.()); const blocks=()=>schedule().blocks; function update(){ @@ -86,6 +106,7 @@ (blocker?'Does not fit imported free time':'Excluded from calendar'); }); qs('#share-week-calendar').disabled=!current.blocks.length||current.blockers.length>0; + qs('#save-week-calendar-times').disabled=!current.blocks.length||current.blockers.length>0; if(current.blockers.length){ const blocker=current.blockers[0]; qs('#week-calendar-status').textContent=blocker.title+' does not fit free time on '+blocker.plan_date+'. Adjust the start, estimate, or calendar import.'; @@ -101,13 +122,22 @@ 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=>'

'+escapeHtml(labels.get(day.plan_date)||day.plan_date)+ '

'+ - day.ids.map(id=>{const item=getItem(id),reference=item?(item.repository+' #'+item.number):String(id).slice(0,96); - return '';}).join('')+'
').join(''); + day.ids.map(id=>{const item=getItem(id),reference=item?(item.repository+' #'+item.number):String(id).slice(0,96),saved=day.start_times?.[id]||''; + return '
';}).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('#save-week-calendar-times').addEventListener('click',async()=>{ + const button=qs('#save-week-calendar-times'),current=schedule();if(!current.blocks.length||current.blockers.length)return; + button.disabled=true;qs('#week-calendar-status').textContent='Saving exact task times…'; + const byDate={};current.blocks.forEach(block=>(byDate[block.plan_date]||(byDate[block.plan_date]={}))[block.id]=block.start_time); + try{await onSave?.(byDate);plan={...plan,days:(plan.days||[]).map(day=>({...day,start_times:{...(byDate[day.plan_date]||{})}}))}; + qs('#week-calendar-status').textContent='Exact task times saved to Week Ahead.';} + catch(error){qs('#week-calendar-status').textContent=(error?.message||'Exact times could not be saved.')+' Retry when connected.';} + finally{button.disabled=false;} + }); qs('#export-saved-week-calendar')?.addEventListener('click',event=>{focusReturn=event.currentTarget;qs('#confirm-week-plan').click();}); qs('#share-week-calendar').addEventListener('click',async()=>{ const button=qs('#share-week-calendar'),current=blocks();if(!current.length)return; diff --git a/frontend/week-plan.js b/frontend/week-plan.js index 91e07b6..abf14b0 100644 --- a/frontend/week-plan.js +++ b/frontend/week-plan.js @@ -14,10 +14,13 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin,coordinat const cloneWindows=value=>Array.isArray(value)?value.map(window=>({ start_time:window.start_time,end_time:window.end_time, })):null; + const cloneStartTimes=day=>Object.fromEntries(Object.entries(day.start_times||{}) + .filter(([id,value])=>(day.ids||[]).includes(id)&&/^\d{2}:\d{2}$/.test(value))); const cloneDay=day=>({ plan_date:day.plan_date,ids:[...(day.ids||[])],capacity_minutes:day.capacity_minutes??null, estimates:{...(day.estimates||{})}, ...(cloneWindows(day.free_windows)?{free_windows:cloneWindows(day.free_windows)}:{}), + ...(Object.keys(cloneStartTimes(day)).length?{start_times:cloneStartTimes(day)}:{}), }); const sanitizeItem=value=>value?{kind:value.kind==='pull'?'pull':'issue', title:String(value.title||'').slice(0,500),repository:String(value.repository||'').slice(0,255), @@ -206,8 +209,16 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin,coordinat return stageDays(week.days.filter(item=>item.plan_date!==planDate).concat([{ plan_date:planDate,ids:[...(value.ids||[])],capacity_minutes:value.capacity_minutes??null, estimates:{...(value.estimates||{})}, + ...(cloneWindows(value.free_windows)?{free_windows:cloneWindows(value.free_windows)}:{}), + ...(Object.keys(cloneStartTimes(value)).length?{start_times:cloneStartTimes(value)}:{}), }])); } + function stageStartTimes(planDate,startTimes) { + const value=day(planDate); + if(!value||!startTimes||typeof startTimes!=='object'||Array.isArray(startTimes))return false; + value.start_times=Object.fromEntries(value.ids.filter(id=>typeof startTimes[id]==='string').map(id=>[id,startTimes[id]])); + return stageDay(planDate,value); + } function stageCapacities(values) { const changed=capacityDays(values); return changed&&stageDays(changed); @@ -695,7 +706,7 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin,coordinat const label=planned.length?`${items} item${items===1?'':'s'} across ${planned.length} day${planned.length===1?'':'s'}`:'Nothing planned'; return label+(pending()?' · sync pending':''); } - return {adopt,state,dates,day,pass,load,saveDay,stageDay,stageCapacities,stageAvailabilityDefaults,disableAvailabilityDefaults,review,previewReflow,applyReflow,previewCapacityReflow,applyCapacityReflow,move,retire,unplan,restore,placement,place,pending,flush,conflict,chooseDay,saveMerged, + return {adopt,state,dates,day,pass,load,saveDay,stageDay,stageStartTimes,stageCapacities,stageAvailabilityDefaults,disableAvailabilityDefaults,review,previewReflow,applyReflow,previewCapacityReflow,applyCapacityReflow,move,retire,unplan,restore,placement,place,pending,flush,conflict,chooseDay,saveMerged, keepLocal,useRemote,promote,pullItem,pendingPull,resumePull,pullConflict,resolvePullConflict,flushPull,startEarly,reconcile:reconcilePromotion,summary,rememberItems,rememberPendingItem, item:id=>pendingItems[id]||confirmedItems[id]||null, offline:()=>offlineSnapshot,request:fetchJson,reschedule:()=>({storage,getLogin})}; diff --git a/src/main.py b/src/main.py index a8228ff..0cfb4b3 100644 --- a/src/main.py +++ b/src/main.py @@ -738,6 +738,7 @@ class WeekPlanDay(BaseModel): capacity_minutes: int | None = Field(default=None, ge=15, le=1440) estimates: dict[str, int] = Field(default_factory=dict, max_length=5) free_windows: list[WeekFreeWindow] | None = Field(default=None, max_length=16) + start_times: dict[str, str] = Field(default_factory=dict, max_length=5) class WeekPlanUpdate(BaseModel): diff --git a/src/today_store.py b/src/today_store.py index e8b68d5..93626a4 100644 --- a/src/today_store.py +++ b/src/today_store.py @@ -578,6 +578,39 @@ class TodayStore: }) previous_end = end plan["free_windows"] = normalized_windows + if "start_times" in day: + start_times = day["start_times"] + if not isinstance(start_times, dict) or len(start_times) > 5: + raise ValueError("task start times must be a bounded object") + if not set(start_times).issubset(plan["ids"]): + raise ValueError("task start times must belong to work on the same date") + occupied = [] + for identity in plan["ids"]: + if identity not in start_times: + continue + value = start_times[identity] + if ( + not isinstance(value, str) or len(value) != 5 or value[2] != ":" + or not value[:2].isdigit() or not value[3:].isdigit() + or int(value[:2]) > 23 or int(value[3:]) > 59 + ): + raise ValueError("task start times must use HH:mm times") + start = int(value[:2]) * 60 + int(value[3:]) + minutes = plan["estimates"].get(identity) + if not isinstance(minutes, int) or start + minutes > 1440: + raise ValueError("task start times must fit their estimates within the date") + end = start + minutes + if any(start < other_end and end > other_start for other_start, other_end in occupied): + raise ValueError("task start times must not overlap") + windows = plan.get("free_windows") + if windows and not any( + int(window["start_time"][:2]) * 60 + int(window["start_time"][3:]) <= start + and end <= int(window["end_time"][:2]) * 60 + int(window["end_time"][3:]) + for window in windows + ): + raise ValueError("task start times must fit retained free windows") + occupied.append((start, end)) + plan["start_times"] = {identity: start_times[identity] for identity in plan["ids"] if identity in start_times} duplicate_ids = seen_ids.intersection(plan["ids"]) if duplicate_ids: raise ValueError("work must be assigned to only one Week Ahead date") diff --git a/tests/e2e/test_mobile_week_ahead_release.py b/tests/e2e/test_mobile_week_ahead_release.py index 0ef3587..d2044cd 100644 --- a/tests/e2e/test_mobile_week_ahead_release.py +++ b/tests/e2e/test_mobile_week_ahead_release.py @@ -328,9 +328,18 @@ def test_release_artifact_plans_seven_touch_safe_mobile_dates( expect(start).to_have_value("09:00") expect(page.locator('[data-week-calendar-preview="issue:acme/mobile:41:"]')).to_have_text("10:00–10:45 · 45 min") expect(page.locator("#week-calendar-status")).to_contain_text("Planning around imported busy time") - for control in (start, page.locator("#back-to-week-review-from-calendar"), page.locator("#share-week-calendar")): + exact = page.locator('[data-week-calendar-exact="issue:acme/mobile:41:"]') + expect(exact).to_have_value("") + exact.fill("14:00") + expect(page.locator('[data-week-calendar-preview="issue:acme/mobile:41:"]')).to_have_text("14:00–14:45 · 45 min") + save_times = page.locator("#save-week-calendar-times") + for control in (start, exact, page.locator("#back-to-week-review-from-calendar"), save_times, page.locator("#share-week-calendar")): bounds = control.bounding_box() assert bounds and bounds["height"] >= 44 + assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth") + save_times.click() + expect(page.locator("#week-calendar-status")).to_have_text("Exact task times saved to Week Ahead.") + assert saved[-1]["days"][0]["start_times"] == {"issue:acme/mobile:41:": "14:00"} page.locator("#back-to-week-review-from-calendar").click() expect(page.locator("#week-review")).to_be_visible() confirm.click() diff --git a/tests/test_week_calendar.py b/tests/test_week_calendar.py index f928c55..696c190 100644 --- a/tests/test_week_calendar.py +++ b/tests/test_week_calendar.py @@ -6,6 +6,7 @@ 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" +CSS = Path(__file__).parents[1] / "frontend" / "dashboard.css" BUNDLE = Path(__file__).parents[1] / "src" / "frontend_bundle.py" @@ -117,6 +118,26 @@ console.log(JSON.stringify(result)); }] +def test_week_calendar_honors_exact_saved_task_times_and_rejects_conflicts(): + source = f""" +const calendar=require({json.dumps(str(WEEK_CALENDAR))}); +const items={{one:{{title:'First'}},two:{{title:'Second'}}}}; +const plan={{days:[{{plan_date:'2026-08-21',ids:['one','two'],estimates:{{one:45,two:90}}, + free_windows:[{{start_time:'09:00',end_time:'10:00'}},{{start_time:'13:00',end_time:'17:00'}}], + start_times:{{one:'09:00',two:'14:00'}}}}]}}; +const saved=calendar.buildWeekSchedule(plan,{{}},id=>items[id]); +const conflict=calendar.buildWeekSchedule(plan,{{one:'14:30',two:'14:00'}},id=>items[id]); +console.log(JSON.stringify({{saved,conflict}})); +""" + + result = run_node(source) + assert [(block["id"], block["start_time"], block["end_time"]) for block in result["saved"]["blocks"]] == [ + ("one", "09:00", "09:45"), ("two", "14:00", "15:30") + ] + assert result["conflict"]["blocks"] == [] + assert {blocker["id"] for blocker in result["conflict"]["blockers"]} == {"one", "two"} + + def test_week_calendar_excludes_unselected_items_and_downloads_when_native_share_is_unavailable(): source = f""" const calendar=require({json.dumps(str(WEEK_CALENDAR))}); @@ -150,6 +171,10 @@ def test_week_calendar_handoff_is_wired_into_confirmation_with_mobile_privacy_co assert 'id="week-calendar-handoff"' in index assert 'id="week-calendar-days"' in index assert 'id="share-week-calendar"' in index + assert 'id="save-week-calendar-times"' in index + assert 'data-week-calendar-exact' in WEEK_CALENDAR.read_text() + assert "onSave" in WEEK_CALENDAR.read_text() + assert 'min-height:44px' in CSS.read_text()[CSS.read_text().index('.week-calendar-time'):] assert 'id="back-to-week-review-from-calendar"' in index assert 'id="export-saved-week-calendar"' in index assert "Add saved week to calendar" in index diff --git a/tests/test_week_plan.py b/tests/test_week_plan.py index d8bb10e..35b7ef6 100644 --- a/tests/test_week_plan.py +++ b/tests/test_week_plan.py @@ -101,6 +101,30 @@ def test_week_plan_persists_only_bounded_non_overlapping_calendar_free_windows(t ) +def test_week_plan_persists_only_valid_exact_task_start_times(tmp_path): + store = TodayStore(tmp_path / "today.sqlite3", encryption_key=b"w" * 32) + days = sample_days() + days[0]["start_times"] = { + "issue:secret/repo:3:": "09:00", + "issue:secret/repo:2:": "10:15", + } + + saved = store.replace_week( + "timmy", base_revision=0, days=days, timezone="UTC", + ) + + assert saved["days"][0]["start_times"] == days[0]["start_times"] + assert store.get_week("timmy") == saved + assert b"09:00" not in (tmp_path / "today.sqlite3").read_bytes() + + invalid = sample_days() + invalid[0]["start_times"] = {"issue:secret/repo:3:": "23:30"} + with pytest.raises(ValueError, match="task start times"): + store.replace_week( + "timmy", base_revision=1, days=invalid, timezone="UTC", + ) + + def test_week_update_model_accepts_zero_capacity_weekday_defaults(): payload = main.WeekPlanUpdate.model_validate({ "base_revision": 2, "timezone": "UTC", "days": [], diff --git a/tests/test_week_plan_frontend.py b/tests/test_week_plan_frontend.py index 8c23629..9a8980b 100644 --- a/tests/test_week_plan_frontend.py +++ b/tests/test_week_plan_frontend.py @@ -276,6 +276,29 @@ console.log(JSON.stringify({pending,request:requests[0],restored:restored.state( assert result["restored"]["days"][0]["free_windows"] == windows +def test_week_controller_keeps_exact_task_times_through_pending_flush_and_reload(): + result = run_controller(""" +const values=new Map();const requests=[];let remote={revision:4,timezone:'UTC',days:[{plan_date:'2026-08-21',ids:['one','two'],capacity_minutes:420, + estimates:{one:45,two:90},start_times:{one:'09:00'}}]}; +const storage={getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)}; +const fetchJson=async(url,options={})=>{ + if(!options.method)return remote; + const body=JSON.parse(options.body);requests.push(body);remote={revision:5,timezone:body.timezone,days:body.days};return remote; +}; +const options={storage,getLogin:()=> 'timmy',fetchJson,localDate:()=> '2026-08-20',timeZone:()=> 'UTC'}; +const first=createWeekPlan(options);await first.load(); +first.stageStartTimes('2026-08-21',{one:'09:00',two:'11:00'}); +const pending=first.pending();await first.flush(); +const restored=createWeekPlan(options);await restored.load(); +console.log(JSON.stringify({pending,request:requests[0],restored:restored.state()})); +""") + + expected = {"one": "09:00", "two": "11:00"} + assert result["pending"]["days"][0]["start_times"] == expected + assert result["request"]["days"][0]["start_times"] == expected + assert result["restored"]["days"][0]["start_times"] == expected + + def test_week_controller_previews_and_atomically_applies_a_capacity_safe_reflow(): result = run_controller(""" const values=new Map();let writes=0; -- 2.43.0