diff --git a/README.md b/README.md index e1eb338..981c6f3 100644 --- a/README.md +++ b/README.md @@ -177,7 +177,11 @@ planning pass. 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; the estimate follows the item and the server rejects duplicate cross-day assignments without advancing the -week revision. Confirmation remains disabled while duplicates exist or the account-bound week is still syncing. +week revision. When capacity changes create overload, **Reflow remaining week** previews a deterministic, +order-preserving redistribution across the seven visible dates. No day exceeds its capacity or five-item limit; +work that cannot fit is named and remains in My Work, while missing estimates block apply. Cancel performs no +write, and apply stages one durable whole-week transition through the existing conflict-safe sync path. +Confirmation remains disabled while duplicates exist or the account-bound week is still syncing. Rapid saves and review moves use one network flight plus a coalesced latest-state delivery, so later staged days are not stranded behind an earlier request. From the same review, **Set capacity from calendar** reads a local `.ics` file on-device, unions overlapping busy periods within chosen working hours, and previews seven daily diff --git a/frontend/dashboard.css b/frontend/dashboard.css index abe421d..782f446 100644 --- a/frontend/dashboard.css +++ b/frontend/dashboard.css @@ -348,6 +348,14 @@ textarea { resize: vertical; min-height: 120px; } #confirm-week-plan { width:100%; min-height:48px; position:sticky; bottom:0; } #edit-week-plan { width:100%; min-height:48px; position:sticky; bottom:0; } #open-week-capacity-import { width:100%; min-height:44px; margin:8px 0 12px; } +#open-week-reflow { width:100%; min-height:48px; margin:0 0 12px; border-color:#60a5fa; } +.week-reflow-review { box-sizing:border-box; width:100%; margin:0 0 14px; padding:14px; border:1px solid #60a5fa; border-radius:12px; background:#10233d; overflow-x:hidden; } +.week-reflow-review h3 { margin:.25rem 0; } +.week-reflow-days { display:grid; gap:6px; margin:12px 0; } +.week-reflow-day { display:flex; justify-content:space-between; gap:10px; min-width:0; padding:8px; border-radius:8px; background:#0b1526; overflow-wrap:anywhere; } +.week-reflow-unscheduled { min-height:1.4em; color:#fde68a; overflow-wrap:anywhere; } +.week-reflow-actions { display:grid; grid-template-columns:1fr 2fr; gap:8px; margin-top:12px; } +.week-reflow-actions button { min-width:0; min-height:48px; } .week-capacity-import { position:fixed; z-index:121; inset:0; box-sizing:border-box; width:100%; max-width:560px; margin-inline:auto; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); background:#0b1526; overflow:auto; overflow-x:hidden; } .week-capacity-import[hidden] { display:none; } .week-capacity-import > header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; } diff --git a/frontend/index.html b/frontend/index.html index d7acd3d..4b39f8e 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -512,6 +512,18 @@
+ +Work keeps its current order. Anything that cannot fit stays unscheduled in My Work.
+ + +No work can be scheduled with the current capacities.
'; + if(preview.unscheduled.length){ + const names=preview.unscheduled.map(value=>(getItem(value.id)||controller.item?.(value.id))?.title||value.id); + unscheduled.textContent=names.length+' item'+(names.length===1?'':'s')+' cannot fit: '+names.join(', ')+'. '+ + (names.length===1?'It':'They')+' will remain in My Work.'; + } else unscheduled.textContent='All planned work fits within capacity.'; + panel.focus?.();return preview; + } + async function applyReflow() { + const button=qs('#apply-week-reflow');button.disabled=true; + const staged=controller.applyReflow?.(); + if(!staged){button.disabled=false;return false;} + closeReflow();renderReview(); + try { + await controller.flush();renderReview(); + qs('#mobile-week-summary').textContent=controller.summary(); + qs('#week-review-status').textContent='Week Ahead reflowed and saved.'; + return true; + } catch(error) { + renderReview(); + qs('#week-review-status').textContent=(error.message||'Week Ahead sync is unavailable.')+' Reflow remains saved on this phone.'; + return false; + } + } function renderReview() { const value=controller.review(),root=qs('#week-review-days'),duplicates=qs('#week-review-duplicates'); const readOnly=Boolean(controller.offline?.()); @@ -572,6 +648,7 @@ function createWeekPlanWorkflow({controller,qs,getItem=()=>null,openItem,openPla 'Add capacity for '+blocker.plan_date+' before confirming.' ):'Week Ahead is balanced and ready.'))); confirm.hidden=overviewing||readOnly; + const reflow=qs('#open-week-reflow');if(reflow)reflow.hidden=!overviewing||readOnly||pending||!value.days.some(day=>day.overloaded); const editWeek=qs('#edit-week-plan');if(editWeek){editWeek.hidden=!overviewing||readOnly;editWeek.textContent=nextUp?'Edit week':'Plan Week Ahead';} root.querySelectorAll('[data-week-move]').forEach(button=>button.addEventListener('click',()=>{ const selector=root.querySelector(`[data-week-move-destination="${button.dataset.weekMove}"]`); @@ -695,9 +772,12 @@ function createWeekPlanWorkflow({controller,qs,getItem=()=>null,openItem,openPla if(!reviewing||!value.can_confirm||controller.pending()) return false; return true; } - function finish(){reviewing=false;overviewing=false;setReviewMode(false);qs('#week-review').hidden=true;return true;} + 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); + qs('#open-week-reflow')?.addEventListener('click',openReflow); + qs('#cancel-week-reflow')?.addEventListener('click',closeReflow); + qs('#apply-week-reflow')?.addEventListener('click',applyReflow); qs('#retry-week-live')?.addEventListener('click',event=>open(event.currentTarget)); async function promote(plan){ try{await controller.load();const promoted=await controller.promote(plan.revision);if(!promoted)return false;blockedReviewOpen=false;todayWork.replace(promoted.ids);todayWork.replacePlanning({capacity_minutes:promoted.capacity_minutes??null,estimates:promoted.estimates||{}});refresh();warm();qs('#mobile-week-summary').textContent=controller.summary();qs('#my-work-action-status').textContent='Your saved Week Ahead plan is now Today.';return true;} diff --git a/tests/e2e/test_mobile_week_ahead_release.py b/tests/e2e/test_mobile_week_ahead_release.py index 20606f0..dd13352 100644 --- a/tests/e2e/test_mobile_week_ahead_release.py +++ b/tests/e2e/test_mobile_week_ahead_release.py @@ -47,8 +47,8 @@ def test_release_artifact_plans_seven_touch_safe_mobile_dates( route.fulfill(status=200, content_type="application/json", body=json.dumps({ "revision": 0, "timezone": None, "days": [{ "plan_date": (date.today() + timedelta(days=1)).isoformat(), - "ids": ["issue:acme/mobile:41:"], "capacity_minutes": 60, - "estimates": {"issue:acme/mobile:41:": 30}, + "ids": ["issue:acme/mobile:41:", "issue:acme/mobile:42:"], "capacity_minutes": 60, + "estimates": {"issue:acme/mobile:41:": 45, "issue:acme/mobile:42:": 45}, }], })) @@ -76,6 +76,30 @@ 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() + reflow = page.locator("#open-week-reflow") + expect(reflow).to_be_visible() + bounds = reflow.bounding_box() + assert bounds and bounds["height"] >= 44 + reflow.click() + preview = page.locator("#week-reflow-review") + expect(preview).to_be_visible() + expect(page.locator("#week-reflow-days")).to_contain_text("1 item · 45 of 60 min") + expect(page.locator("#week-reflow-unscheduled")).to_have_text( + "1 item cannot fit: Polish desktop filters. It will remain in My Work." + ) + for control in (page.locator("#cancel-week-reflow"), page.locator("#apply-week-reflow")): + control_bounds = control.bounding_box() + assert control_bounds and control_bounds["height"] >= 44 + 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" + 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 [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) planned_bounds = planned_item.bounding_box() @@ -88,7 +112,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 saved == [], "opening and inspecting Week Ahead must not write" + 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 @@ -118,7 +142,7 @@ def test_release_artifact_plans_seven_touch_safe_mobile_dates( expect(page.locator("#week-review-days .week-review-day")).to_have_count(7) first_day = page.locator("#week-review-days .week-review-day").first expect(first_day).to_contain_text("Ship mobile capture") - expect(first_day).to_contain_text("acme/mobile #41 · Issue · 30 min") + expect(first_day).to_contain_text("acme/mobile #41 · Issue · 45 min") expect(first_day.locator("code")).to_have_count(0) edit = first_day.locator("[data-week-edit-day]") bounds = edit.bounding_box() @@ -190,7 +214,7 @@ def test_release_artifact_plans_seven_touch_safe_mobile_dates( page.locator("#apply-week-capacities").click() expect(capacity_import).to_be_hidden() expect(page.locator("#week-review")).to_be_visible() - expect(first_day).to_contain_text("30 of 420 min") + expect(first_day).to_contain_text("45 of 420 min") page.wait_for_function("() => !document.querySelector('#confirm-week-plan').disabled") confirm = page.locator("#confirm-week-plan") @@ -201,7 +225,7 @@ def test_release_artifact_plans_seven_touch_safe_mobile_dates( expect(page.locator("#week-review")).to_be_hidden() start = page.locator("[data-week-calendar-start]").first expect(start).to_have_value("09:00") - expect(page.locator('[data-week-calendar-preview="issue:acme/mobile:41:"]')).to_have_text("10:00–10:30 · 30 min") + expect(page.locator('[data-week-calendar-preview="issue:acme/mobile:41:"]')).to_have_text("10:00–10:45 · 45 min") expect(page.locator("#week-calendar-status")).to_contain_text("Planning around imported busy time") for control in (start, page.locator("#back-to-week-review-from-calendar"), page.locator("#share-week-calendar")): bounds = control.bounding_box() diff --git a/tests/test_week_plan_frontend.py b/tests/test_week_plan_frontend.py index 2353bcc..2e8231e 100644 --- a/tests/test_week_plan_frontend.py +++ b/tests/test_week_plan_frontend.py @@ -170,6 +170,110 @@ console.log(JSON.stringify({applied,writes,state:week.state(),pending:week.pendi assert result["pending"]["days"] == result["state"]["days"] +def test_week_controller_previews_and_atomically_applies_a_capacity_safe_reflow(): + 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:['one','two'],capacity_minutes:45,estimates:{one:30,two:30}}, + {plan_date:'2026-08-22',ids:['three'],capacity_minutes:60,estimates:{three:20}}, + {plan_date:'2026-08-23',ids:['large'],capacity_minutes:40,estimates:{large:70}}, + {plan_date:'2026-08-24',ids:[],capacity_minutes:60,estimates:{}}, + {plan_date:'2026-08-25',ids:[],capacity_minutes:0,estimates:{}}, + {plan_date:'2026-08-26',ids:[],capacity_minutes:0,estimates:{}}, + {plan_date:'2026-08-27',ids:[],capacity_minutes:0,estimates:{}} +]}); +const before=week.state(); +const preview=week.previewReflow(); +const unchanged=week.state(); +const applied=week.applyReflow(); +console.log(JSON.stringify({before,preview,unchanged,applied,writes,pending:week.pending()})); +""") + + assert result["preview"]["blockers"] == [] + assert result["preview"]["unscheduled"] == [ + {"id": "large", "estimate": 70, "from_date": "2026-08-23"} + ] + assert [day["ids"] for day in result["preview"]["days"]] == [ + ["one"], ["two", "three"], [], [], [], [], [] + ] + assert result["unchanged"] == result["before"] + assert result["applied"]["sync_pending"] is True + assert result["writes"] == 1 + assert result["pending"]["days"] == result["preview"]["days"] + + +def test_week_controller_reflow_never_places_more_than_five_items_on_a_day(): + result = run_controller(""" +const week=createWeekPlan({fetchJson:async()=>({}),localDate:()=> '2026-08-20',timeZone:()=> 'UTC'}); +const estimates=Object.fromEntries(Array.from({length:7},(_,index)=>['item-'+index,10])); +week.adopt({revision:6,timezone:'UTC',days:[ + {plan_date:'2026-08-21',ids:Object.keys(estimates),capacity_minutes:120,estimates}, + {plan_date:'2026-08-22',ids:[],capacity_minutes:120,estimates:{}} +]}); +console.log(JSON.stringify(week.previewReflow())); +""") + + assert [day["ids"] for day in result["days"][:2]] == [ + ["item-0", "item-1", "item-2", "item-3", "item-4"], + ["item-5", "item-6"], + ] + assert result["unscheduled"] == [] + + +def test_week_controller_reflow_keeps_only_the_first_chronological_assignment(): + result = run_controller(""" +const week=createWeekPlan({fetchJson:async()=>({}),localDate:()=> '2026-08-20',timeZone:()=> 'UTC'}); +week.adopt({revision:6,timezone:'UTC',days:[ + {plan_date:'2026-08-21',ids:['one','shared'],capacity_minutes:120,estimates:{one:30,shared:20}}, + {plan_date:'2026-08-22',ids:['shared','two'],capacity_minutes:120,estimates:{shared:20,two:30}} +]}); +console.log(JSON.stringify(week.previewReflow())); +""") + + assert [item for day in result["days"] for item in day["ids"]] == ["one", "shared", "two"] + assert result["unscheduled"] == [] + + +def test_week_controller_reflow_never_backfills_later_work_ahead_of_earlier_work(): + result = run_controller(""" +const week=createWeekPlan({fetchJson:async()=>({}),localDate:()=> '2026-08-20',timeZone:()=> 'UTC'}); +week.adopt({revision:6,timezone:'UTC',days:[ + {plan_date:'2026-08-21',ids:['large','small'],capacity_minutes:30,estimates:{large:50,small:20}}, + {plan_date:'2026-08-22',ids:[],capacity_minutes:60,estimates:{}}, + {plan_date:'2026-08-23',ids:[],capacity_minutes:30,estimates:{}} +]}); +console.log(JSON.stringify(week.previewReflow())); +""") + + assert [day["ids"] for day in result["days"][:3]] == [[], ["large"], ["small"]] + + +def test_week_controller_blocks_reflow_when_work_has_no_estimate(): + 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:['known','unknown'],capacity_minutes:90,estimates:{known:30}}, + {plan_date:'2026-08-22',ids:[],capacity_minutes:90,estimates:{}} +]}); +const before=week.state();const preview=week.previewReflow();const applied=week.applyReflow(); +console.log(JSON.stringify({before,preview,applied,after:week.state(),writes})); +""") + + assert result["preview"]["blockers"] == [{ + "type": "missing-estimate", "id": "unknown", "plan_date": "2026-08-21" + }] + assert result["preview"]["days"] == [] + assert result["applied"] is False + assert result["after"] == result["before"] + assert result["writes"] == 0 + + def test_week_controller_places_new_work_or_explicitly_moves_existing_work_without_duplicates(): result = run_controller(""" const values=new Map(); @@ -291,6 +395,55 @@ console.log(JSON.stringify({reviewing:workflow.reviewing(),reviewMode,writes,ope assert result["editWeekLabel"] == "Edit week" +def test_week_overview_previews_cancels_and_applies_one_reflow(): + 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:{},listeners:{}, + addEventListener(name,listener){this.listeners[name]=listener;},focus(){this.focused=true;},querySelectorAll:()=>[],querySelector:()=>null}); +for(const selector of ['#week-plan-dates','#week-review-days','#week-review-duplicates','#week-review','#week-review-status', + '#confirm-week-plan','#edit-week-plan','#week-plan-progress','#save-today-plan','#mobile-week-summary','#my-work-action-status', + '#week-offline-snapshot','#retry-week-live','#open-week-capacity-import','#open-week-reflow','#week-reflow-review', + '#week-reflow-days','#week-reflow-unscheduled','#cancel-week-reflow','#apply-week-reflow'])elements.set(selector,makeElement()); +const review={days:dates.map((date,index)=>({plan_date:date,label:'Day '+(index+1),ids:index===0?['one','two']:[], + capacity_minutes:index===0?30:60,estimates:index===0?{one:30,two:30}:{},planned_minutes:index===0?60:0,overloaded:index===0})), + duplicates:[],blockers:[{type:'over-capacity',plan_date:dates[0],minutes:30}],can_confirm:false}; +const preview={days:dates.map((date,index)=>({plan_date:date,ids:index<2?[["one"],["two"]][index]:[],capacity_minutes:index===0?30:60, + estimates:index===0?{one:30}:index===1?{two:30}:{}})),blockers:[],unscheduled:[{id:'large',estimate:90,from_date:dates[0]}]}; +let applied=0,flushed=0; +const controller={dates:()=>dates.map((date,index)=>({date,label:'Day '+(index+1)})),day:date=>review.days.find(day=>day.plan_date===date), + pass:()=>({}),load:async()=>({}),summary:()=>applied?'2 items across 2 days':'2 items across 1 day',review:()=>review, + pending:()=>false,offline:()=>false,move:()=>true,previewReflow:()=>preview,applyReflow:()=>{applied+=1;return {};}, + flush:async()=>{flushed+=1;return {};}}; +const workflow=createWorkflow({controller,qs:selector=>elements.get(selector),getItem:id=>({kind:'issue',title:id==='large'?'Large task':id,repository:'r',number:1}), + openPlanner:()=>{},setReviewMode:()=>{},escapeHtml:value=>value,escapeAttribute:value=>value, + todayWork:{replace:()=>{},replacePlanning:()=>{}},refresh:()=>{},warm:()=>{}}); +await workflow.open({disabled:false}); +const offered=!elements.get('#open-week-reflow').hidden; +await elements.get('#open-week-reflow').listeners.click(); +const firstPreview={hidden:elements.get('#week-reflow-review').hidden,days:elements.get('#week-reflow-days').innerHTML, + unscheduled:elements.get('#week-reflow-unscheduled').textContent}; +await elements.get('#cancel-week-reflow').listeners.click(); +const afterCancel={hidden:elements.get('#week-reflow-review').hidden,applied,flushed}; +await elements.get('#open-week-reflow').listeners.click(); +await elements.get('#apply-week-reflow').listeners.click(); +console.log(JSON.stringify({offered,firstPreview,afterCancel,applied,flushed,status:elements.get('#week-review-status').textContent, + reflowHidden:elements.get('#week-reflow-review').hidden})); +""") + + assert result["offered"] is True + assert result["firstPreview"]["hidden"] is False + assert "Day 1" in result["firstPreview"]["days"] + assert "1 item · 30 of 30 min" in result["firstPreview"]["days"] + assert result["firstPreview"]["unscheduled"] == "1 item cannot fit: Large task. It will remain in My Work." + assert result["afterCancel"] == {"hidden": True, "applied": 0, "flushed": 0} + assert result["applied"] == 1 + assert result["flushed"] == 1 + assert result["status"] == "Week Ahead reflowed and saved." + assert result["reflowHidden"] is True + + def test_week_overview_confirms_and_starts_the_next_day_early_from_empty_today(): result = run_controller(""" const createWorkflow=createWeekPlan.Workflow; @@ -1095,6 +1248,22 @@ def test_mobile_week_ahead_review_is_rendered_touch_safe_and_confirmed_explicitl assert ".week-review-mode .mobile-plan-today-nav" in css +def test_mobile_week_ahead_reflow_has_a_reviewed_touch_safe_flow(): + index = (FRONTEND / "index.html").read_text() + css = (FRONTEND / "dashboard.css").read_text() + + for element_id in [ + "open-week-reflow", "week-reflow-review", "week-reflow-days", + "week-reflow-unscheduled", "cancel-week-reflow", "apply-week-reflow", + ]: + assert f'id="{element_id}"' in index + assert ".week-reflow-actions button" in css + rule = css.split(".week-reflow-actions button", 1)[1].split("}", 1)[0] + assert "min-height:48px" in rule + assert ".week-reflow-review" in css + assert "overflow-x:hidden" in css + + def test_mobile_week_ahead_has_a_touch_safe_offline_snapshot_notice_and_retry(): index = (FRONTEND / "index.html").read_text() css = (FRONTEND / "dashboard.css").read_text()