feat: retire completed Week Ahead work (Closes #1235)

This commit is contained in:
timmy 2026-08-21 20:12:48 +00:00
parent a8c1329676
commit 1be9da3685
19 changed files with 167 additions and 36 deletions

View File

@ -175,7 +175,9 @@ work titles and references, load versus capacity, overloads, and pending sync wi
is empty and no work session is active, **Start this day early** confirms the next planned date, item count, minutes,
and capacity before atomically moving only that day into Today; offline, pending, stale, or non-empty plans remain
unchanged. **Edit day** enters one date and returns to the refreshed overview; **Edit week** starts the continuous
planning pass.
planning pass. Closing an issue from its Week Ahead detail retires that identity and estimate from every future
week day in one durable update while preserving sibling order and daily capacity. The open overview recalculates
immediately; an unavailable save remains visibly **sync pending** and retries through the existing lifecycle.
**Plan Week Ahead** continues through seven local dates and now finishes on a mobile review step instead of
closing after the seventh save. The review shows planned minutes against each days capacity, marks overloads,
and flags work assigned to more than one date. Operators can move an item to another date without copying it;

View File

@ -7115,11 +7115,11 @@
closeIssueSheet();
refreshMyWorkView({ reconcileSession:false });
if (!outcome.advanced) {
qs('#my-work-action-status').textContent = 'Issue closure queued, but Today still needs completion.';
qs('#my-work-action-status').textContent = 'Issue close queued; Today still needs completion.';
} else if (outcome.admission.background) {
qs('#my-work-action-status').textContent = 'Issue closure queued for reconnect. Next Today item opened.';
qs('#my-work-action-status').textContent = 'Issue close queued. Next Today item opened.';
} else {
qs('#my-work-action-status').textContent = 'Issue closure saved for next launch. Next Today item opened.';
qs('#my-work-action-status').textContent = 'Issue close saved. Next Today item opened.';
}
} catch (error) {
qs('#issue-sheet-status').textContent = error.message + ' The issue remains in Today; retry.';

View File

@ -572,6 +572,7 @@ function createIssueSheet({ fetchJson, storage, renderMarkdown = globalThis.rend
headers: { Accept: 'application/json' },
}).then(result => {
if (result?.state !== 'closed') throw new Error('Issue closure was not confirmed.');
globalThis.dispatchEvent?.(new CustomEvent('stackchain:issue-closed',{detail:item}));
return result;
}).finally(() => { closeRequest = null; });
return closeRequest;

View File

@ -1,7 +1,7 @@
const BASE = new URL('./', self.location.href).pathname;
importScripts(BASE + 'static/private-data-registry.js');
importScripts(BASE + 'static/background-issue-sync.js');
const CACHE = 'stackchain-dashboard-shell-v128';
const CACHE = 'stackchain-dashboard-shell-v129';
const OFFLINE_LEASE_URL = new URL(BASE + '__offline-session-lease', self.location.origin).href;
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000;

View File

@ -238,6 +238,16 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin,now=()=>D
if(Number.isFinite(estimate)) destination.estimates[id]=estimate;
return Boolean(stageDays(moved));
}
function retire(id) {
const removed=week.days.reduce((total,item)=>total+(item.ids||[]).filter(value=>value===id).length,0);
if(!id||!removed||offlineSnapshot)return false;
const days=week.days.map(item=>{
const estimates={...(item.estimates||{})};delete estimates[id];
return {...cloneDay(item),ids:(item.ids||[]).filter(value=>value!==id),estimates};
});
const staged=stageDays(days);
return staged?{...staged,removed}:false;
}
function placement(id) {
const found=week.days.find(item=>(item.ids||[]).includes(id));
if(!found)return null;
@ -465,7 +475,7 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin,now=()=>D
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,review,previewReflow,applyReflow,move,placement,place,pending,flush,conflict,chooseDay,saveMerged,
return {adopt,state,dates,day,pass,load,saveDay,stageDay,stageCapacities,review,previewReflow,applyReflow,move,retire,placement,place,pending,flush,conflict,chooseDay,saveMerged,
keepLocal,useRemote,promote,startEarly,reconcile:reconcilePromotion,summary,rememberItems,rememberPendingItem,
item:id=>pendingItems[id]||confirmedItems[id]||null,
offline:()=>offlineSnapshot,request:fetchJson,reschedule:()=>({storage,getLogin})};
@ -772,6 +782,24 @@ function createWeekPlanWorkflow({controller,qs,getItem=()=>null,openItem,openPla
if(!reviewing||!value.can_confirm||controller.pending()) return false;
return true;
}
async function retire(item) {
const staged=controller.retire?.(todayWork.identity({...item,kind:'issue'}));
if(!staged)return false;
const title=String(item?.title||'Completed work');
const refreshOverview=message=>{
if(overviewing||reviewing)renderReview();
qs('#week-review-status').textContent=message;
qs('#edit-week-plan')?.focus?.();
};
try {
await controller.flush();
refreshOverview(title+' removed and Week Ahead saved.');
return 'saved';
} catch(_error) {
refreshOverview(title+' removed · sync pending.');
return 'pending';
}
}
function finish(){reviewing=false;overviewing=false;setReviewMode(false);closeReflow();qs('#week-review').hidden=true;return true;}
qs('#back-to-week-review')?.addEventListener('click',returnToReview);
qs('#edit-week-plan')?.addEventListener('click',editWeek);
@ -792,7 +820,7 @@ function createWeekPlanWorkflow({controller,qs,getItem=()=>null,openItem,openPla
qs('#my-work-action-status').textContent=(error.message||'Week Ahead needs review before promotion.')+' Open Week Ahead to review.';return false;
}
}
const workflow={open,save,advance,confirm,finish,promote,editWeek,renderDates,renderReview,renderConflict,active:()=>Boolean(selectedDate)||reviewing||Boolean(reconciliation),
const workflow={open,save,advance,confirm,finish,promote,retire,editWeek,renderDates,renderReview,renderConflict,active:()=>Boolean(selectedDate)||reviewing||Boolean(reconciliation),
reviewing:()=>reviewing,overviewing:()=>overviewing,selectedDate:()=>selectedDate,
day:()=>reconciliationDay()||(selectedDate?controller.day(selectedDate):(overviewing?{ids:[]}:null)),
copy:()=>reconciliation?{title:"Start today's plan",heading:'Unfinished Today + due Week Ahead',available:'Available today',build:'Build combined Today'}:
@ -800,6 +828,7 @@ function createWeekPlanWorkflow({controller,qs,getItem=()=>null,openItem,openPla
(selectedDate?{title:'Plan Week Ahead',heading:selectedDate+', in order',available:'Available this day',build:'Build this day'}:null)),
clear(){selectedDate=null;reviewing=false;overviewing=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();}};
globalThis.addEventListener?.('stackchain:issue-closed',event=>retire(event.detail));
if(typeof weekCalendarImport!=='undefined')weekCalendarImport.mount(controller,workflow,qs);
if(x)mountTodayWeekReschedule({
qs,week:controller,getToday:t,refresh:r,warm:w,api:controller.request,currentTarget:x,...controller.reschedule(),

View File

@ -53,6 +53,14 @@ def test_release_artifact_plans_seven_touch_safe_mobile_dates(
}))
page.route("**/api/v1/week", week_route)
page.route(
"**/api/v1/repos/acme/mobile/issues/41/close",
lambda route: route.fulfill(
status=200,
content_type="application/json",
body='{"number":41,"state":"closed","updated_at":"2026-08-21T12:00:00Z"}',
),
)
page.goto(origin + "/", wait_until="networkidle")
page.locator('input[name="device_label"]').fill("Week Ahead release phone")
page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
@ -108,11 +116,19 @@ def test_release_artifact_plans_seven_touch_safe_mobile_dates(
expect(page.locator("#issue-sheet")).to_have_class(re.compile(r"\bopen\b"))
expect(page.locator("#issue-sheet-title")).to_have_text("Ship mobile capture")
expect(overview).to_be_visible()
page.locator("#close-issue-sheet").click()
page.once("dialog", lambda dialog: dialog.accept())
page.locator("#close-issue").click()
expect(page.locator("#issue-sheet")).not_to_have_class(re.compile(r"\bopen\b"))
expect(overview).to_be_visible()
expect(planned_item).to_be_focused()
assert len(saved) == 1, "opening and inspecting Week Ahead must not add a write"
expect(cards.first).not_to_contain_text("Ship mobile capture")
expect(cards.first).to_contain_text("0 items · 0 of 60 min")
expect(page.locator("#week-review-status")).to_have_text(
"Ship mobile capture removed and Week Ahead saved."
)
expect(edit_week).to_be_focused()
assert len(saved) == 2, "closing planned work must stage one Week Ahead retirement"
assert saved[-1]["days"][0]["ids"] == []
assert saved[-1]["days"][0]["capacity_minutes"] == 60
for control in (cards.first.locator("[data-week-edit-day]"), edit_week):
bounds = control.bounding_box()
assert bounds and bounds["height"] >= 44

View File

@ -303,4 +303,4 @@ async def test_unread_update_offers_reply_mark_read_and_next_independent_of_toda
assert '.update-reply-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr));' in html
assert '.update-reply-actions button { min-height:44px;' in html
worker = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
assert "stackchain-dashboard-shell-v128" in worker
assert "stackchain-dashboard-shell-v129" in worker

View File

@ -435,5 +435,5 @@ async def test_dashboard_syncs_every_later_change_and_exposes_account_status():
def test_later_sync_ships_atomically_in_the_offline_shell():
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
assert "stackchain-dashboard-shell-v128" in source
assert "stackchain-dashboard-shell-v129" in source
assert "BASE + 'static/later-sync.js'" in source

View File

@ -256,4 +256,4 @@ def test_markdown_work_bodies_are_mobile_safe_block_containers():
assert ".markdown-content { min-width:0; max-width:100%; overflow-wrap:anywhere;" in css
assert ".markdown-content pre { max-width:100%; overflow-x:auto;" in css
assert ".markdown-content a { min-height:44px;" in css
assert "stackchain-dashboard-shell-v128" in worker
assert "stackchain-dashboard-shell-v129" in worker

View File

@ -45,7 +45,7 @@ def test_offline_shell_contains_every_local_dashboard_runtime_asset():
shell_assets = set(re.findall(r"BASE \+ '([^']+)'", worker.split("async function sessionCsrf", 1)[0]))
assert local_assets <= shell_assets, f"Offline shell is missing: {sorted(local_assets - shell_assets)}"
assert "stackchain-dashboard-shell-v128" in worker
assert "stackchain-dashboard-shell-v129" in worker
def test_all_conversation_composers_offer_accessible_mobile_mentions():

View File

@ -383,7 +383,7 @@ def test_mobile_dashboard_mounts_phone_safe_device_setup_flow():
assert "controller.recoverPermission('deadline')" in dashboard
assert "pushControllerReady.then(ensureDeviceSetup)" in dashboard
assert "BASE + 'static/mobile-device-setup.js'" in worker
assert "stackchain-dashboard-shell-v128" in worker
assert "stackchain-dashboard-shell-v129" in worker
assert ".device-setup-panel" in css
assert ".device-readiness-card" in css
assert "overflow-x:hidden" in css

View File

@ -243,5 +243,5 @@ async def test_mobile_home_progressively_discloses_secondary_panels_as_insights(
def test_mobile_insights_rolls_into_the_offline_shell():
worker = (CONTROLLER.parent / "service-worker.js").read_text()
assert "stackchain-dashboard-shell-v128" in worker
assert "stackchain-dashboard-shell-v129" in worker
assert "BASE + 'static/mobile-insights.js'" in worker

View File

@ -358,7 +358,7 @@ async def test_dashboard_wires_thumb_safe_start_day_briefing_into_offline_mobile
assert ".mobile-start-day-finish { min-height:44px;" in html
assert "max-width:100%; overflow-wrap:anywhere;" in html
assert "BASE + 'static/mobile-start-day.js'" in service_worker
assert "stackchain-dashboard-shell-v128" in service_worker
assert "stackchain-dashboard-shell-v129" in service_worker
@pytest.mark.anyio

View File

@ -549,5 +549,5 @@ async def test_offline_today_issue_queues_closure_before_completing_and_advancin
admission = handler.index("await closeOfflineIssue(closing)")
sheet_close = handler.index("closeIssueSheet()")
assert admission < sheet_close
assert "Issue closure queued for reconnect." in handler
assert "Issue close queued." in handler
assert "The issue remains in Today; retry." in handler

View File

@ -410,7 +410,7 @@ async def test_plan_today_wires_cancel_back_and_success_through_overlay_history(
def test_plan_today_controller_is_available_in_the_offline_shell():
source = SERVICE_WORKER.read_text()
assert "stackchain-dashboard-shell-v128" in source
assert "stackchain-dashboard-shell-v129" in source
assert "BASE + 'static/plan-today.js'" in source
assert "BASE + 'static/plan-today-readiness.js'" in source
assert "BASE + 'static/plan-today-preview.js'" in source

View File

@ -180,20 +180,20 @@ async function dispatchPush(payload) {{
def test_private_today_action_mailbox_rolls_the_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v128" in source
assert "stackchain-dashboard-shell-v129" in source
def test_per_day_week_conflict_ui_rolls_the_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v128" in source
assert "stackchain-dashboard-shell-v129" in source
assert "BASE + 'static/week-plan.js'" in source
def test_resumable_today_session_ships_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v128" in source
assert "stackchain-dashboard-shell-v129" in source
assert "BASE + 'static/my-work.js'" in source
assert "BASE + 'static/dashboard.js'" in source
assert "BASE + 'static/dashboard.css'" in source
@ -202,7 +202,7 @@ def test_resumable_today_session_ships_in_a_new_offline_shell():
def test_mobile_conversation_photo_bundles_roll_the_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v128" in source
assert "stackchain-dashboard-shell-v129" in source
assert "BASE + 'static/dashboard.js'" in source
assert "BASE + 'static/authored-outbox.js'" in source
assert "BASE + 'static/background-issue-sync.js'" in source
@ -211,7 +211,7 @@ def test_mobile_conversation_photo_bundles_roll_the_offline_shell():
def test_photo_metadata_sanitizer_rolls_the_cached_optimizer_atomically():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v128" in source
assert "stackchain-dashboard-shell-v129" in source
assert "BASE + 'static/issue-evidence-review.js'" in source
assert "BASE + 'static/issue-attachment.js'" in source
@ -219,14 +219,14 @@ def test_photo_metadata_sanitizer_rolls_the_cached_optimizer_atomically():
def test_ownership_exit_runtime_rolls_the_offline_shell_cache():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v128" in source
assert "stackchain-dashboard-shell-v129" in source
assert "BASE + 'static/dashboard.js'" in source
def test_offline_review_next_ships_today_completion_atomically():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v128" in source
assert "stackchain-dashboard-shell-v129" in source
assert "BASE + 'static/today-completion.js'" in source
assert "BASE + 'static/dashboard.js'" in source
@ -234,7 +234,7 @@ def test_offline_review_next_ships_today_completion_atomically():
def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v128" in source
assert "stackchain-dashboard-shell-v129" in source
assert "BASE + 'static/create-issue-sheet.js'" in source
assert "BASE + 'static/dashboard.js'" in source
@ -242,7 +242,7 @@ def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
def test_inline_checklist_step_flow_rolls_the_offline_shell_atomically():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v128" in source
assert "stackchain-dashboard-shell-v129" in source
assert "BASE + 'static/issue-sheet.js'" in source
assert "BASE + 'static/checklist-conflict.js'" in source
assert "BASE + 'static/dashboard.js'" in source
@ -252,14 +252,14 @@ def test_inline_checklist_step_flow_rolls_the_offline_shell_atomically():
def test_exact_later_picker_ships_atomically_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v128" in source
assert "stackchain-dashboard-shell-v129" in source
assert "BASE + 'static/later-picker.js'" in source
def test_navigation_deadline_ships_in_a_new_shell_cache():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v128" in source
assert "stackchain-dashboard-shell-v129" in source
assert "BASE + 'static/dashboard.css'" in source
assert "BASE + 'static/dashboard.js'" in source
assert "BASE + 'static/install-app.js'" in source
@ -268,21 +268,21 @@ def test_navigation_deadline_ships_in_a_new_shell_cache():
def test_today_convergence_ships_in_a_new_shell_cache():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v128" in source
assert "stackchain-dashboard-shell-v129" in source
assert "BASE + 'static/today-sync.js'" in source
def test_mobile_search_viewport_ships_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v128" in source
assert "stackchain-dashboard-shell-v129" in source
assert "BASE + 'static/mobile-search-viewport.js'" in source
def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v128" in source
assert "stackchain-dashboard-shell-v129" in source
assert "BASE + 'static/update-ownership.js'" in source
@ -1252,7 +1252,7 @@ def test_one_session_bound_csrf_proof_is_reused_for_a_background_drain():
def test_queue_today_ships_atomically_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v128" in source
assert "stackchain-dashboard-shell-v129" in source
assert "BASE + 'static/queue-today.js'" in source

View File

@ -221,7 +221,7 @@ async def test_today_blocker_opens_existing_preview_and_preserves_readiness_gate
def test_readiness_runtime_is_available_in_offline_shell():
service_worker = SERVICE_WORKER.read_text()
assert "const CACHE = 'stackchain-dashboard-shell-v128';" in service_worker
assert "const CACHE = 'stackchain-dashboard-shell-v129';" in service_worker
assert "BASE + 'static/today-readiness.js'" in service_worker

View File

@ -343,7 +343,7 @@ listeners['stackchain:first-task-complete']();
def test_inflight_today_drain_ships_in_a_new_offline_shell():
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
assert "stackchain-dashboard-shell-v128" in source
assert "stackchain-dashboard-shell-v129" in source
assert "BASE + 'static/today-sync.js'" in source

View File

@ -300,6 +300,35 @@ console.log(JSON.stringify({before,refused,moved,added,state:week.state(),pendin
assert result["pending"]["days"] == result["state"]["days"]
def test_week_controller_retires_completed_work_from_every_day_in_one_durable_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:['completed','sibling'],capacity_minutes:90,estimates:{completed:30,sibling:45}},
{plan_date:'2026-08-22',ids:['other','completed'],capacity_minutes:120,estimates:{other:60,completed:30}},
{plan_date:'2026-08-23',ids:['untouched'],capacity_minutes:75,estimates:{untouched:25}}
]});
const retired=week.retire('completed');
console.log(JSON.stringify({retired,writes,state:week.state(),pending:week.pending()}));
""")
assert result["retired"]["removed"] == 2
assert result["retired"]["sync_pending"] is True
assert result["writes"] == 1
assert result["state"]["days"] == [
{"plan_date": "2026-08-21", "ids": ["sibling"], "capacity_minutes": 90,
"estimates": {"sibling": 45}},
{"plan_date": "2026-08-22", "ids": ["other"], "capacity_minutes": 120,
"estimates": {"other": 60}},
{"plan_date": "2026-08-23", "ids": ["untouched"], "capacity_minutes": 75,
"estimates": {"untouched": 25}},
]
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;
@ -554,6 +583,60 @@ def test_dashboard_routes_week_overview_controls_through_existing_detail_flow_wi
assert "width:100%" in start_rule
def test_successful_issue_close_notifies_week_ahead_only_after_upstream_confirmation():
issue_sheet = (FRONTEND / "issue-sheet.js").read_text()
week_plan = (FRONTEND / "week-plan.js").read_text()
close_method = issue_sheet.split(" close(item) {", 1)[1].split(" release(item)", 1)[0]
confirmed = close_method.index("result?.state !== 'closed'")
notified = close_method.index("stackchain:issue-closed")
assert confirmed < notified
assert "detail:item" in close_method
assert "addEventListener?.('stackchain:issue-closed'" in week_plan
assert "retire(event.detail)" in week_plan
def test_week_workflow_refreshes_the_open_overview_and_reports_pending_retirement():
result = run_controller("""
const createWorkflow=createWeekPlan.Workflow;
const dates=['2026-08-21','2026-08-22','2026-08-23','2026-08-24','2026-08-25','2026-08-26','2026-08-27'];
const elements=new Map();
const makeElement=()=>({hidden:false,textContent:'',disabled:false,innerHTML:'',dataset:{},focused:false,
addEventListener:()=>{},focus(){this.focused=true;},querySelectorAll:()=>[],querySelector:()=>null,scrollIntoView:()=>{}});
elements.set('#week-plan-dates',makeElement());
const reviewDays=makeElement();elements.set('#week-review-days',reviewDays);
for(const selector of ['#mobile-week-summary','#my-work-action-status','#week-plan-progress','#save-today-plan','#week-review',
'#week-review-duplicates','#confirm-week-plan','#week-review-status','#back-to-week-review','#edit-week-plan',
'#week-offline-snapshot','#retry-week-live','#open-week-capacity-import','#open-week-reflow','#week-reflow-review'])
if(!elements.has(selector))elements.set(selector,makeElement());
let ids=['completed','sibling'];
const review=()=>({days:dates.map((date,index)=>({plan_date:date,label:'Day '+(index+1),ids:index===0?[...ids]:[],
capacity_minutes:90,estimates:index===0?Object.fromEntries(ids.map(id=>[id,id==='completed'?30:45])):{},
planned_minutes:index===0?ids.reduce((sum,id)=>sum+(id==='completed'?30:45),0):0,overloaded:false})),
duplicates:[],blockers:[],can_confirm:true});
const controller={dates:()=>dates.map((date,index)=>({date,label:'Day '+(index+1)})),day:date=>review().days.find(day=>day.plan_date===date),
load:async()=>({}),summary:()=>ids.length+' items across 1 day',review,pending:()=>false,offline:()=>false,
retire:id=>{ids=ids.filter(value=>value!==id);return {removed:1,sync_pending:true};},
flush:async()=>{throw new Error('offline');}};
let identityKind=null;
const workflow=createWorkflow({controller,qs:selector=>elements.get(selector),
getItem:id=>({kind:'issue',title:id==='completed'?'Completed work':'Sibling work',repository:'r',number:id==='completed'?1:2}),
openPlanner:()=>{},setReviewMode:()=>{},escapeHtml:value=>value,escapeAttribute:value=>value,
todayWork:{identity:item=>{identityKind=item.kind;return item.id;},replace:()=>{},replacePlanning:()=>{}},refresh:()=>{},warm:()=>{}});
await workflow.open({disabled:false});
const outcome=await workflow.retire({id:'completed',title:'Completed work'});
console.log(JSON.stringify({outcome,identityKind,markup:reviewDays.innerHTML,status:elements.get('#week-review-status').textContent,
editFocused:elements.get('#edit-week-plan').focused}));
""")
assert result["outcome"] == "pending"
assert result["identityKind"] == "issue"
assert "Completed work" not in result["markup"]
assert "Sibling work" in result["markup"]
assert result["status"] == "Completed work removed · sync pending."
assert result["editFocused"] is True
def test_week_workflow_marks_a_confirmed_fallback_read_only_and_retries_live_data():
result = run_controller("""
const createWorkflow=createWeekPlan.Workflow;