diff --git a/frontend/week-plan.js b/frontend/week-plan.js index 9cd00f4..e2f41a1 100644 --- a/frontend/week-plan.js +++ b/frontend/week-plan.js @@ -464,11 +464,12 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin,now=()=>D plan_date:due.plan_date,today_revision:todayRevision, })}); } - async function pullItem(identity,today,operationId) { + async function pullItem(identity,today,operationId,allowOverCapacity=false) { if(offlineSnapshot||pending()||conflict()||!identity||!operationId|| !Number.isInteger(today?.revision)||!(today.ids||[]).length)return false; const result=await fetchJson('api/v1/week/pull-item',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({ operation_id:operationId,identity,today_revision:today.revision,week_revision:week.revision, + allow_over_capacity:Boolean(allowOverCapacity), })}); adoptConfirmed(result.week,{...confirmedItems,...pendingItems}); return result; @@ -702,8 +703,11 @@ function createWeekPlanWorkflow({controller,qs,getItem=()=>null,openItem,openPla const pull=id=>{ const inToday=(today?.ids||[]).includes(id),full=(today?.ids||[]).length>=5; if(!overviewing||readOnly||pending||!Number.isInteger(today?.revision)||!(today?.ids||[]).length||inToday)return ''; + const todayMinutes=(today.ids||[]).reduce((total,itemId)=>total+(Number(today.estimates?.[itemId])||0),0); + const projected=todayMinutes+(Number(day.estimates?.[id])||0),capacity=Number(today.capacity_minutes)||0; + const overload=capacity&&projected>capacity; return ''; + (full?'Today is full':(overload?'Review Today · '+projected+' of '+capacity+' min':'Add to Today'))+''; }; const items=day.ids.length?'': '

Nothing planned.

'; @@ -751,15 +755,19 @@ function createWeekPlanWorkflow({controller,qs,getItem=()=>null,openItem,openPla root.querySelectorAll('[data-week-pull-item]').forEach(button=>button.addEventListener('click',async event=>{ const id=event.currentTarget.dataset.weekPullItem,current=getTodayPlan?.(); if(!id||!Number.isInteger(current?.revision)||!(current.ids||[]).length||current.ids.length>=5)return; + const source=value.days.find(day=>day.ids.includes(id)),estimate=Number(source?.estimates?.[id])||0; + const todayMinutes=current.ids.reduce((total,itemId)=>total+(Number(current.estimates?.[itemId])||0),0); + const projected=todayMinutes+estimate,capacity=Number(current.capacity_minutes)||0; + const overCapacity=Boolean(capacity&&projected>capacity),item=getItem(id)||controller.item?.(id); + if(overCapacity&&!confirmEarly('Add '+String(item?.title||'work')+' to Today?\n\nToday will be '+projected+' of '+capacity+' min · '+(projected-capacity)+' min over capacity.'))return; event.currentTarget.disabled=true; try{ - const result=await controller.pullItem?.(id,current,operationId()); + const result=await controller.pullItem?.(id,current,operationId(),overCapacity); if(!result)return; getTodayPlan?.(result.today);todayWork.replace(result.today.ids); todayWork.replacePlanning({capacity_minutes:result.today.capacity_minutes??null,estimates:result.today.estimates||{}}); refresh();warm();renderReview(); qs('#mobile-week-summary').textContent=controller.summary(); - const item=getItem(id)||controller.item?.(id); qs('#week-review-status').textContent=String(item?.title||'Work')+' added to Today.'; }catch(error){ renderReview(); diff --git a/src/main.py b/src/main.py index 78937f3..b7ab600 100644 --- a/src/main.py +++ b/src/main.py @@ -762,6 +762,7 @@ class WeekItemPull(BaseModel): identity: str = Field(min_length=1, max_length=500) today_revision: int = Field(ge=0) week_revision: int = Field(ge=0) + allow_over_capacity: bool = False class WeekReconciliation(WeekPromotion): diff --git a/src/today_store.py b/src/today_store.py index 0bcd55c..924cf52 100644 --- a/src/today_store.py +++ b/src/today_store.py @@ -684,7 +684,7 @@ class TodayStore: def pull_week_item( self, login: str, *, operation_id: str, identity: str, - today_revision: int, week_revision: int, + today_revision: int, week_revision: int, allow_over_capacity: bool = False, ) -> dict: """Atomically append one Week Ahead item to Today and remove it from Week.""" login = self._normalize_login(login) @@ -727,6 +727,15 @@ class TodayStore: if source is None: raise WeekPlanConflict(week) estimate = source.get("estimates", {}).get(identity) + planned_minutes = sum( + int(today["estimates"].get(item_id, 0)) for item_id in today["ids"] + ) + (int(estimate) if estimate else 0) + if ( + today.get("capacity_minutes") is not None + and planned_minutes > today["capacity_minutes"] + and not allow_over_capacity + ): + raise ValueError("moving work over Today capacity requires explicit overload confirmation") today_result = { **today, "revision": today_revision + 1, "ids": [*today["ids"], identity], diff --git a/tests/e2e/test_mobile_week_ahead_release.py b/tests/e2e/test_mobile_week_ahead_release.py index d90c5d5..0fc4453 100644 --- a/tests/e2e/test_mobile_week_ahead_release.py +++ b/tests/e2e/test_mobile_week_ahead_release.py @@ -39,7 +39,7 @@ def test_release_artifact_plans_seven_touch_safe_mobile_dates( page.route("**/api/v1/today", lambda route: route.fulfill( status=200, content_type="application/json", body=json.dumps({ "revision": 3, "ids": ["issue:acme/mobile:99:"], - "capacity_minutes": 120, "estimates": {"issue:acme/mobile:99:": 30}, + "capacity_minutes": 60, "estimates": {"issue:acme/mobile:99:": 30}, }), )) @@ -48,7 +48,8 @@ def test_release_artifact_plans_seven_touch_safe_mobile_dates( pulled.append(body) route.fulfill(status=200, content_type="application/json", body=json.dumps({ "today": {"revision": 4, "ids": ["issue:acme/mobile:99:", body["identity"]], - "capacity_minutes": 120, "estimates": {body["identity"]: 45}}, + "capacity_minutes": 60, + "estimates": {"issue:acme/mobile:99:": 30, body["identity"]: 45}}, "week": {"revision": 99, "timezone": "UTC", "days": [{ "plan_date": (date.today() + timedelta(days=1)).isoformat(), "ids": [], "capacity_minutes": 60, "estimates": {}, @@ -319,9 +320,15 @@ def test_release_artifact_plans_seven_touch_safe_mobile_dates( assert saved[-1]["days"][0]["ids"] == ["issue:acme/mobile:42:"] assert saved[-1]["days"][0]["capacity_minutes"] == 60 pull = cards.first.locator('[data-week-pull-item="issue:acme/mobile:42:"]') - expect(pull).to_have_text("Add to Today") + expect(pull).to_have_text("Review Today · 75 of 60 min") pull_bounds = pull.bounding_box() assert pull_bounds and pull_bounds["height"] >= 44 + page.once("dialog", lambda dialog: dialog.dismiss()) + pull.click() + expect(cards.first).to_contain_text("Polish desktop filters") + assert pulled == [], "cancelling the overload review must not submit" + expect(pull).to_have_text("Review Today · 75 of 60 min") + page.once("dialog", lambda dialog: dialog.accept()) pull.click() expect(cards.first).not_to_contain_text("Polish desktop filters") expect(page.locator("#week-review-status")).to_have_text( @@ -329,6 +336,7 @@ def test_release_artifact_plans_seven_touch_safe_mobile_dates( ) assert pulled and pulled[-1]["identity"] == "issue:acme/mobile:42:" assert pulled[-1]["today_revision"] == 3 + assert pulled[-1]["allow_over_capacity"] is True assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth") browser.close() finally: diff --git a/tests/test_week_plan.py b/tests/test_week_plan.py index d0f19a0..c9dc3fb 100644 --- a/tests/test_week_plan.py +++ b/tests/test_week_plan.py @@ -237,6 +237,47 @@ def test_pull_week_item_rejects_full_or_changed_today_without_partial_write(tmp_ assert store.get_week("timmy") == week +def test_pull_week_item_requires_explicit_today_capacity_overload_confirmation(tmp_path): + store = TodayStore( + tmp_path / "today.sqlite3", encryption_key=b"c" * 32, + clock=lambda: datetime(2026, 8, 22, 8, tzinfo=timezone.utc).timestamp(), + ) + active_id = "issue:stackchain/dashboard:1:" + pulled_id = "issue:stackchain/dashboard:2:" + seed = store.replace_week( + "timmy", base_revision=0, timezone="UTC", days=[{ + "plan_date": "2026-08-22", "ids": [active_id], + "capacity_minutes": 60, "estimates": {active_id: 45}, + }], + ) + today = store.promote_week( + "timmy", promotion_id="seed-today", week_revision=seed["revision"], + plan_date="2026-08-22", today_revision=0, + ) + week = store.replace_week( + "timmy", base_revision=2, timezone="UTC", days=[{ + "plan_date": "2026-08-23", "ids": [pulled_id], + "capacity_minutes": 60, "estimates": {pulled_id: 30}, + }], + ) + + with pytest.raises(ValueError, match="explicit overload confirmation"): + store.pull_week_item( + "timmy", operation_id="unconfirmed", identity=pulled_id, + today_revision=today["revision"], week_revision=week["revision"], + ) + assert store.get("timmy") == today + assert store.get_week("timmy") == week + + moved = store.pull_week_item( + "timmy", operation_id="confirmed", identity=pulled_id, + today_revision=today["revision"], week_revision=week["revision"], + allow_over_capacity=True, + ) + assert moved["today"]["capacity_minutes"] == 60 + assert moved["today"]["estimates"] == {active_id: 45, pulled_id: 30} + + def test_normal_week_promotion_still_rejects_a_future_day_without_writes(tmp_path): store = TodayStore( tmp_path / "today.sqlite3", diff --git a/tests/test_week_plan_frontend.py b/tests/test_week_plan_frontend.py index 595423b..2936616 100644 --- a/tests/test_week_plan_frontend.py +++ b/tests/test_week_plan_frontend.py @@ -309,7 +309,7 @@ const week=createWeekPlan({fetchJson:async(url,options={})=>{ week:{revision:8,timezone:'UTC',days:[{plan_date:'2026-08-21',ids:['sibling'],capacity_minutes:90,estimates:{sibling:30}}]}}; },localDate:()=> '2026-08-20',timeZone:()=> 'UTC'}); week.adopt({revision:7,timezone:'UTC',days:[{plan_date:'2026-08-21',ids:['pull','sibling'],capacity_minutes:90,estimates:{pull:45,sibling:30}}]}); -const pulled=await week.pullItem('pull',{revision:4,ids:['active']},'stable-pull'); +const pulled=await week.pullItem('pull',{revision:4,ids:['active']},'stable-pull',true); console.log(JSON.stringify({pulled,requests,state:week.state()})); """) @@ -318,6 +318,7 @@ console.log(JSON.stringify({pulled,requests,state:week.state()})); "body": { "operation_id": "stable-pull", "identity": "pull", "today_revision": 4, "week_revision": 7, + "allow_over_capacity": True, }, }] assert result["pulled"]["today"]["ids"] == ["active", "pull"] @@ -489,6 +490,61 @@ console.log(JSON.stringify({reviewing:workflow.reviewing(),reviewMode,writes,ope assert result["editWeekLabel"] == "Edit week" +def test_week_overview_reviews_today_capacity_before_confirming_an_overload(): + 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:'',addEventListener:()=>{},focus:()=>{}, + querySelectorAll:()=>[],querySelector:()=>null,scrollIntoView:()=>{}}); +elements.set('#week-plan-dates',makeElement()); +const reviewDays=makeElement(); +Object.defineProperty(reviewDays,'innerHTML',{set(value){this.value=value;this.pullButtons=[...value.matchAll(/data-week-pull-item=\"([^\"]+)/g)].map(match=>({ + dataset:{weekPullItem:match[1]},listeners:{},disabled:false, + addEventListener(name,listener){this.listeners[name]=listener;},focus(){this.focused=true;} +}));},get(){return this.value||'';}}); +reviewDays.querySelectorAll=selector=>selector==='[data-week-pull-item]'?reviewDays.pullButtons||[]:[]; +elements.set('#week-review-days',reviewDays); +for(const selector of ['#mobile-week-summary','#my-work-action-status','#week-plan-progress','#save-today-plan', + '#week-review','#week-review-duplicates','#confirm-week-plan','#week-review-status','#back-to-week-review','#edit-week-plan']) + if(!elements.has(selector))elements.set(selector,makeElement()); +const review={days:dates.map((date,index)=>({plan_date:date,label:'Day '+(index+1), + ids:index===0?['issue:stackchain/dashboard:44:']:[],capacity_minutes:60, + estimates:index===0?{'issue:stackchain/dashboard:44:':30}:{},planned_minutes:index===0?30:0,overloaded:false})), + duplicates:[],blockers:[],can_confirm:true}; +const pulls=[],confirmations=[];let approve=false; +const controller={dates:()=>dates.map((date,index)=>({date,label:'Day '+(index+1)})),day:date=>review.days.find(day=>day.plan_date===date), + pass:date=>({position:dates.indexOf(date)+1,total:7,planned:1,next_date:null,last:false}),load:async()=>({}), + summary:()=> '1 item across 1 day',review:()=>review,pending:()=>false,offline:()=>false,move:()=>true,flush:async()=>({}), + pullItem:async(id,today,operationId,allowOverCapacity)=>{pulls.push({id,operationId,allowOverCapacity}); + return {today:{revision:4,ids:['active',id],capacity_minutes:60,estimates:{active:45,[id]:30}},week:{revision:8,days:[]}};}}; +const workflow=createWorkflow({controller,qs:selector=>elements.get(selector), + getItem:id=>({kind:'issue',title:'Capacity review',repository:'stackchain/dashboard',number:44}), + openPlanner:()=>{},escapeHtml:value=>value,escapeAttribute:value=>value, + today:()=>({revision:3,ids:['active'],capacity_minutes:60,estimates:{active:45}}),operationId:()=> 'capacity-pull', + confirmEarly:message=>{confirmations.push(message);return approve;}, + todayWork:{replace:()=>{},replacePlanning:()=>{}},refresh:()=>{},warm:()=>{}}); +await workflow.open({disabled:false}); +const markup=reviewDays.innerHTML,button=reviewDays.pullButtons[0]; +await button.listeners.click({currentTarget:button}); +const afterCancel={pulls:pulls.length,disabled:button.disabled,status:elements.get('#week-review-status').textContent}; +approve=true; +await button.listeners.click({currentTarget:button}); +console.log(JSON.stringify({markup,confirmations,afterCancel,pulls})); +""") + + assert "75 of 60 min" in result["markup"] + assert result["afterCancel"] == {"pulls": 0, "disabled": False, "status": "Week Ahead overview · no changes made."} + assert result["confirmations"] == [ + "Add Capacity review to Today?\n\nToday will be 75 of 60 min · 15 min over capacity.", + "Add Capacity review to Today?\n\nToday will be 75 of 60 min · 15 min over capacity.", + ] + assert result["pulls"] == [{ + "id": "issue:stackchain/dashboard:44:", "operationId": "capacity-pull", + "allowOverCapacity": True, + }] + + def test_week_overview_removes_active_work_and_undo_restores_it(): result = run_controller(""" const createWorkflow=createWeekPlan.Workflow;