feat: reconcile Week Ahead edits per day (Closes #1184)
CI / lint (pull_request) Successful in 3m52s
CI / build-release (pull_request) Successful in 10s
CI / browser-journey (pull_request) Successful in 5m36s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy committed 2026-08-20 15:43:35 +00:00
1 parent 43066a0822
commit e0f7c711ed
18 files changed
+344 -52

No files matched your search

+9
View File
@@ -273,6 +273,15 @@ textarea { resize: vertical; min-height: 120px; }
.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-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; }
.plan-today-sheet.week-conflict-mode .week-plan-dates,
.plan-today-sheet.week-conflict-mode .mobile-plan-today-nav,
+22 -17
View File
@@ -3063,30 +3063,15 @@
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);
const focus=weekWorkflow.renderConflict(conflict);
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());
focus.focus();requestAnimationFrame(()=>focus.focus());
}
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.';
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('#plan-today-sheet').addEventListener('click', event => {
if (event.target === qs('#plan-today-sheet')) closePlanToday();
+6 -4
View File
@@ -483,9 +483,11 @@
</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">
<h3 id="week-conflict-title">Resolve Week Ahead changes</h3>
<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-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">
<h4 id="week-conflict-phone-title">This phone</h4>
<div id="week-conflict-phone-plan"></div>
@@ -495,7 +497,7 @@
<div id="week-conflict-server-plan"></div>
</section>
</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="use-server-week" type="button">Use saved account week</button>
</div>
+1 -1
View File
@@ -1,7 +1,7 @@
const BASE = new URL('./', self.location.href).pathname;
importScripts(BASE + 'static/private-data-registry.js');
importScripts(BASE + 'static/background-issue-sync.js');
const CACHE = 'stackchain-dashboard-shell-v126';
const CACHE = 'stackchain-dashboard-shell-v127';
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;
+113 -6
View File
@@ -21,6 +21,9 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin}={}) {
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,
...(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;
} 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([{
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))};
}]).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; }
lastConflict=null;
@@ -68,6 +72,22 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin}={}) {
function deliveryBody(value) {
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() {
if(flushing) return flushing;
const queued=pending();
@@ -83,8 +103,18 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin}={}) {
}).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)}};
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'},
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;
}).finally(()=>{flushing=null;});
@@ -108,10 +138,54 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin}={}) {
}
}
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)},
remote:{...lastConflict.remote,days:lastConflict.remote.days.map(cloneDay)},
}: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() {
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';
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,
todayWork,refresh,warm}={}) {
let selectedDate=null;
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() {
const root=qs('#week-plan-dates');
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;
}
}
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,
copy:()=>selectedDate?{title:'Plan Week Ahead',heading:selectedDate+', in order',available:'Available this day',build:'Build this day'}:null,
clear(){selectedDate=null;renderDates();}};