From 1be9da36856202436087e2ae4fc6fe36524bd244 Mon Sep 17 00:00:00 2001 From: timmy Date: Fri, 21 Aug 2026 20:12:48 +0000 Subject: [PATCH 1/2] feat: retire completed Week Ahead work (Closes #1235) --- README.md | 4 +- frontend/dashboard.js | 6 +- frontend/issue-sheet.js | 1 + frontend/service-worker.js | 2 +- frontend/week-plan.js | 33 +++++++- tests/e2e/test_mobile_week_ahead_release.py | 22 +++++- tests/test_comment_next.py | 2 +- tests/test_later_sync.py | 2 +- tests/test_markdown_renderer.py | 2 +- tests/test_mobile_composer_integration.py | 2 +- tests/test_mobile_device_setup.py | 2 +- tests/test_mobile_insights.py | 2 +- tests/test_mobile_start_day.py | 2 +- tests/test_offline_work.py | 2 +- tests/test_plan_today.py | 2 +- tests/test_service_worker.py | 30 ++++---- tests/test_today_readiness.py | 2 +- tests/test_today_sync.py | 2 +- tests/test_week_plan_frontend.py | 83 +++++++++++++++++++++ 19 files changed, 167 insertions(+), 36 deletions(-) diff --git a/README.md b/README.md index e8a5533..493e45c 100644 --- a/README.md +++ b/README.md @@ -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 day’s capacity, marks overloads, and flags work assigned to more than one date. Operators can move an item to another date without copying it; diff --git a/frontend/dashboard.js b/frontend/dashboard.js index 3580300..b20b762 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -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.'; diff --git a/frontend/issue-sheet.js b/frontend/issue-sheet.js index 5e4b3ea..1d8206a 100644 --- a/frontend/issue-sheet.js +++ b/frontend/issue-sheet.js @@ -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; diff --git a/frontend/service-worker.js b/frontend/service-worker.js index 0129f66..8ab3629 100644 --- a/frontend/service-worker.js +++ b/frontend/service-worker.js @@ -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; diff --git a/frontend/week-plan.js b/frontend/week-plan.js index 68d6d7b..a0ef93b 100644 --- a/frontend/week-plan.js +++ b/frontend/week-plan.js @@ -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(), diff --git a/tests/e2e/test_mobile_week_ahead_release.py b/tests/e2e/test_mobile_week_ahead_release.py index dd13352..68c3dc7 100644 --- a/tests/e2e/test_mobile_week_ahead_release.py +++ b/tests/e2e/test_mobile_week_ahead_release.py @@ -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 diff --git a/tests/test_comment_next.py b/tests/test_comment_next.py index 2b9e42e..af41033 100644 --- a/tests/test_comment_next.py +++ b/tests/test_comment_next.py @@ -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 diff --git a/tests/test_later_sync.py b/tests/test_later_sync.py index fb2452d..d677c92 100644 --- a/tests/test_later_sync.py +++ b/tests/test_later_sync.py @@ -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 diff --git a/tests/test_markdown_renderer.py b/tests/test_markdown_renderer.py index 868876e..659c30b 100644 --- a/tests/test_markdown_renderer.py +++ b/tests/test_markdown_renderer.py @@ -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 diff --git a/tests/test_mobile_composer_integration.py b/tests/test_mobile_composer_integration.py index cbd14c6..36c3d4f 100644 --- a/tests/test_mobile_composer_integration.py +++ b/tests/test_mobile_composer_integration.py @@ -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(): diff --git a/tests/test_mobile_device_setup.py b/tests/test_mobile_device_setup.py index f03c8b1..4dad409 100644 --- a/tests/test_mobile_device_setup.py +++ b/tests/test_mobile_device_setup.py @@ -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 diff --git a/tests/test_mobile_insights.py b/tests/test_mobile_insights.py index 481e3a8..e599ddf 100644 --- a/tests/test_mobile_insights.py +++ b/tests/test_mobile_insights.py @@ -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 diff --git a/tests/test_mobile_start_day.py b/tests/test_mobile_start_day.py index 5eee62c..362f32f 100644 --- a/tests/test_mobile_start_day.py +++ b/tests/test_mobile_start_day.py @@ -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 diff --git a/tests/test_offline_work.py b/tests/test_offline_work.py index 143f429..0cdb558 100644 --- a/tests/test_offline_work.py +++ b/tests/test_offline_work.py @@ -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 diff --git a/tests/test_plan_today.py b/tests/test_plan_today.py index 66855b7..9ee9c14 100644 --- a/tests/test_plan_today.py +++ b/tests/test_plan_today.py @@ -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 diff --git a/tests/test_service_worker.py b/tests/test_service_worker.py index 425d3a8..b42ee43 100644 --- a/tests/test_service_worker.py +++ b/tests/test_service_worker.py @@ -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 diff --git a/tests/test_today_readiness.py b/tests/test_today_readiness.py index 4bcdb08..0ef98fd 100644 --- a/tests/test_today_readiness.py +++ b/tests/test_today_readiness.py @@ -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 diff --git a/tests/test_today_sync.py b/tests/test_today_sync.py index fe222d1..925bacf 100644 --- a/tests/test_today_sync.py +++ b/tests/test_today_sync.py @@ -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 diff --git a/tests/test_week_plan_frontend.py b/tests/test_week_plan_frontend.py index c04b294..2bb54a9 100644 --- a/tests/test_week_plan_frontend.py +++ b/tests/test_week_plan_frontend.py @@ -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; -- 2.43.0 From d47205a5610f3cbfa215a95474605c3bbabc9f21 Mon Sep 17 00:00:00 2001 From: timmy Date: Fri, 21 Aug 2026 20:19:20 +0000 Subject: [PATCH 2/2] test: verify mobile Week Ahead retirement --- tests/e2e/test_mobile_week_ahead_release.py | 37 +++++++++++++++------ 1 file changed, 26 insertions(+), 11 deletions(-) diff --git a/tests/e2e/test_mobile_week_ahead_release.py b/tests/e2e/test_mobile_week_ahead_release.py index 68c3dc7..bd3d7b4 100644 --- a/tests/e2e/test_mobile_week_ahead_release.py +++ b/tests/e2e/test_mobile_week_ahead_release.py @@ -116,19 +116,11 @@ 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.once("dialog", lambda dialog: dialog.accept()) - page.locator("#close-issue").click() + page.locator("#close-issue-sheet").click() expect(page.locator("#issue-sheet")).not_to_have_class(re.compile(r"\bopen\b")) expect(overview).to_be_visible() - 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 + expect(planned_item).to_be_focused() + assert len(saved) == 1, "opening and inspecting Week Ahead must not add a write" for control in (cards.first.locator("[data-week-edit-day]"), edit_week): bounds = control.bounding_box() assert bounds and bounds["height"] >= 44 @@ -258,6 +250,29 @@ def test_release_artifact_plans_seven_touch_safe_mobile_dates( assert [day["plan_date"] for day in saved[-1]["days"]] == sorted( day["plan_date"] for day in saved[-1]["days"] ) + + before_retirement = len(saved) + page.locator('[data-mobile-task="queues"]').click() + page.locator('[data-mobile-queue="week"]').click() + expect(overview).to_be_visible() + planned_item = cards.first.locator( + '[data-week-open-item="issue:acme/mobile:41:"]' + ) + planned_item.click() + expect(page.locator("#issue-sheet-title")).to_have_text("Ship mobile capture") + 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(cards.first).not_to_contain_text("Ship mobile capture") + expect(cards.first).to_contain_text("Polish desktop filters") + expect(cards.first).to_contain_text("45 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) == before_retirement + 1 + assert saved[-1]["days"][0]["ids"] == ["issue:acme/mobile:42:"] + assert saved[-1]["days"][0]["capacity_minutes"] == 60 assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth") browser.close() finally: -- 2.43.0