feat: unplan Week Ahead work with undo (Closes #1240)
All checks were successful
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 2026-08-21 22:43:54 +00:00
parent 95887d7493
commit 449ab78991
18 changed files with 216 additions and 32 deletions

View File

@ -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

View File

@ -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; }

View File

@ -526,6 +526,8 @@
</section>
<div class="week-review-duplicates" id="week-review-duplicates" role="alert" hidden></div>
<div class="week-review-days" id="week-review-days"></div>
<div class="week-unplan-receipt" id="week-unplan-receipt" role="status" aria-live="polite" hidden></div>
<button id="undo-week-unplan" type="button" hidden>Undo</button>
<div class="small week-review-status" id="week-review-status" role="status" aria-live="polite"></div>
<button id="edit-week-plan" type="button" hidden>Edit week</button>
<button id="confirm-week-plan" type="button">Confirm Week Ahead</button>

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-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;

View File

@ -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?'':'<div class="week-review-move"><label>Move to <select data-week-move-destination="'+escapeAttribute(id)+'">'+destinations+
'</select></label><button type="button" data-week-move="'+escapeAttribute(id)+'">Move</button></div>';
const items=day.ids.length?'<ul>'+day.ids.map(id=>'<li>'+itemMarkup(id,day)+move(id)+'</li>').join('')+'</ul>':
const unplan=id=>overviewing&&!readOnly?'<button type="button" class="week-review-unplan" data-week-unplan="'+escapeAttribute(id)+'">Remove from week</button>':'';
const items=day.ids.length?'<ul>'+day.ids.map(id=>'<li>'+itemMarkup(id,day)+move(id)+unplan(id)+'</li>').join('')+'</ul>':
'<p class="small muted">Nothing planned.</p>';
const edit=readOnly?'':'<button type="button" data-week-edit-day="'+escapeAttribute(day.plan_date)+'" aria-label="Edit '+escapeAttribute(day.label)+'">Edit day</button>';
const start=day===nextUp&&canStartEarly?'<button type="button" class="week-start-early" data-week-start-early="'+escapeAttribute(day.plan_date)+'">Start this day early</button>':'';
@ -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);

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

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

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

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

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():

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

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

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

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

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

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

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

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;