Merge pull request 'Reconcile concurrent Week Ahead edits per day' (#1185) from timmy/1184-reconcile-week-ahead-per-day into main
This commit is contained in:
commit
6718160381
|
|
@ -273,6 +273,15 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
.week-conflict-day strong { display:block; color:#bfdbfe; }
|
.week-conflict-day strong { display:block; color:#bfdbfe; }
|
||||||
.week-conflict-actions { display:flex; gap:8px; margin-top:14px; }
|
.week-conflict-actions { display:flex; gap:8px; margin-top:14px; }
|
||||||
.week-conflict-actions button { min-height:44px; flex:1 1 0; }
|
.week-conflict-actions button { min-height:44px; flex:1 1 0; }
|
||||||
|
.week-conflict-days { display:grid; gap:12px; }
|
||||||
|
.week-conflict-date { min-width:0; padding:12px; border:1px solid #31577f; border-radius:12px; background:#10233a; }
|
||||||
|
.week-conflict-date h4 { margin:0 0 8px; color:#bfdbfe; }
|
||||||
|
.week-conflict-choice { min-height:44px; display:flex; align-items:center; gap:10px; padding:8px; border-radius:8px; }
|
||||||
|
.week-conflict-choice:has(input:checked) { background:#17365a; outline:1px solid #60a5fa; }
|
||||||
|
.week-conflict-choice input { min-width:20px; min-height:20px; }
|
||||||
|
.week-conflict-choice span { display:grid; min-width:0; }
|
||||||
|
.week-conflict-choice small { color:#a9bdd3; }
|
||||||
|
#save-merged-week { width:100%; min-height:44px; margin-top:14px; }
|
||||||
.week-conflict-status { min-height:1.4em; margin-top:8px; }
|
.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 .week-plan-dates,
|
||||||
.plan-today-sheet.week-conflict-mode .mobile-plan-today-nav,
|
.plan-today-sheet.week-conflict-mode .mobile-plan-today-nav,
|
||||||
|
|
|
||||||
|
|
@ -3063,30 +3063,15 @@
|
||||||
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) {
|
function showWeekConflict(conflict) {
|
||||||
qs('#plan-today-title').textContent='Resolve Week Ahead conflict';
|
qs('#plan-today-title').textContent='Resolve Week Ahead conflict';
|
||||||
qs('#week-conflict-phone-plan').innerHTML=weekConflictMarkup(conflict.local);
|
const focus=weekWorkflow.renderConflict(conflict);
|
||||||
qs('#week-conflict-server-plan').innerHTML=weekConflictMarkup(conflict.remote);
|
|
||||||
qs('#week-conflict-status').textContent='';
|
qs('#week-conflict-status').textContent='';
|
||||||
qs('#week-conflict-review').hidden=false;
|
qs('#week-conflict-review').hidden=false;
|
||||||
qs('#plan-today-sheet').classList.add('week-conflict-mode');
|
qs('#plan-today-sheet').classList.add('week-conflict-mode');
|
||||||
qs('#plan-today-sheet').hidden=false;
|
qs('#plan-today-sheet').hidden=false;
|
||||||
document.body.classList.add('task-overlay-open');
|
document.body.classList.add('task-overlay-open');
|
||||||
const keep=qs('#keep-phone-week');
|
focus.focus();requestAnimationFrame(()=>focus.focus());
|
||||||
keep.focus();requestAnimationFrame(()=>keep.focus());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function openPlanToday(trigger, navigate = true, actualMinutes = null) {
|
function openPlanToday(trigger, navigate = true, actualMinutes = null) {
|
||||||
|
|
@ -7890,6 +7875,26 @@
|
||||||
qs('#my-work-action-status').textContent='Saved account Week Ahead plan selected. Today was not changed.';
|
qs('#my-work-action-status').textContent='Saved account Week Ahead plan selected. Today was not changed.';
|
||||||
closePlanToday();
|
closePlanToday();
|
||||||
});
|
});
|
||||||
|
qs('#save-merged-week').addEventListener('click', async () => {
|
||||||
|
const save=qs('#save-merged-week');
|
||||||
|
save.disabled=true;
|
||||||
|
qs('#week-conflict-status').textContent='Saving combined Week Ahead plan…';
|
||||||
|
try {
|
||||||
|
await weekPlan.saveMerged();
|
||||||
|
qs('#mobile-week-summary').textContent=weekPlan.summary();
|
||||||
|
qs('#my-work-action-status').textContent='Combined Week Ahead changes are saved. 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. The latest days are preserved; review the remaining choices.':
|
||||||
|
`${error.message||'The combined week could not be saved.'} Your phone plan is still preserved.`;
|
||||||
|
} finally {
|
||||||
|
const conflict=weekPlan.conflict();
|
||||||
|
save.disabled=Boolean(conflict?.conflicts?.some(item=>!item.choice));
|
||||||
|
}
|
||||||
|
});
|
||||||
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();
|
||||||
|
|
|
||||||
|
|
@ -483,9 +483,11 @@
|
||||||
</section>
|
</section>
|
||||||
<section class="week-conflict-review" id="week-conflict-review" aria-labelledby="week-conflict-title" hidden>
|
<section class="week-conflict-review" id="week-conflict-review" aria-labelledby="week-conflict-title" hidden>
|
||||||
<div class="small">Cross-device change</div>
|
<div class="small">Cross-device change</div>
|
||||||
<h3 id="week-conflict-title">Choose which Week Ahead plan to keep</h3>
|
<h3 id="week-conflict-title">Resolve Week Ahead changes</h3>
|
||||||
<p class="small muted">Both complete weeks stay preserved until one choice succeeds. Today will not change.</p>
|
<p class="small muted">Changes on different days are combined automatically. Choose only where both devices changed the same day.</p>
|
||||||
<div class="week-conflict-plans">
|
<div class="week-conflict-days" id="week-conflict-days" hidden></div>
|
||||||
|
<button id="save-merged-week" type="button" hidden disabled>Save combined week</button>
|
||||||
|
<div class="week-conflict-plans" id="week-conflict-legacy-plans">
|
||||||
<section aria-labelledby="week-conflict-phone-title">
|
<section aria-labelledby="week-conflict-phone-title">
|
||||||
<h4 id="week-conflict-phone-title">This phone</h4>
|
<h4 id="week-conflict-phone-title">This phone</h4>
|
||||||
<div id="week-conflict-phone-plan"></div>
|
<div id="week-conflict-phone-plan"></div>
|
||||||
|
|
@ -495,7 +497,7 @@
|
||||||
<div id="week-conflict-server-plan"></div>
|
<div id="week-conflict-server-plan"></div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
<div class="week-conflict-actions">
|
<div class="week-conflict-actions" id="week-conflict-legacy-actions">
|
||||||
<button id="keep-phone-week" type="button">Keep this phone’s week</button>
|
<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>
|
<button id="use-server-week" type="button">Use saved account week</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -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-v126';
|
const CACHE = 'stackchain-dashboard-shell-v127';
|
||||||
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;
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,9 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin}={}) {
|
||||||
return Number.isInteger(value?.base_revision)&&Array.isArray(value?.days)?{
|
return Number.isInteger(value?.base_revision)&&Array.isArray(value?.days)?{
|
||||||
revision:value.base_revision,base_revision:value.base_revision,timezone:value.timezone||null,
|
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,
|
days:value.days.map(cloneDay).sort((left,right)=>left.plan_date.localeCompare(right.plan_date)),sync_pending:true,
|
||||||
|
...(Array.isArray(value.base_days)?{base_days:value.base_days.map(cloneDay)
|
||||||
|
.sort((left,right)=>left.plan_date.localeCompare(right.plan_date))}:{}),
|
||||||
|
...(value.resolutions&&typeof value.resolutions==='object'?{resolutions:{...value.resolutions}}:{}),
|
||||||
}:false;
|
}:false;
|
||||||
} catch(_error) { return false; }
|
} catch(_error) { return false; }
|
||||||
}
|
}
|
||||||
|
|
@ -58,7 +61,8 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin}={}) {
|
||||||
timezone:timeZone(),days:week.days.filter(item=>item.plan_date!==planDate).concat([{
|
timezone:timeZone(),days:week.days.filter(item=>item.plan_date!==planDate).concat([{
|
||||||
plan_date:planDate,ids:[...(value.ids||[])],capacity_minutes:value.capacity_minutes??null,
|
plan_date:planDate,ids:[...(value.ids||[])],capacity_minutes:value.capacity_minutes??null,
|
||||||
estimates:{...(value.estimates||{})},
|
estimates:{...(value.estimates||{})},
|
||||||
}]).sort((left,right)=>left.plan_date.localeCompare(right.plan_date))};
|
}]).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)); }
|
try { storage.setItem(key,JSON.stringify(queued)); }
|
||||||
catch(_error) { return false; }
|
catch(_error) { return false; }
|
||||||
lastConflict=null;
|
lastConflict=null;
|
||||||
|
|
@ -68,6 +72,22 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin}={}) {
|
||||||
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)};
|
||||||
}
|
}
|
||||||
|
function reconcile(local,remote) {
|
||||||
|
if(!Array.isArray(local.base_days)) return null;
|
||||||
|
const byDate=(days,date)=>days.find(day=>day.plan_date===date)||
|
||||||
|
{plan_date:date,ids:[],capacity_minutes:null,estimates:{}};
|
||||||
|
const same=(left,right)=>JSON.stringify(cloneDay(left))===JSON.stringify(cloneDay(right));
|
||||||
|
const dates=[...new Set([...local.base_days,...local.days,...remote.days].map(day=>day.plan_date))].sort();
|
||||||
|
const conflicts=[],days=dates.map(date=>{
|
||||||
|
const base=byDate(local.base_days,date),phone=byDate(local.days,date),account=byDate(remote.days,date);
|
||||||
|
const phoneChanged=!same(phone,base),accountChanged=!same(account,base);
|
||||||
|
if(phoneChanged&&accountChanged&&!same(phone,account)){
|
||||||
|
conflicts.push({plan_date:date,local:cloneDay(phone),remote:cloneDay(account)});return cloneDay(phone);
|
||||||
|
}
|
||||||
|
return cloneDay(phoneChanged?phone:account);
|
||||||
|
});
|
||||||
|
return {days,conflicts};
|
||||||
|
}
|
||||||
function flush() {
|
function flush() {
|
||||||
if(flushing) return flushing;
|
if(flushing) return flushing;
|
||||||
const queued=pending();
|
const queued=pending();
|
||||||
|
|
@ -83,8 +103,18 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin}={}) {
|
||||||
}).catch(async error=>{
|
}).catch(async error=>{
|
||||||
if(error?.status===409){
|
if(error?.status===409){
|
||||||
const remote=await fetchJson('api/v1/week');
|
const remote=await fetchJson('api/v1/week');
|
||||||
lastConflict={key,local:pending(),remote:{revision:remote.revision,timezone:remote.timezone||null,
|
const local=pending();
|
||||||
days:remote.days.map(cloneDay)}};
|
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'},
|
||||||
|
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);
|
||||||
|
return saved;
|
||||||
|
}
|
||||||
|
lastConflict={key,local,remote:{revision:remote.revision,timezone:remote.timezone||null,
|
||||||
|
days:remote.days.map(cloneDay)},merged,choices:{...(local.resolutions||{})}};
|
||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
}).finally(()=>{flushing=null;});
|
}).finally(()=>{flushing=null;});
|
||||||
|
|
@ -108,10 +138,54 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin}={}) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function conflict() {
|
function conflict() {
|
||||||
return lastConflict&&(!lastConflict.key||lastConflict.key===storageKey())?{
|
const value=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;
|
||||||
|
if(value&&lastConflict.merged) value.conflicts=lastConflict.merged.conflicts.map(item=>({
|
||||||
|
plan_date:item.plan_date,local:cloneDay(item.local),remote:cloneDay(item.remote),
|
||||||
|
choice:lastConflict.choices[item.plan_date]||null,
|
||||||
|
}));
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
function chooseDay(planDate,source) {
|
||||||
|
if(!lastConflict?.merged||!['phone','account'].includes(source)||
|
||||||
|
!lastConflict.merged.conflicts.some(item=>item.plan_date===planDate)) return false;
|
||||||
|
const key=storageKey(),queued=pending();
|
||||||
|
if(key&&queued){
|
||||||
|
try {
|
||||||
|
const raw=JSON.parse(storage.getItem(key));
|
||||||
|
raw.resolutions={...(raw.resolutions||{}),[planDate]:source};
|
||||||
|
storage.setItem(key,JSON.stringify(raw));
|
||||||
|
} catch(_error) { return false; }
|
||||||
|
}
|
||||||
|
lastConflict.choices[planDate]=source;
|
||||||
|
return conflict();
|
||||||
|
}
|
||||||
|
async function saveMerged() {
|
||||||
|
if(!lastConflict?.merged||lastConflict.key!==storageKey()) return false;
|
||||||
|
if(lastConflict.merged.conflicts.some(item=>!lastConflict.choices[item.plan_date])) return false;
|
||||||
|
const choices=lastConflict.choices;
|
||||||
|
const days=lastConflict.merged.days.map(day=>{
|
||||||
|
const item=lastConflict.merged.conflicts.find(value=>value.plan_date===day.plan_date);
|
||||||
|
return item?cloneDay(choices[day.plan_date]==='account'?item.remote:item.local):cloneDay(day);
|
||||||
|
});
|
||||||
|
const local=lastConflict.local,body={base_revision:lastConflict.remote.revision,timezone:local.timezone,days};
|
||||||
|
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'),merged=reconcile(local,remote);
|
||||||
|
lastConflict={key:storageKey(),local,remote:{revision:remote.revision,timezone:remote.timezone||null,
|
||||||
|
days:remote.days.map(cloneDay)},merged,choices:{...(local.resolutions||{})}};
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
async function keepLocal() {
|
async function keepLocal() {
|
||||||
if(!lastConflict||lastConflict.key!==storageKey()) return false;
|
if(!lastConflict||lastConflict.key!==storageKey()) return false;
|
||||||
|
|
@ -157,12 +231,45 @@ 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,load,saveDay,stageDay,pending,flush,conflict,keepLocal,useRemote,promote,summary};
|
return {adopt,state,dates,day,load,saveDay,stageDay,pending,flush,conflict,chooseDay,saveMerged,
|
||||||
|
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}={}) {
|
||||||
let selectedDate=null;
|
let selectedDate=null;
|
||||||
let blockedReviewOpen=false;
|
let blockedReviewOpen=false;
|
||||||
|
function conflictDetail(day) {
|
||||||
|
const estimates=day.estimates||{},ids=day.ids||[];
|
||||||
|
const minutes=ids.reduce((total,id)=>total+(Number(estimates[id])||0),0),capacity=Number(day.capacity_minutes)||0;
|
||||||
|
return ids.length+' item'+(ids.length===1?'':'s')+(minutes&&capacity?' · '+minutes+' of '+capacity+' min':'');
|
||||||
|
}
|
||||||
|
function conflictMarkup(value) {
|
||||||
|
const days=value?.days||[];
|
||||||
|
return days.length?days.map(day=>'<p class="week-conflict-day"><strong>'+escapeHtml(day.plan_date)+'</strong>'+escapeHtml(conflictDetail(day))+'</p>').join(''):
|
||||||
|
'<p class="muted">Nothing planned.</p>';
|
||||||
|
}
|
||||||
|
function renderConflict(conflict) {
|
||||||
|
qs('#week-conflict-phone-plan').innerHTML=conflictMarkup(conflict.local);
|
||||||
|
qs('#week-conflict-server-plan').innerHTML=conflictMarkup(conflict.remote);
|
||||||
|
const perDay=Array.isArray(conflict.conflicts),days=qs('#week-conflict-days'),save=qs('#save-merged-week');
|
||||||
|
qs('#week-conflict-legacy-plans').hidden=perDay;qs('#week-conflict-legacy-actions').hidden=perDay;
|
||||||
|
days.hidden=!perDay;save.hidden=!perDay;
|
||||||
|
if(perDay){
|
||||||
|
days.innerHTML=conflict.conflicts.map(item=>{
|
||||||
|
const date=escapeAttribute(item.plan_date),name='week-conflict-'+date;
|
||||||
|
const choice=source=>'<label class="week-conflict-choice"><input type="radio" name="'+name+
|
||||||
|
'" data-week-conflict-choice="'+source+'" data-week-conflict-date="'+date+'"'+(item.choice===source?' checked':'')+
|
||||||
|
'><span><strong>'+(source==='phone'?'This phone':'Saved account')+'</strong><small>'+escapeHtml(conflictDetail(source==='phone'?item.local:item.remote))+'</small></span></label>';
|
||||||
|
return '<section class="week-conflict-date"><h4>'+escapeHtml(item.plan_date)+'</h4>'+choice('phone')+choice('account')+'</section>';
|
||||||
|
}).join('');
|
||||||
|
save.disabled=conflict.conflicts.some(item=>!item.choice);
|
||||||
|
days.querySelectorAll('[data-week-conflict-choice]').forEach(input=>input.addEventListener('change',event=>{
|
||||||
|
controller.chooseDay(event.currentTarget.dataset.weekConflictDate,event.currentTarget.dataset.weekConflictChoice);
|
||||||
|
save.disabled=controller.conflict().conflicts.some(item=>!item.choice);
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
return perDay?days.querySelector('[data-week-conflict-choice]'):qs('#keep-phone-week');
|
||||||
|
}
|
||||||
function renderDates() {
|
function renderDates() {
|
||||||
const root=qs('#week-plan-dates');
|
const root=qs('#week-plan-dates');
|
||||||
root.hidden=!selectedDate;
|
root.hidden=!selectedDate;
|
||||||
|
|
@ -203,7 +310,7 @@ 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,promote,renderDates,active:()=>Boolean(selectedDate),selectedDate:()=>selectedDate,
|
return {open,save,promote,renderDates,renderConflict,active:()=>Boolean(selectedDate),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;renderDates();}};
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ from __future__ import annotations
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import threading
|
import threading
|
||||||
|
from datetime import date, timedelta
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
@ -77,3 +78,80 @@ def test_release_artifact_plans_seven_touch_safe_mobile_dates(
|
||||||
fake.shutdown()
|
fake.shutdown()
|
||||||
fake.server_close()
|
fake.server_close()
|
||||||
thread.join(timeout=5)
|
thread.join(timeout=5)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(("width", "height"), [(320, 568), (390, 844)])
|
||||||
|
def test_release_artifact_reconciles_only_the_week_day_changed_on_both_devices(
|
||||||
|
tmp_path: Path, width: int, height: int
|
||||||
|
):
|
||||||
|
archives = sorted((ROOT / "dist").glob("stackchain-dashboard-*.tar.gz"))
|
||||||
|
assert len(archives) == 1, "browser job must download exactly one assembled release archive"
|
||||||
|
fake = FakeGiteaServer(("127.0.0.1", 0))
|
||||||
|
thread = threading.Thread(target=fake.serve_forever, daemon=True)
|
||||||
|
thread.start()
|
||||||
|
puts: list[dict] = []
|
||||||
|
try:
|
||||||
|
with release_server(archives[0], tmp_path, f"http://127.0.0.1:{fake.server_port}") as origin, sync_playwright() as playwright:
|
||||||
|
browser = playwright.chromium.launch(args=["--ignore-certificate-errors"])
|
||||||
|
page = browser.new_page(viewport={"width": width, "height": height})
|
||||||
|
page_errors: list[str] = []
|
||||||
|
page.on("pageerror", lambda error: page_errors.append(str(error)))
|
||||||
|
|
||||||
|
def week_route(route):
|
||||||
|
if route.request.method == "PUT":
|
||||||
|
body = json.loads(route.request.post_data or "{}")
|
||||||
|
puts.append(body)
|
||||||
|
if len(puts) == 1:
|
||||||
|
route.fulfill(status=409, content_type="application/json", body='{"message":"changed"}')
|
||||||
|
return
|
||||||
|
route.fulfill(status=200, content_type="application/json", body=json.dumps({
|
||||||
|
"revision": 6, "timezone": body["timezone"], "days": body["days"],
|
||||||
|
}))
|
||||||
|
return
|
||||||
|
if puts:
|
||||||
|
changed_date = puts[0]["days"][0]["plan_date"]
|
||||||
|
following_date = (date.fromisoformat(changed_date) + timedelta(days=1)).isoformat()
|
||||||
|
route.fulfill(status=200, content_type="application/json", body=json.dumps({
|
||||||
|
"revision": 5, "timezone": puts[0]["timezone"], "days": [
|
||||||
|
{"plan_date": changed_date, "ids": [], "capacity_minutes": 90, "estimates": {}},
|
||||||
|
{"plan_date": following_date, "ids": [], "capacity_minutes": 30, "estimates": {}},
|
||||||
|
],
|
||||||
|
}))
|
||||||
|
return
|
||||||
|
route.fulfill(status=200, content_type="application/json", body=json.dumps({
|
||||||
|
"revision": 4, "timezone": None, "days": [],
|
||||||
|
}))
|
||||||
|
|
||||||
|
page.route("**/api/v1/week", week_route)
|
||||||
|
page.goto(origin + "/", wait_until="networkidle")
|
||||||
|
page.locator('input[name="device_label"]').fill("Week conflict release phone")
|
||||||
|
page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
|
||||||
|
page.locator("#submit-sign-in").click()
|
||||||
|
page.wait_for_url(origin + "/", wait_until="networkidle")
|
||||||
|
page.locator('[data-mobile-task="queues"]').click()
|
||||||
|
page.locator('[data-mobile-queue="week"]').click()
|
||||||
|
page.locator("#plan-today-available").fill("60")
|
||||||
|
page.locator("#save-today-plan").click()
|
||||||
|
page.wait_for_function("() => document.querySelector('#mobile-week-summary').textContent.includes('Conflict')")
|
||||||
|
|
||||||
|
page.locator('[data-mobile-task="queues"]').click()
|
||||||
|
page.locator('[data-mobile-queue="week"]').click()
|
||||||
|
choices = page.locator("[data-week-conflict-choice]")
|
||||||
|
expect(choices).to_have_count(2)
|
||||||
|
for index in range(2):
|
||||||
|
bounds = choices.nth(index).locator("xpath=..").bounding_box()
|
||||||
|
assert bounds and bounds["height"] >= 44
|
||||||
|
page.locator('[data-week-conflict-choice="phone"]').check()
|
||||||
|
expect(page.locator("#save-merged-week")).to_be_enabled()
|
||||||
|
page.locator("#save-merged-week").click()
|
||||||
|
expect(page.locator("#plan-today-sheet")).to_be_hidden()
|
||||||
|
|
||||||
|
assert len(puts) == 2
|
||||||
|
assert [day["capacity_minutes"] for day in puts[-1]["days"]] == [60, 30]
|
||||||
|
assert not page_errors, f"Week conflict recovery raised: {page_errors}"
|
||||||
|
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||||
|
browser.close()
|
||||||
|
finally:
|
||||||
|
fake.shutdown()
|
||||||
|
fake.server_close()
|
||||||
|
thread.join(timeout=5)
|
||||||
|
|
|
||||||
|
|
@ -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-v126" in worker
|
assert "stackchain-dashboard-shell-v127" 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-v126" in source
|
assert "stackchain-dashboard-shell-v127" 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-v126" in worker
|
assert "stackchain-dashboard-shell-v127" 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-v126" in worker
|
assert "stackchain-dashboard-shell-v127" 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-v126" in worker
|
assert "stackchain-dashboard-shell-v127" 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-v126" in worker
|
assert "stackchain-dashboard-shell-v127" 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-v126" in service_worker
|
assert "stackchain-dashboard-shell-v127" 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-v126" in source
|
assert "stackchain-dashboard-shell-v127" 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,13 +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-v126" in source
|
assert "stackchain-dashboard-shell-v127" 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 "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-v126" in source
|
assert "stackchain-dashboard-shell-v127" 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
|
||||||
|
|
@ -195,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-v126" in source
|
assert "stackchain-dashboard-shell-v127" 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
|
||||||
|
|
@ -204,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-v126" in source
|
assert "stackchain-dashboard-shell-v127" 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
|
||||||
|
|
||||||
|
|
@ -212,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-v126" in source
|
assert "stackchain-dashboard-shell-v127" 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-v126" in source
|
assert "stackchain-dashboard-shell-v127" 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
|
||||||
|
|
||||||
|
|
@ -227,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-v126" in source
|
assert "stackchain-dashboard-shell-v127" 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
|
||||||
|
|
||||||
|
|
@ -235,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-v126" in source
|
assert "stackchain-dashboard-shell-v127" 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
|
||||||
|
|
@ -245,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-v126" in source
|
assert "stackchain-dashboard-shell-v127" 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-v126" in source
|
assert "stackchain-dashboard-shell-v127" 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
|
||||||
|
|
@ -261,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-v126" in source
|
assert "stackchain-dashboard-shell-v127" 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-v126" in source
|
assert "stackchain-dashboard-shell-v127" 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-v126" in source
|
assert "stackchain-dashboard-shell-v127" in source
|
||||||
assert "BASE + 'static/update-ownership.js'" in source
|
assert "BASE + 'static/update-ownership.js'" in source
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1245,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-v126" in source
|
assert "stackchain-dashboard-shell-v127" 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-v126';" in service_worker
|
assert "const CACHE = 'stackchain-dashboard-shell-v127';" 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-v126" in source
|
assert "stackchain-dashboard-shell-v127" in source
|
||||||
assert "BASE + 'static/today-sync.js'" in source
|
assert "BASE + 'static/today-sync.js'" in source
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -67,6 +67,84 @@ console.log(JSON.stringify({conflict:week.conflict(),state:week.state()}));
|
||||||
assert result["state"]["revision"] == 7
|
assert result["state"]["revision"] == 7
|
||||||
|
|
||||||
|
|
||||||
|
def test_week_controller_automatically_merges_changes_made_to_different_days():
|
||||||
|
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 base={revision:7,timezone:'UTC',days:[
|
||||||
|
{plan_date:'2026-08-21',ids:['tuesday-base'],capacity_minutes:60,estimates:{'tuesday-base':30}},
|
||||||
|
{plan_date:'2026-08-22',ids:['friday-base'],capacity_minutes:90,estimates:{'friday-base':45}}
|
||||||
|
]};
|
||||||
|
const remote={revision:8,timezone:'UTC',days:[
|
||||||
|
{plan_date:'2026-08-21',ids:['tuesday-account'],capacity_minutes:60,estimates:{'tuesday-account':30}},
|
||||||
|
{plan_date:'2026-08-22',ids:['friday-base'],capacity_minutes:90,estimates:{'friday-base':45}}
|
||||||
|
]};
|
||||||
|
const fetchJson=async(_url,options={})=>{
|
||||||
|
const body=options.body?JSON.parse(options.body):null;requests.push({method:options.method||'GET',body});
|
||||||
|
if(options.method==='PUT'&&puts++===0){const error=new Error('changed');error.status=409;throw error;}
|
||||||
|
if(options.method==='PUT')return {revision:9,timezone:body.timezone,days:body.days};
|
||||||
|
return remote;
|
||||||
|
};
|
||||||
|
const week=createWeekPlan({storage,getLogin:()=> 'timmy',fetchJson,localDate:()=> '2026-08-20',timeZone:()=> 'UTC'});
|
||||||
|
week.adopt(base);
|
||||||
|
week.stageDay('2026-08-22',{ids:['friday-phone'],capacity_minutes:90,estimates:{'friday-phone':50}});
|
||||||
|
const saved=await week.flush();
|
||||||
|
console.log(JSON.stringify({saved,requests,pending:week.pending(),conflict:week.conflict()}));
|
||||||
|
""")
|
||||||
|
|
||||||
|
assert [request["method"] for request in result["requests"]] == ["PUT", "GET", "PUT"]
|
||||||
|
assert result["requests"][2]["body"]["base_revision"] == 8
|
||||||
|
assert result["saved"]["days"] == [
|
||||||
|
{"plan_date": "2026-08-21", "ids": ["tuesday-account"], "capacity_minutes": 60,
|
||||||
|
"estimates": {"tuesday-account": 30}},
|
||||||
|
{"plan_date": "2026-08-22", "ids": ["friday-phone"], "capacity_minutes": 90,
|
||||||
|
"estimates": {"friday-phone": 50}},
|
||||||
|
]
|
||||||
|
assert result["pending"] is False
|
||||||
|
assert result["conflict"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_week_controller_resolves_only_the_same_day_conflict_and_keeps_other_account_changes():
|
||||||
|
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 base={revision:4,timezone:'UTC',days:[
|
||||||
|
{plan_date:'2026-08-21',ids:['same-base'],capacity_minutes:60,estimates:{}},
|
||||||
|
{plan_date:'2026-08-22',ids:['other-base'],capacity_minutes:60,estimates:{}}
|
||||||
|
]};
|
||||||
|
const remote={revision:5,timezone:'UTC',days:[
|
||||||
|
{plan_date:'2026-08-21',ids:['same-account'],capacity_minutes:60,estimates:{}},
|
||||||
|
{plan_date:'2026-08-22',ids:['other-account'],capacity_minutes:60,estimates:{}}
|
||||||
|
]};
|
||||||
|
const fetchJson=async(_url,options={})=>{
|
||||||
|
const body=options.body?JSON.parse(options.body):null;requests.push({method:options.method||'GET',body});
|
||||||
|
if(options.method==='PUT'&&puts++===0){const error=new Error('changed');error.status=409;throw error;}
|
||||||
|
if(options.method==='PUT')return {revision:6,timezone:body.timezone,days:body.days};
|
||||||
|
return remote;
|
||||||
|
};
|
||||||
|
const week=createWeekPlan({storage,getLogin:()=> 'timmy',fetchJson,localDate:()=> '2026-08-20',timeZone:()=> 'UTC'});
|
||||||
|
week.adopt(base);
|
||||||
|
week.stageDay('2026-08-21',{ids:['same-phone'],capacity_minutes:90,estimates:{}});
|
||||||
|
try { await week.flush(); } catch(_error) {}
|
||||||
|
const before=week.conflict();
|
||||||
|
week.chooseDay('2026-08-21','phone');
|
||||||
|
const selected=JSON.parse(values.get('stackchain.week-sync.v1.timmy'));
|
||||||
|
const saved=await week.saveMerged();
|
||||||
|
console.log(JSON.stringify({before,selected,saved,requests,pending:week.pending(),conflict:week.conflict()}));
|
||||||
|
""")
|
||||||
|
|
||||||
|
assert [item["plan_date"] for item in result["before"]["conflicts"]] == ["2026-08-21"]
|
||||||
|
assert result["before"]["conflicts"][0]["local"]["ids"] == ["same-phone"]
|
||||||
|
assert result["before"]["conflicts"][0]["remote"]["ids"] == ["same-account"]
|
||||||
|
assert result["selected"]["resolutions"] == {"2026-08-21": "phone"}
|
||||||
|
assert result["requests"][2]["body"]["base_revision"] == 5
|
||||||
|
assert [day["ids"] for day in result["saved"]["days"]] == [["same-phone"], ["other-account"]]
|
||||||
|
assert result["pending"] is False
|
||||||
|
assert result["conflict"] is None
|
||||||
|
|
||||||
|
|
||||||
def test_week_controller_restores_an_account_bound_week_before_network_delivery():
|
def test_week_controller_restores_an_account_bound_week_before_network_delivery():
|
||||||
result = run_controller("""
|
result = run_controller("""
|
||||||
const values=new Map();
|
const values=new Map();
|
||||||
|
|
@ -294,10 +372,16 @@ def test_mobile_week_ahead_exposes_durable_save_and_touch_safe_conflict_choices(
|
||||||
dashboard = (FRONTEND / "dashboard.js").read_text()
|
dashboard = (FRONTEND / "dashboard.js").read_text()
|
||||||
|
|
||||||
assert 'id="week-conflict-review"' in index
|
assert 'id="week-conflict-review"' in index
|
||||||
|
assert 'id="week-conflict-days"' in index
|
||||||
|
assert 'id="save-merged-week"' in index
|
||||||
assert 'id="keep-phone-week"' in index
|
assert 'id="keep-phone-week"' in index
|
||||||
assert 'id="use-server-week"' in index
|
assert 'id="use-server-week"' in index
|
||||||
assert "storage:localStorage" in dashboard
|
assert "storage:localStorage" in dashboard
|
||||||
assert "controller.stageDay" in CONTROLLER.read_text()
|
assert "controller.stageDay" in CONTROLLER.read_text()
|
||||||
assert "weekPlan.flush" in dashboard
|
assert "weekPlan.flush" in dashboard
|
||||||
assert "week-conflict-mode" in dashboard
|
assert "week-conflict-mode" in dashboard
|
||||||
|
assert "controller.chooseDay" in CONTROLLER.read_text()
|
||||||
|
assert "weekPlan.saveMerged" in dashboard
|
||||||
|
assert 'data-week-conflict-choice' in CONTROLLER.read_text()
|
||||||
assert ".week-conflict-actions button { min-height:44px;" in css
|
assert ".week-conflict-actions button { min-height:44px;" in css
|
||||||
|
assert ".week-conflict-choice { min-height:44px;" in css
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user