Merge pull request 'Keep Week Ahead plans through offline saves' (#1181) from timmy/1180-offline-week-ahead-sync into main
This commit is contained in:
commit
3edc4e36b3
|
|
@ -264,6 +264,26 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
.tomorrow-conflict-plans { grid-template-columns:1fr; }
|
.tomorrow-conflict-plans { grid-template-columns:1fr; }
|
||||||
.tomorrow-conflict-actions { flex-direction:column; }
|
.tomorrow-conflict-actions { flex-direction:column; }
|
||||||
}
|
}
|
||||||
|
.week-conflict-review { margin-top:14px; }
|
||||||
|
.week-conflict-plans { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:12px; }
|
||||||
|
.week-conflict-plans > section { min-width:0; padding:12px; border:1px solid #31577f; border-radius:12px; background:#10233a; }
|
||||||
|
.week-conflict-plans h4 { margin:0 0 8px; }
|
||||||
|
.week-conflict-day { margin:0 0 10px; padding-bottom:10px; border-bottom:1px solid #31577f; }
|
||||||
|
.week-conflict-day:last-child { margin-bottom:0; padding-bottom:0; border-bottom:0; }
|
||||||
|
.week-conflict-day strong { display:block; color:#bfdbfe; }
|
||||||
|
.week-conflict-actions { display:flex; gap:8px; margin-top:14px; }
|
||||||
|
.week-conflict-actions button { min-height:44px; flex:1 1 0; }
|
||||||
|
.week-conflict-status { min-height:1.4em; margin-top:8px; }
|
||||||
|
.plan-today-sheet.week-conflict-mode .week-plan-dates,
|
||||||
|
.plan-today-sheet.week-conflict-mode .mobile-plan-today-nav,
|
||||||
|
.plan-today-sheet.week-conflict-mode #plan-today-fit,
|
||||||
|
.plan-today-sheet.week-conflict-mode #plan-today-selected,
|
||||||
|
.plan-today-sheet.week-conflict-mode #plan-today-available-work,
|
||||||
|
.plan-today-sheet.week-conflict-mode .plan-today-actions { display:none; }
|
||||||
|
@media (max-width:480px) {
|
||||||
|
.week-conflict-plans { grid-template-columns:1fr; }
|
||||||
|
.week-conflict-actions { flex-direction:column; }
|
||||||
|
}
|
||||||
.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; }
|
||||||
|
|
|
||||||
|
|
@ -391,6 +391,7 @@
|
||||||
});
|
});
|
||||||
const weekPlan = createWeekPlan({
|
const weekPlan = createWeekPlan({
|
||||||
fetchJson:fetchReviewJson,localDate:todayRollover.localDate,timeZone:todayRollover.timeZone,
|
fetchJson:fetchReviewJson,localDate:todayRollover.localDate,timeZone:todayRollover.timeZone,
|
||||||
|
storage:localStorage,getLogin:() => planningOwnerLogin,
|
||||||
});
|
});
|
||||||
function openWeekPlanner(trigger) { planningTomorrow=false; return weekWorkflow.open(trigger); }
|
function openWeekPlanner(trigger) { planningTomorrow=false; return weekWorkflow.open(trigger); }
|
||||||
function renderTomorrowQueueSummary(value) {
|
function renderTomorrowQueueSummary(value) {
|
||||||
|
|
@ -511,6 +512,7 @@
|
||||||
documentObject:document,
|
documentObject:document,
|
||||||
check:() => Promise.all([
|
check:() => Promise.all([
|
||||||
syncPendingTomorrow(),
|
syncPendingTomorrow(),
|
||||||
|
weekPlan.pending() ? weekPlan.flush().catch(() => false) : false,
|
||||||
latestTodayPlan ? weekWorkflow.promote(latestTodayPlan) : false,
|
latestTodayPlan ? weekWorkflow.promote(latestTodayPlan) : false,
|
||||||
]),
|
]),
|
||||||
});
|
});
|
||||||
|
|
@ -2700,6 +2702,8 @@
|
||||||
weekWorkflow.clear();
|
weekWorkflow.clear();
|
||||||
qs('#tomorrow-conflict-review').hidden = true;
|
qs('#tomorrow-conflict-review').hidden = true;
|
||||||
qs('#plan-today-sheet').classList.remove('tomorrow-conflict-mode');
|
qs('#plan-today-sheet').classList.remove('tomorrow-conflict-mode');
|
||||||
|
qs('#week-conflict-review').hidden = true;
|
||||||
|
qs('#plan-today-sheet').classList.remove('week-conflict-mode');
|
||||||
qs('#plan-today-sheet').hidden = true;
|
qs('#plan-today-sheet').hidden = true;
|
||||||
document.body.classList.remove('task-overlay-open');
|
document.body.classList.remove('task-overlay-open');
|
||||||
if (planTodayTrigger?.dataset.mobileQueue === 'tomorrow') {
|
if (planTodayTrigger?.dataset.mobileQueue === 'tomorrow') {
|
||||||
|
|
@ -3059,6 +3063,32 @@
|
||||||
requestAnimationFrame(() => keep.focus());
|
requestAnimationFrame(() => keep.focus());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function weekConflictMarkup(value) {
|
||||||
|
const days=value?.days||[];
|
||||||
|
if(!days.length)return '<p class="muted">Nothing planned.</p>';
|
||||||
|
return days.map(day=>{
|
||||||
|
const estimates=day.estimates||{};
|
||||||
|
const minutes=(day.ids||[]).reduce((total,id)=>total+(Number(estimates[id])||0),0);
|
||||||
|
const capacity=Number(day.capacity_minutes)||0;
|
||||||
|
const detail=(day.ids||[]).length+' item'+((day.ids||[]).length===1?'':'s')+
|
||||||
|
(minutes&&capacity?' · '+minutes+' of '+capacity+' min':'');
|
||||||
|
return '<p class="week-conflict-day"><strong>'+escapeHtml(day.plan_date)+'</strong>'+escapeHtml(detail)+'</p>';
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function showWeekConflict(conflict) {
|
||||||
|
qs('#plan-today-title').textContent='Resolve Week Ahead conflict';
|
||||||
|
qs('#week-conflict-phone-plan').innerHTML=weekConflictMarkup(conflict.local);
|
||||||
|
qs('#week-conflict-server-plan').innerHTML=weekConflictMarkup(conflict.remote);
|
||||||
|
qs('#week-conflict-status').textContent='';
|
||||||
|
qs('#week-conflict-review').hidden=false;
|
||||||
|
qs('#plan-today-sheet').classList.add('week-conflict-mode');
|
||||||
|
qs('#plan-today-sheet').hidden=false;
|
||||||
|
document.body.classList.add('task-overlay-open');
|
||||||
|
const keep=qs('#keep-phone-week');
|
||||||
|
keep.focus();requestAnimationFrame(()=>keep.focus());
|
||||||
|
}
|
||||||
|
|
||||||
function openPlanToday(trigger, navigate = true, actualMinutes = null) {
|
function openPlanToday(trigger, navigate = true, actualMinutes = null) {
|
||||||
if (!planningOwnerLogin && !weekWorkflow.active()) {
|
if (!planningOwnerLogin && !weekWorkflow.active()) {
|
||||||
qs('#my-work-action-status').textContent = 'Planning is unavailable until your operator identity is restored.';
|
qs('#my-work-action-status').textContent = 'Planning is unavailable until your operator identity is restored.';
|
||||||
|
|
@ -3078,8 +3108,12 @@
|
||||||
showTomorrowConflict(conflict);
|
showTomorrowConflict(conflict);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const weekConflict=weekWorkflow.active()?weekPlan.conflict():null;
|
||||||
|
if(weekConflict){showWeekConflict(weekConflict);return;}
|
||||||
qs('#tomorrow-conflict-review').hidden = true;
|
qs('#tomorrow-conflict-review').hidden = true;
|
||||||
qs('#plan-today-sheet').classList.remove('tomorrow-conflict-mode');
|
qs('#plan-today-sheet').classList.remove('tomorrow-conflict-mode');
|
||||||
|
qs('#week-conflict-review').hidden=true;
|
||||||
|
qs('#plan-today-sheet').classList.remove('week-conflict-mode');
|
||||||
const recommendations = actualMinutes || pendingPlanActualMinutes || todayRecapView.pendingReplan()?.actual_minutes;
|
const recommendations = actualMinutes || pendingPlanActualMinutes || todayRecapView.pendingReplan()?.actual_minutes;
|
||||||
pendingPlanActualMinutes = null;
|
pendingPlanActualMinutes = null;
|
||||||
const protectProposal = pendingProtectToday;
|
const protectProposal = pendingProtectToday;
|
||||||
|
|
@ -7832,6 +7866,30 @@
|
||||||
qs('#my-work-action-status').textContent = 'Saved account Tomorrow plan selected. Today was not changed.';
|
qs('#my-work-action-status').textContent = 'Saved account Tomorrow plan selected. Today was not changed.';
|
||||||
closePlanToday();
|
closePlanToday();
|
||||||
});
|
});
|
||||||
|
qs('#keep-phone-week').addEventListener('click', async () => {
|
||||||
|
const keep=qs('#keep-phone-week'),use=qs('#use-server-week');
|
||||||
|
keep.disabled=true;use.disabled=true;
|
||||||
|
qs('#week-conflict-status').textContent='Saving this phone’s complete week…';
|
||||||
|
try {
|
||||||
|
await weekPlan.keepLocal();
|
||||||
|
qs('#mobile-week-summary').textContent=weekPlan.summary();
|
||||||
|
qs('#my-work-action-status').textContent='This phone’s Week Ahead plan is saved to your account. Today was not changed.';
|
||||||
|
closePlanToday();
|
||||||
|
} catch(error) {
|
||||||
|
const conflict=weekPlan.conflict();
|
||||||
|
if(conflict)showWeekConflict(conflict);
|
||||||
|
qs('#week-conflict-status').textContent=error?.status===409?
|
||||||
|
'Week Ahead changed again. Both latest weeks are preserved; choose again.':
|
||||||
|
`${error.message||'Week Ahead could not be saved.'} Both weeks are still preserved.`;
|
||||||
|
} finally {keep.disabled=false;use.disabled=false;}
|
||||||
|
});
|
||||||
|
qs('#use-server-week').addEventListener('click', () => {
|
||||||
|
const adopted=weekPlan.useRemote();
|
||||||
|
if(!adopted)return;
|
||||||
|
qs('#mobile-week-summary').textContent=weekPlan.summary();
|
||||||
|
qs('#my-work-action-status').textContent='Saved account Week Ahead plan selected. Today was not changed.';
|
||||||
|
closePlanToday();
|
||||||
|
});
|
||||||
qs('#cancel-plan-today').addEventListener('click', closePlanToday);
|
qs('#cancel-plan-today').addEventListener('click', closePlanToday);
|
||||||
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();
|
||||||
|
|
|
||||||
|
|
@ -481,6 +481,26 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="small tomorrow-conflict-status" id="tomorrow-conflict-status" role="status" aria-live="assertive"></div>
|
<div class="small tomorrow-conflict-status" id="tomorrow-conflict-status" role="status" aria-live="assertive"></div>
|
||||||
</section>
|
</section>
|
||||||
|
<section class="week-conflict-review" id="week-conflict-review" aria-labelledby="week-conflict-title" hidden>
|
||||||
|
<div class="small">Cross-device change</div>
|
||||||
|
<h3 id="week-conflict-title">Choose which Week Ahead plan to keep</h3>
|
||||||
|
<p class="small muted">Both complete weeks stay preserved until one choice succeeds. Today will not change.</p>
|
||||||
|
<div class="week-conflict-plans">
|
||||||
|
<section aria-labelledby="week-conflict-phone-title">
|
||||||
|
<h4 id="week-conflict-phone-title">This phone</h4>
|
||||||
|
<div id="week-conflict-phone-plan"></div>
|
||||||
|
</section>
|
||||||
|
<section aria-labelledby="week-conflict-server-title">
|
||||||
|
<h4 id="week-conflict-server-title">Saved on your account</h4>
|
||||||
|
<div id="week-conflict-server-plan"></div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
<div class="week-conflict-actions">
|
||||||
|
<button id="keep-phone-week" type="button">Keep this phone’s week</button>
|
||||||
|
<button id="use-server-week" type="button">Use saved account week</button>
|
||||||
|
</div>
|
||||||
|
<div class="small week-conflict-status" id="week-conflict-status" role="status" aria-live="assertive"></div>
|
||||||
|
</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,11 +1,29 @@
|
||||||
function createWeekPlan({fetchJson,localDate,timeZone}={}) {
|
function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin}={}) {
|
||||||
let week={revision:0,timezone:null,days:[]};
|
let week={revision:0,timezone:null,days:[]};
|
||||||
|
let flushing=null;
|
||||||
let lastConflict=null;
|
let lastConflict=null;
|
||||||
|
const storagePrefix='stackchain.week-sync.v1.';
|
||||||
const cloneDay=day=>({
|
const cloneDay=day=>({
|
||||||
plan_date:day.plan_date,ids:[...(day.ids||[])],capacity_minutes:day.capacity_minutes??null,
|
plan_date:day.plan_date,ids:[...(day.ids||[])],capacity_minutes:day.capacity_minutes??null,
|
||||||
estimates:{...(day.estimates||{})},
|
estimates:{...(day.estimates||{})},
|
||||||
});
|
});
|
||||||
const state=()=>({revision:week.revision,timezone:week.timezone,days:week.days.map(cloneDay)});
|
const state=()=>({revision:week.revision,timezone:week.timezone,days:week.days.map(cloneDay),
|
||||||
|
...(week.sync_pending?{base_revision:week.base_revision,sync_pending:true}:{})});
|
||||||
|
function storageKey() {
|
||||||
|
const login=String(getLogin?.()||'').trim().toLowerCase();
|
||||||
|
return login?storagePrefix+encodeURIComponent(login):'';
|
||||||
|
}
|
||||||
|
function pending() {
|
||||||
|
const key=storageKey();
|
||||||
|
if(!key||!storage) return false;
|
||||||
|
try {
|
||||||
|
const value=JSON.parse(storage.getItem(key)||'null');
|
||||||
|
return Number.isInteger(value?.base_revision)&&Array.isArray(value?.days)?{
|
||||||
|
revision:value.base_revision,base_revision:value.base_revision,timezone:value.timezone||null,
|
||||||
|
days:value.days.map(cloneDay).sort((left,right)=>left.plan_date.localeCompare(right.plan_date)),sync_pending:true,
|
||||||
|
}:false;
|
||||||
|
} catch(_error) { return false; }
|
||||||
|
}
|
||||||
function adopt(value) {
|
function adopt(value) {
|
||||||
if(!Number.isInteger(value?.revision)||!Array.isArray(value?.days)) return false;
|
if(!Number.isInteger(value?.revision)||!Array.isArray(value?.days)) return false;
|
||||||
week={revision:value.revision,timezone:value.timezone||null,days:value.days.map(cloneDay)
|
week={revision:value.revision,timezone:value.timezone||null,days:value.days.map(cloneDay)
|
||||||
|
|
@ -28,7 +46,50 @@ function createWeekPlan({fetchJson,localDate,timeZone}={}) {
|
||||||
const found=week.days.find(item=>item.plan_date===planDate);
|
const found=week.days.find(item=>item.plan_date===planDate);
|
||||||
return found?cloneDay(found):{plan_date:planDate,ids:[],capacity_minutes:null,estimates:{}};
|
return found?cloneDay(found):{plan_date:planDate,ids:[],capacity_minutes:null,estimates:{}};
|
||||||
}
|
}
|
||||||
async function load() { return adopt(await fetchJson('api/v1/week')); }
|
async function load() {
|
||||||
|
const queued=pending();
|
||||||
|
if(queued){week=queued;return state();}
|
||||||
|
return adopt(await fetchJson('api/v1/week'));
|
||||||
|
}
|
||||||
|
function stageDay(planDate,value) {
|
||||||
|
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))};
|
||||||
|
try { storage.setItem(key,JSON.stringify(queued)); }
|
||||||
|
catch(_error) { return false; }
|
||||||
|
lastConflict=null;
|
||||||
|
week={revision:queued.base_revision,...queued,sync_pending:true};
|
||||||
|
return state();
|
||||||
|
}
|
||||||
|
function deliveryBody(value) {
|
||||||
|
return {base_revision:value.base_revision,timezone:value.timezone,days:value.days.map(cloneDay)};
|
||||||
|
}
|
||||||
|
function flush() {
|
||||||
|
if(flushing) return flushing;
|
||||||
|
const queued=pending();
|
||||||
|
const key=storageKey();
|
||||||
|
if(!queued||!key) return Promise.resolve(false);
|
||||||
|
const body=deliveryBody(queued);
|
||||||
|
flushing=fetchJson('api/v1/week',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)})
|
||||||
|
.then(saved=>{
|
||||||
|
const current=pending();
|
||||||
|
if(current&&JSON.stringify(deliveryBody(current))===JSON.stringify(body)) storage.removeItem(key);
|
||||||
|
if(!pending()) adopt(saved);
|
||||||
|
return saved;
|
||||||
|
}).catch(async error=>{
|
||||||
|
if(error?.status===409){
|
||||||
|
const remote=await fetchJson('api/v1/week');
|
||||||
|
lastConflict={key,local:pending(),remote:{revision:remote.revision,timezone:remote.timezone||null,
|
||||||
|
days:remote.days.map(cloneDay)}};
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}).finally(()=>{flushing=null;});
|
||||||
|
return flushing;
|
||||||
|
}
|
||||||
async function saveDay(planDate, value) {
|
async function saveDay(planDate, value) {
|
||||||
const local={revision:week.revision,timezone:timeZone(),days:week.days
|
const local={revision:week.revision,timezone:timeZone(),days:week.days
|
||||||
.filter(item=>item.plan_date!==planDate).concat([{
|
.filter(item=>item.plan_date!==planDate).concat([{
|
||||||
|
|
@ -47,12 +108,42 @@ function createWeekPlan({fetchJson,localDate,timeZone}={}) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function conflict() {
|
function conflict() {
|
||||||
return lastConflict?{
|
return lastConflict&&(!lastConflict.key||lastConflict.key===storageKey())?{
|
||||||
local:{...lastConflict.local,days:lastConflict.local.days.map(cloneDay)},
|
local:{...lastConflict.local,days:lastConflict.local.days.map(cloneDay)},
|
||||||
remote:{...lastConflict.remote,days:lastConflict.remote.days.map(cloneDay)},
|
remote:{...lastConflict.remote,days:lastConflict.remote.days.map(cloneDay)},
|
||||||
}:null;
|
}:null;
|
||||||
}
|
}
|
||||||
|
async function keepLocal() {
|
||||||
|
if(!lastConflict||lastConflict.key!==storageKey()) return false;
|
||||||
|
const conflictKey=lastConflict.key;
|
||||||
|
const local={...lastConflict.local,days:lastConflict.local.days.map(cloneDay)};
|
||||||
|
const body=deliveryBody({...local,base_revision:lastConflict.remote.revision});
|
||||||
|
try {
|
||||||
|
const saved=await fetchJson('api/v1/week',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
|
||||||
|
const current=pending();
|
||||||
|
if(current&&JSON.stringify(deliveryBody(current))===JSON.stringify(deliveryBody(local))) storage.removeItem(storageKey());
|
||||||
|
if(!pending()) adopt(saved);
|
||||||
|
lastConflict=null;
|
||||||
|
return saved;
|
||||||
|
} catch(error) {
|
||||||
|
if(error?.status===409){
|
||||||
|
const remote=await fetchJson('api/v1/week');
|
||||||
|
lastConflict={key:conflictKey,local,remote:{revision:remote.revision,timezone:remote.timezone||null,
|
||||||
|
days:remote.days.map(cloneDay)}};
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function useRemote() {
|
||||||
|
if(!lastConflict||lastConflict.key!==storageKey()) return false;
|
||||||
|
const remote=lastConflict.remote;
|
||||||
|
storage.removeItem(storageKey());
|
||||||
|
const adopted=adopt(remote);
|
||||||
|
lastConflict=null;
|
||||||
|
return adopted;
|
||||||
|
}
|
||||||
async function promote(todayRevision) {
|
async function promote(todayRevision) {
|
||||||
|
if(pending()||conflict()) return false;
|
||||||
const due=week.days.find(item=>item.plan_date<=localDate()&&item.ids.length);
|
const due=week.days.find(item=>item.plan_date<=localDate()&&item.ids.length);
|
||||||
if(!due) return false;
|
if(!due) return false;
|
||||||
return fetchJson('api/v1/week/promote',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({
|
return fetchJson('api/v1/week/promote',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({
|
||||||
|
|
@ -63,9 +154,10 @@ function createWeekPlan({fetchJson,localDate,timeZone}={}) {
|
||||||
function summary() {
|
function summary() {
|
||||||
const planned=week.days.filter(item=>item.ids.length);
|
const planned=week.days.filter(item=>item.ids.length);
|
||||||
const items=planned.reduce((total,item)=>total+item.ids.length,0);
|
const items=planned.reduce((total,item)=>total+item.ids.length,0);
|
||||||
return 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 {adopt,state,dates,day,load,saveDay,conflict,promote,summary};
|
return {adopt,state,dates,day,load,saveDay,stageDay,pending,flush,conflict,keepLocal,useRemote,promote,summary};
|
||||||
}
|
}
|
||||||
function createWeekPlanWorkflow({controller,qs,getLogin,openPlanner,escapeHtml,escapeAttribute,
|
function createWeekPlanWorkflow({controller,qs,getLogin,openPlanner,escapeHtml,escapeAttribute,
|
||||||
todayWork,refresh,warm}={}) {
|
todayWork,refresh,warm}={}) {
|
||||||
|
|
@ -90,8 +182,12 @@ function createWeekPlanWorkflow({controller,qs,getLogin,openPlanner,escapeHtml,e
|
||||||
}
|
}
|
||||||
function save(plan){
|
function save(plan){
|
||||||
const date=selectedDate,normalized=Array.isArray(plan)?{ids:plan,capacity_minutes:null,estimates:{}}:plan;
|
const date=selectedDate,normalized=Array.isArray(plan)?{ids:plan,capacity_minutes:null,estimates:{}}:plan;
|
||||||
controller.saveDay(date,normalized).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+'.';})
|
const staged=controller.stageDay(date,normalized);
|
||||||
.catch(error=>{qs('#mobile-week-summary').textContent=controller.conflict()?'Conflict · review required':'Unavailable · tap to retry';qs('#my-work-action-status').textContent=controller.conflict()?'Another device changed Week Ahead. Both versions are preserved; reopen Week Ahead to review.':(error.message||'Week Ahead could not be saved.')+' Retry when connected.';});
|
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+'.';})
|
||||||
|
.catch(error=>{qs('#mobile-week-summary').textContent=controller.conflict()?'Conflict · review required':controller.summary();qs('#my-work-action-status').textContent=controller.conflict()?'Another device changed Week Ahead. Both versions are preserved; open Week Ahead to choose one.':(error.message||'Week Ahead sync is unavailable.')+' Saved on this phone · sync pending.';});
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
async function promote(plan){
|
async function promote(plan){
|
||||||
|
|
|
||||||
|
|
@ -67,6 +67,143 @@ console.log(JSON.stringify({conflict:week.conflict(),state:week.state()}));
|
||||||
assert result["state"]["revision"] == 7
|
assert result["state"]["revision"] == 7
|
||||||
|
|
||||||
|
|
||||||
|
def test_week_controller_restores_an_account_bound_week_before_network_delivery():
|
||||||
|
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 login='Timmy';
|
||||||
|
let requests=0;
|
||||||
|
const options={storage,getLogin:()=>login,fetchJson:async()=>{requests+=1;throw new Error('offline');},
|
||||||
|
localDate:()=> '2026-08-20',timeZone:()=> 'America/New_York'};
|
||||||
|
const first=createWeekPlan(options);
|
||||||
|
first.adopt({revision:7,timezone:'UTC',days:[{plan_date:'2026-08-21',ids:['old'],capacity_minutes:30,estimates:{old:15}}]});
|
||||||
|
const queued=first.stageDay('2026-08-22',{ids:['issue:r:3:','issue:r:2:'],capacity_minutes:120,
|
||||||
|
estimates:{'issue:r:3:':45,'issue:r:2:':30}});
|
||||||
|
const restored=await createWeekPlan(options).load();
|
||||||
|
login='alexander';
|
||||||
|
let otherError='';
|
||||||
|
try { await createWeekPlan(options).load(); } catch(error) { otherError=error.message; }
|
||||||
|
console.log(JSON.stringify({queued,restored,requests,otherError,keys:[...values.keys()]}));
|
||||||
|
""")
|
||||||
|
|
||||||
|
assert result["queued"]["sync_pending"] is True
|
||||||
|
assert result["restored"] == result["queued"]
|
||||||
|
assert result["restored"]["timezone"] == "America/New_York"
|
||||||
|
assert [day["plan_date"] for day in result["restored"]["days"]] == ["2026-08-21", "2026-08-22"]
|
||||||
|
assert result["requests"] == 1
|
||||||
|
assert result["otherError"] == "offline"
|
||||||
|
assert result["keys"] == ["stackchain.week-sync.v1.timmy"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_week_controller_flushes_once_and_removes_only_the_matching_pending_week():
|
||||||
|
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 release;
|
||||||
|
let requests=0;
|
||||||
|
const fetchJson=async(_url,options={})=>{
|
||||||
|
requests+=1;const body=JSON.parse(options.body);
|
||||||
|
await new Promise(resolve=>{release=resolve});
|
||||||
|
return {revision:9,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:['phone'],capacity_minutes:60,estimates:{phone:30}});
|
||||||
|
const first=week.flush(),second=week.flush();
|
||||||
|
await Promise.resolve();
|
||||||
|
const during=week.pending();
|
||||||
|
release();
|
||||||
|
const [saved,reused]=await Promise.all([first,second]);
|
||||||
|
console.log(JSON.stringify({requests,during,saved,reused,pendingAfter:week.pending(),state:week.state()}));
|
||||||
|
""")
|
||||||
|
|
||||||
|
assert result["requests"] == 1
|
||||||
|
assert result["during"]["days"][0]["ids"] == ["phone"]
|
||||||
|
assert result["saved"]["revision"] == 9
|
||||||
|
assert result["reused"]["revision"] == 9
|
||||||
|
assert result["pendingAfter"] is False
|
||||||
|
assert result["state"]["revision"] == 9
|
||||||
|
|
||||||
|
|
||||||
|
def test_week_controller_keeps_phone_week_by_rebasing_onto_the_latest_revision():
|
||||||
|
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 requests=[];let puts=0;
|
||||||
|
const remote={revision:12,timezone:'UTC',days:[{plan_date:'2026-08-21',ids:['server'],capacity_minutes:60,estimates:{}}]};
|
||||||
|
const fetchJson=async(_url,options={})=>{
|
||||||
|
requests.push({method:options.method||'GET',body:options.body?JSON.parse(options.body):null});
|
||||||
|
if(options.method==='PUT'&&puts++===0){const error=new Error('changed');error.status=409;throw error;}
|
||||||
|
if(options.method==='PUT')return {revision:13,timezone:'UTC',days:JSON.parse(options.body).days};
|
||||||
|
return remote;
|
||||||
|
};
|
||||||
|
const week=createWeekPlan({storage,getLogin:()=> 'timmy',fetchJson,localDate:()=> '2026-08-20',timeZone:()=> 'UTC'});
|
||||||
|
week.adopt({revision:11,timezone:'UTC',days:[]});
|
||||||
|
week.stageDay('2026-08-21',{ids:['phone'],capacity_minutes:90,estimates:{phone:45}});
|
||||||
|
try { await week.flush(); } catch(_error) {}
|
||||||
|
const before=week.conflict();
|
||||||
|
const saved=await week.keepLocal();
|
||||||
|
console.log(JSON.stringify({before,saved,requests,pending:week.pending(),conflict:week.conflict()}));
|
||||||
|
""")
|
||||||
|
|
||||||
|
assert result["before"]["local"]["days"][0]["ids"] == ["phone"]
|
||||||
|
assert result["before"]["remote"]["days"][0]["ids"] == ["server"]
|
||||||
|
assert [request["method"] for request in result["requests"]] == ["PUT", "GET", "PUT"]
|
||||||
|
assert result["requests"][2]["body"]["base_revision"] == 12
|
||||||
|
assert result["saved"]["revision"] == 13
|
||||||
|
assert result["pending"] is False
|
||||||
|
assert result["conflict"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_week_controller_refreshes_account_week_after_a_second_conflict():
|
||||||
|
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 revision=12;
|
||||||
|
const fetchJson=async(_url,options={})=>{
|
||||||
|
if(options.method==='PUT'){const error=new Error('changed again');error.status=409;throw error;}
|
||||||
|
return {revision:revision++,timezone:'UTC',days:[{plan_date:'2026-08-21',ids:['server-'+(revision-1)],capacity_minutes:60,estimates:{}}]};
|
||||||
|
};
|
||||||
|
const week=createWeekPlan({storage,getLogin:()=> 'timmy',fetchJson,localDate:()=> '2026-08-20',timeZone:()=> 'UTC'});
|
||||||
|
week.adopt({revision:11,timezone:'UTC',days:[]});
|
||||||
|
week.stageDay('2026-08-21',{ids:['phone'],capacity_minutes:90,estimates:{}});
|
||||||
|
try { await week.flush(); } catch(_error) {}
|
||||||
|
try { await week.keepLocal(); } catch(_error) {}
|
||||||
|
console.log(JSON.stringify({conflict:week.conflict(),pending:week.pending()}));
|
||||||
|
""")
|
||||||
|
|
||||||
|
assert result["conflict"]["local"]["days"][0]["ids"] == ["phone"]
|
||||||
|
assert result["conflict"]["remote"]["revision"] == 13
|
||||||
|
assert result["conflict"]["remote"]["days"][0]["ids"] == ["server-13"]
|
||||||
|
assert result["pending"]["days"][0]["ids"] == ["phone"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_week_controller_uses_account_week_without_a_second_write():
|
||||||
|
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 requests=[];
|
||||||
|
const remote={revision:12,timezone:'UTC',days:[{plan_date:'2026-08-21',ids:['server'],capacity_minutes:60,estimates:{server:30}}]};
|
||||||
|
const fetchJson=async(_url,options={})=>{
|
||||||
|
requests.push(options.method||'GET');
|
||||||
|
if(options.method==='PUT'){const error=new Error('changed');error.status=409;throw error;}
|
||||||
|
return remote;
|
||||||
|
};
|
||||||
|
const week=createWeekPlan({storage,getLogin:()=> 'timmy',fetchJson,localDate:()=> '2026-08-20',timeZone:()=> 'UTC'});
|
||||||
|
week.adopt({revision:11,timezone:'UTC',days:[]});
|
||||||
|
week.stageDay('2026-08-21',{ids:['phone'],capacity_minutes:90,estimates:{}});
|
||||||
|
try { await week.flush(); } catch(_error) {}
|
||||||
|
const adopted=week.useRemote();
|
||||||
|
console.log(JSON.stringify({adopted,requests,pending:week.pending(),conflict:week.conflict(),state:week.state()}));
|
||||||
|
""")
|
||||||
|
|
||||||
|
assert result["requests"] == ["PUT", "GET"]
|
||||||
|
assert result["adopted"]["days"][0]["ids"] == ["server"]
|
||||||
|
assert result["pending"] is False
|
||||||
|
assert result["conflict"] is None
|
||||||
|
assert result["state"]["revision"] == 12
|
||||||
|
|
||||||
|
|
||||||
def test_week_controller_promotes_only_the_earliest_due_plan_with_stable_identity():
|
def test_week_controller_promotes_only_the_earliest_due_plan_with_stable_identity():
|
||||||
result = run_controller("""
|
result = run_controller("""
|
||||||
const requests=[];
|
const requests=[];
|
||||||
|
|
@ -93,6 +230,22 @@ console.log(JSON.stringify({promoted,requests}));
|
||||||
}]
|
}]
|
||||||
|
|
||||||
|
|
||||||
|
def test_week_controller_does_not_promote_a_pending_week():
|
||||||
|
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 requests=0;
|
||||||
|
const week=createWeekPlan({storage,getLogin:()=> 'timmy',fetchJson:async()=>{requests+=1;return {};},
|
||||||
|
localDate:()=> '2026-08-23',timeZone:()=> 'UTC'});
|
||||||
|
week.adopt({revision:6,timezone:'UTC',days:[]});
|
||||||
|
week.stageDay('2026-08-21',{ids:['oldest'],capacity_minutes:60,estimates:{}});
|
||||||
|
const promoted=await week.promote(3);
|
||||||
|
console.log(JSON.stringify({promoted,requests}));
|
||||||
|
""")
|
||||||
|
|
||||||
|
assert result == {"promoted": False, "requests": 0}
|
||||||
|
|
||||||
|
|
||||||
def test_mobile_week_ahead_entry_and_date_strip_are_touch_safe():
|
def test_mobile_week_ahead_entry_and_date_strip_are_touch_safe():
|
||||||
index = (FRONTEND / "index.html").read_text()
|
index = (FRONTEND / "index.html").read_text()
|
||||||
css = (FRONTEND / "dashboard.css").read_text()
|
css = (FRONTEND / "dashboard.css").read_text()
|
||||||
|
|
@ -105,3 +258,18 @@ def test_mobile_week_ahead_entry_and_date_strip_are_touch_safe():
|
||||||
assert "weekWorkflow.save" in dashboard
|
assert "weekWorkflow.save" in dashboard
|
||||||
assert ".week-plan-dates button { min-height:44px;" in css
|
assert ".week-plan-dates button { min-height:44px;" in css
|
||||||
assert "overflow-x:auto" in css
|
assert "overflow-x:auto" 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()
|
||||||
|
dashboard = (FRONTEND / "dashboard.js").read_text()
|
||||||
|
|
||||||
|
assert 'id="week-conflict-review"' in index
|
||||||
|
assert 'id="keep-phone-week"' in index
|
||||||
|
assert 'id="use-server-week"' in index
|
||||||
|
assert "storage:localStorage" in dashboard
|
||||||
|
assert "controller.stageDay" in CONTROLLER.read_text()
|
||||||
|
assert "weekPlan.flush" in dashboard
|
||||||
|
assert "week-conflict-mode" in dashboard
|
||||||
|
assert ".week-conflict-actions button { min-height:44px;" in css
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user