feat: review and rebalance Week Ahead (Closes #1188)
All checks were successful
CI / lint (pull_request) Successful in 3m31s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 4m48s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy 2026-08-20 17:36:02 +00:00
parent 69532bdc1f
commit d5f13c4afb
21 changed files with 351 additions and 53 deletions

View File

@ -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 days capacity, marks overloads,
and flags work assigned to more than one date. Operators can move an item to another date without copying it;
the estimate follows the item and the server rejects duplicate cross-day assignments without advancing the
week revision. Confirmation remains disabled while duplicates exist or the account-bound week is still syncing.
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

View File

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

View File

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

View File

@ -504,6 +504,15 @@
</div>
<div class="small week-conflict-status" id="week-conflict-status" role="status" aria-live="assertive"></div>
</section>
<section class="week-review" id="week-review" aria-labelledby="week-review-title" hidden>
<div class="small">Final planning step</div>
<h2 id="week-review-title">Review &amp; rebalance Week Ahead</h2>
<p class="small muted">Check each days load. Move work instead of copying it, then confirm one executable week.</p>
<div class="week-review-duplicates" id="week-review-duplicates" role="alert" hidden></div>
<div class="week-review-days" id="week-review-days"></div>
<div class="small week-review-status" id="week-review-status" role="status" aria-live="polite"></div>
<button id="confirm-week-plan" type="button">Confirm Week Ahead</button>
</section>
<nav class="mobile-plan-today-nav" aria-label="Plan Today sections">
<button type="button" data-plan-today-section="fit" aria-current="location">Fit</button>
<button type="button" data-plan-today-section="today">Today</button>

View File

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

View File

@ -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);
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);
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=>{
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=>'<option value="'+escapeAttribute(day.plan_date)+'">'+escapeHtml(day.label)+'</option>').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?'<ul>'+day.ids.map(id=>'<li><code>'+escapeHtml(id)+'</code><label>Move to <select data-week-move-destination="'+
escapeAttribute(id)+'">'+destinations+'</select></label><button type="button" data-week-move="'+escapeAttribute(id)+'">Move</button></li>').join('')+'</ul>':
'<p class="small muted">Nothing planned.</p>';
return '<article class="week-review-day'+(day.overloaded?' is-overloaded':'')+'"><h3>'+escapeHtml(day.label)+'</h3><p class="week-review-load">'+
escapeHtml(load)+'</p>'+items+'</article>';
}).join('');
duplicates.hidden=!value.duplicates.length;
duplicates.innerHTML=value.duplicates.length?'<strong>Choose one date for duplicated work before confirming.</strong><ul>'+value.duplicates.map(item=>
'<li><code>'+escapeHtml(item.id)+'</code> · '+escapeHtml(item.dates.join(', '))+'</li>').join('')+'</ul>':'';
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;

View File

@ -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"])

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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