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=>''+
- 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;