feat: unplan Week Ahead work with undo (Closes #1240)
CI / lint (pull_request) Successful in 3m58s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 5m20s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy committed 2026-08-21 22:45:15 +00:00
1 parent 95887d7493
commit 449ab78991
18 files changed
+216 -32

No files matched your search

+24 -3
View File
@@ -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
+1 -1
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-v129" in worker
assert "stackchain-dashboard-shell-v130" in worker
+1 -1
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-v129" in source
assert "stackchain-dashboard-shell-v130" in source
assert "BASE + 'static/later-sync.js'" in source
+1 -1
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-v129" in worker
assert "stackchain-dashboard-shell-v130" in worker
+1 -1
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-v129" in worker
assert "stackchain-dashboard-shell-v130" in worker
def test_all_conversation_composers_offer_accessible_mobile_mentions():
+1 -1
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-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
+1 -1
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-v129" in worker
assert "stackchain-dashboard-shell-v130" in worker
assert "BASE + 'static/mobile-insights.js'" in worker
+1 -1
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-v129" in service_worker
assert "stackchain-dashboard-shell-v130" in service_worker
@pytest.mark.anyio
+1 -1
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-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
+23 -15
View File
@@ -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
+1 -1
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-v129';" in service_worker
assert "const CACHE = 'stackchain-dashboard-shell-v130';" in service_worker
assert "BASE + 'static/today-readiness.js'" in service_worker
+1 -1
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-v129" in source
assert "stackchain-dashboard-shell-v130" in source
assert "BASE + 'static/today-sync.js'" in source
+80
View File
@@ -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;