Merge pull request 'Reflow overloaded Week Ahead plans' (#1227) from timmy/1226-week-ahead-reflow into main
All checks were successful
CI / lint (push) Successful in 3m31s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 5m38s
CI / release-candidate (push) Successful in 7s

This commit is contained in:
rockachopa 2026-08-21 15:49:07 +00:00
commit c63df3c46d
6 changed files with 306 additions and 9 deletions

View File

@ -177,7 +177,11 @@ planning pass.
closing after the seventh save. The review shows planned minutes against each days 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

View File

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

View File

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

View File

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

View File

@ -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:0010:30 · 30 min")
expect(page.locator('[data-week-calendar-preview="issue:acme/mobile:41:"]')).to_have_text("10:0010: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()

View File

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