feat: reflow overloaded Week Ahead plans (Closes #1226)
This commit is contained in:
1 parent
b7dd9757aa
commit
ffba6f1212
6 files changed
+306
-9
No files matched your search
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in new issue
Block a user