diff --git a/README.md b/README.md
index 35166e4..f230cb3 100644
--- a/README.md
+++ b/README.md
@@ -155,6 +155,13 @@ 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.
+**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;
+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.
+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.
Planning edits can remain offline for up to 30 days. After that, the
expired edit is discarded visibly and the account plan is kept rather than replaying stale
intent. The server retains no more than 4,096 operation receipts per account and removes
diff --git a/frontend/dashboard.css b/frontend/dashboard.css
index 3dfb3fc..0594813 100644
--- a/frontend/dashboard.css
+++ b/frontend/dashboard.css
@@ -294,6 +294,30 @@ textarea { resize: vertical; min-height: 120px; }
.week-conflict-plans { grid-template-columns:1fr; }
.week-conflict-actions { flex-direction:column; }
}
+.week-review { margin-top:14px; }
+.week-review h2 { margin-bottom:6px; }
+.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 h3, .week-review-day p { margin:0 0 8px; }
+.week-review-load { color:#bfdbfe; font-weight:700; }
+.week-review-day ul { display:grid; gap:10px; margin:0; padding:0; list-style:none; }
+.week-review-day li { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:8px; align-items:end; padding-top:8px; border-top:1px solid #31577f; overflow-wrap:anywhere; }
+.week-review-day label { display:grid; gap:4px; font-size:12px; }
+.week-review-day select, .week-review-day button { min-height:44px; }
+.week-review-day button { min-height:44px; }
+.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; }
+.plan-today-sheet.week-review-mode .week-plan-dates,
+.plan-today-sheet.week-review-mode .mobile-plan-today-nav,
+.plan-today-sheet.week-review-mode #plan-today-fit,
+.plan-today-sheet.week-review-mode #plan-today-selected,
+.plan-today-sheet.week-review-mode #plan-today-available-work,
+.plan-today-sheet.week-review-mode .plan-today-actions { display:none; }
+@media (max-width:480px) {
+ .week-review-day li { grid-template-columns:1fr; }
+}
.mobile-plan-today-nav { position:sticky; top:0; z-index:5; display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:4px; margin:0 -18px 10px; padding:4px 18px; background:rgba(11,21,38,.98); border-block:1px solid #2a496e; }
.mobile-plan-today-nav button { min-width:0; min-height:44px; padding:4px; border-color:transparent; font-size:12px; }
.mobile-plan-today-nav button[aria-current="location"] { color:#bfdbfe; background:#17365a; border-color:#31577f; }
diff --git a/frontend/dashboard.js b/frontend/dashboard.js
index 507ce06..6715fe4 100644
--- a/frontend/dashboard.js
+++ b/frontend/dashboard.js
@@ -453,7 +453,8 @@
getLogin: () => planningOwnerLogin,
});
const weekWorkflow=createWeekPlanWorkflow({controller:weekPlan,qs,getLogin:()=>planningOwnerLogin,
- openPlanner:openPlanToday,escapeHtml,escapeAttribute:escAttr,todayWork,
+ openPlanner:openPlanToday,setReviewMode:value=>qs('#plan-today-sheet').classList.toggle('week-review-mode',value),
+ escapeHtml,escapeAttribute:escAttr,todayWork,
refresh:refreshMyWorkView,warm:warmTodayOffline});
const todaySync = createTodaySync({
storage: localStorage,
@@ -7896,6 +7897,15 @@
}
});
qs('#cancel-plan-today').addEventListener('click', closePlanToday);
+ qs('#confirm-week-plan').addEventListener('click', () => {
+ if(!weekWorkflow.confirm()){
+ qs('#week-review-status').textContent=weekPlan.pending()?'Wait for Week Ahead to finish syncing before confirming.':'Move duplicated work to one date before confirming.';
+ return;
+ }
+ qs('#mobile-week-summary').textContent=weekPlan.summary();
+ qs('#my-work-action-status').textContent='Week Ahead reviewed and confirmed.';
+ taskOverlayHistory.leave();
+ });
qs('#plan-today-sheet').addEventListener('click', event => {
if (event.target === qs('#plan-today-sheet')) closePlanToday();
});
diff --git a/frontend/index.html b/frontend/index.html
index 6bd717b..df8d2c5 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -504,6 +504,15 @@
+
+ Final planning step
+ Review & rebalance Week Ahead
+ Check each day’s load. Move work instead of copying it, then confirm one executable week.
+
+
+
+ Confirm Week Ahead
+
Fit
Today
diff --git a/frontend/service-worker.js b/frontend/service-worker.js
index 73f3ba5..c2df80a 100644
--- a/frontend/service-worker.js
+++ b/frontend/service-worker.js
@@ -1,7 +1,7 @@
const BASE = new URL('./', self.location.href).pathname;
importScripts(BASE + 'static/private-data-registry.js');
importScripts(BASE + 'static/background-issue-sync.js');
-const CACHE = 'stackchain-dashboard-shell-v127';
+const CACHE = 'stackchain-dashboard-shell-v128';
const OFFLINE_LEASE_URL = new URL(BASE + '__offline-session-lease', self.location.origin).href;
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000;
diff --git a/frontend/week-plan.js b/frontend/week-plan.js
index 030da88..3158bb0 100644
--- a/frontend/week-plan.js
+++ b/frontend/week-plan.js
@@ -60,14 +60,11 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin}={}) {
if(queued){week=queued;return state();}
return adopt(await fetchJson('api/v1/week'));
}
- function stageDay(planDate,value) {
+ function stageDays(days) {
const key=storageKey();
if(!key||!storage) return false;
const queued={base_revision:Number.isInteger(week.base_revision)?week.base_revision:week.revision,
- timezone:timeZone(),days:week.days.filter(item=>item.plan_date!==planDate).concat([{
- plan_date:planDate,ids:[...(value.ids||[])],capacity_minutes:value.capacity_minutes??null,
- estimates:{...(value.estimates||{})},
- }]).sort((left,right)=>left.plan_date.localeCompare(right.plan_date)),
+ timezone:timeZone(),days:days.map(cloneDay).sort((left,right)=>left.plan_date.localeCompare(right.plan_date)),
base_days:(week.base_days||week.days).map(cloneDay)};
try { storage.setItem(key,JSON.stringify(queued)); }
catch(_error) { return false; }
@@ -75,6 +72,41 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin}={}) {
week={revision:queued.base_revision,...queued,sync_pending:true};
return state();
}
+ function stageDay(planDate,value) {
+ return stageDays(week.days.filter(item=>item.plan_date!==planDate).concat([{
+ plan_date:planDate,ids:[...(value.ids||[])],capacity_minutes:value.capacity_minutes??null,
+ estimates:{...(value.estimates||{})},
+ }]));
+ }
+ function review() {
+ const assigned=new Map();
+ const days=dates().map(item=>{
+ const value=day(item.date),estimates=value.estimates||{};
+ value.ids.forEach(id=>assigned.set(id,[...(assigned.get(id)||[]),item.date]));
+ const planned_minutes=value.ids.reduce((total,id)=>total+(Number(estimates[id])||0),0);
+ const capacity_minutes=Number(value.capacity_minutes)||0;
+ return {...value,label:item.label,planned_minutes,
+ overloaded:Boolean(capacity_minutes&&planned_minutes>capacity_minutes)};
+ });
+ const duplicates=[...assigned.entries()].filter(([_id,assignedDates])=>assignedDates.length>1)
+ .map(([id,assignedDates])=>({id,dates:assignedDates}));
+ return {days,duplicates,can_confirm:duplicates.length===0};
+ }
+ function move(id,toDate) {
+ if(!dates().some(item=>item.date===toDate)) return false;
+ const sources=week.days.filter(item=>(item.ids||[]).includes(id));
+ if(!sources.length) return false;
+ const estimate=sources.map(item=>Number(item.estimates?.[id])).find(Number.isFinite);
+ const moved=week.days.map(item=>{
+ const estimates={...(item.estimates||{})};delete estimates[id];
+ return {...cloneDay(item),ids:(item.ids||[]).filter(value=>value!==id),estimates};
+ });
+ let destination=moved.find(item=>item.plan_date===toDate);
+ if(!destination){destination={plan_date:toDate,ids:[],capacity_minutes:null,estimates:{}};moved.push(destination);}
+ destination.ids.push(id);
+ if(Number.isFinite(estimate)) destination.estimates[id]=estimate;
+ return Boolean(stageDays(moved));
+ }
function deliveryBody(value) {
return {base_revision:value.base_revision,timezone:value.timezone,days:value.days.map(cloneDay)};
}
@@ -96,34 +128,53 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin}={}) {
}
function flush() {
if(flushing) return flushing;
- const queued=pending();
const key=storageKey();
- if(!queued||!key) return Promise.resolve(false);
- const body=deliveryBody(queued);
- flushing=fetchJson('api/v1/week',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)})
- .then(saved=>{
- const current=pending();
- if(current&&JSON.stringify(deliveryBody(current))===JSON.stringify(body)) storage.removeItem(key);
- if(!pending()) adopt(saved);
- return saved;
- }).catch(async error=>{
+ if(!pending()||!key) return Promise.resolve(false);
+ const sameBody=(value,body)=>JSON.stringify(deliveryBody(value))===JSON.stringify(body);
+ const deliver=async()=>{
+ const queued=pending();
+ if(!queued)return false;
+ const body=deliveryBody(queued);
+ let saved;
+ try {
+ saved=await fetchJson('api/v1/week',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
+ } catch(error) {
if(error?.status===409){
const remote=await fetchJson('api/v1/week');
const local=pending();
const merged=local&&reconcile(local,remote);
if(merged&&!merged.conflicts.length){
- const saved=await fetchJson('api/v1/week',{method:'PUT',headers:{'Content-Type':'application/json'},
+ saved=await fetchJson('api/v1/week',{method:'PUT',headers:{'Content-Type':'application/json'},
body:JSON.stringify({base_revision:remote.revision,timezone:local.timezone,days:merged.days})});
const current=pending();
- if(current&&JSON.stringify(deliveryBody(current))===JSON.stringify(body)) storage.removeItem(key);
- if(!pending()) adopt(saved);
+ if(current&&sameBody(current,deliveryBody(local)))storage.removeItem(key);
+ if(!pending())adopt(saved);
return saved;
}
lastConflict={key,local,remote:{revision:remote.revision,timezone:remote.timezone||null,
- days:remote.days.map(cloneDay)},merged,choices:{...(local.resolutions||{})}};
+ days:remote.days.map(cloneDay)},merged,choices:{...(local?.resolutions||{})}};
+ } else {
+ const current=pending();
+ if(current&&!sameBody(current,body))return deliver();
}
throw error;
- }).finally(()=>{flushing=null;});
+ }
+ const current=pending();
+ if(current&&sameBody(current,body)){
+ storage.removeItem(key);adopt(saved);return saved;
+ }
+ if(current){
+ try {
+ const raw=JSON.parse(storage.getItem(key));
+ raw.base_revision=saved.revision;raw.base_days=saved.days.map(cloneDay);
+ storage.setItem(key,JSON.stringify(raw));
+ week={revision:raw.base_revision,...raw,sync_pending:true};
+ } catch(_error){return saved;}
+ return deliver();
+ }
+ adopt(saved);return saved;
+ };
+ flushing=deliver().finally(()=>{flushing=null;});
return flushing;
}
async function saveDay(planDate, value) {
@@ -237,15 +288,22 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin}={}) {
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,pending,flush,conflict,chooseDay,saveMerged,
+ return {adopt,state,dates,day,pass,load,saveDay,stageDay,review,move,pending,flush,conflict,chooseDay,saveMerged,
keepLocal,useRemote,promote,summary};
}
-function createWeekPlanWorkflow({controller,qs,getLogin,openPlanner,escapeHtml,escapeAttribute,
+function createWeekPlanWorkflow({controller,qs,getLogin,openPlanner,setReviewMode=()=>{},escapeHtml,escapeAttribute,
todayWork,refresh,warm}={}) {
let selectedDate=null;
+ let reviewing=false;
let blockedReviewOpen=false;
function renderPass() {
const progress=qs('#week-plan-progress'),save=qs('#save-today-plan');
+ if(reviewing){
+ const value=controller.review();
+ progress.hidden=false;
+ progress.textContent=`Review week · ${value.days.filter(day=>day.ids.length).length} planned`;
+ return;
+ }
if(!selectedDate){progress.hidden=true;return;}
const current=controller.pass(selectedDate);
progress.hidden=false;
@@ -296,8 +354,38 @@ function createWeekPlanWorkflow({controller,qs,getLogin,openPlanner,escapeHtml,e
selectedDate=button.dataset.weekPlanDate;renderDates();openPlanner(null,false);
}));
}
+ function renderReview() {
+ const value=controller.review(),root=qs('#week-review-days'),duplicates=qs('#week-review-duplicates');
+ const destinations=value.days.map(day=>''+escapeHtml(day.label)+' ').join('');
+ 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?''+day.ids.map(id=>''+escapeHtml(id)+'Move to '+destinations+' Move ').join('')+' ':
+ 'Nothing planned.
';
+ return ''+escapeHtml(day.label)+' '+
+ escapeHtml(load)+'
'+items+' ';
+ }).join('');
+ duplicates.hidden=!value.duplicates.length;
+ duplicates.innerHTML=value.duplicates.length?'Choose one date for duplicated work before confirming. '+value.duplicates.map(item=>
+ ''+escapeHtml(item.id)+' · '+escapeHtml(item.dates.join(', '))+' ').join('')+' ':'';
+ const confirm=qs('#confirm-week-plan');
+ const pending=Boolean(controller.pending?.());
+ confirm.disabled=!value.can_confirm||pending;
+ qs('#week-review-status').textContent=value.duplicates.length?'Duplicate work must be moved to one date.':
+ (pending?'Saving the latest week to your account…':'Week Ahead is balanced and ready.');
+ 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;
+ renderReview();renderPass();
+ controller.flush().then(()=>{renderReview();qs('#mobile-week-summary').textContent=controller.summary();})
+ .catch(error=>{qs('#week-review-status').textContent=(error.message||'Week Ahead sync is unavailable.')+' Changes remain on this phone.';});
+ }));
+ renderPass();
+ }
async function open(trigger) {
- trigger.disabled=true;selectedDate=controller.dates()[0].date;renderDates();openPlanner(trigger);
+ trigger.disabled=true;reviewing=false;setReviewMode(false);const reviewRoot=qs('#week-review');if(reviewRoot)reviewRoot.hidden=true;
+ selectedDate=controller.dates()[0].date;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;}
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;}
@@ -309,7 +397,7 @@ function createWeekPlanWorkflow({controller,qs,getLogin,openPlanner,escapeHtml,e
if(!staged){qs('#my-work-action-status').textContent='Week Ahead could not be saved on this phone. Free browser storage and retry.';return false;}
qs('#mobile-week-summary').textContent=controller.summary();
qs('#my-work-action-status').textContent='Week Ahead saved on this phone · sync pending.';
- controller.flush().then(()=>{qs('#mobile-week-summary').textContent=controller.summary();qs('#my-work-action-status').textContent=(normalized.ids.length?'Week Ahead saved for ':'Week Ahead cleared for ')+date+'.';})
+ controller.flush().then(()=>{qs('#mobile-week-summary').textContent=controller.summary();qs('#my-work-action-status').textContent=(normalized.ids.length?'Week Ahead saved for ':'Week Ahead cleared for ')+date+'.';if(reviewing)renderReview();})
.catch(error=>{
const conflict=controller.conflict();
qs('#mobile-week-summary').textContent=conflict?'Conflict · review required':controller.summary();
@@ -321,12 +409,21 @@ function createWeekPlanWorkflow({controller,qs,getLogin,openPlanner,escapeHtml,e
}
function advance() {
const current=selectedDate&&controller.pass(selectedDate);
+ if(current?.last){
+ reviewing=true;selectedDate=null;setReviewMode(true);qs('#week-review').hidden=false;
+ renderDates();renderReview();openPlanner(null,false);qs('#confirm-week-plan').focus?.();return true;
+ }
if(!current?.next_date) return false;
selectedDate=current.next_date;renderDates();openPlanner(null,false);
const selected=qs('#week-plan-dates').querySelector(`[data-week-plan-date="${selectedDate}"]`);
selected?.scrollIntoView({block:'nearest',inline:'center'});selected?.focus?.();
return true;
}
+ function confirm() {
+ const value=controller.review();
+ if(!reviewing||!value.can_confirm||controller.pending()) return false;
+ reviewing=false;setReviewMode(false);qs('#week-review').hidden=true;return true;
+ }
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){
@@ -339,10 +436,11 @@ function createWeekPlanWorkflow({controller,qs,getLogin,openPlanner,escapeHtml,e
qs('#my-work-action-status').textContent=(error.message||'Week Ahead needs review before promotion.')+' Open Week Ahead to review.';return false;
}
}
- return {open,save,advance,promote,renderDates,renderConflict,active:()=>Boolean(selectedDate),selectedDate:()=>selectedDate,
+ return {open,save,advance,confirm,promote,renderDates,renderConflict,active:()=>Boolean(selectedDate)||reviewing,
+ reviewing:()=>reviewing,selectedDate:()=>selectedDate,
day:()=>selectedDate?controller.day(selectedDate):null,
copy:()=>selectedDate?{title:'Plan Week Ahead',heading:selectedDate+', in order',available:'Available this day',build:'Build this day'}:null,
- clear(){selectedDate=null;renderDates();}};
+ clear(){selectedDate=null;reviewing=false;setReviewMode(false);qs('#week-review').hidden=true;renderDates();}};
}
if(typeof module!=='undefined'&&module.exports){
module.exports=createWeekPlan;
diff --git a/src/today_store.py b/src/today_store.py
index 7e148a0..d2ca401 100644
--- a/src/today_store.py
+++ b/src/today_store.py
@@ -495,6 +495,7 @@ class TodayStore:
raise ValueError("timezone must be a valid IANA timezone") from None
normalized = []
seen_dates = set()
+ seen_ids = set()
for day in days:
if not isinstance(day, dict):
raise ValueError("each Week Ahead date must be an object")
@@ -511,6 +512,10 @@ class TodayStore:
ids=day.get("ids", []), capacity_minutes=day.get("capacity_minutes"),
estimates=day.get("estimates", {}), plan_date=plan_date, timezone=timezone,
)
+ duplicate_ids = seen_ids.intersection(plan["ids"])
+ if duplicate_ids:
+ raise ValueError("work must be assigned to only one Week Ahead date")
+ seen_ids.update(plan["ids"])
plan.pop("timezone")
normalized.append(plan)
normalized.sort(key=lambda item: item["plan_date"])
diff --git a/tests/e2e/test_mobile_week_ahead_release.py b/tests/e2e/test_mobile_week_ahead_release.py
index a0dde6d..92e80bd 100644
--- a/tests/e2e/test_mobile_week_ahead_release.py
+++ b/tests/e2e/test_mobile_week_ahead_release.py
@@ -76,6 +76,14 @@ def test_release_artifact_plans_seven_touch_safe_mobile_dates(
dates.nth(6).click()
expect(page.locator("#save-today-plan")).to_have_text("Save week")
page.locator("#save-today-plan").click()
+ expect(page.locator("#plan-today-sheet")).to_be_visible()
+ expect(page.locator("#week-review")).to_be_visible()
+ expect(page.locator("#week-review-days .week-review-day")).to_have_count(7)
+ page.wait_for_function("() => !document.querySelector('#confirm-week-plan').disabled")
+ confirm = page.locator("#confirm-week-plan")
+ bounds = confirm.bounding_box()
+ assert bounds and bounds["height"] >= 44
+ confirm.click()
expect(page.locator("#plan-today-sheet")).to_be_hidden()
page.wait_for_function("() => document.querySelector('#mobile-week-summary').textContent !== 'Loading Week Ahead…'")
assert saved and len(saved[-1]["days"]) == 2
diff --git a/tests/test_comment_next.py b/tests/test_comment_next.py
index 7510433..2b9e42e 100644
--- a/tests/test_comment_next.py
+++ b/tests/test_comment_next.py
@@ -303,4 +303,4 @@ async def test_unread_update_offers_reply_mark_read_and_next_independent_of_toda
assert '.update-reply-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr));' in html
assert '.update-reply-actions button { min-height:44px;' in html
worker = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
- assert "stackchain-dashboard-shell-v127" in worker
+ assert "stackchain-dashboard-shell-v128" in worker
diff --git a/tests/test_later_sync.py b/tests/test_later_sync.py
index 46e8f8b..fb2452d 100644
--- a/tests/test_later_sync.py
+++ b/tests/test_later_sync.py
@@ -435,5 +435,5 @@ async def test_dashboard_syncs_every_later_change_and_exposes_account_status():
def test_later_sync_ships_atomically_in_the_offline_shell():
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
- assert "stackchain-dashboard-shell-v127" in source
+ assert "stackchain-dashboard-shell-v128" in source
assert "BASE + 'static/later-sync.js'" in source
diff --git a/tests/test_markdown_renderer.py b/tests/test_markdown_renderer.py
index bee7739..868876e 100644
--- a/tests/test_markdown_renderer.py
+++ b/tests/test_markdown_renderer.py
@@ -256,4 +256,4 @@ def test_markdown_work_bodies_are_mobile_safe_block_containers():
assert ".markdown-content { min-width:0; max-width:100%; overflow-wrap:anywhere;" in css
assert ".markdown-content pre { max-width:100%; overflow-x:auto;" in css
assert ".markdown-content a { min-height:44px;" in css
- assert "stackchain-dashboard-shell-v127" in worker
+ assert "stackchain-dashboard-shell-v128" in worker
diff --git a/tests/test_mobile_composer_integration.py b/tests/test_mobile_composer_integration.py
index 31306ea..cbd14c6 100644
--- a/tests/test_mobile_composer_integration.py
+++ b/tests/test_mobile_composer_integration.py
@@ -45,7 +45,7 @@ def test_offline_shell_contains_every_local_dashboard_runtime_asset():
shell_assets = set(re.findall(r"BASE \+ '([^']+)'", worker.split("async function sessionCsrf", 1)[0]))
assert local_assets <= shell_assets, f"Offline shell is missing: {sorted(local_assets - shell_assets)}"
- assert "stackchain-dashboard-shell-v127" in worker
+ assert "stackchain-dashboard-shell-v128" in worker
def test_all_conversation_composers_offer_accessible_mobile_mentions():
diff --git a/tests/test_mobile_device_setup.py b/tests/test_mobile_device_setup.py
index bff2813..f03c8b1 100644
--- a/tests/test_mobile_device_setup.py
+++ b/tests/test_mobile_device_setup.py
@@ -383,7 +383,7 @@ def test_mobile_dashboard_mounts_phone_safe_device_setup_flow():
assert "controller.recoverPermission('deadline')" in dashboard
assert "pushControllerReady.then(ensureDeviceSetup)" in dashboard
assert "BASE + 'static/mobile-device-setup.js'" in worker
- assert "stackchain-dashboard-shell-v127" in worker
+ assert "stackchain-dashboard-shell-v128" in worker
assert ".device-setup-panel" in css
assert ".device-readiness-card" in css
assert "overflow-x:hidden" in css
diff --git a/tests/test_mobile_insights.py b/tests/test_mobile_insights.py
index 82bdc71..481e3a8 100644
--- a/tests/test_mobile_insights.py
+++ b/tests/test_mobile_insights.py
@@ -243,5 +243,5 @@ async def test_mobile_home_progressively_discloses_secondary_panels_as_insights(
def test_mobile_insights_rolls_into_the_offline_shell():
worker = (CONTROLLER.parent / "service-worker.js").read_text()
- assert "stackchain-dashboard-shell-v127" in worker
+ assert "stackchain-dashboard-shell-v128" in worker
assert "BASE + 'static/mobile-insights.js'" in worker
diff --git a/tests/test_mobile_start_day.py b/tests/test_mobile_start_day.py
index a9c7492..5eee62c 100644
--- a/tests/test_mobile_start_day.py
+++ b/tests/test_mobile_start_day.py
@@ -358,7 +358,7 @@ async def test_dashboard_wires_thumb_safe_start_day_briefing_into_offline_mobile
assert ".mobile-start-day-finish { min-height:44px;" in html
assert "max-width:100%; overflow-wrap:anywhere;" in html
assert "BASE + 'static/mobile-start-day.js'" in service_worker
- assert "stackchain-dashboard-shell-v127" in service_worker
+ assert "stackchain-dashboard-shell-v128" in service_worker
@pytest.mark.anyio
diff --git a/tests/test_plan_today.py b/tests/test_plan_today.py
index bf38b2a..66855b7 100644
--- a/tests/test_plan_today.py
+++ b/tests/test_plan_today.py
@@ -410,7 +410,7 @@ async def test_plan_today_wires_cancel_back_and_success_through_overlay_history(
def test_plan_today_controller_is_available_in_the_offline_shell():
source = SERVICE_WORKER.read_text()
- assert "stackchain-dashboard-shell-v127" in source
+ assert "stackchain-dashboard-shell-v128" in source
assert "BASE + 'static/plan-today.js'" in source
assert "BASE + 'static/plan-today-readiness.js'" in source
assert "BASE + 'static/plan-today-preview.js'" in source
diff --git a/tests/test_service_worker.py b/tests/test_service_worker.py
index 62fc461..968e82a 100644
--- a/tests/test_service_worker.py
+++ b/tests/test_service_worker.py
@@ -180,20 +180,20 @@ async function dispatchPush(payload) {{
def test_private_today_action_mailbox_rolls_the_offline_shell():
source = WORKER.read_text()
- assert "stackchain-dashboard-shell-v127" in source
+ assert "stackchain-dashboard-shell-v128" in source
def test_per_day_week_conflict_ui_rolls_the_offline_shell():
source = WORKER.read_text()
- assert "stackchain-dashboard-shell-v127" in source
+ assert "stackchain-dashboard-shell-v128" in source
assert "BASE + 'static/week-plan.js'" in source
def test_resumable_today_session_ships_in_a_new_offline_shell():
source = WORKER.read_text()
- assert "stackchain-dashboard-shell-v127" in source
+ assert "stackchain-dashboard-shell-v128" in source
assert "BASE + 'static/my-work.js'" in source
assert "BASE + 'static/dashboard.js'" in source
assert "BASE + 'static/dashboard.css'" in source
@@ -202,7 +202,7 @@ def test_resumable_today_session_ships_in_a_new_offline_shell():
def test_mobile_conversation_photo_bundles_roll_the_offline_shell():
source = WORKER.read_text()
- assert "stackchain-dashboard-shell-v127" in source
+ assert "stackchain-dashboard-shell-v128" in source
assert "BASE + 'static/dashboard.js'" in source
assert "BASE + 'static/authored-outbox.js'" in source
assert "BASE + 'static/background-issue-sync.js'" in source
@@ -211,7 +211,7 @@ def test_mobile_conversation_photo_bundles_roll_the_offline_shell():
def test_photo_metadata_sanitizer_rolls_the_cached_optimizer_atomically():
source = WORKER.read_text()
- assert "stackchain-dashboard-shell-v127" in source
+ assert "stackchain-dashboard-shell-v128" in source
assert "BASE + 'static/issue-evidence-review.js'" in source
assert "BASE + 'static/issue-attachment.js'" in source
@@ -219,14 +219,14 @@ def test_photo_metadata_sanitizer_rolls_the_cached_optimizer_atomically():
def test_ownership_exit_runtime_rolls_the_offline_shell_cache():
source = WORKER.read_text()
- assert "stackchain-dashboard-shell-v127" in source
+ assert "stackchain-dashboard-shell-v128" in source
assert "BASE + 'static/dashboard.js'" in source
def test_offline_review_next_ships_today_completion_atomically():
source = WORKER.read_text()
- assert "stackchain-dashboard-shell-v127" in source
+ assert "stackchain-dashboard-shell-v128" in source
assert "BASE + 'static/today-completion.js'" in source
assert "BASE + 'static/dashboard.js'" in source
@@ -234,7 +234,7 @@ def test_offline_review_next_ships_today_completion_atomically():
def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
source = WORKER.read_text()
- assert "stackchain-dashboard-shell-v127" in source
+ assert "stackchain-dashboard-shell-v128" in source
assert "BASE + 'static/create-issue-sheet.js'" in source
assert "BASE + 'static/dashboard.js'" in source
@@ -242,7 +242,7 @@ def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
def test_inline_checklist_step_flow_rolls_the_offline_shell_atomically():
source = WORKER.read_text()
- assert "stackchain-dashboard-shell-v127" in source
+ assert "stackchain-dashboard-shell-v128" in source
assert "BASE + 'static/issue-sheet.js'" in source
assert "BASE + 'static/checklist-conflict.js'" in source
assert "BASE + 'static/dashboard.js'" in source
@@ -252,14 +252,14 @@ def test_inline_checklist_step_flow_rolls_the_offline_shell_atomically():
def test_exact_later_picker_ships_atomically_in_a_new_offline_shell():
source = WORKER.read_text()
- assert "stackchain-dashboard-shell-v127" in source
+ assert "stackchain-dashboard-shell-v128" in source
assert "BASE + 'static/later-picker.js'" in source
def test_navigation_deadline_ships_in_a_new_shell_cache():
source = WORKER.read_text()
- assert "stackchain-dashboard-shell-v127" in source
+ assert "stackchain-dashboard-shell-v128" in source
assert "BASE + 'static/dashboard.css'" in source
assert "BASE + 'static/dashboard.js'" in source
assert "BASE + 'static/install-app.js'" in source
@@ -268,21 +268,21 @@ def test_navigation_deadline_ships_in_a_new_shell_cache():
def test_today_convergence_ships_in_a_new_shell_cache():
source = WORKER.read_text()
- assert "stackchain-dashboard-shell-v127" in source
+ assert "stackchain-dashboard-shell-v128" in source
assert "BASE + 'static/today-sync.js'" in source
def test_mobile_search_viewport_ships_in_a_new_offline_shell():
source = WORKER.read_text()
- assert "stackchain-dashboard-shell-v127" in source
+ assert "stackchain-dashboard-shell-v128" in source
assert "BASE + 'static/mobile-search-viewport.js'" in source
def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell():
source = WORKER.read_text()
- assert "stackchain-dashboard-shell-v127" in source
+ assert "stackchain-dashboard-shell-v128" in source
assert "BASE + 'static/update-ownership.js'" in source
@@ -1252,7 +1252,7 @@ def test_one_session_bound_csrf_proof_is_reused_for_a_background_drain():
def test_queue_today_ships_atomically_in_a_new_offline_shell():
source = WORKER.read_text()
- assert "stackchain-dashboard-shell-v127" in source
+ assert "stackchain-dashboard-shell-v128" in source
assert "BASE + 'static/queue-today.js'" in source
diff --git a/tests/test_today_readiness.py b/tests/test_today_readiness.py
index ef2830f..4bcdb08 100644
--- a/tests/test_today_readiness.py
+++ b/tests/test_today_readiness.py
@@ -221,7 +221,7 @@ async def test_today_blocker_opens_existing_preview_and_preserves_readiness_gate
def test_readiness_runtime_is_available_in_offline_shell():
service_worker = SERVICE_WORKER.read_text()
- assert "const CACHE = 'stackchain-dashboard-shell-v127';" in service_worker
+ assert "const CACHE = 'stackchain-dashboard-shell-v128';" in service_worker
assert "BASE + 'static/today-readiness.js'" in service_worker
diff --git a/tests/test_today_sync.py b/tests/test_today_sync.py
index 1b9d798..fe222d1 100644
--- a/tests/test_today_sync.py
+++ b/tests/test_today_sync.py
@@ -343,7 +343,7 @@ listeners['stackchain:first-task-complete']();
def test_inflight_today_drain_ships_in_a_new_offline_shell():
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
- assert "stackchain-dashboard-shell-v127" in source
+ assert "stackchain-dashboard-shell-v128" in source
assert "BASE + 'static/today-sync.js'" in source
diff --git a/tests/test_week_plan.py b/tests/test_week_plan.py
index d3f056a..b12ae4e 100644
--- a/tests/test_week_plan.py
+++ b/tests/test_week_plan.py
@@ -51,6 +51,29 @@ def test_week_plan_is_encrypted_account_scoped_revisioned_and_preserves_other_da
assert conflict.value.snapshot == updated
+def test_week_plan_rejects_the_same_work_on_multiple_dates_without_advancing_revision(tmp_path):
+ store = TodayStore(tmp_path / "today.sqlite3", encryption_key=b"w" * 32)
+ original = store.replace_week(
+ "timmy", base_revision=0, days=sample_days(), timezone="UTC"
+ )
+ duplicated = [
+ sample_days()[0],
+ {
+ "plan_date": "2026-08-22",
+ "ids": ["issue:secret/repo:3:"],
+ "capacity_minutes": 60,
+ "estimates": {"issue:secret/repo:3:": 30},
+ },
+ ]
+
+ with pytest.raises(ValueError, match="work must be assigned to only one Week Ahead date"):
+ store.replace_week(
+ "timmy", base_revision=original["revision"], days=duplicated, timezone="UTC"
+ )
+
+ assert store.get_week("timmy") == original
+
+
def test_week_promotion_moves_only_due_date_to_today_exactly_once(tmp_path):
store = TodayStore(
tmp_path / "today.sqlite3",
diff --git a/tests/test_week_plan_frontend.py b/tests/test_week_plan_frontend.py
index 7bfe069..0fe5e77 100644
--- a/tests/test_week_plan_frontend.py
+++ b/tests/test_week_plan_frontend.py
@@ -67,6 +67,34 @@ console.log(JSON.stringify({first,middle,last}));
}
+def test_week_controller_reviews_capacity_duplicates_and_moves_work_without_copying():
+ result = run_controller("""
+const values=new Map();
+const storage={getItem:key=>values.get(key)||null,setItem:(key,value)=>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:['shared','one'],capacity_minutes:45,estimates:{shared:30,one:30}},
+ {plan_date:'2026-08-22',ids:['shared','two'],capacity_minutes:120,estimates:{shared:30,two:40}}
+]});
+const before=week.review();
+const moved=week.move('shared','2026-08-23');
+const after=week.review();
+console.log(JSON.stringify({before,moved,after,pending:week.pending()}));
+""")
+
+ assert len(result["before"]["days"]) == 7
+ assert result["before"]["days"][0]["planned_minutes"] == 60
+ assert result["before"]["days"][0]["overloaded"] is True
+ assert result["before"]["duplicates"] == [{
+ "id": "shared", "dates": ["2026-08-21", "2026-08-22"]
+ }]
+ assert result["moved"] is True
+ assert result["after"]["duplicates"] == []
+ assert [day["ids"] for day in result["after"]["days"][:3]] == [["one"], ["two"], ["shared"]]
+ assert result["pending"]["days"][2]["estimates"] == {"shared": 30}
+
+
def test_week_workflow_saves_and_advances_without_closing_the_planner():
result = run_controller("""
const createWorkflow=createWeekPlan.Workflow;
@@ -104,6 +132,47 @@ console.log(JSON.stringify({continued,selected:workflow.selectedDate(),opened,st
assert result["label"] == "Save & next"
+def test_week_workflow_opens_review_after_day_seven_and_blocks_duplicate_confirmation():
+ 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 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());
+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})),
+ duplicates:[{id:'shared',dates:dates.slice(0,2)}],can_confirm:false};
+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:2,next_date:dates[index+1]||null,last:index===6};},
+ load:async()=>({}),summary:()=> '2 items across 2 days',review:()=>review,move:()=>true,flush:async()=>({})};
+const workflow=createWorkflow({controller,qs:selector=>elements.get(selector),getLogin:()=> 'timmy',
+ openPlanner:()=>{},setReviewMode:value=>{reviewMode=value;},escapeHtml:value=>value,escapeAttribute:value=>value,
+ todayWork:{replace:()=>{},replacePlanning:()=>{}},refresh:()=>{},warm:()=>{}});
+await workflow.open({disabled:false});
+for(let index=0;index<6;index++) workflow.advance();
+const advanced=workflow.advance();
+console.log(JSON.stringify({advanced,reviewing:workflow.reviewing(),reviewMode,
+ reviewHidden:elements.get('#week-review').hidden,progress:elements.get('#week-plan-progress').textContent,
+ confirmDisabled:elements.get('#confirm-week-plan').disabled,days:elements.get('#week-review-days').innerHTML,
+ duplicates:elements.get('#week-review-duplicates').innerHTML}));
+""")
+
+ assert result["advanced"] is True
+ assert result["reviewing"] is True
+ assert result["reviewMode"] is True
+ assert result["reviewHidden"] is False
+ assert result["progress"] == "Review week · 2 planned"
+ assert result["confirmDisabled"] is True
+ assert "45 of 30 min · over capacity" in result["days"]
+ assert "shared" in result["duplicates"]
+
+
def test_week_controller_preserves_both_versions_when_another_device_changes_the_week():
result = run_controller("""
let calls=0;
@@ -260,6 +329,35 @@ console.log(JSON.stringify({requests,during,saved,reused,pendingAfter:week.pendi
assert result["state"]["revision"] == 9
+def test_week_controller_drains_work_staged_while_a_save_is_in_flight():
+ result = run_controller("""
+const values=new Map();
+const storage={getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)};
+let releaseFirst;const bodies=[];
+const fetchJson=async(_url,options={})=>{
+ const body=JSON.parse(options.body);bodies.push(body);
+ if(bodies.length===1)await new Promise(resolve=>{releaseFirst=resolve});
+ return {revision:8+bodies.length,timezone:body.timezone,days:body.days};
+};
+const week=createWeekPlan({storage,getLogin:()=> 'timmy',fetchJson,localDate:()=> '2026-08-20',timeZone:()=> 'UTC'});
+week.adopt({revision:8,timezone:'UTC',days:[]});
+week.stageDay('2026-08-21',{ids:['one'],capacity_minutes:60,estimates:{one:30}});
+const first=week.flush();await Promise.resolve();
+week.stageDay('2026-08-22',{ids:['two'],capacity_minutes:90,estimates:{two:45}});
+const second=week.flush();releaseFirst();
+const [saved,reused]=await Promise.all([first,second]);
+console.log(JSON.stringify({bodies,saved,reused,pending:week.pending(),state:week.state()}));
+""")
+
+ assert len(result["bodies"]) == 2
+ assert [day["plan_date"] for day in result["bodies"][1]["days"]] == ["2026-08-21", "2026-08-22"]
+ assert result["bodies"][1]["base_revision"] == 9
+ assert result["saved"]["revision"] == 10
+ assert result["reused"]["revision"] == 10
+ assert result["pending"] is False
+ assert result["state"]["revision"] == 10
+
+
def test_week_controller_keeps_phone_week_by_rebasing_onto_the_latest_revision():
result = run_controller("""
const values=new Map();
@@ -423,6 +521,22 @@ def test_mobile_week_ahead_entry_and_date_strip_are_touch_safe():
assert "overflow-x:auto" in css
+def test_mobile_week_ahead_review_is_rendered_touch_safe_and_confirmed_explicitly():
+ index = (FRONTEND / "index.html").read_text()
+ css = (FRONTEND / "dashboard.css").read_text()
+ dashboard = (FRONTEND / "dashboard.js").read_text()
+
+ assert 'id="week-review"' in index
+ assert 'id="week-review-days"' in index
+ assert 'id="week-review-duplicates"' in index
+ assert 'id="confirm-week-plan"' in index
+ assert "setReviewMode:value=>qs('#plan-today-sheet').classList.toggle('week-review-mode',value)" in dashboard
+ assert "weekWorkflow.confirm()" in dashboard
+ assert ".week-review-day.is-overloaded" in css
+ assert ".week-review-day button { min-height:44px;" in css
+ assert ".week-review-mode .mobile-plan-today-nav" in css
+
+
def test_mobile_week_ahead_exposes_durable_save_and_touch_safe_conflict_choices():
index = (FRONTEND / "index.html").read_text()
css = (FRONTEND / "dashboard.css").read_text()