diff --git a/README.md b/README.md index b3a6746..8f8e2fb 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,11 @@ over-capacity admission requires a second confirmation. The action claims unassi checks, stages the canonical issue through the account-bound Week Ahead outbox, and preserves the Search preview through cancel or browser Back. Offline saves report **sync pending**; if assignment succeeds but local planning fails, the dashboard reports **Assigned, not planned** and leaves the issue recoverable in My Work. +The live mobile Week Ahead overview also offers **Remove from week** for active planned work. It removes only the +private planning placement—not assignment, issue state, or Gitea content—and immediately recalculates the day's +load. A 10-second **Undo** receipt restores the exact day, list position, and estimate. Both transitions use the +account-bound Week Ahead outbox, remain **sync pending** after delivery failure, and stay unavailable in a read-only +offline snapshot. Search selection mode extends that flow across several open issues with **Plan Week Ahead**. One phone-safe review assigns each issue a future day and estimate, validates five-item limits and daily capacity before any claim, and requires a second confirmation for overload. Confirmed rows are staged into one canonical Week diff --git a/frontend/dashboard.css b/frontend/dashboard.css index 089de0d..86f8d5d 100644 --- a/frontend/dashboard.css +++ b/frontend/dashboard.css @@ -346,6 +346,9 @@ textarea { resize: vertical; min-height: 120px; } .week-review-item-open { display:block; width:100%; min-width:0; min-height:44px; padding:8px; border:0; border-radius:8px; background:transparent; color:inherit; text-align:left; } .week-review-item-open:hover { background:#173453; } .week-review-item-open:focus-visible { outline:3px solid #93c5fd; outline-offset:2px; } +.week-review-unplan { width:100%; min-height:44px; border-color:#6b87a6; background:transparent; color:#d7e5f5; } +.week-unplan-receipt { margin:12px 0 6px; padding:10px 12px; border:1px solid #60a5fa; border-radius:10px; background:#112d4d; color:#dbeafe; } +#undo-week-unplan { width:100%; min-height:44px; margin-bottom:8px; border-color:#60a5fa; background:#1d4f7a; color:#eff6ff; font-weight:800; } .week-start-early { display:block; width:100%; min-height:44px; margin-top:12px; border-color:#60a5fa; background:#1d4f7a; color:#eff6ff; font-weight:800; } .week-start-early:focus-visible { outline:3px solid #bfdbfe; outline-offset:2px; } .week-review-item-copy strong, .week-review-item-copy small { overflow-wrap:anywhere; } diff --git a/frontend/index.html b/frontend/index.html index 4a03c9c..31711a0 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -526,6 +526,8 @@
+ + diff --git a/frontend/service-worker.js b/frontend/service-worker.js index 8ab3629..3d49b4e 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-v129'; +const CACHE = 'stackchain-dashboard-shell-v130'; 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 a0ef93b..1f4dea6 100644 --- a/frontend/week-plan.js +++ b/frontend/week-plan.js @@ -248,6 +248,24 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin,now=()=>D const staged=stageDays(days); return staged?{...staged,removed}:false; } + function unplan(id) { + if(!id||offlineSnapshot)return false; + const source=week.days.find(item=>(item.ids||[]).includes(id)); + if(!source)return false; + const index=source.ids.indexOf(id),estimate=Number(source.estimates?.[id]); + const receipt={id,plan_date:source.plan_date,index,estimate:Number.isFinite(estimate)?estimate:null}; + const staged=retire(id); + return staged?receipt:false; + } + function restore(receipt) { + if(!receipt?.id||!receipt.plan_date||!Number.isInteger(receipt.index)||offlineSnapshot||placement(receipt.id))return false; + const days=week.days.map(cloneDay); + let destination=days.find(item=>item.plan_date===receipt.plan_date); + if(!destination){destination={plan_date:receipt.plan_date,ids:[],capacity_minutes:null,estimates:{}};days.push(destination);} + destination.ids.splice(Math.min(receipt.index,destination.ids.length),0,receipt.id); + if(Number.isFinite(Number(receipt.estimate)))destination.estimates[receipt.id]=Number(receipt.estimate); + return stageDays(days); + } function placement(id) { const found=week.days.find(item=>(item.ids||[]).includes(id)); if(!found)return null; @@ -475,14 +493,14 @@ 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,retire,placement,place,pending,flush,conflict,chooseDay,saveMerged, + return {adopt,state,dates,day,pass,load,saveDay,stageDay,stageCapacities,review,previewReflow,applyReflow,move,retire,unplan,restore,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})}; } function createWeekPlanWorkflow({controller,qs,getItem=()=>null,openItem,openPlanner,setReviewMode=()=>{},escapeHtml,escapeAttribute, todayWork,refresh:r=()=>{},warm:w=()=>{},today:t=()=>null,r:refresh=r,w:warm=w,t:getTodayPlan=t, - confirmEarly=message=>globalThis.confirm?.(message)??false,x=null}={}) { + confirmEarly=message=>globalThis.confirm?.(message)??false,setTimer=globalThis.setTimeout,clearTimer=globalThis.clearTimeout,x=null}={}) { let selectedDate=null; let reviewing=false; let overviewing=false; @@ -490,6 +508,8 @@ function createWeekPlanWorkflow({controller,qs,getItem=()=>null,openItem,openPla let blockedReviewOpen=false; let reconciliation=null; let loadState=null; + let unplanReceipt=null; + let unplanTimer=null; function renderOfflineState(value=loadState) { const notice=qs('#week-offline-snapshot'),retry=qs('#retry-week-live'),capacity=qs('#open-week-capacity-import'); const offline=Boolean(value?.offline_snapshot); @@ -616,6 +636,36 @@ function createWeekPlanWorkflow({controller,qs,getItem=()=>null,openItem,openPla return false; } } + function dismissUnplanReceipt({focus=false}={}) { + if(unplanTimer){clearTimer?.(unplanTimer);unplanTimer=null;} + unplanReceipt=null; + const receipt=qs('#week-unplan-receipt'),undo=qs('#undo-week-unplan'); + if(receipt)receipt.hidden=true; + if(undo)undo.hidden=true; + if(focus)qs('#edit-week-plan')?.focus?.(); + } + function showUnplanReceipt(title) { + const receipt=qs('#week-unplan-receipt'),undo=qs('#undo-week-unplan'); + if(receipt){receipt.textContent=title+' removed from Week Ahead.';receipt.hidden=false;} + if(undo){undo.hidden=false;undo.focus?.();} + if(unplanTimer)clearTimer?.(unplanTimer); + unplanTimer=setTimer?.(()=>dismissUnplanReceipt({focus:true}),10000)||null; + } + async function undoUnplan() { + const receipt=unplanReceipt; + if(!receipt||!controller.restore?.(receipt))return false; + dismissUnplanReceipt();renderReview();renderPass(); + try{ + await controller.flush(); + renderReview(); + qs('#week-review-status').textContent='Restored to Week Ahead.'; + }catch(error){ + qs('#week-review-status').textContent=(error.message||'Week Ahead sync is unavailable.')+' Restore remains saved on this phone.'; + } + qs('#week-review-days').querySelector?.(`[data-week-unplan="${receipt.id}"]`)?.focus?.(); + return true; + } + qs('#undo-week-unplan')?.addEventListener('click',undoUnplan); function renderReview() { const value=controller.review(),root=qs('#week-review-days'),duplicates=qs('#week-review-duplicates'); const readOnly=Boolean(controller.offline?.()); @@ -638,7 +688,8 @@ function createWeekPlanWorkflow({controller,qs,getItem=()=>null,openItem,openPla const load=day.planned_minutes+(capacity?' of '+capacity:'')+' min'+(day.overloaded?' · over capacity':''); const move=id=>overviewing?'':''; - const items=day.ids.length?'Nothing planned.
'; const edit=readOnly?'':''; const start=day===nextUp&&canStartEarly?'':''; @@ -667,6 +718,20 @@ function createWeekPlanWorkflow({controller,qs,getItem=()=>null,openItem,openPla controller.flush().then(()=>{renderReview();qs('#mobile-week-summary').textContent=controller.summary();}) .catch(error=>{qs('#week-review-status').textContent=(error.message||'Week Ahead sync is unavailable.')+' Changes remain on this phone.';}); })); + root.querySelectorAll('[data-week-unplan]').forEach(button=>button.addEventListener('click',async event=>{ + const id=event.currentTarget.dataset.weekUnplan,item=getItem(id)||controller.item?.(id); + const receipt=controller.unplan?.(id); + if(!receipt)return; + unplanReceipt=receipt; + renderReview();renderPass();showUnplanReceipt(String(item?.title||'Work')); + try{ + await controller.flush(); + renderReview(); + qs('#mobile-week-summary').textContent=controller.summary(); + }catch(error){ + qs('#week-review-status').textContent=(error.message||'Week Ahead sync is unavailable.')+' Removal remains saved on this phone.'; + } + })); root.querySelectorAll('[data-week-open-item]').forEach(button=>button.addEventListener('click',event=>{ const id=button.dataset.weekOpenItem,item=getItem(id)||controller.item?.(id); if(item)openItem?.(item,event.currentTarget); diff --git a/tests/e2e/test_mobile_week_ahead_release.py b/tests/e2e/test_mobile_week_ahead_release.py index bd3d7b4..db4542a 100644 --- a/tests/e2e/test_mobile_week_ahead_release.py +++ b/tests/e2e/test_mobile_week_ahead_release.py @@ -84,6 +84,27 @@ def test_release_artifact_plans_seven_touch_safe_mobile_dates( expect(page.locator("#confirm-week-plan")).to_be_hidden() edit_week = page.locator("#edit-week-plan") expect(edit_week).to_be_visible() + remove_actions = cards.first.locator("[data-week-unplan]") + expect(remove_actions).to_have_count(2) + remove_bounds = remove_actions.nth(1).bounding_box() + assert remove_bounds and remove_bounds["height"] >= 44 + remove_actions.nth(1).click() + expect(cards.first).not_to_contain_text("Polish desktop filters") + expect(page.locator("#week-unplan-receipt")).to_have_text( + "Polish desktop filters removed from Week Ahead." + ) + undo = page.locator("#undo-week-unplan") + expect(undo).to_be_visible() + undo_bounds = undo.bounding_box() + assert undo_bounds and undo_bounds["height"] >= 44 + undo.click() + expect(cards.first).to_contain_text("Polish desktop filters") + expect(page.locator("#week-unplan-receipt")).to_be_hidden() + assert saved[-2]["days"][0]["ids"] == ["issue:acme/mobile:41:"] + assert saved[-1]["days"][0]["ids"] == [ + "issue:acme/mobile:41:", "issue:acme/mobile:42:" + ] + writes_before_reflow = len(saved) reflow = page.locator("#open-week-reflow") expect(reflow).to_be_visible() bounds = reflow.bounding_box() @@ -101,12 +122,12 @@ def test_release_artifact_plans_seven_touch_safe_mobile_dates( assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth") page.locator("#cancel-week-reflow").click() expect(preview).to_be_hidden() - assert saved == [], "cancelling reflow must not write" + assert len(saved) == writes_before_reflow, "cancelling reflow must not write" reflow.click() page.locator("#apply-week-reflow").click() expect(page.locator("#week-review-status")).to_have_text("Week Ahead reflowed and saved.") expect(preview).to_be_hidden() - assert len(saved) == 1 + assert len(saved) == writes_before_reflow + 1 assert [day["ids"] for day in saved[-1]["days"]][:2] == [["issue:acme/mobile:41:"], []] planned_item = cards.first.locator("[data-week-open-item]") expect(planned_item).to_have_count(1) @@ -120,7 +141,7 @@ def test_release_artifact_plans_seven_touch_safe_mobile_dates( 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" + assert len(saved) == writes_before_reflow + 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 diff --git a/tests/test_comment_next.py b/tests/test_comment_next.py index af41033..04f4d35 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-v129" in worker + assert "stackchain-dashboard-shell-v130" in worker diff --git a/tests/test_later_sync.py b/tests/test_later_sync.py index d677c92..8861d2f 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-v129" in source + assert "stackchain-dashboard-shell-v130" 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 659c30b..8ea176c 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-v129" in worker + assert "stackchain-dashboard-shell-v130" in worker diff --git a/tests/test_mobile_composer_integration.py b/tests/test_mobile_composer_integration.py index 36c3d4f..468c91f 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-v129" in worker + assert "stackchain-dashboard-shell-v130" 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 4dad409..547f31e 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-v129" in worker + assert "stackchain-dashboard-shell-v130" 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 e599ddf..fb377dd 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-v129" in worker + assert "stackchain-dashboard-shell-v130" 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 362f32f..58a0e66 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-v129" in service_worker + assert "stackchain-dashboard-shell-v130" in service_worker @pytest.mark.anyio diff --git a/tests/test_plan_today.py b/tests/test_plan_today.py index 9ee9c14..c579db7 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-v129" in source + assert "stackchain-dashboard-shell-v130" 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 b42ee43..c328506 100644 --- a/tests/test_service_worker.py +++ b/tests/test_service_worker.py @@ -177,23 +177,31 @@ async function dispatchPush(payload) {{ return json.loads(completed.stdout) +def test_week_unplan_undo_rolls_the_offline_shell(): + source = WORKER.read_text() + + assert "stackchain-dashboard-shell-v130" in source + assert "BASE + 'static/week-plan.js'" in source + assert "BASE + 'static/dashboard.css'" in source + + def test_private_today_action_mailbox_rolls_the_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v129" in source + assert "stackchain-dashboard-shell-v130" in source def test_per_day_week_conflict_ui_rolls_the_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v129" in source + assert "stackchain-dashboard-shell-v130" 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-v129" in source + assert "stackchain-dashboard-shell-v130" 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 +210,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-v129" in source + assert "stackchain-dashboard-shell-v130" 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 +219,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-v129" in source + assert "stackchain-dashboard-shell-v130" in source assert "BASE + 'static/issue-evidence-review.js'" in source assert "BASE + 'static/issue-attachment.js'" in source @@ -219,14 +227,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-v129" in source + assert "stackchain-dashboard-shell-v130" 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-v129" in source + assert "stackchain-dashboard-shell-v130" in source assert "BASE + 'static/today-completion.js'" in source assert "BASE + 'static/dashboard.js'" in source @@ -234,7 +242,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-v129" in source + assert "stackchain-dashboard-shell-v130" in source assert "BASE + 'static/create-issue-sheet.js'" in source assert "BASE + 'static/dashboard.js'" in source @@ -242,7 +250,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-v129" in source + assert "stackchain-dashboard-shell-v130" 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 +260,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-v129" in source + assert "stackchain-dashboard-shell-v130" 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-v129" in source + assert "stackchain-dashboard-shell-v130" 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 +276,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-v129" in source + assert "stackchain-dashboard-shell-v130" 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-v129" in source + assert "stackchain-dashboard-shell-v130" 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-v129" in source + assert "stackchain-dashboard-shell-v130" in source assert "BASE + 'static/update-ownership.js'" in source @@ -1252,7 +1260,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-v129" in source + assert "stackchain-dashboard-shell-v130" 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 0ef98fd..3d64d8d 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-v129';" in service_worker + assert "const CACHE = 'stackchain-dashboard-shell-v130';" 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 925bacf..beb15cd 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-v129" in source + assert "stackchain-dashboard-shell-v130" 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 2bb54a9..48294ca 100644 --- a/tests/test_week_plan_frontend.py +++ b/tests/test_week_plan_frontend.py @@ -329,6 +329,33 @@ console.log(JSON.stringify({retired,writes,state:week.state(),pending:week.pendi assert result["pending"]["days"] == result["state"]["days"] +def test_week_controller_unplans_active_work_and_restores_exact_placement(): + 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:['first','remove','last'],capacity_minutes:120,estimates:{first:20,remove:35,last:40}}, + {plan_date:'2026-08-22',ids:['other'],capacity_minutes:90,estimates:{other:30}} +]}); +const receipt=week.unplan('remove'); +const removed=week.state(); +const restored=week.restore(receipt); +console.log(JSON.stringify({receipt,removed,restored,writes,state:week.state(),pending:week.pending()})); +""") + + assert result["receipt"] == { + "id": "remove", "plan_date": "2026-08-21", "index": 1, "estimate": 35, + } + assert result["removed"]["days"][0]["ids"] == ["first", "last"] + assert result["restored"]["sync_pending"] is True + assert result["writes"] == 2 + assert result["state"]["days"][0]["ids"] == ["first", "remove", "last"] + assert result["state"]["days"][0]["estimates"]["remove"] == 35 + 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; @@ -424,6 +451,59 @@ console.log(JSON.stringify({reviewing:workflow.reviewing(),reviewMode,writes,ope assert result["editWeekLabel"] == "Edit week" +def test_week_overview_removes_active_work_and_undo_restores_it(): + 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:'',listeners:{},dataset:{}, + addEventListener(name,listener){this.listeners[name]=listener;},focus(){this.focused=true;},querySelectorAll:()=>[],querySelector:()=>null}); +const reviewDays=makeElement(); +Object.defineProperty(reviewDays,'innerHTML',{set(value){this.value=value;this.removeButtons=[...value.matchAll(/data-week-unplan=\"([^\"]+)/g)].map(match=>({ + dataset:{weekUnplan:match[1]},listeners:{},addEventListener(name,listener){this.listeners[name]=listener;},focus(){this.focused=true;} +}));},get(){return this.value||'';}}); +reviewDays.querySelectorAll=selector=>selector==='[data-week-unplan]'?reviewDays.removeButtons||[]:[]; +elements.set('#week-review-days',reviewDays); +const undo=makeElement();elements.set('#undo-week-unplan',undo); +for(const selector of ['#week-plan-dates','#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-unplan-receipt']) + if(!elements.has(selector))elements.set(selector,makeElement()); +let ids=['one','remove','last'],unplanned=null,restored=null,flushes=0,timer=null; +const buildReview=()=>({days:dates.map((date,index)=>({plan_date:date,label:'Day '+(index+1),ids:index===0?[...ids]:[], + capacity_minutes:120,estimates:index===0?{one:20,remove:35,last:40}:{},planned_minutes:index===0?ids.reduce((sum,id)=>sum+({one:20,remove:35,last:40}[id]),0):0,overloaded:false})),duplicates:[],blockers:[],can_confirm:true}); +const controller={dates:()=>dates.map((date,index)=>({date,label:'Day '+(index+1)})),day:date=>buildReview().days.find(day=>day.plan_date===date), + load:async()=>({}),summary:()=> ids.length+' items across 1 day',review:buildReview,pending:()=>false,offline:()=>false, + unplan:id=>{const index=ids.indexOf(id);if(index<0)return false;ids.splice(index,1);return unplanned={id,plan_date:dates[0],index,estimate:35};}, + restore:receipt=>{ids.splice(receipt.index,0,receipt.id);restored=receipt;return {};},flush:async()=>{flushes+=1;return {};}}; +const workflow=createWorkflow({controller,qs:selector=>elements.get(selector),getItem:id=>({kind:'issue',title:id==='remove'?'Remove me':id,repository:'r',number:2}), + openPlanner:()=>{},setReviewMode:()=>{},escapeHtml:value=>value,escapeAttribute:value=>value, + setTimer:callback=>{timer=callback;return 1;},clearTimer:()=>{timer=null;}, + todayWork:{replace:()=>{},replacePlanning:()=>{}},refresh:()=>{},warm:()=>{}}); +await workflow.open({disabled:false}); +const remove=reviewDays.removeButtons.find(button=>button.dataset.weekUnplan==='remove'); +await remove.listeners.click({currentTarget:remove}); +const afterRemove={ids:[...ids],flushes,receiptHidden:elements.get('#week-unplan-receipt').hidden, + status:elements.get('#week-unplan-receipt').textContent,undoHidden:undo.hidden,timer:Boolean(timer)}; +await undo.listeners.click({currentTarget:undo}); +console.log(JSON.stringify({buttonCount:reviewDays.removeButtons.length,unplanned,afterRemove,restored,ids,flushes, + receiptHidden:elements.get('#week-unplan-receipt').hidden,undoFocused:undo.focused||false})); +""") + + assert result["buttonCount"] == 3 + assert result["unplanned"] == { + "id": "remove", "plan_date": "2026-08-21", "index": 1, "estimate": 35, + } + assert result["afterRemove"] == { + "ids": ["one", "last"], "flushes": 1, "receiptHidden": False, + "status": "Remove me removed from Week Ahead.", "undoHidden": False, "timer": True, + } + assert result["restored"] == result["unplanned"] + assert result["ids"] == ["one", "remove", "last"] + assert result["flushes"] == 2 + assert result["receiptHidden"] is True + + def test_week_overview_previews_cancels_and_applies_one_reflow(): result = run_controller(""" const createWorkflow=createWeekPlan.Workflow;