Merge pull request 'Confirm Today capacity before pulling Week Ahead work' (#1245) from timmy/1244-capacity-confirm-week-pull into main
This commit is contained in:
commit
a13a13d90c
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user