Reflow overloaded Week Ahead plans #1227
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -512,6 +512,18 @@
|
|||
<p class="week-offline-snapshot" id="week-offline-snapshot" role="status" aria-live="polite" hidden></p>
|
||||
<button id="retry-week-live" type="button" hidden>Retry live Week Ahead</button>
|
||||
<button id="open-week-capacity-import" type="button">Set capacity from calendar</button>
|
||||
<button id="open-week-reflow" type="button" hidden>Reflow remaining week</button>
|
||||
<section class="week-reflow-review" id="week-reflow-review" aria-labelledby="week-reflow-title" hidden>
|
||||
<div class="small">Capacity-safe preview</div>
|
||||
<h3 id="week-reflow-title">Reflow remaining week?</h3>
|
||||
<p class="small muted">Work keeps its current order. Anything that cannot fit stays unscheduled in My Work.</p>
|
||||
<div class="week-reflow-days" id="week-reflow-days"></div>
|
||||
<div class="week-reflow-unscheduled" id="week-reflow-unscheduled" role="status"></div>
|
||||
<div class="week-reflow-actions">
|
||||
<button id="cancel-week-reflow" type="button">Cancel</button>
|
||||
<button id="apply-week-reflow" type="button">Apply reflow</button>
|
||||
</div>
|
||||
</section>
|
||||
<div class="week-review-duplicates" id="week-review-duplicates" role="alert" hidden></div>
|
||||
<div class="week-review-days" id="week-review-days"></div>
|
||||
<div class="small week-review-status" id="week-review-status" role="status" aria-live="polite"></div>
|
||||
|
|
|
|||
|
|
@ -193,6 +193,36 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin,now=()=>D
|
|||
['over-capacity','missing-estimate','missing-capacity'].indexOf(left.type)-['over-capacity','missing-estimate','missing-capacity'].indexOf(right.type));
|
||||
return {days,duplicates,blockers,can_confirm:duplicates.length===0&&blockers.length===0};
|
||||
}
|
||||
function previewReflow() {
|
||||
const available=dates().map(item=>day(item.date));
|
||||
const seen=new Set(),work=[];
|
||||
available.forEach(source=>(source.ids||[]).forEach(id=>{
|
||||
if(seen.has(id))return;
|
||||
seen.add(id);
|
||||
const estimate=Number(source.estimates?.[id]);
|
||||
work.push({id,estimate:Number.isFinite(estimate)?estimate:null,from_date:source.plan_date});
|
||||
}));
|
||||
const blockers=work.filter(item=>!(item.estimate>0)).map(item=>({
|
||||
type:'missing-estimate',id:item.id,plan_date:item.from_date,
|
||||
}));
|
||||
if(blockers.length)return {days:[],blockers,unscheduled:[]};
|
||||
const days=available.map(source=>({plan_date:source.plan_date,ids:[],
|
||||
capacity_minutes:source.capacity_minutes??null,estimates:{}}));
|
||||
const unscheduled=[];let cursor=0;
|
||||
if(!blockers.length)work.forEach(item=>{
|
||||
const destination=days.slice(cursor).find(value=>value.ids.length<5&&Number(value.capacity_minutes)>0&&
|
||||
value.ids.reduce((total,id)=>total+Number(value.estimates[id]),0)+item.estimate<=Number(value.capacity_minutes));
|
||||
if(!destination){unscheduled.push({...item});return;}
|
||||
cursor=days.indexOf(destination);
|
||||
destination.ids.push(item.id);destination.estimates[item.id]=item.estimate;
|
||||
});
|
||||
return {days,blockers,unscheduled};
|
||||
}
|
||||
function applyReflow() {
|
||||
const preview=previewReflow();
|
||||
if(preview.blockers.length||offlineSnapshot)return false;
|
||||
return stageDays(preview.days);
|
||||
}
|
||||
function move(id,toDate) {
|
||||
if(!dates().some(item=>item.date===toDate)) return false;
|
||||
const sources=week.days.filter(item=>(item.ids||[]).includes(id));
|
||||
|
|
@ -435,7 +465,7 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin,now=()=>D
|
|||
const label=planned.length?`${items} item${items===1?'':'s'} across ${planned.length} day${planned.length===1?'':'s'}`:'Nothing planned';
|
||||
return label+(pending()?' · sync pending':'');
|
||||
}
|
||||
return {adopt,state,dates,day,pass,load,saveDay,stageDay,stageCapacities,review,move,placement,place,pending,flush,conflict,chooseDay,saveMerged,
|
||||
return {adopt,state,dates,day,pass,load,saveDay,stageDay,stageCapacities,review,previewReflow,applyReflow,move,placement,place,pending,flush,conflict,chooseDay,saveMerged,
|
||||
keepLocal,useRemote,promote,startEarly,reconcile:reconcilePromotion,summary,rememberItems,rememberPendingItem,
|
||||
item:id=>pendingItems[id]||confirmedItems[id]||null,
|
||||
offline:()=>offlineSnapshot};
|
||||
|
|
@ -530,6 +560,52 @@ function createWeekPlanWorkflow({controller,qs,getItem=()=>null,openItem,openPla
|
|||
selectedDate=button.dataset.weekPlanDate;renderDates();openPlanner(null,false);
|
||||
}));
|
||||
}
|
||||
function closeReflow() {
|
||||
const panel=qs('#week-reflow-review');if(panel)panel.hidden=true;
|
||||
const apply=qs('#apply-week-reflow');if(apply)apply.disabled=false;
|
||||
return true;
|
||||
}
|
||||
function openReflow() {
|
||||
const preview=controller.previewReflow?.();
|
||||
if(!preview)return false;
|
||||
const labels=new Map(controller.dates().map(item=>[item.date,item.label]));
|
||||
const panel=qs('#week-reflow-review'),days=qs('#week-reflow-days'),unscheduled=qs('#week-reflow-unscheduled');
|
||||
panel.hidden=false;
|
||||
if(preview.blockers.length){
|
||||
days.innerHTML='';
|
||||
const blocker=preview.blockers[0],item=getItem(blocker.id)||controller.item?.(blocker.id);
|
||||
unscheduled.textContent='Add an estimate for '+(item?.title||blocker.id)+' before reflowing.';
|
||||
qs('#apply-week-reflow').disabled=true;
|
||||
panel.focus?.();return preview;
|
||||
}
|
||||
days.innerHTML=preview.days.filter(day=>day.ids.length).map(day=>{
|
||||
const minutes=day.ids.reduce((total,id)=>total+Number(day.estimates?.[id]||0),0);
|
||||
return '<div class="week-reflow-day"><strong>'+escapeHtml(labels.get(day.plan_date)||day.plan_date)+
|
||||
'</strong><span>'+escapeHtml(day.ids.length+' item'+(day.ids.length===1?'':'s')+' · '+minutes+' of '+Number(day.capacity_minutes||0)+' min')+'</span></div>';
|
||||
}).join('')||'<p class="small muted">No work can be scheduled with the current capacities.</p>';
|
||||
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;}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user