feat: add read-first Week Ahead overview (Closes #1216)
All checks were successful
CI / lint (pull_request) Successful in 3m37s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 5m19s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy 2026-08-21 09:05:18 +00:00
parent 45e2798d37
commit 42e97b4a79
6 changed files with 121 additions and 20 deletions

View File

@ -167,6 +167,9 @@ storage before closing on a phone. The Queues summary marks it **sync pending**
foreground, and midnight lifecycle checks share one delivery flight. A successful account receipt removes foreground, and midnight lifecycle checks share one delivery flight. A successful account receipt removes
the pending copy, while a revision conflict preserves both the phone plan and fresh server snapshot for the pending copy, while a revision conflict preserves both the phone plan and fresh server snapshot for
review. Unsynced Tomorrow work is never promoted into Today. review. Unsynced Tomorrow work is never promoted into Today.
**Week Ahead** opens as a read-first seven-day mobile overview, so operators can inspect the next planned day,
work titles and references, load versus capacity, overloads, and pending sync without staging a change. **Edit day**
enters one date and returns to the refreshed overview; **Edit week** starts the continuous planning pass.
**Plan Week Ahead** continues through seven local dates and now finishes on a mobile review step instead of **Plan Week Ahead** continues through seven local dates and now finishes on a mobile review step instead of
closing after the seventh save. The review shows planned minutes against each days capacity, marks overloads, 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; and flags work assigned to more than one date. Operators can move an item to another date without copying it;

View File

@ -321,6 +321,8 @@ textarea { resize: vertical; min-height: 120px; }
.week-review-days { display:grid; gap:12px; } .week-review-days { display:grid; gap:12px; }
.week-review-day { padding:12px; border:1px solid #31577f; border-radius:12px; background:#10233a; } .week-review-day { padding:12px; border:1px solid #31577f; border-radius:12px; background:#10233a; }
.week-review-day.is-overloaded { border-color:#f59e0b; background:#2a1c12; } .week-review-day.is-overloaded { border-color:#f59e0b; background:#2a1c12; }
.week-review-day.is-next-up { border-color:#60a5fa; box-shadow:0 0 0 1px #60a5fa inset; }
.week-next-up { display:inline-block; margin-left:6px; padding:2px 7px; border-radius:999px; background:#1d4f7a; color:#dbeafe; font-size:11px; vertical-align:middle; }
.week-review-day header { display:flex; align-items:center; justify-content:space-between; gap:8px; } .week-review-day header { display:flex; align-items:center; justify-content:space-between; gap:8px; }
.week-review-day header button { flex:0 0 auto; min-height:44px; } .week-review-day header button { flex:0 0 auto; min-height:44px; }
.week-review-day h3, .week-review-day p { margin:0 0 8px; } .week-review-day h3, .week-review-day p { margin:0 0 8px; }
@ -337,6 +339,7 @@ textarea { resize: vertical; min-height: 120px; }
.week-review-duplicates { margin:12px 0; padding:12px; border:1px solid #f59e0b; border-radius:10px; background:#2a1c12; } .week-review-duplicates { margin:12px 0; padding:12px; border:1px solid #f59e0b; border-radius:10px; background:#2a1c12; }
.week-review-status { min-height:1.4em; margin:10px 0; } .week-review-status { min-height:1.4em; margin:10px 0; }
#confirm-week-plan { width:100%; min-height:48px; position:sticky; bottom:0; } #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-capacity-import { width:100%; min-height:44px; margin:8px 0 12px; }
.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 { 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[hidden] { display:none; }

View File

@ -506,13 +506,14 @@
<div class="small week-conflict-status" id="week-conflict-status" role="status" aria-live="assertive"></div> <div class="small week-conflict-status" id="week-conflict-status" role="status" aria-live="assertive"></div>
</section> </section>
<section class="week-review" id="week-review" aria-labelledby="week-review-title" hidden> <section class="week-review" id="week-review" aria-labelledby="week-review-title" hidden>
<div class="small">Final planning step</div> <div class="small">Seven-day overview</div>
<h2 id="week-review-title">Review &amp; rebalance Week Ahead</h2> <h2 id="week-review-title">Week Ahead</h2>
<p class="small muted">Check each days load. Move work instead of copying it, then confirm one executable week.</p> <p class="small muted">See what is next and check each days load. Edit only when you are ready.</p>
<button id="open-week-capacity-import" type="button">Set capacity from calendar</button> <button id="open-week-capacity-import" type="button">Set capacity from calendar</button>
<div class="week-review-duplicates" id="week-review-duplicates" role="alert" hidden></div> <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="week-review-days" id="week-review-days"></div>
<div class="small week-review-status" id="week-review-status" role="status" aria-live="polite"></div> <div class="small week-review-status" id="week-review-status" role="status" aria-live="polite"></div>
<button id="edit-week-plan" type="button" hidden>Edit week</button>
<button id="confirm-week-plan" type="button">Confirm Week Ahead</button> <button id="confirm-week-plan" type="button">Confirm Week Ahead</button>
</section> </section>
<section class="week-capacity-import" id="week-capacity-import" aria-labelledby="week-capacity-title" hidden> <section class="week-capacity-import" id="week-capacity-import" aria-labelledby="week-capacity-title" hidden>

View File

@ -348,6 +348,7 @@ function createWeekPlanWorkflow({controller,qs,getLogin,getItem=()=>null,openPla
todayWork,refresh,warm}={}) { todayWork,refresh,warm}={}) {
let selectedDate=null; let selectedDate=null;
let reviewing=false; let reviewing=false;
let overviewing=false;
let editingFromReview=false; let editingFromReview=false;
let blockedReviewOpen=false; let blockedReviewOpen=false;
let reconciliation=null; let reconciliation=null;
@ -419,6 +420,7 @@ function createWeekPlanWorkflow({controller,qs,getLogin,getItem=()=>null,openPla
} }
function renderReview() { function renderReview() {
const value=controller.review(),root=qs('#week-review-days'),duplicates=qs('#week-review-duplicates'); const value=controller.review(),root=qs('#week-review-days'),duplicates=qs('#week-review-duplicates');
const nextUp=overviewing?value.days.find(day=>day.ids.length):null;
const destinations=value.days.map(day=>'<option value="'+escapeAttribute(day.plan_date)+'">'+escapeHtml(day.label)+'</option>').join(''); const destinations=value.days.map(day=>'<option value="'+escapeAttribute(day.plan_date)+'">'+escapeHtml(day.label)+'</option>').join('');
const itemMarkup=(id,day)=>{ const itemMarkup=(id,day)=>{
const item=getItem(id),estimate=Number(day.estimates?.[id])||0; const item=getItem(id),estimate=Number(day.estimates?.[id])||0;
@ -430,10 +432,11 @@ function createWeekPlanWorkflow({controller,qs,getLogin,getItem=()=>null,openPla
root.innerHTML=value.days.map(day=>{ root.innerHTML=value.days.map(day=>{
const capacity=Number(day.capacity_minutes)||0; const capacity=Number(day.capacity_minutes)||0;
const load=day.planned_minutes+(capacity?' of '+capacity:'')+' min'+(day.overloaded?' · over capacity':''); const load=day.planned_minutes+(capacity?' of '+capacity:'')+' min'+(day.overloaded?' · over capacity':'');
const items=day.ids.length?'<ul>'+day.ids.map(id=>'<li>'+itemMarkup(id,day)+'<div class="week-review-move"><label>Move to <select data-week-move-destination="'+ const move=id=>overviewing?'':'<div class="week-review-move"><label>Move to <select data-week-move-destination="'+escapeAttribute(id)+'">'+destinations+
escapeAttribute(id)+'">'+destinations+'</select></label><button type="button" data-week-move="'+escapeAttribute(id)+'">Move</button></div></li>').join('')+'</ul>': '</select></label><button type="button" data-week-move="'+escapeAttribute(id)+'">Move</button></div>';
const items=day.ids.length?'<ul>'+day.ids.map(id=>'<li>'+itemMarkup(id,day)+move(id)+'</li>').join('')+'</ul>':
'<p class="small muted">Nothing planned.</p>'; '<p class="small muted">Nothing planned.</p>';
return '<article class="week-review-day'+(day.overloaded?' is-overloaded':'')+'"><header><h3>'+escapeHtml(day.label)+'</h3><button type="button" data-week-edit-day="'+ return '<article class="week-review-day'+(day.overloaded?' is-overloaded':'')+(day===nextUp?' is-next-up':'')+'"><header><h3>'+escapeHtml(day.label)+(day===nextUp?' <span class="week-next-up">Next up</span>':'')+'</h3><button type="button" data-week-edit-day="'+
escapeAttribute(day.plan_date)+'" aria-label="Edit '+escapeAttribute(day.label)+'">Edit day</button></header><p class="week-review-load">'+ escapeAttribute(day.plan_date)+'" aria-label="Edit '+escapeAttribute(day.label)+'">Edit day</button></header><p class="week-review-load">'+
escapeHtml(load)+'</p>'+items+'</article>'; escapeHtml(load)+'</p>'+items+'</article>';
}).join(''); }).join('');
@ -444,12 +447,14 @@ function createWeekPlanWorkflow({controller,qs,getLogin,getItem=()=>null,openPla
const pending=Boolean(controller.pending?.()); const pending=Boolean(controller.pending?.());
confirm.disabled=!value.can_confirm||pending; confirm.disabled=!value.can_confirm||pending;
const blocker=value.blockers?.[0]; const blocker=value.blockers?.[0];
qs('#week-review-status').textContent=value.duplicates.length?'Duplicate work must be moved to one date.': qs('#week-review-status').textContent=overviewing?'Week Ahead overview · '+(pending?'sync pending.':'no changes made.'):(value.duplicates.length?'Duplicate work must be moved to one date.':
(pending?'Saving the latest week to your account…':blocker?( (pending?'Saving the latest week to your account…':blocker?(
blocker.type==='over-capacity'?blocker.plan_date+' is '+blocker.minutes+' min over capacity. Edit that day before confirming.': blocker.type==='over-capacity'?blocker.plan_date+' is '+blocker.minutes+' min over capacity. Edit that day before confirming.':
blocker.type==='missing-estimate'?'Add an estimate for work on '+blocker.plan_date+' before confirming.': blocker.type==='missing-estimate'?'Add an estimate for work on '+blocker.plan_date+' before confirming.':
'Add capacity for '+blocker.plan_date+' before confirming.' 'Add capacity for '+blocker.plan_date+' before confirming.'
):'Week Ahead is balanced and ready.'); ):'Week Ahead is balanced and ready.'));
confirm.hidden=overviewing;
const editWeek=qs('#edit-week-plan');if(editWeek){editWeek.hidden=!overviewing;editWeek.textContent=nextUp?'Edit week':'Plan Week Ahead';}
root.querySelectorAll('[data-week-move]').forEach(button=>button.addEventListener('click',()=>{ root.querySelectorAll('[data-week-move]').forEach(button=>button.addEventListener('click',()=>{
const selector=root.querySelector(`[data-week-move-destination="${button.dataset.weekMove}"]`); const selector=root.querySelector(`[data-week-move-destination="${button.dataset.weekMove}"]`);
if(!controller.move(button.dataset.weekMove,selector?.value))return; if(!controller.move(button.dataset.weekMove,selector?.value))return;
@ -469,6 +474,12 @@ function createWeekPlanWorkflow({controller,qs,getLogin,getItem=()=>null,openPla
qs('#week-plan-progress')?.focus?.(); qs('#week-plan-progress')?.focus?.();
return true; return true;
} }
function editWeek() {
overviewing=false;reviewing=false;selectedDate=controller.dates()[0].date;setReviewMode(false);
qs('#week-review').hidden=true;qs('#confirm-week-plan').hidden=false;
const edit=qs('#edit-week-plan');if(edit)edit.hidden=true;
renderDates();openPlanner(null,false);qs('#week-plan-progress')?.focus?.();return true;
}
function returnToReview() { function returnToReview() {
if(!editingFromReview)return false; if(!editingFromReview)return false;
const editedDate=selectedDate; const editedDate=selectedDate;
@ -480,11 +491,12 @@ function createWeekPlanWorkflow({controller,qs,getLogin,getItem=()=>null,openPla
return true; return true;
} }
async function open(trigger) { async function open(trigger) {
trigger.disabled=true;reviewing=false;editingFromReview=false;setReviewMode(false);const reviewRoot=qs('#week-review');if(reviewRoot)reviewRoot.hidden=true; trigger.disabled=true;reviewing=false;overviewing=false;editingFromReview=false;setReviewMode(false);const reviewRoot=qs('#week-review');if(reviewRoot)reviewRoot.hidden=true;
const back=qs('#back-to-week-review');if(back)back.hidden=true; const back=qs('#back-to-week-review');if(back)back.hidden=true;
selectedDate=controller.dates()[0].date;renderDates();openPlanner(trigger); selectedDate=null;renderDates();openPlanner(trigger);
qs('#mobile-week-summary').textContent='Loading Week Ahead…'; qs('#mobile-week-summary').textContent='Loading Week Ahead…';
try{await controller.load();qs('#mobile-week-summary').textContent=controller.summary();renderDates();openPlanner(null,false);return true;} try{await controller.load();qs('#mobile-week-summary').textContent=controller.summary();overviewing=true;reviewing=true;
setReviewMode(true);reviewRoot.hidden=false;renderDates();renderReview();openPlanner(null,false);return true;}
catch(error){qs('#mobile-week-summary').textContent='Unavailable · tap to retry';qs('#my-work-action-status').textContent=(error.message||'Week Ahead is unavailable.')+' Retry when connected.';return false;} catch(error){qs('#mobile-week-summary').textContent='Unavailable · tap to retry';qs('#my-work-action-status').textContent=(error.message||'Week Ahead is unavailable.')+' Retry when connected.';return false;}
finally{trigger.disabled=false;} finally{trigger.disabled=false;}
} }
@ -534,8 +546,9 @@ function createWeekPlanWorkflow({controller,qs,getLogin,getItem=()=>null,openPla
if(!reviewing||!value.can_confirm||controller.pending()) return false; if(!reviewing||!value.can_confirm||controller.pending()) return false;
return true; return true;
} }
function finish(){reviewing=false;setReviewMode(false);qs('#week-review').hidden=true;return true;} function finish(){reviewing=false;overviewing=false;setReviewMode(false);qs('#week-review').hidden=true;return true;}
qs('#back-to-week-review')?.addEventListener('click',returnToReview); qs('#back-to-week-review')?.addEventListener('click',returnToReview);
qs('#edit-week-plan')?.addEventListener('click',editWeek);
async function promote(plan){ 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;} 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;}
catch(error){ catch(error){
@ -549,12 +562,13 @@ function createWeekPlanWorkflow({controller,qs,getLogin,getItem=()=>null,openPla
qs('#my-work-action-status').textContent=(error.message||'Week Ahead needs review before promotion.')+' Open Week Ahead to review.';return false; qs('#my-work-action-status').textContent=(error.message||'Week Ahead needs review before promotion.')+' Open Week Ahead to review.';return false;
} }
} }
const workflow={open,save,advance,confirm,finish,promote,renderDates,renderReview,renderConflict,active:()=>Boolean(selectedDate)||reviewing||Boolean(reconciliation), const workflow={open,save,advance,confirm,finish,promote,editWeek,renderDates,renderReview,renderConflict,active:()=>Boolean(selectedDate)||reviewing||Boolean(reconciliation),
reviewing:()=>reviewing,selectedDate:()=>selectedDate, reviewing:()=>reviewing,overviewing:()=>overviewing,selectedDate:()=>selectedDate,
day:()=>reconciliationDay()||(selectedDate?controller.day(selectedDate):null), day:()=>reconciliationDay()||(selectedDate?controller.day(selectedDate):null),
copy:()=>reconciliation?{title:"Start today's plan",heading:'Unfinished Today + due Week Ahead',available:'Available today',build:'Build combined Today'}: copy:()=>reconciliation?{title:"Start today's plan",heading:'Unfinished Today + due Week Ahead',available:'Available today',build:'Build combined Today'}:
(selectedDate?{title:'Plan Week Ahead',heading:selectedDate+', in order',available:'Available this day',build:'Build this day'}:null), (overviewing?{title:'Week Ahead',heading:'Seven-day overview',available:'Planned work',build:'Edit week'}:
clear(){selectedDate=null;reviewing=false;editingFromReview=false;reconciliation=null;setReviewMode(false);qs('#week-review').hidden=true; (selectedDate?{title:'Plan Week Ahead',heading:selectedDate+', in order',available:'Available this day',build:'Build this day'}:null)),
clear(){selectedDate=null;reviewing=false;overviewing=false;editingFromReview=false;reconciliation=null;setReviewMode(false);qs('#week-review').hidden=true;
const back=qs('#back-to-week-review');if(back)back.hidden=true;renderDates();}}; const back=qs('#back-to-week-review');if(back)back.hidden=true;renderDates();}};
if(typeof weekCalendarImport!=='undefined')weekCalendarImport.mount(controller,workflow,qs); if(typeof weekCalendarImport!=='undefined')weekCalendarImport.mount(controller,workflow,qs);
return workflow; return workflow;

View File

@ -62,6 +62,26 @@ def test_release_artifact_plans_seven_touch_safe_mobile_dates(
page.wait_for_timeout(100) page.wait_for_timeout(100)
assert not page_errors, f"Week Ahead launch raised: {page_errors}" assert not page_errors, f"Week Ahead launch raised: {page_errors}"
expect(page.locator("#plan-today-title")).to_have_text("Week Ahead")
overview = page.locator("#week-review")
expect(overview).to_be_visible()
cards = page.locator("#week-review-days .week-review-day")
expect(cards).to_have_count(7)
expect(cards.first).to_contain_text("Next up")
expect(cards.first).to_contain_text("Ship mobile capture")
expect(page.locator("#week-review-status")).to_have_text(
"Week Ahead overview · no changes made."
)
expect(page.locator("#confirm-week-plan")).to_be_hidden()
edit_week = page.locator("#edit-week-plan")
expect(edit_week).to_be_visible()
assert saved == [], "opening and inspecting Week Ahead must not write"
for control in (cards.first.locator("[data-week-edit-day]"), edit_week):
bounds = control.bounding_box()
assert bounds and bounds["height"] >= 44
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
edit_week.click()
expect(overview).to_be_hidden()
expect(page.locator("#plan-today-title")).to_have_text("Plan Week Ahead") expect(page.locator("#plan-today-title")).to_have_text("Plan Week Ahead")
dates = page.locator("#week-plan-dates button") dates = page.locator("#week-plan-dates button")
expect(dates).to_have_count(7) expect(dates).to_have_count(7)
@ -243,6 +263,7 @@ def test_release_artifact_reconciles_only_the_week_day_changed_on_both_devices(
page.wait_for_url(origin + "/", wait_until="networkidle") page.wait_for_url(origin + "/", wait_until="networkidle")
page.locator('[data-mobile-task="queues"]').click() page.locator('[data-mobile-task="queues"]').click()
page.locator('[data-mobile-queue="week"]').click() page.locator('[data-mobile-queue="week"]').click()
page.locator("#edit-week-plan").click()
page.locator("#plan-today-available").fill("60") page.locator("#plan-today-available").fill("60")
page.locator("#save-today-plan").click() page.locator("#save-today-plan").click()
page.wait_for_function("() => document.querySelector('#mobile-week-summary').textContent.includes('Conflict')") page.wait_for_function("() => document.querySelector('#mobile-week-summary').textContent.includes('Conflict')")

View File

@ -179,15 +179,18 @@ Object.defineProperty(root,'innerHTML',{set(value){this.value=value;this.buttons
root.querySelectorAll=()=>root.buttons||[]; root.querySelectorAll=()=>root.buttons||[];
root.querySelector=selector=>(root.buttons||[]).find(button=>selector.includes(button.dataset.weekPlanDate))||null; root.querySelector=selector=>(root.buttons||[]).find(button=>selector.includes(button.dataset.weekPlanDate))||null;
elements.set('#week-plan-dates',root); elements.set('#week-plan-dates',root);
for(const selector of ['#mobile-week-summary','#my-work-action-status','#week-plan-progress','#save-today-plan']) elements.set(selector,makeElement()); for(const selector of ['#mobile-week-summary','#my-work-action-status','#week-plan-progress','#save-today-plan',
'#week-review','#week-review-days','#week-review-duplicates','#confirm-week-plan','#week-review-status','#edit-week-plan']) elements.set(selector,makeElement());
let opened=0,staged=[]; let opened=0,staged=[];
const controller={dates:()=>dates.map((date,index)=>({date,label:'Day '+(index+1)})),day:date=>({plan_date:date,ids:[]}), const controller={dates:()=>dates.map((date,index)=>({date,label:'Day '+(index+1)})),day:date=>({plan_date:date,ids:[]}),
pass:date=>{const index=dates.indexOf(date);return {position:index+1,total:7,planned:staged.length,next_date:dates[index+1]||null,last:index===6};}, pass:date=>{const index=dates.indexOf(date);return {position:index+1,total:7,planned:staged.length,next_date:dates[index+1]||null,last:index===6};},
load:async()=>({}),summary:()=> 'Nothing planned',stageDay:(date,plan)=>{staged.push({date,plan});return true;},flush:async()=>({})}; load:async()=>({}),summary:()=> 'Nothing planned',review:()=>({days:dates.map(date=>({plan_date:date,label:date,ids:[],capacity_minutes:null,estimates:{},planned_minutes:0,overloaded:false})),duplicates:[],blockers:[],can_confirm:true}),pending:()=>false,
stageDay:(date,plan)=>{staged.push({date,plan});return true;},flush:async()=>({})};
const workflow=createWorkflow({controller,qs:selector=>elements.get(selector),getLogin:()=> 'timmy', const workflow=createWorkflow({controller,qs:selector=>elements.get(selector),getLogin:()=> 'timmy',
openPlanner:()=>{opened+=1;},escapeHtml:value=>value,escapeAttribute:value=>value, openPlanner:()=>{opened+=1;},escapeHtml:value=>value,escapeAttribute:value=>value,
todayWork:{replace:()=>{},replacePlanning:()=>{}},refresh:()=>{},warm:()=>{}}); todayWork:{replace:()=>{},replacePlanning:()=>{}},refresh:()=>{},warm:()=>{}});
await workflow.open({disabled:false}); await workflow.open({disabled:false});
workflow.editWeek();
workflow.save({ids:['issue:r:1:'],capacity_minutes:60,estimates:{'issue:r:1:':30}}); workflow.save({ids:['issue:r:1:'],capacity_minutes:60,estimates:{'issue:r:1:':30}});
const continued=workflow.advance(); const continued=workflow.advance();
console.log(JSON.stringify({continued,selected:workflow.selectedDate(),opened,staged, console.log(JSON.stringify({continued,selected:workflow.selectedDate(),opened,staged,
@ -196,12 +199,67 @@ console.log(JSON.stringify({continued,selected:workflow.selectedDate(),opened,st
assert result["continued"] is True assert result["continued"] is True
assert result["selected"] == "2026-08-22" assert result["selected"] == "2026-08-22"
assert result["opened"] == 3 assert result["opened"] == 4
assert result["staged"][0]["date"] == "2026-08-21" assert result["staged"][0]["date"] == "2026-08-21"
assert result["progress"] == "Day 2 of 7 · 1 planned" assert result["progress"] == "Day 2 of 7 · 1 planned"
assert result["label"] == "Save & next" assert result["label"] == "Save & next"
def test_week_workflow_opens_a_read_only_overview_with_next_up_work():
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:()=>{}});
const dateRoot=makeElement();
Object.defineProperty(dateRoot,'innerHTML',{set(value){this.value=value;this.buttons=[];},get(){return this.value||'';}});
elements.set('#week-plan-dates',dateRoot);
const reviewDays=makeElement();
Object.defineProperty(reviewDays,'innerHTML',{set(value){this.value=value;},get(){return this.value||'';}});
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());
let writes=0,opened=0,reviewMode=false;
const review={days:dates.map((date,index)=>({plan_date:date,label:'Day '+(index+1),
ids:index===2?['issue:stackchain/dashboard:42:']:[],capacity_minutes:60,
estimates:index===2?{'issue:stackchain/dashboard:42:':30}:{},planned_minutes:index===2?30:0,overloaded:false})),
duplicates:[],blockers:[],can_confirm:true};
const controller={dates:()=>dates.map((date,index)=>({date,label:'Day '+(index+1)})),day:date=>review.days.find(day=>day.plan_date===date),
pass:date=>{const index=dates.indexOf(date);return {position:index+1,total:7,planned:1,next_date:dates[index+1]||null,last:index===6};},
load:async()=>({}),summary:()=> '1 item across 1 day',review:()=>review,pending:()=>false,
move:()=>true,flush:async()=>{writes+=1;}};
const workflow=createWorkflow({controller,qs:selector=>elements.get(selector),getLogin:()=> 'timmy',
getItem:id=>id.includes(':42:')?{kind:'issue',title:'Ship mobile overview',repository:'stackchain/dashboard',number:42}:null,
openPlanner:()=>{opened+=1;},setReviewMode:value=>{reviewMode=value;},escapeHtml:value=>value,escapeAttribute:value=>value,
todayWork:{replace:()=>{},replacePlanning:()=>{}},refresh:()=>{},warm:()=>{}});
await workflow.open({disabled:false});
console.log(JSON.stringify({reviewing:workflow.reviewing(),reviewMode,writes,opened,copy:workflow.copy(),
reviewHidden:elements.get('#week-review').hidden,datesHidden:elements.get('#week-plan-dates').hidden,
markup:reviewDays.innerHTML,status:elements.get('#week-review-status').textContent,
confirmHidden:elements.get('#confirm-week-plan').hidden,editWeekHidden:elements.get('#edit-week-plan').hidden,
editWeekLabel:elements.get('#edit-week-plan').textContent}));
""")
assert result["reviewing"] is True
assert result["reviewMode"] is True
assert result["writes"] == 0
assert result["copy"] == {
"title": "Week Ahead", "heading": "Seven-day overview",
"available": "Planned work", "build": "Edit week",
}
assert result["reviewHidden"] is False
assert result["datesHidden"] is True
assert "Ship mobile overview" in result["markup"]
assert "Next up" in result["markup"]
assert "data-week-move" not in result["markup"]
assert result["status"] == "Week Ahead overview · no changes made."
assert result["confirmHidden"] is True
assert result["editWeekHidden"] is False
assert result["editWeekLabel"] == "Edit week"
def test_week_workflow_opens_review_after_day_seven_and_blocks_duplicate_confirmation(): def test_week_workflow_opens_review_after_day_seven_and_blocks_duplicate_confirmation():
result = run_controller(""" result = run_controller("""
const createWorkflow=createWeekPlan.Workflow; const createWorkflow=createWeekPlan.Workflow;
@ -213,7 +271,7 @@ const root=makeElement();
Object.defineProperty(root,'innerHTML',{set(value){this.value=value;this.buttons=[];},get(){return this.value||'';}}); Object.defineProperty(root,'innerHTML',{set(value){this.value=value;this.buttons=[];},get(){return this.value||'';}});
elements.set('#week-plan-dates',root); elements.set('#week-plan-dates',root);
for(const selector of ['#mobile-week-summary','#my-work-action-status','#week-plan-progress','#save-today-plan', for(const selector of ['#mobile-week-summary','#my-work-action-status','#week-plan-progress','#save-today-plan',
'#week-review','#week-review-days','#week-review-duplicates','#confirm-week-plan','#week-review-status']) elements.set(selector,makeElement()); '#week-review','#week-review-days','#week-review-duplicates','#confirm-week-plan','#week-review-status','#edit-week-plan']) elements.set(selector,makeElement());
let reviewMode=false; let reviewMode=false;
const review={days:dates.map((date,index)=>({plan_date:date,label:'Day '+(index+1),ids:index<2?['shared']:[], const review={days:dates.map((date,index)=>({plan_date:date,label:'Day '+(index+1),ids:index<2?['shared']:[],
capacity_minutes:index===0?30:60,planned_minutes:index===0?45:15,overloaded:index===0})), capacity_minutes:index===0?30:60,planned_minutes:index===0?45:15,overloaded:index===0})),
@ -225,6 +283,7 @@ const workflow=createWorkflow({controller,qs:selector=>elements.get(selector),ge
openPlanner:()=>{},setReviewMode:value=>{reviewMode=value;},escapeHtml:value=>value,escapeAttribute:value=>value, openPlanner:()=>{},setReviewMode:value=>{reviewMode=value;},escapeHtml:value=>value,escapeAttribute:value=>value,
todayWork:{replace:()=>{},replacePlanning:()=>{}},refresh:()=>{},warm:()=>{}}); todayWork:{replace:()=>{},replacePlanning:()=>{}},refresh:()=>{},warm:()=>{}});
await workflow.open({disabled:false}); await workflow.open({disabled:false});
workflow.editWeek();
for(let index=0;index<6;index++) workflow.advance(); for(let index=0;index<6;index++) workflow.advance();
const advanced=workflow.advance(); const advanced=workflow.advance();
console.log(JSON.stringify({advanced,reviewing:workflow.reviewing(),reviewMode, console.log(JSON.stringify({advanced,reviewing:workflow.reviewing(),reviewMode,