diff --git a/README.md b/README.md
index d9e15fc..cd3fba3 100644
--- a/README.md
+++ b/README.md
@@ -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
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.
+**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
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;
diff --git a/frontend/dashboard.css b/frontend/dashboard.css
index d7521d3..0b278c2 100644
--- a/frontend/dashboard.css
+++ b/frontend/dashboard.css
@@ -321,6 +321,8 @@ textarea { resize: vertical; min-height: 120px; }
.week-review-days { display:grid; gap:12px; }
.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-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 button { flex:0 0 auto; min-height:44px; }
.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-status { min-height:1.4em; margin:10px 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; }
.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; }
diff --git a/frontend/index.html b/frontend/index.html
index 4caeb60..7be1c29 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -506,13 +506,14 @@
- Final planning step
- Review & rebalance Week Ahead
- Check each day’s load. Move work instead of copying it, then confirm one executable week.
+ Seven-day overview
+ Week Ahead
+ See what is next and check each day’s load. Edit only when you are ready.
Set capacity from calendar
+ Edit week
Confirm Week Ahead
diff --git a/frontend/week-plan.js b/frontend/week-plan.js
index 6f8772a..d1b0fa1 100644
--- a/frontend/week-plan.js
+++ b/frontend/week-plan.js
@@ -348,6 +348,7 @@ function createWeekPlanWorkflow({controller,qs,getLogin,getItem=()=>null,openPla
todayWork,refresh,warm}={}) {
let selectedDate=null;
let reviewing=false;
+ let overviewing=false;
let editingFromReview=false;
let blockedReviewOpen=false;
let reconciliation=null;
@@ -419,6 +420,7 @@ function createWeekPlanWorkflow({controller,qs,getLogin,getItem=()=>null,openPla
}
function renderReview() {
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=>''+escapeHtml(day.label)+' ').join('');
const itemMarkup=(id,day)=>{
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=>{
const capacity=Number(day.capacity_minutes)||0;
const load=day.planned_minutes+(capacity?' of '+capacity:'')+' min'+(day.overloaded?' · over capacity':'');
- const items=day.ids.length?'':
+ const move=id=>overviewing?'':'Move to '+destinations+
+ ' Move
';
+ const items=day.ids.length?''+day.ids.map(id=>''+itemMarkup(id,day)+move(id)+' ').join('')+' ':
'Nothing planned.
';
- return ''+escapeHtml(day.label)+' '+escapeHtml(day.label)+(day===nextUp?' Next up ':'')+' Edit day '+
escapeHtml(load)+'
'+items+' ';
}).join('');
@@ -444,12 +447,14 @@ function createWeekPlanWorkflow({controller,qs,getLogin,getItem=()=>null,openPla
const pending=Boolean(controller.pending?.());
confirm.disabled=!value.can_confirm||pending;
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?(
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.':
'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',()=>{
const selector=root.querySelector(`[data-week-move-destination="${button.dataset.weekMove}"]`);
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?.();
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() {
if(!editingFromReview)return false;
const editedDate=selectedDate;
@@ -480,11 +491,12 @@ function createWeekPlanWorkflow({controller,qs,getLogin,getItem=()=>null,openPla
return true;
}
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;
- selectedDate=controller.dates()[0].date;renderDates();openPlanner(trigger);
+ selectedDate=null;renderDates();openPlanner(trigger);
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;}
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;
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('#edit-week-plan')?.addEventListener('click',editWeek);
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;}
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;
}
}
- const workflow={open,save,advance,confirm,finish,promote,renderDates,renderReview,renderConflict,active:()=>Boolean(selectedDate)||reviewing||Boolean(reconciliation),
- reviewing:()=>reviewing,selectedDate:()=>selectedDate,
+ const workflow={open,save,advance,confirm,finish,promote,editWeek,renderDates,renderReview,renderConflict,active:()=>Boolean(selectedDate)||reviewing||Boolean(reconciliation),
+ reviewing:()=>reviewing,overviewing:()=>overviewing,selectedDate:()=>selectedDate,
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'}:
- (selectedDate?{title:'Plan Week Ahead',heading:selectedDate+', in order',available:'Available this day',build:'Build this day'}:null),
- clear(){selectedDate=null;reviewing=false;editingFromReview=false;reconciliation=null;setReviewMode(false);qs('#week-review').hidden=true;
+ (overviewing?{title:'Week Ahead',heading:'Seven-day overview',available:'Planned work',build:'Edit week'}:
+ (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();}};
if(typeof weekCalendarImport!=='undefined')weekCalendarImport.mount(controller,workflow,qs);
return workflow;
diff --git a/tests/e2e/test_mobile_week_ahead_release.py b/tests/e2e/test_mobile_week_ahead_release.py
index 0762620..d0bce13 100644
--- a/tests/e2e/test_mobile_week_ahead_release.py
+++ b/tests/e2e/test_mobile_week_ahead_release.py
@@ -62,6 +62,26 @@ def test_release_artifact_plans_seven_touch_safe_mobile_dates(
page.wait_for_timeout(100)
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")
dates = page.locator("#week-plan-dates button")
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.locator('[data-mobile-task="queues"]').click()
page.locator('[data-mobile-queue="week"]').click()
+ page.locator("#edit-week-plan").click()
page.locator("#plan-today-available").fill("60")
page.locator("#save-today-plan").click()
page.wait_for_function("() => document.querySelector('#mobile-week-summary').textContent.includes('Conflict')")
diff --git a/tests/test_week_plan_frontend.py b/tests/test_week_plan_frontend.py
index 1a994fc..e3b2f21 100644
--- a/tests/test_week_plan_frontend.py
+++ b/tests/test_week_plan_frontend.py
@@ -179,15 +179,18 @@ Object.defineProperty(root,'innerHTML',{set(value){this.value=value;this.buttons
root.querySelectorAll=()=>root.buttons||[];
root.querySelector=selector=>(root.buttons||[]).find(button=>selector.includes(button.dataset.weekPlanDate))||null;
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=[];
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};},
- 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',
openPlanner:()=>{opened+=1;},escapeHtml:value=>value,escapeAttribute:value=>value,
todayWork:{replace:()=>{},replacePlanning:()=>{}},refresh:()=>{},warm:()=>{}});
await workflow.open({disabled:false});
+workflow.editWeek();
workflow.save({ids:['issue:r:1:'],capacity_minutes:60,estimates:{'issue:r:1:':30}});
const continued=workflow.advance();
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["selected"] == "2026-08-22"
- assert result["opened"] == 3
+ assert result["opened"] == 4
assert result["staged"][0]["date"] == "2026-08-21"
assert result["progress"] == "Day 2 of 7 · 1 planned"
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():
result = run_controller("""
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||'';}});
elements.set('#week-plan-dates',root);
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;
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})),
@@ -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,
todayWork:{replace:()=>{},replacePlanning:()=>{}},refresh:()=>{},warm:()=>{}});
await workflow.open({disabled:false});
+workflow.editWeek();
for(let index=0;index<6;index++) workflow.advance();
const advanced=workflow.advance();
console.log(JSON.stringify({advanced,reviewing:workflow.reviewing(),reviewMode,