@@ -1988,6 +2006,7 @@
+
diff --git a/frontend/search-week-plan.js b/frontend/search-week-plan.js
new file mode 100644
index 0000000..3c8bd3e
--- /dev/null
+++ b/frontend/search-week-plan.js
@@ -0,0 +1,115 @@
+function createSearchWeekPlan({week,claim,accept=item=>item,identity,maxItems=5}={}) {
+ let running=null;
+ const eligible=detail=>Boolean(detail&&detail.kind==='issue'&&detail.state==='open'&&
+ (detail.claimable||detail.assigned_to_me));
+ const loadDay=date=>{
+ const value=week.day(date),estimates=value.estimates||{};
+ return {...value,planned_minutes:(value.ids||[]).reduce((total,id)=>total+(Number(estimates[id])||0),0)};
+ };
+ async function preview(detail) {
+ if(!eligible(detail))return {eligible:false,days:[]};
+ await week.load();
+ const id=identity(detail),existing=week.placement(id);
+ return {eligible:true,existing,days:week.dates().map(item=>({...item,...loadDay(item.date)}))};
+ }
+ function plan(detail,options={}) {
+ if(running)return running;
+ const perform=async()=>{
+ if(!eligible(detail))return {status:'ineligible'};
+ const estimate=Number(options.estimate);
+ if(!Number.isFinite(estimate)||estimate<=0)return {status:'estimate-required'};
+ await week.load();
+ if(!week.dates().some(item=>item.date===options.date))return {status:'date-required'};
+ const originalId=identity(detail),existing=week.placement(originalId);
+ if(existing&&existing.date!==options.date&&!options.move)
+ return {status:'already-planned',date:existing.date,estimate:existing.estimate};
+ const destination=loadDay(options.date);
+ const alreadyThere=(destination.ids||[]).includes(originalId);
+ if(!alreadyThere&&(destination.ids||[]).length>=maxItems)return {status:'day-full',limit:maxItems};
+ const previous=alreadyThere?(Number(destination.estimates?.[originalId])||0):0;
+ const plannedMinutes=destination.planned_minutes-previous+estimate;
+ if(Number(destination.capacity_minutes)>0&&plannedMinutes>Number(destination.capacity_minutes)&&!options.confirmOverload)
+ return {status:'overload-confirmation-required',planned_minutes:plannedMinutes,
+ capacity_minutes:Number(destination.capacity_minutes)};
+ let item=detail,assigned=false;
+ if(detail.claimable){item=await claim(detail);assigned=true;}
+ item=accept(item)||item;
+ const id=identity(item);
+ if(!week.place(id,options.date,estimate,{move:Boolean(options.move)}))
+ return {status:assigned?'assigned-not-planned':'not-planned',item};
+ try {
+ await week.flush();
+ return {status:'planned',date:options.date,estimate,item};
+ } catch(_error) {
+ return {status:'planned-pending',date:options.date,estimate};
+ }
+ };
+ running=perform().finally(()=>{running=null;});
+ return running;
+ }
+ return {eligible,preview,plan,pending:()=>Boolean(running)};
+}
+function createSearchWeekPlanUI({planner,document,window,getDetail,onPlanned=()=>{},announce=()=>{},escapeHtml,escapeAttribute}={}) {
+ const qs=selector=>document.querySelector(selector);
+ let trigger=null,detail=null,preview=null,overload=false;
+ function close(navigate=false) {
+ if(navigate&&window.history.state?.searchWeekPlan){window.history.back();return;}
+ qs('#search-week-plan-sheet').hidden=true;
+ detail=null;preview=null;overload=false;trigger?.focus?.();trigger=null;
+ }
+ function selectDate(date) {
+ qs('#search-week-plan-days').querySelectorAll('[data-search-week-date]').forEach(button=>
+ button.setAttribute('aria-pressed',String(button.dataset.searchWeekDate===date)));
+ qs('#search-week-plan-days').dataset.selected=date;
+ overload=false;qs('#confirm-search-week-plan').textContent='Plan';
+ }
+ async function open(button) {
+ const selected=getDetail();
+ if(!selected||planner.pending())return false;
+ trigger=button;detail=selected;overload=false;
+ const sheet=qs('#search-week-plan-sheet'),status=qs('#search-week-plan-status');
+ sheet.hidden=false;status.textContent='Loading Week Ahead…';qs('#confirm-search-week-plan').disabled=true;
+ window.history.pushState({...window.history.state,searchWeekPlan:true},'',window.location.href);
+ qs('#search-week-plan-copy').textContent=detail.title||'Untitled issue';
+ try {
+ preview=await planner.preview(detail);
+ const existing=preview.existing;
+ qs('#search-week-plan-days').innerHTML=preview.days.map((day,index)=>
+ '').join('');
+ qs('#search-week-plan-days').querySelectorAll('[data-search-week-date]').forEach(dayButton=>
+ dayButton.addEventListener('click',()=>selectDate(dayButton.dataset.searchWeekDate)));
+ selectDate(existing?.date||preview.days[0].date);
+ qs('#search-week-plan-estimate').value=existing?.estimate||'';
+ status.textContent=existing?'Already planned for '+existing.date+'. Choose another day to move it.':'Choose a day and add an estimate.';
+ qs('#confirm-search-week-plan').disabled=false;qs('#search-week-plan-estimate').focus();return true;
+ } catch(error) {status.textContent=(error.message||'Week Ahead is unavailable.')+' Retry when connected.';return false;}
+ }
+ async function confirm() {
+ if(!detail||planner.pending())return;
+ const date=qs('#search-week-plan-days').dataset.selected,estimate=Number(qs('#search-week-plan-estimate').value);
+ const existing=preview?.existing,move=Boolean(existing&&existing.date!==date);
+ const button=qs('#confirm-search-week-plan'),status=qs('#search-week-plan-status');button.disabled=true;
+ const outcome=await planner.plan(detail,{date,estimate,move,confirmOverload:overload});
+ if(outcome.status==='overload-confirmation-required'){
+ overload=true;button.textContent='Confirm over capacity';
+ status.textContent=outcome.planned_minutes+' min exceeds '+outcome.capacity_minutes+' min capacity. Confirm to plan anyway.';
+ } else if(outcome.status==='estimate-required')status.textContent='Enter an estimate in minutes.';
+ else if(outcome.status==='day-full')status.textContent='That day already has '+outcome.limit+' items. Choose another day.';
+ else if(outcome.status==='already-planned')status.textContent='Already planned for '+outcome.date+'. Choose Move to change the day.';
+ else if(outcome.status==='assigned-not-planned')status.textContent='Assigned, not planned. Find it in My Work and retry.';
+ else if(outcome.status==='planned-pending'){announce('Planned for '+date+' · sync pending.');onPlanned(detail,true);close(true);}
+ else if(outcome.status==='planned'){announce('Planned for '+date+'.');onPlanned(detail,false);close(true);}
+ else status.textContent='Could not plan this issue. Retry.';
+ button.disabled=false;
+ }
+ qs('#plan-search-result').addEventListener('click',event=>open(event.currentTarget));
+ qs('#cancel-search-week-plan').addEventListener('click',()=>close(true));
+ qs('#confirm-search-week-plan').addEventListener('click',confirm);
+ window.addEventListener('popstate',()=>{if(!qs('#search-week-plan-sheet').hidden)close();});
+ document.addEventListener('keydown',event=>{
+ if(event.key==='Escape'&&!qs('#search-week-plan-sheet').hidden){event.preventDefault();close(true);}
+ });
+ return {open,close,confirm};
+}
+if(typeof module!=='undefined'&&module.exports){module.exports=createSearchWeekPlan;module.exports.UI=createSearchWeekPlanUI;}
\ No newline at end of file
diff --git a/frontend/service-worker.js b/frontend/service-worker.js
index c2df80a..046c27a 100644
--- a/frontend/service-worker.js
+++ b/frontend/service-worker.js
@@ -125,6 +125,7 @@ const SHELL = [
BASE + 'static/plan-today-preview.js',
BASE + 'static/tomorrow-plan.js',
BASE + 'static/week-plan.js',
+ BASE + 'static/search-week-plan.js',
BASE + 'static/today-sync.js',
BASE + 'static/today-rollover.js',
BASE + 'static/update-ownership.js',
diff --git a/frontend/week-plan.js b/frontend/week-plan.js
index 4fc8878..7dea960 100644
--- a/frontend/week-plan.js
+++ b/frontend/week-plan.js
@@ -107,6 +107,27 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin}={}) {
if(Number.isFinite(estimate)) destination.estimates[id]=estimate;
return Boolean(stageDays(moved));
}
+ function placement(id) {
+ const found=week.days.find(item=>(item.ids||[]).includes(id));
+ if(!found)return null;
+ const estimate=Number(found.estimates?.[id]);
+ return {date:found.plan_date,estimate:Number.isFinite(estimate)?estimate:null};
+ }
+ function place(id,toDate,estimate,{move:allowMove=false}={}) {
+ if(!id||!dates().some(item=>item.date===toDate)||!Number.isFinite(Number(estimate))||Number(estimate)<=0)return false;
+ const existing=placement(id);
+ if(existing&&existing.date!==toDate&&!allowMove)return false;
+ const changed=week.days.map(item=>{
+ const estimates={...(item.estimates||{})};
+ if(item.plan_date!==toDate){delete estimates[id];return {...cloneDay(item),ids:(item.ids||[]).filter(value=>value!==id),estimates};}
+ return cloneDay(item);
+ });
+ let destination=changed.find(item=>item.plan_date===toDate);
+ if(!destination){destination={plan_date:toDate,ids:[],capacity_minutes:null,estimates:{}};changed.push(destination);}
+ if(!destination.ids.includes(id))destination.ids.push(id);
+ destination.estimates[id]=Number(estimate);
+ return Boolean(stageDays(changed));
+ }
function deliveryBody(value) {
return {base_revision:value.base_revision,timezone:value.timezone,days:value.days.map(cloneDay)};
}
@@ -288,7 +309,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,pending,flush,conflict,chooseDay,saveMerged,
+ return {adopt,state,dates,day,pass,load,saveDay,stageDay,review,move,placement,place,pending,flush,conflict,chooseDay,saveMerged,
keepLocal,useRemote,promote,summary};
}
function createWeekPlanWorkflow({controller,qs,getLogin,getItem=()=>null,openPlanner,setReviewMode=()=>{},escapeHtml,escapeAttribute,
diff --git a/src/frontend_bundle.py b/src/frontend_bundle.py
index 8a14251..3674622 100644
--- a/src/frontend_bundle.py
+++ b/src/frontend_bundle.py
@@ -36,7 +36,7 @@ FEATURE_SOURCES = {
"planning": (
"static/plan-today.js", "static/plan-today-readiness.js",
"static/plan-today-preview.js", "static/today-rollover.js", "static/today-readiness.js",
- "static/tomorrow-plan.js", "static/week-plan.js", "static/mobile-plan-today-nav.js",
+ "static/tomorrow-plan.js", "static/week-plan.js", "static/search-week-plan.js", "static/agenda-session-launcher.js", "static/mobile-plan-today-nav.js",
),
"today-timer": (
"static/conversation.js", "static/widgets.js", "static/voice-transcript-store.js", "static/voice-conversation-capture.js", "static/mobile-launch.js", "static/mobile-insights.js", "static/mobile-app-shortcuts.js", "static/mobile-find-work-nav.js", "static/mobile-pull-refresh.js", "static/live-data-status.js",
diff --git a/tests/e2e/test_mobile_search_week_plan.py b/tests/e2e/test_mobile_search_week_plan.py
new file mode 100644
index 0000000..6f8a130
--- /dev/null
+++ b/tests/e2e/test_mobile_search_week_plan.py
@@ -0,0 +1,43 @@
+from pathlib import Path
+
+import pytest
+
+
+sync_api = pytest.importorskip("playwright.sync_api")
+
+FRONTEND = Path(__file__).parents[2] / "frontend"
+
+
+@pytest.mark.parametrize("width,height", [(320, 568), (390, 844)])
+def test_search_week_plan_sheet_fits_small_phones_with_touch_safe_controls(width, height):
+ html = (FRONTEND / "index.html").read_text()
+ with sync_api.sync_playwright() as playwright:
+ try:
+ browser = playwright.chromium.launch(headless=True)
+ except Exception as error:
+ pytest.skip(f"Chromium unavailable: {error}")
+ page = browser.new_page(viewport={"width": width, "height": height})
+ page.set_content(html)
+ page.add_style_tag(path=FRONTEND / "dashboard.css")
+ page.locator("#search-week-plan-days").evaluate(
+ """node => node.innerHTML = Array.from({length:7}, (_, index) =>
+ ``).join('')"""
+ )
+ page.locator("#search-week-plan-sheet").evaluate("node => node.hidden = false")
+ page.locator("#search-week-plan-estimate").fill("45")
+
+ expect = sync_api.expect
+ expect(page.locator("#search-week-plan-sheet")).to_be_visible()
+ expect(page.locator("#confirm-search-week-plan")).to_be_visible()
+ for button in page.locator("#search-week-plan-days button").all():
+ bounds = button.bounding_box()
+ assert bounds and bounds["height"] >= 44
+ assert page.locator("#confirm-search-week-plan").bounding_box()["height"] >= 44
+ assert page.locator("#cancel-search-week-plan").bounding_box()["height"] >= 44
+ assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
+ assert page.locator(".search-week-plan-panel").evaluate(
+ "node => node.scrollWidth <= node.clientWidth"
+ )
+ browser.close()
diff --git a/tests/test_ci_workflow.py b/tests/test_ci_workflow.py
index 01be3a6..409a2db 100644
--- a/tests/test_ci_workflow.py
+++ b/tests/test_ci_workflow.py
@@ -61,6 +61,7 @@ def test_release_promotion_waits_for_packaged_mobile_journeys():
assert (
"python3 -m pytest tests/e2e/test_mobile_offline_issue_release.py "
"tests/e2e/test_mobile_search_preview_navigation.py "
+ "tests/e2e/test_mobile_search_week_plan.py "
"tests/e2e/test_mobile_find_work_release.py "
"tests/e2e/test_mobile_home_bootstrap_release.py "
"tests/e2e/test_mobile_sign_out_release.py "
diff --git a/tests/test_search_week_plan.py b/tests/test_search_week_plan.py
new file mode 100644
index 0000000..dbb6521
--- /dev/null
+++ b/tests/test_search_week_plan.py
@@ -0,0 +1,101 @@
+import json
+import subprocess
+from pathlib import Path
+
+
+FRONTEND = Path(__file__).parents[1] / "frontend"
+MODULE = FRONTEND / "search-week-plan.js"
+
+
+def run_planner(scenario: str) -> dict:
+ harness = f"""
+const createSearchWeekPlan = 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_search_week_plan_previews_seven_days_and_requires_explicit_overload_confirmation():
+ result = run_planner("""
+let claims=0,stages=0;
+const days=Array.from({length:7},(_,index)=>({date:`2026-08-${21+index}`,label:`Day ${index+1}`}));
+const week={load:async()=>({}),dates:()=>days,day:date=>date==='2026-08-21'
+ ? {plan_date:date,ids:['existing'],capacity_minutes:60,estimates:{existing:50}}
+ : {plan_date:date,ids:[],capacity_minutes:90,estimates:{}},
+ placement:()=>null,place:()=>{stages+=1;return true;},flush:async()=>({revision:2})};
+const planner=createSearchWeekPlan({week,claim:async item=>{claims+=1;return {...item,assigned_to_me:true,claimable:false};},
+ accept:item=>item,identity:item=>`issue:${item.repository}:${item.number}:`});
+const detail={kind:'issue',state:'open',repository:'stackchain/dashboard',number:42,claimable:true};
+const preview=await planner.preview(detail);
+const invalid=await planner.plan(detail,{date:'2026-08-21',estimate:0});
+const overload=await planner.plan(detail,{date:'2026-08-21',estimate:30});
+console.log(JSON.stringify({preview,invalid,overload,claims,stages}));
+""")
+
+ assert len(result["preview"]["days"]) == 7
+ assert result["preview"]["days"][0]["planned_minutes"] == 50
+ assert result["preview"]["days"][0]["capacity_minutes"] == 60
+ assert result["invalid"] == {"status": "estimate-required"}
+ assert result["overload"]["status"] == "overload-confirmation-required"
+ assert result["overload"]["planned_minutes"] == 80
+ assert result["claims"] == 0
+ assert result["stages"] == 0
+
+
+def test_search_week_plan_assigns_once_then_durably_places_canonical_issue():
+ result = run_planner("""
+let release,claims=0,places=0,flushes=0;
+const week={load:async()=>({}),dates:()=>[{date:'2026-08-22',label:'Sat, Aug 22'}],
+ day:date=>({plan_date:date,ids:[],capacity_minutes:120,estimates:{}}),placement:()=>null,
+ place:(id,date,estimate)=>{places+=1;return {id,date,estimate};},flush:async()=>{flushes+=1;return {revision:3};}};
+const planner=createSearchWeekPlan({week,
+ claim:async item=>{claims+=1;await new Promise(resolve=>{release=resolve});return {...item,assigned_to_me:true,claimable:false,title:'Canonical'};},
+ accept:item=>({...item,accepted:true}),identity:item=>`issue:${item.repository}:${item.number}:`});
+const detail={kind:'issue',state:'open',repository:'stackchain/dashboard',number:42,claimable:true};
+const first=planner.plan(detail,{date:'2026-08-22',estimate:45});
+const second=planner.plan(detail,{date:'2026-08-22',estimate:45});
+await Promise.resolve();release();
+const outcomes=await Promise.all([first,second]);
+console.log(JSON.stringify({outcomes,claims,places,flushes}));
+""")
+
+ assert result["claims"] == 1
+ assert result["places"] == 1
+ assert result["flushes"] == 1
+ assert result["outcomes"][0] == result["outcomes"][1]
+ assert result["outcomes"][0]["status"] == "planned"
+ assert result["outcomes"][0]["item"]["accepted"] is True
+
+
+def test_search_week_plan_reports_existing_placement_and_partial_assignment_truthfully():
+ result = run_planner("""
+const base={load:async()=>({}),dates:()=>[{date:'2026-08-22',label:'Sat'}],
+ day:date=>({plan_date:date,ids:[],capacity_minutes:120,estimates:{}}),flush:async()=>({})};
+const detail={kind:'issue',state:'open',repository:'stackchain/dashboard',number:42,claimable:true};
+const existing=createSearchWeekPlan({week:{...base,placement:()=>({date:'2026-08-23',estimate:30}),place:()=>true},
+ claim:async item=>item,accept:item=>item,identity:()=> 'work'});
+const blocked=await existing.plan(detail,{date:'2026-08-22',estimate:45});
+const partial=createSearchWeekPlan({week:{...base,placement:()=>null,place:()=>false},
+ claim:async item=>({...item,assigned_to_me:true,claimable:false}),accept:item=>item,identity:()=> 'work'});
+const failed=await partial.plan(detail,{date:'2026-08-22',estimate:45});
+console.log(JSON.stringify({blocked,failed}));
+""")
+
+ assert result["blocked"] == {"status": "already-planned", "date": "2026-08-23", "estimate": 30}
+ assert result["failed"]["status"] == "assigned-not-planned"
+
+
+def test_search_week_plan_moves_without_duplicates_and_keeps_pending_sync_on_network_failure():
+ result = run_planner("""
+const week={load:async()=>({}),dates:()=>[{date:'2026-08-22',label:'Sat'}],
+ day:date=>({plan_date:date,ids:[],capacity_minutes:120,estimates:{}}),
+ placement:()=>({date:'2026-08-21',estimate:30}),place:()=>true,
+ flush:async()=>{throw new Error('offline');}};
+const planner=createSearchWeekPlan({week,claim:async item=>item,accept:item=>item,identity:()=> 'work'});
+const outcome=await planner.plan({kind:'issue',state:'open',assigned_to_me:true},
+ {date:'2026-08-22',estimate:45,move:true});
+console.log(JSON.stringify(outcome));
+""")
+
+ assert result == {"status": "planned-pending", "date": "2026-08-22", "estimate": 45}
diff --git a/tests/test_search_week_plan_ui.py b/tests/test_search_week_plan_ui.py
new file mode 100644
index 0000000..691e4c1
--- /dev/null
+++ b/tests/test_search_week_plan_ui.py
@@ -0,0 +1,51 @@
+from pathlib import Path
+
+from src.frontend_bundle import build_frontend
+
+
+ROOT = Path(__file__).parents[1]
+FRONTEND = ROOT / "frontend"
+
+
+def test_search_preview_exposes_accessible_plan_ahead_sheet_and_packaged_controller():
+ html = (FRONTEND / "index.html").read_text()
+ build = build_frontend(FRONTEND)
+
+ assert 'id="plan-search-result"' in html
+ assert 'id="search-week-plan-sheet" role="dialog" aria-modal="true"' in html
+ assert 'aria-labelledby="search-week-plan-title"' in html
+ assert 'id="search-week-plan-days"' in html
+ assert 'id="search-week-plan-estimate"' in html
+ assert 'id="confirm-search-week-plan"' in html
+ assert 'id="cancel-search-week-plan"' in html
+ assert "static/search-week-plan.js" in build.page_sources
+ assert b"createSearchWeekPlan" in build.feature_bundles["planning"].runtime_bytes
+
+
+def test_search_plan_ahead_ui_connects_eligibility_preview_and_truthful_outcomes():
+ dashboard = (FRONTEND / "dashboard.js").read_text()
+ source = (FRONTEND / "search-week-plan.js").read_text()
+
+ assert "const searchWeekPlan = createSearchWeekPlan" in dashboard
+ assert "createSearchWeekPlanUI" in dashboard
+ assert "planButton.hidden = !searchWeekPlan.eligible(detail)" in dashboard
+ assert "await planner.preview(detail)" in source
+ assert "overload-confirmation-required" in source
+ assert "assigned-not-planned" in source
+ assert "planned-pending" in source
+ assert "Already planned for" in source
+ assert "planner.pending()" in source
+ assert "searchWeekPlan:true" in source
+ assert "if(!qs('#search-week-plan-sheet').hidden)" in source
+ assert "window.history.back()" in source
+
+
+def test_search_plan_ahead_sheet_is_phone_safe_and_touch_sized():
+ css = (FRONTEND / "dashboard.css").read_text()
+
+ assert ".search-week-plan-sheet" in css
+ assert "padding-bottom:calc(18px + env(safe-area-inset-bottom))" in css
+ assert ".search-week-plan-days button" in css
+ assert "min-height:44px" in css
+ assert "overflow-x:hidden" in css
+ assert "@media (max-width:359px)" in css
diff --git a/tests/test_service_worker.py b/tests/test_service_worker.py
index 968e82a..5137b71 100644
--- a/tests/test_service_worker.py
+++ b/tests/test_service_worker.py
@@ -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-plan.js",
+ "/dashboard/static/search-week-plan.js",
"/dashboard/static/today-sync.js",
"/dashboard/static/today-rollover.js",
"/dashboard/static/update-ownership.js",
diff --git a/tests/test_week_plan_frontend.py b/tests/test_week_plan_frontend.py
index 11be7cd..e0bc5fa 100644
--- a/tests/test_week_plan_frontend.py
+++ b/tests/test_week_plan_frontend.py
@@ -95,6 +95,32 @@ console.log(JSON.stringify({before,moved,after,pending:week.pending()}));
assert result["pending"]["days"][2]["estimates"] == {"shared": 30}
+def test_week_controller_places_new_work_or_explicitly_moves_existing_work_without_duplicates():
+ result = run_controller("""
+const values=new Map();
+const storage={getItem:key=>values.get(key)||null,setItem:(key,value)=>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:['existing'],capacity_minutes:60,estimates:{existing:30}},
+ {plan_date:'2026-08-22',ids:['other'],capacity_minutes:120,estimates:{other:45}}
+]});
+const before=week.placement('existing');
+const refused=week.place('existing','2026-08-22',50);
+const moved=week.place('existing','2026-08-22',50,{move:true});
+const added=week.place('new','2026-08-23',25);
+console.log(JSON.stringify({before,refused,moved,added,state:week.state(),pending:week.pending()}));
+""")
+
+ assert result["before"] == {"date": "2026-08-21", "estimate": 30}
+ assert result["refused"] is False
+ assert result["moved"] is True
+ assert result["added"] is True
+ assert [day["ids"] for day in result["state"]["days"]] == [[], ["other", "existing"], ["new"]]
+ assert result["state"]["days"][1]["estimates"]["existing"] == 50
+ assert result["pending"]["days"] == result["state"]["days"]
+
+
def test_week_workflow_saves_and_advances_without_closing_the_planner():
result = run_controller("""
const createWorkflow=createWeekPlan.Workflow;