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
|
||||
the pending copy, while a revision conflict preserves both the phone plan and fresh server snapshot for
|
||||
review. Unsynced Tomorrow work is never promoted into Today.
|
||||
**Plan Week Ahead** continues through seven local dates and now finishes on a mobile review step instead of
|
||||
closing after the seventh save. The review shows planned minutes against each day’s capacity, marks overloads,
|
||||
and flags work assigned to more than one date. Operators can move an item to another date without copying it;
|
||||
the estimate follows the item and the server rejects duplicate cross-day assignments without advancing the
|
||||
week revision. Confirmation remains disabled while duplicates exist or the account-bound week is still syncing.
|
||||
Rapid saves and review moves use one network flight plus a coalesced latest-state delivery, so later staged days
|
||||
are not stranded behind an earlier request.
|
||||
Planning edits can remain offline for up to 30 days. After that, the
|
||||
expired edit is discarded visibly and the account plan is kept rather than replaying stale
|
||||
intent. The server retains no more than 4,096 operation receipts per account and removes
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 & 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">
|
||||
<button type="button" data-plan-today-section="fit" aria-current="location">Fit</button>
|
||||
<button type="button" data-plan-today-section="today">Today</button>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"])
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user