function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin}={}) { let week={revision:0,timezone:null,days:[]}; let flushing=null; let lastConflict=null; const storagePrefix='stackchain.week-sync.v1.'; const cloneDay=day=>({ plan_date:day.plan_date,ids:[...(day.ids||[])],capacity_minutes:day.capacity_minutes??null, estimates:{...(day.estimates||{})}, }); 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, ...(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; } } function adopt(value) { if(!Number.isInteger(value?.revision)||!Array.isArray(value?.days)) return false; week={revision:value.revision,timezone:value.timezone||null,days:value.days.map(cloneDay) .sort((left,right)=>left.plan_date.localeCompare(right.plan_date))}; lastConflict=null; return state(); } function addDays(value, amount) { const [year,month,day]=value.split('-').map(Number); return new Date(Date.UTC(year,month-1,day+amount)).toISOString().slice(0,10); } function dates() { return Array.from({length:7},(_,index)=>{ const date=addDays(localDate(),index+1); const parsed=new Date(date+'T12:00:00Z'); return {date,label:new Intl.DateTimeFormat('en',{weekday:'short',month:'short',day:'numeric',timeZone:'UTC'}).format(parsed)}; }); } function day(planDate) { const found=week.days.find(item=>item.plan_date===planDate); return found?cloneDay(found):{plan_date:planDate,ids:[],capacity_minutes:null,estimates:{}}; } 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)), base_days:(week.base_days||week.days).map(cloneDay)}; 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 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(); 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'); 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;}); return flushing; } async function saveDay(planDate, value) { const local={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))}; const body={base_revision:local.revision,timezone:local.timezone,days:local.days}; try { return adopt(await fetchJson('api/v1/week',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)})); } catch(error) { if(error?.status===409) { const remote=await fetchJson('api/v1/week'); lastConflict={local,remote:{revision:remote.revision,timezone:remote.timezone,days:remote.days.map(cloneDay)}}; } throw error; } } function conflict() { 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; 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) { if(pending()||conflict()) return false; const due=week.days.find(item=>item.plan_date<=localDate()&&item.ids.length); if(!due) return false; return fetchJson('api/v1/week/promote',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({ promotion_id:`week-${due.plan_date}-r${week.revision}`,week_revision:week.revision, plan_date:due.plan_date,today_revision:todayRevision, })}); } function summary() { const planned=week.days.filter(item=>item.ids.length); const items=planned.reduce((total,item)=>total+item.ids.length,0); 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,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=>'
'+escapeHtml(day.plan_date)+''+escapeHtml(conflictDetail(day))+'
').join(''): 'Nothing planned.
'; } 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=>''; return '