feat: review and rebalance Week Ahead (Closes #1188)
This commit is contained in:
parent
69532bdc1f
commit
d5f13c4afb
|
|
@ -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
|
foreground, and midnight lifecycle checks share one delivery flight. A successful account receipt removes
|
||||||
the pending copy, while a revision conflict preserves both the phone plan and fresh server snapshot for
|
the pending copy, while a revision conflict preserves both the phone plan and fresh server snapshot for
|
||||||
review. Unsynced Tomorrow work is never promoted into Today.
|
review. Unsynced Tomorrow work is never promoted into Today.
|
||||||
|
**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
|
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
|
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
|
intent. The server retains no more than 4,096 operation receipts per account and removes
|
||||||
|
|
|
||||||
|
|
@ -294,6 +294,30 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
.week-conflict-plans { grid-template-columns:1fr; }
|
.week-conflict-plans { grid-template-columns:1fr; }
|
||||||
.week-conflict-actions { flex-direction:column; }
|
.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 { 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 { 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; }
|
.mobile-plan-today-nav button[aria-current="location"] { color:#bfdbfe; background:#17365a; border-color:#31577f; }
|
||||||
|
|
|
||||||
|
|
@ -453,7 +453,8 @@
|
||||||
getLogin: () => planningOwnerLogin,
|
getLogin: () => planningOwnerLogin,
|
||||||
});
|
});
|
||||||
const weekWorkflow=createWeekPlanWorkflow({controller:weekPlan,qs,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});
|
refresh:refreshMyWorkView,warm:warmTodayOffline});
|
||||||
const todaySync = createTodaySync({
|
const todaySync = createTodaySync({
|
||||||
storage: localStorage,
|
storage: localStorage,
|
||||||
|
|
@ -7896,6 +7897,15 @@
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
qs('#cancel-plan-today').addEventListener('click', closePlanToday);
|
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 => {
|
qs('#plan-today-sheet').addEventListener('click', event => {
|
||||||
if (event.target === qs('#plan-today-sheet')) closePlanToday();
|
if (event.target === qs('#plan-today-sheet')) closePlanToday();
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -504,6 +504,15 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="small week-conflict-status" id="week-conflict-status" role="status" aria-live="assertive"></div>
|
<div class="small week-conflict-status" id="week-conflict-status" role="status" aria-live="assertive"></div>
|
||||||
</section>
|
</section>
|
||||||
|
<section class="week-review" id="week-review" aria-labelledby="week-review-title" hidden>
|
||||||
|
<div class="small">Final planning step</div>
|
||||||
|
<h2 id="week-review-title">Review & rebalance Week Ahead</h2>
|
||||||
|
<p class="small muted">Check each day’s 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">
|
<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="fit" aria-current="location">Fit</button>
|
||||||
<button type="button" data-plan-today-section="today">Today</button>
|
<button type="button" data-plan-today-section="today">Today</button>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
const BASE = new URL('./', self.location.href).pathname;
|
const BASE = new URL('./', self.location.href).pathname;
|
||||||
importScripts(BASE + 'static/private-data-registry.js');
|
importScripts(BASE + 'static/private-data-registry.js');
|
||||||
importScripts(BASE + 'static/background-issue-sync.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 OFFLINE_LEASE_URL = new URL(BASE + '__offline-session-lease', self.location.origin).href;
|
||||||
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
|
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
|
||||||
const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000;
|
const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000;
|
||||||
|
|
|
||||||
|
|
@ -60,14 +60,11 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin}={}) {
|
||||||
if(queued){week=queued;return state();}
|
if(queued){week=queued;return state();}
|
||||||
return adopt(await fetchJson('api/v1/week'));
|
return adopt(await fetchJson('api/v1/week'));
|
||||||
}
|
}
|
||||||
function stageDay(planDate,value) {
|
function stageDays(days) {
|
||||||
const key=storageKey();
|
const key=storageKey();
|
||||||
if(!key||!storage) return false;
|
if(!key||!storage) return false;
|
||||||
const queued={base_revision:Number.isInteger(week.base_revision)?week.base_revision:week.revision,
|
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([{
|
timezone:timeZone(),days:days.map(cloneDay).sort((left,right)=>left.plan_date.localeCompare(right.plan_date)),
|
||||||
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)),
|
|
||||||
base_days:(week.base_days||week.days).map(cloneDay)};
|
base_days:(week.base_days||week.days).map(cloneDay)};
|
||||||
try { storage.setItem(key,JSON.stringify(queued)); }
|
try { storage.setItem(key,JSON.stringify(queued)); }
|
||||||
catch(_error) { return false; }
|
catch(_error) { return false; }
|
||||||
|
|
@ -75,6 +72,41 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin}={}) {
|
||||||
week={revision:queued.base_revision,...queued,sync_pending:true};
|
week={revision:queued.base_revision,...queued,sync_pending:true};
|
||||||
return state();
|
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) {
|
function deliveryBody(value) {
|
||||||
return {base_revision:value.base_revision,timezone:value.timezone,days:value.days.map(cloneDay)};
|
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() {
|
function flush() {
|
||||||
if(flushing) return flushing;
|
if(flushing) return flushing;
|
||||||
const queued=pending();
|
|
||||||
const key=storageKey();
|
const key=storageKey();
|
||||||
if(!queued||!key) return Promise.resolve(false);
|
if(!pending()||!key) return Promise.resolve(false);
|
||||||
const body=deliveryBody(queued);
|
const sameBody=(value,body)=>JSON.stringify(deliveryBody(value))===JSON.stringify(body);
|
||||||
flushing=fetchJson('api/v1/week',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)})
|
const deliver=async()=>{
|
||||||
.then(saved=>{
|
const queued=pending();
|
||||||
const current=pending();
|
if(!queued)return false;
|
||||||
if(current&&JSON.stringify(deliveryBody(current))===JSON.stringify(body)) storage.removeItem(key);
|
const body=deliveryBody(queued);
|
||||||
if(!pending()) adopt(saved);
|
let saved;
|
||||||
return saved;
|
try {
|
||||||
}).catch(async error=>{
|
saved=await fetchJson('api/v1/week',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
|
||||||
|
} catch(error) {
|
||||||
if(error?.status===409){
|
if(error?.status===409){
|
||||||
const remote=await fetchJson('api/v1/week');
|
const remote=await fetchJson('api/v1/week');
|
||||||
const local=pending();
|
const local=pending();
|
||||||
const merged=local&&reconcile(local,remote);
|
const merged=local&&reconcile(local,remote);
|
||||||
if(merged&&!merged.conflicts.length){
|
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})});
|
body:JSON.stringify({base_revision:remote.revision,timezone:local.timezone,days:merged.days})});
|
||||||
const current=pending();
|
const current=pending();
|
||||||
if(current&&JSON.stringify(deliveryBody(current))===JSON.stringify(body)) storage.removeItem(key);
|
if(current&&sameBody(current,deliveryBody(local)))storage.removeItem(key);
|
||||||
if(!pending()) adopt(saved);
|
if(!pending())adopt(saved);
|
||||||
return saved;
|
return saved;
|
||||||
}
|
}
|
||||||
lastConflict={key,local,remote:{revision:remote.revision,timezone:remote.timezone||null,
|
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;
|
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;
|
return flushing;
|
||||||
}
|
}
|
||||||
async function saveDay(planDate, value) {
|
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';
|
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 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};
|
keepLocal,useRemote,promote,summary};
|
||||||
}
|
}
|
||||||
function createWeekPlanWorkflow({controller,qs,getLogin,openPlanner,escapeHtml,escapeAttribute,
|
function createWeekPlanWorkflow({controller,qs,getLogin,openPlanner,setReviewMode=()=>{},escapeHtml,escapeAttribute,
|
||||||
todayWork,refresh,warm}={}) {
|
todayWork,refresh,warm}={}) {
|
||||||
let selectedDate=null;
|
let selectedDate=null;
|
||||||
|
let reviewing=false;
|
||||||
let blockedReviewOpen=false;
|
let blockedReviewOpen=false;
|
||||||
function renderPass() {
|
function renderPass() {
|
||||||
const progress=qs('#week-plan-progress'),save=qs('#save-today-plan');
|
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;}
|
if(!selectedDate){progress.hidden=true;return;}
|
||||||
const current=controller.pass(selectedDate);
|
const current=controller.pass(selectedDate);
|
||||||
progress.hidden=false;
|
progress.hidden=false;
|
||||||
|
|
@ -296,8 +354,38 @@ function createWeekPlanWorkflow({controller,qs,getLogin,openPlanner,escapeHtml,e
|
||||||
selectedDate=button.dataset.weekPlanDate;renderDates();openPlanner(null,false);
|
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) {
|
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…';
|
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();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;}
|
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;}
|
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('#mobile-week-summary').textContent=controller.summary();
|
||||||
qs('#my-work-action-status').textContent='Week Ahead saved on this phone · sync pending.';
|
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=>{
|
.catch(error=>{
|
||||||
const conflict=controller.conflict();
|
const conflict=controller.conflict();
|
||||||
qs('#mobile-week-summary').textContent=conflict?'Conflict · review required':controller.summary();
|
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() {
|
function advance() {
|
||||||
const current=selectedDate&&controller.pass(selectedDate);
|
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;
|
if(!current?.next_date) return false;
|
||||||
selectedDate=current.next_date;renderDates();openPlanner(null,false);
|
selectedDate=current.next_date;renderDates();openPlanner(null,false);
|
||||||
const selected=qs('#week-plan-dates').querySelector(`[data-week-plan-date="${selectedDate}"]`);
|
const selected=qs('#week-plan-dates').querySelector(`[data-week-plan-date="${selectedDate}"]`);
|
||||||
selected?.scrollIntoView({block:'nearest',inline:'center'});selected?.focus?.();
|
selected?.scrollIntoView({block:'nearest',inline:'center'});selected?.focus?.();
|
||||||
return true;
|
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){
|
async function promote(plan){
|
||||||
try{await controller.load();const promoted=await controller.promote(plan.revision);if(!promoted)return false;blockedReviewOpen=false;todayWork.replace(promoted.ids);todayWork.replacePlanning({capacity_minutes:promoted.capacity_minutes??null,estimates:promoted.estimates||{}});refresh();warm();qs('#mobile-week-summary').textContent=controller.summary();qs('#my-work-action-status').textContent='Your saved Week Ahead plan is now Today.';return true;}
|
try{await controller.load();const promoted=await controller.promote(plan.revision);if(!promoted)return false;blockedReviewOpen=false;todayWork.replace(promoted.ids);todayWork.replacePlanning({capacity_minutes:promoted.capacity_minutes??null,estimates:promoted.estimates||{}});refresh();warm();qs('#mobile-week-summary').textContent=controller.summary();qs('#my-work-action-status').textContent='Your saved Week Ahead plan is now Today.';return true;}
|
||||||
catch(error){
|
catch(error){
|
||||||
|
|
@ -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;
|
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,
|
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,
|
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){
|
if(typeof module!=='undefined'&&module.exports){
|
||||||
module.exports=createWeekPlan;
|
module.exports=createWeekPlan;
|
||||||
|
|
|
||||||
|
|
@ -495,6 +495,7 @@ class TodayStore:
|
||||||
raise ValueError("timezone must be a valid IANA timezone") from None
|
raise ValueError("timezone must be a valid IANA timezone") from None
|
||||||
normalized = []
|
normalized = []
|
||||||
seen_dates = set()
|
seen_dates = set()
|
||||||
|
seen_ids = set()
|
||||||
for day in days:
|
for day in days:
|
||||||
if not isinstance(day, dict):
|
if not isinstance(day, dict):
|
||||||
raise ValueError("each Week Ahead date must be an object")
|
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"),
|
ids=day.get("ids", []), capacity_minutes=day.get("capacity_minutes"),
|
||||||
estimates=day.get("estimates", {}), plan_date=plan_date, timezone=timezone,
|
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")
|
plan.pop("timezone")
|
||||||
normalized.append(plan)
|
normalized.append(plan)
|
||||||
normalized.sort(key=lambda item: item["plan_date"])
|
normalized.sort(key=lambda item: item["plan_date"])
|
||||||
|
|
|
||||||
|
|
@ -76,6 +76,14 @@ def test_release_artifact_plans_seven_touch_safe_mobile_dates(
|
||||||
dates.nth(6).click()
|
dates.nth(6).click()
|
||||||
expect(page.locator("#save-today-plan")).to_have_text("Save week")
|
expect(page.locator("#save-today-plan")).to_have_text("Save week")
|
||||||
page.locator("#save-today-plan").click()
|
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()
|
expect(page.locator("#plan-today-sheet")).to_be_hidden()
|
||||||
page.wait_for_function("() => document.querySelector('#mobile-week-summary').textContent !== 'Loading Week Ahead…'")
|
page.wait_for_function("() => document.querySelector('#mobile-week-summary').textContent !== 'Loading Week Ahead…'")
|
||||||
assert saved and len(saved[-1]["days"]) == 2
|
assert saved and len(saved[-1]["days"]) == 2
|
||||||
|
|
|
||||||
|
|
@ -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 { display:grid; grid-template-columns:repeat(2,minmax(0,1fr));' in html
|
||||||
assert '.update-reply-actions button { min-height:44px;' in html
|
assert '.update-reply-actions button { min-height:44px;' in html
|
||||||
worker = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
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
|
||||||
|
|
|
||||||
|
|
@ -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():
|
def test_later_sync_ships_atomically_in_the_offline_shell():
|
||||||
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
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
|
assert "BASE + 'static/later-sync.js'" in source
|
||||||
|
|
|
||||||
|
|
@ -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 { 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 pre { max-width:100%; overflow-x:auto;" in css
|
||||||
assert ".markdown-content a { min-height:44px;" 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
|
||||||
|
|
|
||||||
|
|
@ -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]))
|
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 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():
|
def test_all_conversation_composers_offer_accessible_mobile_mentions():
|
||||||
|
|
|
||||||
|
|
@ -383,7 +383,7 @@ def test_mobile_dashboard_mounts_phone_safe_device_setup_flow():
|
||||||
assert "controller.recoverPermission('deadline')" in dashboard
|
assert "controller.recoverPermission('deadline')" in dashboard
|
||||||
assert "pushControllerReady.then(ensureDeviceSetup)" in dashboard
|
assert "pushControllerReady.then(ensureDeviceSetup)" in dashboard
|
||||||
assert "BASE + 'static/mobile-device-setup.js'" in worker
|
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-setup-panel" in css
|
||||||
assert ".device-readiness-card" in css
|
assert ".device-readiness-card" in css
|
||||||
assert "overflow-x:hidden" in css
|
assert "overflow-x:hidden" in css
|
||||||
|
|
|
||||||
|
|
@ -243,5 +243,5 @@ async def test_mobile_home_progressively_discloses_secondary_panels_as_insights(
|
||||||
def test_mobile_insights_rolls_into_the_offline_shell():
|
def test_mobile_insights_rolls_into_the_offline_shell():
|
||||||
worker = (CONTROLLER.parent / "service-worker.js").read_text()
|
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
|
assert "BASE + 'static/mobile-insights.js'" in worker
|
||||||
|
|
|
||||||
|
|
@ -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 ".mobile-start-day-finish { min-height:44px;" in html
|
||||||
assert "max-width:100%; overflow-wrap:anywhere;" in html
|
assert "max-width:100%; overflow-wrap:anywhere;" in html
|
||||||
assert "BASE + 'static/mobile-start-day.js'" in service_worker
|
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
|
@pytest.mark.anyio
|
||||||
|
|
|
||||||
|
|
@ -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():
|
def test_plan_today_controller_is_available_in_the_offline_shell():
|
||||||
source = SERVICE_WORKER.read_text()
|
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.js'" in source
|
||||||
assert "BASE + 'static/plan-today-readiness.js'" in source
|
assert "BASE + 'static/plan-today-readiness.js'" in source
|
||||||
assert "BASE + 'static/plan-today-preview.js'" in source
|
assert "BASE + 'static/plan-today-preview.js'" in source
|
||||||
|
|
|
||||||
|
|
@ -180,20 +180,20 @@ async function dispatchPush(payload) {{
|
||||||
def test_private_today_action_mailbox_rolls_the_offline_shell():
|
def test_private_today_action_mailbox_rolls_the_offline_shell():
|
||||||
source = WORKER.read_text()
|
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():
|
def test_per_day_week_conflict_ui_rolls_the_offline_shell():
|
||||||
source = WORKER.read_text()
|
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
|
assert "BASE + 'static/week-plan.js'" in source
|
||||||
|
|
||||||
|
|
||||||
def test_resumable_today_session_ships_in_a_new_offline_shell():
|
def test_resumable_today_session_ships_in_a_new_offline_shell():
|
||||||
source = WORKER.read_text()
|
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/my-work.js'" in source
|
||||||
assert "BASE + 'static/dashboard.js'" in source
|
assert "BASE + 'static/dashboard.js'" in source
|
||||||
assert "BASE + 'static/dashboard.css'" 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():
|
def test_mobile_conversation_photo_bundles_roll_the_offline_shell():
|
||||||
source = WORKER.read_text()
|
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/dashboard.js'" in source
|
||||||
assert "BASE + 'static/authored-outbox.js'" in source
|
assert "BASE + 'static/authored-outbox.js'" in source
|
||||||
assert "BASE + 'static/background-issue-sync.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():
|
def test_photo_metadata_sanitizer_rolls_the_cached_optimizer_atomically():
|
||||||
source = WORKER.read_text()
|
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-evidence-review.js'" in source
|
||||||
assert "BASE + 'static/issue-attachment.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():
|
def test_ownership_exit_runtime_rolls_the_offline_shell_cache():
|
||||||
source = WORKER.read_text()
|
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/dashboard.js'" in source
|
||||||
|
|
||||||
|
|
||||||
def test_offline_review_next_ships_today_completion_atomically():
|
def test_offline_review_next_ships_today_completion_atomically():
|
||||||
source = WORKER.read_text()
|
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/today-completion.js'" in source
|
||||||
assert "BASE + 'static/dashboard.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():
|
def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
|
||||||
source = WORKER.read_text()
|
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/create-issue-sheet.js'" in source
|
||||||
assert "BASE + 'static/dashboard.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():
|
def test_inline_checklist_step_flow_rolls_the_offline_shell_atomically():
|
||||||
source = WORKER.read_text()
|
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/issue-sheet.js'" in source
|
||||||
assert "BASE + 'static/checklist-conflict.js'" in source
|
assert "BASE + 'static/checklist-conflict.js'" in source
|
||||||
assert "BASE + 'static/dashboard.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():
|
def test_exact_later_picker_ships_atomically_in_a_new_offline_shell():
|
||||||
source = WORKER.read_text()
|
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
|
assert "BASE + 'static/later-picker.js'" in source
|
||||||
|
|
||||||
|
|
||||||
def test_navigation_deadline_ships_in_a_new_shell_cache():
|
def test_navigation_deadline_ships_in_a_new_shell_cache():
|
||||||
source = WORKER.read_text()
|
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.css'" in source
|
||||||
assert "BASE + 'static/dashboard.js'" in source
|
assert "BASE + 'static/dashboard.js'" in source
|
||||||
assert "BASE + 'static/install-app.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():
|
def test_today_convergence_ships_in_a_new_shell_cache():
|
||||||
source = WORKER.read_text()
|
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
|
assert "BASE + 'static/today-sync.js'" in source
|
||||||
|
|
||||||
|
|
||||||
def test_mobile_search_viewport_ships_in_a_new_offline_shell():
|
def test_mobile_search_viewport_ships_in_a_new_offline_shell():
|
||||||
source = WORKER.read_text()
|
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
|
assert "BASE + 'static/mobile-search-viewport.js'" in source
|
||||||
|
|
||||||
|
|
||||||
def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell():
|
def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell():
|
||||||
source = WORKER.read_text()
|
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
|
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():
|
def test_queue_today_ships_atomically_in_a_new_offline_shell():
|
||||||
source = WORKER.read_text()
|
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
|
assert "BASE + 'static/queue-today.js'" in source
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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():
|
def test_readiness_runtime_is_available_in_offline_shell():
|
||||||
service_worker = SERVICE_WORKER.read_text()
|
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
|
assert "BASE + 'static/today-readiness.js'" in service_worker
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -343,7 +343,7 @@ listeners['stackchain:first-task-complete']();
|
||||||
def test_inflight_today_drain_ships_in_a_new_offline_shell():
|
def test_inflight_today_drain_ships_in_a_new_offline_shell():
|
||||||
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
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
|
assert "BASE + 'static/today-sync.js'" in source
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,29 @@ def test_week_plan_is_encrypted_account_scoped_revisioned_and_preserves_other_da
|
||||||
assert conflict.value.snapshot == updated
|
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):
|
def test_week_promotion_moves_only_due_date_to_today_exactly_once(tmp_path):
|
||||||
store = TodayStore(
|
store = TodayStore(
|
||||||
tmp_path / "today.sqlite3",
|
tmp_path / "today.sqlite3",
|
||||||
|
|
|
||||||
|
|
@ -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():
|
def test_week_workflow_saves_and_advances_without_closing_the_planner():
|
||||||
result = run_controller("""
|
result = run_controller("""
|
||||||
const createWorkflow=createWeekPlan.Workflow;
|
const createWorkflow=createWeekPlan.Workflow;
|
||||||
|
|
@ -104,6 +132,47 @@ console.log(JSON.stringify({continued,selected:workflow.selectedDate(),opened,st
|
||||||
assert result["label"] == "Save & next"
|
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():
|
def test_week_controller_preserves_both_versions_when_another_device_changes_the_week():
|
||||||
result = run_controller("""
|
result = run_controller("""
|
||||||
let calls=0;
|
let calls=0;
|
||||||
|
|
@ -260,6 +329,35 @@ console.log(JSON.stringify({requests,during,saved,reused,pendingAfter:week.pendi
|
||||||
assert result["state"]["revision"] == 9
|
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():
|
def test_week_controller_keeps_phone_week_by_rebasing_onto_the_latest_revision():
|
||||||
result = run_controller("""
|
result = run_controller("""
|
||||||
const values=new Map();
|
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
|
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():
|
def test_mobile_week_ahead_exposes_durable_save_and_touch_safe_conflict_choices():
|
||||||
index = (FRONTEND / "index.html").read_text()
|
index = (FRONTEND / "index.html").read_text()
|
||||||
css = (FRONTEND / "dashboard.css").read_text()
|
css = (FRONTEND / "dashboard.css").read_text()
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user