feat: confirm capacity before Week-to-Today pulls (Closes #1244)
All checks were successful
CI / lint (pull_request) Successful in 3m11s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 5m18s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy 2026-08-22 01:33:04 +00:00
parent d01f502e70
commit 666671ee59
6 changed files with 132 additions and 9 deletions

View File

@ -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 '<button type="button" class="week-review-pull" data-week-pull-item="'+escapeAttribute(id)+'"'+(full?' disabled':'')+'>'+
(full?'Today is full':'Add to Today')+'</button>';
(full?'Today is full':(overload?'Review Today · '+projected+' of '+capacity+' min':'Add to Today'))+'</button>';
};
const items=day.ids.length?'<ul>'+day.ids.map(id=>'<li>'+itemMarkup(id,day)+move(id)+pull(id)+unplan(id)+'</li>').join('')+'</ul>':
'<p class="small muted">Nothing planned.</p>';
@ -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();

View File

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

View File

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

View File

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

View File

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

View File

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