function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin,coordinator,now=()=>Date.now()}={}) { let week={revision:0,timezone:null,days:[]}; let flushing=null; let lastConflict=null; let offlineSnapshot=false; let refreshedAt=null; let confirmedItems={}; let pendingItems={}; let pulling=null; const storagePrefix='stackchain.week-sync.v1.'; const confirmedPrefix='stackchain.week-confirmed.v1.'; const pullPrefix='stackchain.week-today-pull.v1.'; const cloneDay=day=>({ plan_date:day.plan_date,ids:[...(day.ids||[])],capacity_minutes:day.capacity_minutes??null, estimates:{...(day.estimates||{})}, }); const sanitizeItem=value=>value?{kind:value.kind==='pull'?'pull':'issue', title:String(value.title||'').slice(0,500),repository:String(value.repository||'').slice(0,255), number:Number.isInteger(value.number)?value.number:null}:null; function sanitizeItems(days,sources={}) { const sanitized={}; new Set((days||[]).flatMap(day=>day.ids||[])).forEach(id=>{ const value=sanitizeItem(sources[id]);if(value)sanitized[id]=value; }); return sanitized; } const state=()=>({revision:week.revision,timezone:week.timezone,days:week.days.map(cloneDay), offline_snapshot:offlineSnapshot,...(refreshedAt?{refreshed_at:refreshedAt}:{}), ...(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 confirmedKey() { const login=String(getLogin?.()||'').trim().toLowerCase(); return login?confirmedPrefix+encodeURIComponent(login):''; } function pullKey() { const login=String(getLogin?.()||'').trim().toLowerCase(); return login?pullPrefix+encodeURIComponent(login):''; } function pullQueue() { const key=pullKey(); if(!key||!storage)return []; try { const value=JSON.parse(storage.getItem(key)||'null'); const entries=Array.isArray(value?.entries)?value.entries:(value?[value]:[]); return entries.filter(entry=>typeof entry?.body?.operation_id==='string'&&Array.isArray(entry?.today?.ids)&& Number.isInteger(entry?.week?.revision)&&Array.isArray(entry?.week?.days)); } catch(_error){return [];} } function writePullQueue(entries) { const key=pullKey();if(!key||!storage)return false; try { if(entries.length)storage.setItem(key,JSON.stringify({version:2,entries})); else storage.removeItem(key); return true; } catch(_error){return false;} } function pendingPull() { return pullQueue()[0]||null; } function resumePull() { const queue=pullQueue(),conflicted=queue.find(entry=>entry.conflict&&entry.current?.week); const value=conflicted||queue[queue.length-1]; if(!value)return null; adopt(conflicted?conflicted.current.week:value.week); return {...value,...(conflicted?.current||{}),sync_pending:true}; } function readConfirmed() { const key=confirmedKey(); if(!key||!storage)return null; try { const value=JSON.parse(storage.getItem(key)||'null'); if(!Number.isInteger(value?.week?.revision)||!Array.isArray(value?.week?.days)|| typeof value?.refreshed_at!=='string')return null; return value; } catch(_error){return null;} } function persistConfirmed() { const key=confirmedKey(); if(!key||!storage||week.sync_pending)return false; const record={refreshed_at:new Date(now()).toISOString(),week:{revision:week.revision, timezone:week.timezone,days:week.days.map(cloneDay)},items:{...confirmedItems}}; try {storage.setItem(key,JSON.stringify(record));refreshedAt=record.refreshed_at;return true;} catch(_error){return false;} } function rememberItems(items={}) { confirmedItems=sanitizeItems(week.days,{...confirmedItems,...items}); return persistConfirmed(); } 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, items:sanitizeItems(value.days,value.items||{}), ...(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))}; offlineSnapshot=false; pendingItems={}; lastConflict=null; return state(); } function adoptConfirmed(value,items=confirmedItems) { const adopted=adopt(value); if(adopted){confirmedItems=sanitizeItems(week.days,items);persistConfirmed();} return adopted; } 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:{}}; } function pass(planDate) { const available=dates(),index=available.findIndex(item=>item.date===planDate); if(index<0) return null; return {position:index+1,total:available.length,planned:week.days.filter(item=>item.ids.length).length, next_date:available[index+1]?.date||null,last:index===available.length-1}; } async function load() { const queued=pending(); if(queued){ week=queued;pendingItems=queued.items||{}; confirmedItems=sanitizeItems(week.days,readConfirmed()?.items||{}); offlineSnapshot=false;return state(); } try { adopt(await fetchJson('api/v1/week')); const cached=readConfirmed();confirmedItems=sanitizeItems(week.days,cached?.items||{});persistConfirmed(); return state(); } catch(error) { if(error?.status===401||error?.status===403)throw error; const cached=readConfirmed(); if(!cached)throw error; adopt(cached.week);offlineSnapshot=true;refreshedAt=cached.refreshed_at; confirmedItems=sanitizeItems(week.days,cached.items||{}); return state(); } } function stageDays(days) { if(offlineSnapshot)return false; 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:days.map(cloneDay).sort((left,right)=>left.plan_date.localeCompare(right.plan_date)), base_days:(week.base_days||week.days).map(cloneDay)}; queued.items=sanitizeItems(queued.days,{...confirmedItems,...pendingItems,...(pending()?.items||{})}); try { storage.setItem(key,JSON.stringify(queued)); } catch(_error) { return false; } lastConflict=null; week={revision:queued.base_revision,...queued,sync_pending:true}; pendingItems=queued.items; return state(); } function rememberPendingItem(id,value) { const key=storageKey(),item=sanitizeItem(value); if(!key||!storage||!item)return false; try { const raw=JSON.parse(storage.getItem(key)||'null'); if(!Number.isInteger(raw?.base_revision)||!Array.isArray(raw?.days)|| !raw.days.some(day=>(day.ids||[]).includes(id)))return false; raw.items=sanitizeItems(raw.days,{...(raw.items||{}),[id]:item}); storage.setItem(key,JSON.stringify(raw));pendingItems=raw.items; if(week.sync_pending)week={...week,items:raw.items}; return true; } catch(_error){return false;} } function stageDay(planDate,value) { return stageDays(week.days.filter(item=>item.plan_date!==planDate).concat([{ plan_date:planDate,ids:[...(value.ids||[])],capacity_minutes:value.capacity_minutes??null, estimates:{...(value.estimates||{})}, }])); } function stageCapacities(values) { const available=dates().map(item=>item.date); if(!Array.isArray(values)||values.length!==available.length)return false; const capacities=new Map(values.map(value=>[value?.plan_date,value?.capacity_minutes])); if(capacities.size!==available.length||available.some(date=>{ const value=capacities.get(date);return !Number.isInteger(value)||value<0||value>1440; }))return false; return stageDays(available.map(planDate=>{ const value=day(planDate);value.capacity_minutes=capacities.get(planDate);return value; })); } function review() { const assigned=new Map(); const days=dates().map(item=>{ const value=day(item.date),estimates=value.estimates||{}; value.ids.forEach(id=>assigned.set(id,[...(assigned.get(id)||[]),item.date])); const planned_minutes=value.ids.reduce((total,id)=>total+(Number(estimates[id])||0),0); const capacity_minutes=Number(value.capacity_minutes)||0; return {...value,label:item.label,planned_minutes, overloaded:Boolean(capacity_minutes&&planned_minutes>capacity_minutes)}; }); const duplicates=[...assigned.entries()].filter(([_id,assignedDates])=>assignedDates.length>1) .map(([id,assignedDates])=>({id,dates:assignedDates})); const blockers=[]; days.forEach(value=>{ if(value.ids.length&&!Number(value.capacity_minutes))blockers.push({type:'missing-capacity',plan_date:value.plan_date}); value.ids.forEach(id=>{if(!(Number(value.estimates?.[id])>0))blockers.push({type:'missing-estimate',plan_date:value.plan_date,id});}); if(value.overloaded)blockers.push({type:'over-capacity',plan_date:value.plan_date, minutes:value.planned_minutes-Number(value.capacity_minutes)}); }); blockers.sort((left,right)=>left.plan_date.localeCompare(right.plan_date)|| ['over-capacity','missing-estimate','missing-capacity'].indexOf(left.type)-['over-capacity','missing-estimate','missing-capacity'].indexOf(right.type)); return {days,duplicates,blockers,can_confirm:duplicates.length===0&&blockers.length===0}; } function previewReflow() { const available=dates().map(item=>day(item.date)); const seen=new Set(),work=[]; available.forEach(source=>(source.ids||[]).forEach(id=>{ if(seen.has(id))return; seen.add(id); const estimate=Number(source.estimates?.[id]); work.push({id,estimate:Number.isFinite(estimate)?estimate:null,from_date:source.plan_date}); })); const blockers=work.filter(item=>!(item.estimate>0)).map(item=>({ type:'missing-estimate',id:item.id,plan_date:item.from_date, })); if(blockers.length)return {days:[],blockers,unscheduled:[]}; const days=available.map(source=>({plan_date:source.plan_date,ids:[], capacity_minutes:source.capacity_minutes??null,estimates:{}})); const unscheduled=[];let cursor=0; if(!blockers.length)work.forEach(item=>{ const destination=days.slice(cursor).find(value=>value.ids.length<5&&Number(value.capacity_minutes)>0&& value.ids.reduce((total,id)=>total+Number(value.estimates[id]),0)+item.estimate<=Number(value.capacity_minutes)); if(!destination){unscheduled.push({...item});return;} cursor=days.indexOf(destination); destination.ids.push(item.id);destination.estimates[item.id]=item.estimate; }); return {days,blockers,unscheduled}; } function applyReflow() { const preview=previewReflow(); if(preview.blockers.length||offlineSnapshot)return false; return stageDays(preview.days); } function move(id,toDate) { if(!dates().some(item=>item.date===toDate)) return false; const sources=week.days.filter(item=>(item.ids||[]).includes(id)); if(!sources.length) return false; const estimate=sources.map(item=>Number(item.estimates?.[id])).find(Number.isFinite); const moved=week.days.map(item=>{ const estimates={...(item.estimates||{})};delete estimates[id]; return {...cloneDay(item),ids:(item.ids||[]).filter(value=>value!==id),estimates}; }); let destination=moved.find(item=>item.plan_date===toDate); if(!destination){destination={plan_date:toDate,ids:[],capacity_minutes:null,estimates:{}};moved.push(destination);} destination.ids.push(id); if(Number.isFinite(estimate)) destination.estimates[id]=estimate; return Boolean(stageDays(moved)); } function retire(id) { const removed=week.days.reduce((total,item)=>total+(item.ids||[]).filter(value=>value===id).length,0); if(!id||!removed||offlineSnapshot)return false; const days=week.days.map(item=>{ const estimates={...(item.estimates||{})};delete estimates[id]; return {...cloneDay(item),ids:(item.ids||[]).filter(value=>value!==id),estimates}; }); const staged=stageDays(days); return staged?{...staged,removed}:false; } function unplan(id) { if(!id||offlineSnapshot)return false; const source=week.days.find(item=>(item.ids||[]).includes(id)); if(!source)return false; const index=source.ids.indexOf(id),estimate=Number(source.estimates?.[id]); const receipt={id,plan_date:source.plan_date,index,estimate:Number.isFinite(estimate)?estimate:null}; const staged=retire(id); return staged?receipt:false; } function restore(receipt) { if(!receipt?.id||!receipt.plan_date||!Number.isInteger(receipt.index)||offlineSnapshot||placement(receipt.id))return false; const days=week.days.map(cloneDay); let destination=days.find(item=>item.plan_date===receipt.plan_date); if(!destination){destination={plan_date:receipt.plan_date,ids:[],capacity_minutes:null,estimates:{}};days.push(destination);} destination.ids.splice(Math.min(receipt.index,destination.ids.length),0,receipt.id); if(Number.isFinite(Number(receipt.estimate)))destination.estimates[receipt.id]=Number(receipt.estimate); return stageDays(days); } function placement(id) { const found=week.days.find(item=>(item.ids||[]).includes(id)); if(!found)return null; const estimate=Number(found.estimates?.[id]); return {date:found.plan_date,estimate:Number.isFinite(estimate)?estimate:null}; } function place(id,toDate,estimate,{move:allowMove=false}={}) { if(!id||!dates().some(item=>item.date===toDate)||!Number.isFinite(Number(estimate))||Number(estimate)<=0)return false; const existing=placement(id); if(existing&&existing.date!==toDate&&!allowMove)return false; const changed=week.days.map(item=>{ const estimates={...(item.estimates||{})}; if(item.plan_date!==toDate){delete estimates[id];return {...cloneDay(item),ids:(item.ids||[]).filter(value=>value!==id),estimates};} return cloneDay(item); }); let destination=changed.find(item=>item.plan_date===toDate); if(!destination){destination={plan_date:toDate,ids:[],capacity_minutes:null,estimates:{}};changed.push(destination);} if(!destination.ids.includes(id))destination.ids.push(id); destination.estimates[id]=Number(estimate); return Boolean(stageDays(changed)); } 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 key=storageKey(); if(!pending()||!key) return Promise.resolve(false); const sameBody=(value,body)=>JSON.stringify(deliveryBody(value))===JSON.stringify(body); const deliver=async()=>{ const queued=pending(); if(!queued)return false; const body=deliveryBody(queued); let saved; try { saved=await fetchJson('api/v1/week',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)}); } catch(error) { if(error?.status===409){ const remote=await fetchJson('api/v1/week'); const local=pending(); const merged=local&&reconcile(local,remote); if(merged&&!merged.conflicts.length){ 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&&sameBody(current,deliveryBody(local)))storage.removeItem(key); if(!pending())adoptConfirmed(saved,{...confirmedItems,...(local.items||{})}); return saved; } lastConflict={key,local,remote:{revision:remote.revision,timezone:remote.timezone||null, days:remote.days.map(cloneDay)},merged,choices:{...(local?.resolutions||{})}}; } else { const current=pending(); if(current&&!sameBody(current,body))return deliver(); } throw error; } const current=pending(); if(current&&sameBody(current,body)){ storage.removeItem(key);adoptConfirmed(saved,{...confirmedItems,...(current.items||{})});return saved; } if(current){ try { const raw=JSON.parse(storage.getItem(key)); raw.base_revision=saved.revision;raw.base_days=saved.days.map(cloneDay); storage.setItem(key,JSON.stringify(raw)); week={revision:raw.base_revision,...raw,sync_pending:true}; pendingItems=raw.items||{}; } catch(_error){return saved;} return deliver(); } adoptConfirmed(saved,{...confirmedItems,...(queued.items||{})});return saved; }; flushing=deliver().finally(()=>{flushing=null;}); return flushing; } async function saveDay(planDate, value) { if(offlineSnapshot)return false; 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 adoptConfirmed(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()) adoptConfirmed(saved,{...confirmedItems,...(local.items||{})}); 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()) adoptConfirmed(saved,{...confirmedItems,...(local.items||{})}); 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=adoptConfirmed(remote); lastConflict=null; return adopted; } async function promote(todayRevision) { if(offlineSnapshot||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, })}); } async function pullItem(identity,today,operationId,allowOverCapacity=false) { if(offlineSnapshot||pending()||conflict()||!identity||!operationId|| !Number.isInteger(today?.revision)||!(today.ids||[]).length)return false; const body={operation_id:operationId,identity,today_revision:today.revision,week_revision:week.revision, allow_over_capacity:Boolean(allowOverCapacity)}; const key=pullKey(); if(key&&storage){ const source=week.days.find(day=>(day.ids||[]).includes(identity)); if(!source)return false; const estimate=Number(source.estimates?.[identity]); const optimisticToday={...today,ids:[...(today.ids||[]),identity],estimates:{...(today.estimates||{}), ...(Number.isFinite(estimate)?{[identity]:estimate}:{})}}; const optimisticWeek={revision:week.revision,timezone:week.timezone,days:week.days.map(day=>{ const estimates={...(day.estimates||{})};delete estimates[identity]; return {...cloneDay(day),ids:(day.ids||[]).filter(id=>id!==identity),estimates}; })}; const record={body,today:optimisticToday,week:optimisticWeek,queued_at:now()}; const queue=pullQueue(); if(!writePullQueue([...queue,record]))throw new Error('Could not save this pull on this device. Nothing changed.'); adopt(optimisticWeek); try{return await flushPull();} catch(error){ if(error?.status===401||error?.status===403)throw error; return {...record,sync_pending:true}; } } const result=await fetchJson('api/v1/week/pull-item',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)}); adoptConfirmed(result.week,{...confirmedItems,...pendingItems});return result; } function flushPull() { if(pulling)return pulling; if(!pendingPull()||!pullKey())return Promise.resolve(false); const run=async()=>{ let result,lastResult=false; while(pendingPull()){ const current=pendingPull(); try { result=await fetchJson('api/v1/week/pull-item',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(current.body)}); } catch(error) { if(error?.status!==409)throw error; const [today,currentWeek]=await Promise.all([fetchJson('api/v1/today'),fetchJson('api/v1/week')]); const queue=pullQueue(),conflicted={...current,conflict:true,current:{today,week:currentWeek}}; if(!writePullQueue([conflicted,...queue.slice(1)]))throw new Error('Could not preserve queued Week Ahead work on this device.'); adopt(currentWeek); return {today,week:currentWeek,conflict:true,sync_pending:true}; } const queue=pullQueue(); if(queue[0]?.body?.operation_id!==current.body.operation_id)continue; const remaining=queue.slice(1).map(entry=>{ const identity=entry.body.identity; const source=entry.week.days.find(day=>(day.ids||[]).includes(identity)); const estimate=Number(entry.today?.estimates?.[identity]??source?.estimates?.[identity]); const today={...result.today,ids:(result.today.ids||[]).includes(identity)?[...result.today.ids]:[...result.today.ids,identity], estimates:{...(result.today.estimates||{}),...(Number.isFinite(estimate)?{[identity]:estimate}:{})}}; const nextWeek={revision:result.week.revision,timezone:result.week.timezone||null,days:result.week.days.map(day=>{ const estimates={...(day.estimates||{})};delete estimates[identity]; return {...cloneDay(day),ids:(day.ids||[]).filter(id=>id!==identity),estimates}; })}; result={today,week:nextWeek}; return {...entry,body:{...entry.body,today_revision:today.revision,week_revision:nextWeek.revision},today,week:nextWeek}; }); if(!writePullQueue(remaining))throw new Error('Could not update queued Week Ahead work on this device.'); lastResult=result; } adoptConfirmed(lastResult.week,{...confirmedItems,...pendingItems}); return {...lastResult,sync_pending:false}; }; const delivery=coordinator?coordinator.runExclusive('week',run):run(); pulling=Promise.resolve(delivery).finally(()=>{pulling=null;}); return pulling; } async function startEarly(planDate,todayRevision) { if(offlineSnapshot||pending()||conflict()||!Number.isInteger(todayRevision))return false; const day=week.days.find(item=>item.plan_date===planDate&&item.ids.length); if(!day)return false; const promoted=await fetchJson('api/v1/week/start-early',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({ promotion_id:`early-${day.plan_date}-r${week.revision}`,week_revision:week.revision, plan_date:day.plan_date,today_revision:todayRevision, })}); await load(); return promoted; } async function reconcilePromotion(preserved,selection) { if(offlineSnapshot||pending()||conflict()) return false; const due=(preserved?.week?.days||week.days).find(item=>item.plan_date<=localDate()&&item.ids.length); if(!due||!Number.isInteger(preserved?.today?.revision)) return false; const result=await fetchJson('api/v1/week/reconcile',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({ promotion_id:`reconcile-${due.plan_date}-r${preserved.week.revision}`, week_revision:preserved.week.revision,plan_date:due.plan_date,today_revision:preserved.today.revision, ids:[...(selection.ids||[])],capacity_minutes:selection.capacity_minutes??null,estimates:{...(selection.estimates||{})}, })}); adoptConfirmed(result.week); return result; } 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,pass,load,saveDay,stageDay,stageCapacities,review,previewReflow,applyReflow,move,retire,unplan,restore,placement,place,pending,flush,conflict,chooseDay,saveMerged, keepLocal,useRemote,promote,pullItem,pendingPull,resumePull,flushPull,startEarly,reconcile:reconcilePromotion,summary,rememberItems,rememberPendingItem, item:id=>pendingItems[id]||confirmedItems[id]||null, offline:()=>offlineSnapshot,request:fetchJson,reschedule:()=>({storage,getLogin})}; } function createWeekPlanWorkflow({controller,qs,getItem=()=>null,openItem,openPlanner,setReviewMode=()=>{},escapeHtml,escapeAttribute, todayWork,refresh:r=()=>{},warm:w=()=>{},today:t=()=>null,r:refresh=r,w:warm=w,t:getTodayPlan=t, confirmEarly=message=>globalThis.confirm?.(message)??false,setTimer=globalThis.setTimeout,clearTimer=globalThis.clearTimeout, operationId=()=>globalThis.crypto?.randomUUID?.()||('pull-'+Date.now()+'-'+Math.random().toString(16).slice(2)),x=null}={}) { let selectedDate=null; let reviewing=false; let overviewing=false; let editingFromReview=false; let blockedReviewOpen=false; let reconciliation=null; let loadState=null; let unplanReceipt=null; let unplanTimer=null; function renderOfflineState(value=loadState) { const notice=qs('#week-offline-snapshot'),retry=qs('#retry-week-live'),capacity=qs('#open-week-capacity-import'); const offline=Boolean(value?.offline_snapshot); if(notice){ notice.hidden=!offline; if(offline){ const refreshed=new Date(value.refreshed_at); const label=Number.isNaN(refreshed.getTime())?'an earlier sync':refreshed.toLocaleString('en',{month:'short',day:'numeric',hour:'numeric',minute:'2-digit'}); notice.textContent='Offline snapshot · refreshed '+label+'. Viewing only until live data returns.'; } } if(retry)retry.hidden=!offline; if(capacity)capacity.hidden=offline; } function reconciliationDay(){ if(!reconciliation)return null; const due=(reconciliation.week?.days||[]).find(day=>day.ids?.length); if(!due)return null; const ids=[...new Set([...(reconciliation.today?.ids||[]),...(due.ids||[])])]; return {plan_date:due.plan_date,ids,capacity_minutes:due.capacity_minutes??reconciliation.today?.capacity_minutes??null, estimates:{...(due.estimates||{}),...(reconciliation.today?.estimates||{})}}; } function renderPass() { const progress=qs('#week-plan-progress'),save=qs('#save-today-plan'); if(reviewing){ const value=controller.review(); progress.hidden=false; progress.textContent=`Review week · ${value.days.filter(day=>day.ids.length).length} planned`; return; } if(!selectedDate){progress.hidden=true;return;} const current=controller.pass(selectedDate); progress.hidden=false; progress.textContent=`Day ${current.position} of ${current.total} · ${current.planned} planned`; save.textContent=editingFromReview?'Save & review':(current.last?'Save week':'Save & next'); } 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 '

'+escapeHtml(item.plan_date)+'

'+choice('phone')+choice('account')+'
'; }).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; renderPass(); if(!selectedDate)return; root.innerHTML=controller.dates().map(item=>'').join(''); root.querySelectorAll('[data-week-plan-date]').forEach(button=>button.addEventListener('click',()=>{ selectedDate=button.dataset.weekPlanDate;renderDates();openPlanner(null,false); })); } function closeReflow() { const panel=qs('#week-reflow-review');if(panel)panel.hidden=true; const apply=qs('#apply-week-reflow');if(apply)apply.disabled=false; return true; } function openReflow() { const preview=controller.previewReflow?.(); if(!preview)return false; const labels=new Map(controller.dates().map(item=>[item.date,item.label])); const panel=qs('#week-reflow-review'),days=qs('#week-reflow-days'),unscheduled=qs('#week-reflow-unscheduled'); panel.hidden=false; if(preview.blockers.length){ days.innerHTML=''; const blocker=preview.blockers[0],item=getItem(blocker.id)||controller.item?.(blocker.id); unscheduled.textContent='Add an estimate for '+(item?.title||blocker.id)+' before reflowing.'; qs('#apply-week-reflow').disabled=true; panel.focus?.();return preview; } days.innerHTML=preview.days.filter(day=>day.ids.length).map(day=>{ const minutes=day.ids.reduce((total,id)=>total+Number(day.estimates?.[id]||0),0); return '
'+escapeHtml(labels.get(day.plan_date)||day.plan_date)+ ''+escapeHtml(day.ids.length+' item'+(day.ids.length===1?'':'s')+' · '+minutes+' of '+Number(day.capacity_minutes||0)+' min')+'
'; }).join('')||'

No work can be scheduled with the current capacities.

'; if(preview.unscheduled.length){ const names=preview.unscheduled.map(value=>(getItem(value.id)||controller.item?.(value.id))?.title||value.id); unscheduled.textContent=names.length+' item'+(names.length===1?'':'s')+' cannot fit: '+names.join(', ')+'. '+ (names.length===1?'It':'They')+' will remain in My Work.'; } else unscheduled.textContent='All planned work fits within capacity.'; panel.focus?.();return preview; } async function applyReflow() { const button=qs('#apply-week-reflow');button.disabled=true; const staged=controller.applyReflow?.(); if(!staged){button.disabled=false;return false;} closeReflow();renderReview(); try { await controller.flush();renderReview(); qs('#mobile-week-summary').textContent=controller.summary(); qs('#week-review-status').textContent='Week Ahead reflowed and saved.'; return true; } catch(error) { renderReview(); qs('#week-review-status').textContent=(error.message||'Week Ahead sync is unavailable.')+' Reflow remains saved on this phone.'; return false; } } function dismissUnplanReceipt({focus=false}={}) { if(unplanTimer){clearTimer?.(unplanTimer);unplanTimer=null;} unplanReceipt=null; const receipt=qs('#week-unplan-receipt'),undo=qs('#undo-week-unplan'); if(receipt)receipt.hidden=true; if(undo)undo.hidden=true; if(focus)qs('#edit-week-plan')?.focus?.(); } function showUnplanReceipt(title) { const receipt=qs('#week-unplan-receipt'),undo=qs('#undo-week-unplan'); if(receipt){receipt.textContent=title+' removed from Week Ahead.';receipt.hidden=false;} if(undo){undo.hidden=false;undo.focus?.();} if(unplanTimer)clearTimer?.(unplanTimer); unplanTimer=setTimer?.(()=>dismissUnplanReceipt({focus:true}),10000)||null; } async function undoUnplan() { const receipt=unplanReceipt; if(!receipt||!controller.restore?.(receipt))return false; dismissUnplanReceipt();renderReview();renderPass(); try{ await controller.flush(); renderReview(); qs('#week-review-status').textContent='Restored to Week Ahead.'; }catch(error){ qs('#week-review-status').textContent=(error.message||'Week Ahead sync is unavailable.')+' Restore remains saved on this phone.'; } qs('#week-review-days').querySelector?.(`[data-week-unplan="${receipt.id}"]`)?.focus?.(); return true; } qs('#undo-week-unplan')?.addEventListener('click',undoUnplan); function renderReview() { const value=controller.review(),root=qs('#week-review-days'),duplicates=qs('#week-review-duplicates'); const readOnly=Boolean(controller.offline?.()); const pending=Boolean(controller.pending?.()); const nextUp=overviewing?value.days.find(day=>day.ids.length):null; const today=getTodayPlan?.(); const canStartEarly=Boolean(nextUp&&!readOnly&&!pending&&Number.isInteger(today?.revision)&&!today.ids?.length); const destinations=value.days.map(day=>'').join(''); const itemMarkup=(id,day)=>{ const item=getItem(id)||controller.item?.(id),estimate=Number(day.estimates?.[id])||0; if(!item)return '
Work details unavailable'+escapeHtml(String(id).slice(0,96))+'
'; const kind=item.kind==='pull'?'Pull request':'Issue'; const reference=item.repository&&item.number!=null?item.repository+' #'+item.number:kind; const copy=''+escapeHtml(item.title||'Untitled work')+''+escapeHtml(reference+' · '+kind+(estimate?' · '+estimate+' min':''))+''; const actionable=overviewing&&['issue','pull'].includes(item.kind)&&item.repository&&item.number>0; return actionable?'':copy; }; root.innerHTML=value.days.map(day=>{ const capacity=Number(day.capacity_minutes)||0; const load=day.planned_minutes+(capacity?' of '+capacity:'')+' min'+(day.overloaded?' · over capacity':''); const move=id=>overviewing?'':'
'; const unplan=id=>overviewing&&!readOnly?'':''; const pull=id=>{ const inToday=(today?.ids||[]).includes(id),full=(today?.ids||[]).length>=5; if(!overviewing||readOnly||pending||!Number.isInteger(today?.revision)||!(today?.ids||[]).length||inToday)return ''; const todayMinutes=(today.ids||[]).reduce((total,itemId)=>total+(Number(today.estimates?.[itemId])||0),0); const projected=todayMinutes+(Number(day.estimates?.[id])||0),capacity=Number(today.capacity_minutes)||0; const overload=capacity&&projected>capacity; return ''; }; const items=day.ids.length?'': '

Nothing planned.

'; const edit=readOnly?'':''; const start=day===nextUp&&canStartEarly?'':''; return '

'+escapeHtml(day.label)+(day===nextUp?' Next up':'')+'

'+edit+'

'+ escapeHtml(load)+'

'+items+start+'
'; }).join(''); duplicates.hidden=!value.duplicates.length; duplicates.innerHTML=value.duplicates.length?'Choose one date for duplicated work before confirming.':''; const confirm=qs('#confirm-week-plan'); confirm.disabled=!value.can_confirm||pending; const blocker=value.blockers?.[0]; qs('#week-review-status').textContent=readOnly?'Offline snapshot · viewing only. Retry for live editing.':(overviewing?'Week Ahead overview · '+(pending?'sync pending.':'no changes made.'):(value.duplicates.length?'Duplicate work must be moved to one date.': (pending?'Saving the latest week to your account…':blocker?( blocker.type==='over-capacity'?blocker.plan_date+' is '+blocker.minutes+' min over capacity. Edit that day before confirming.': blocker.type==='missing-estimate'?'Add an estimate for work on '+blocker.plan_date+' before confirming.': 'Add capacity for '+blocker.plan_date+' before confirming.' ):'Week Ahead is balanced and ready.'))); confirm.hidden=overviewing||readOnly; const reflow=qs('#open-week-reflow');if(reflow)reflow.hidden=!overviewing||readOnly||pending||!value.days.some(day=>day.overloaded); const editWeek=qs('#edit-week-plan');if(editWeek){editWeek.hidden=!overviewing||readOnly;editWeek.textContent=nextUp?'Edit week':'Plan Week Ahead';} root.querySelectorAll('[data-week-move]').forEach(button=>button.addEventListener('click',()=>{ const selector=root.querySelector(`[data-week-move-destination="${button.dataset.weekMove}"]`); if(!controller.move(button.dataset.weekMove,selector?.value))return; renderReview();renderPass(); controller.flush().then(()=>{renderReview();qs('#mobile-week-summary').textContent=controller.summary();}) .catch(error=>{qs('#week-review-status').textContent=(error.message||'Week Ahead sync is unavailable.')+' Changes remain on this phone.';}); })); root.querySelectorAll('[data-week-unplan]').forEach(button=>button.addEventListener('click',async event=>{ const id=event.currentTarget.dataset.weekUnplan,item=getItem(id)||controller.item?.(id); const receipt=controller.unplan?.(id); if(!receipt)return; unplanReceipt=receipt; renderReview();renderPass();showUnplanReceipt(String(item?.title||'Work')); try{ await controller.flush(); renderReview(); qs('#mobile-week-summary').textContent=controller.summary(); }catch(error){ qs('#week-review-status').textContent=(error.message||'Week Ahead sync is unavailable.')+' Removal remains saved on this phone.'; } })); root.querySelectorAll('[data-week-pull-item]').forEach(button=>button.addEventListener('click',async event=>{ const id=event.currentTarget.dataset.weekPullItem,current=getTodayPlan?.(); if(!id||!Number.isInteger(current?.revision)||!(current.ids||[]).length||current.ids.length>=5)return; const source=value.days.find(day=>day.ids.includes(id)),estimate=Number(source?.estimates?.[id])||0; const todayMinutes=current.ids.reduce((total,itemId)=>total+(Number(current.estimates?.[itemId])||0),0); const projected=todayMinutes+estimate,capacity=Number(current.capacity_minutes)||0; const overCapacity=Boolean(capacity&&projected>capacity),item=getItem(id)||controller.item?.(id); if(overCapacity&&!confirmEarly('Add '+String(item?.title||'work')+' to Today?\n\nToday will be '+projected+' of '+capacity+' min · '+(projected-capacity)+' min over capacity.'))return; event.currentTarget.disabled=true; try{ const result=await controller.pullItem?.(id,current,operationId(),overCapacity); if(!result)return; getTodayPlan?.(result.today);todayWork.replace(result.today.ids); todayWork.replacePlanning({capacity_minutes:result.today.capacity_minutes??null,estimates:result.today.estimates||{}}); refresh();warm();renderReview(); qs('#mobile-week-summary').textContent=controller.summary(); qs('#week-review-status').textContent=result.sync_pending?String(item?.title||'Work')+' added to Today · sync pending.': String(item?.title||'Work')+' added to Today.'; }catch(error){ renderReview(); qs('#week-review-status').textContent=error?.code==='today_full'?'Today is full. Finish or move work before adding more.': ((error.message||'Today or Week Ahead changed.')+' Reopen Week Ahead and retry.'); } })); root.querySelectorAll('[data-week-open-item]').forEach(button=>button.addEventListener('click',event=>{ const id=button.dataset.weekOpenItem,item=getItem(id)||controller.item?.(id); if(item)openItem?.(item,event.currentTarget); })); root.querySelectorAll('[data-week-start-early]').forEach(button=>button.addEventListener('click',async event=>{ const day=value.days.find(item=>item.plan_date===event.currentTarget.dataset.weekStartEarly); const current=getTodayPlan?.(); if(!day||!Number.isInteger(current?.revision)||current.ids?.length)return; const capacity=Number(day.capacity_minutes)||0; const detail=day.ids.length+' item'+(day.ids.length===1?'':'s')+' · '+day.planned_minutes+(capacity?' of '+capacity:'')+' min'; if(!confirmEarly('Start '+day.label+' early?\n\n'+detail+' will move into Today.'))return; event.currentTarget.disabled=true; try{ const promoted=await controller.startEarly(day.plan_date,current.revision); if(!promoted)return; todayWork.replace(promoted.ids);todayWork.replacePlanning(promoted);refresh();warm(); qs('#mobile-week-summary').textContent=controller.summary(); qs('#my-work-action-status').textContent=day.label+' is now Today.'; finish(); }catch(error){ qs('#my-work-action-status').textContent=(error.message||'Week Ahead changed before it could start.')+' Reopen Week Ahead and review.'; event.currentTarget.disabled=false; } })); root.querySelectorAll('[data-week-edit-day]').forEach(button=>button.addEventListener('click',()=>editDay(button.dataset.weekEditDay))); renderPass(); } function editDay(planDate) { if(!controller.dates().some(item=>item.date===planDate))return false; reviewing=false;editingFromReview=true;selectedDate=planDate;setReviewMode(false); qs('#week-review').hidden=true; const back=qs('#back-to-week-review');if(back)back.hidden=false; renderDates();openPlanner(null,false); qs('#week-plan-progress')?.focus?.(); return true; } function editWeek() { overviewing=false;reviewing=false;selectedDate=controller.dates()[0].date;setReviewMode(false); qs('#week-review').hidden=true;qs('#confirm-week-plan').hidden=false; const edit=qs('#edit-week-plan');if(edit)edit.hidden=true; renderDates();openPlanner(null,false);qs('#week-plan-progress')?.focus?.();return true; } function returnToReview() { if(!editingFromReview)return false; const editedDate=selectedDate; editingFromReview=false;reviewing=true;selectedDate=null;setReviewMode(true); qs('#week-review').hidden=false; const back=qs('#back-to-week-review');if(back)back.hidden=true; renderDates();renderReview();openPlanner(null,false); qs('#week-review-days').querySelector(`[data-week-edit-day="${editedDate}"]`)?.focus?.(); return true; } async function open(trigger) { if(trigger)trigger.disabled=true;reviewing=false;overviewing=false;editingFromReview=false;setReviewMode(false);const reviewRoot=qs('#week-review');if(reviewRoot)reviewRoot.hidden=true; const back=qs('#back-to-week-review');if(back)back.hidden=true; selectedDate=null;renderDates();openPlanner(trigger); qs('#mobile-week-summary').textContent='Loading Week Ahead…'; try{ loadState=await controller.load(); if(!loadState.offline_snapshot&&controller.rememberItems){ const items={}; (loadState.days||controller.state?.().days||[]).flatMap(day=>day.ids||[]).forEach(id=>{const value=getItem(id);if(value)items[id]=value;}); controller.rememberItems(items); } renderOfflineState();qs('#mobile-week-summary').textContent=controller.summary();overviewing=true;reviewing=true; setReviewMode(true);reviewRoot.hidden=false;renderDates();renderReview();openPlanner(null,false);return true;} catch(error){renderOfflineState(null);qs('#mobile-week-summary').textContent='Unavailable · tap to retry';qs('#my-work-action-status').textContent=(error.message||'Week Ahead is unavailable.')+' Retry when connected.';return false;} finally{if(trigger)trigger.disabled=false;} } function save(plan){ const date=selectedDate,normalized=Array.isArray(plan)?{ids:plan,capacity_minutes:null,estimates:{}}:plan; if(reconciliation){ const preserved=reconciliation; controller.reconcile(preserved,normalized).then(result=>{ reconciliation=null;blockedReviewOpen=false; todayWork.replace(result.today.ids);todayWork.replacePlanning(result.today); refresh();warm();qs('#mobile-week-summary').textContent=controller.summary(); qs('#my-work-action-status').textContent='Today started from unfinished and Week Ahead work.'; }).catch(error=>{ qs('#my-work-action-status').textContent=(error.message||'Plans changed during review.')+' Reopen Start today’s plan.'; }); return true; } const staged=controller.stageDay(date,normalized); 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+'.';if(reviewing)renderReview();}) .catch(error=>{ const conflict=controller.conflict(); qs('#mobile-week-summary').textContent=conflict?'Conflict · review required':controller.summary(); qs('#my-work-action-status').textContent=conflict?'Another device changed Week Ahead. Both versions are preserved; choose each changed day.': (error.message||'Week Ahead sync is unavailable.')+' Saved on this phone · sync pending.'; if(conflict) openPlanner(null,false); }); return true; } function advance() { if(editingFromReview)return returnToReview(); const current=selectedDate&&controller.pass(selectedDate); if(current?.last){ reviewing=true;selectedDate=null;setReviewMode(true);qs('#week-review').hidden=false; renderDates();renderReview();openPlanner(null,false);qs('#confirm-week-plan').focus?.();return true; } if(!current?.next_date) return false; selectedDate=current.next_date;renderDates();openPlanner(null,false); const selected=qs('#week-plan-dates').querySelector(`[data-week-plan-date="${selectedDate}"]`); selected?.scrollIntoView({block:'nearest',inline:'center'});selected?.focus?.(); return true; } function confirm() { const value=controller.review(); if(!reviewing||!value.can_confirm||controller.pending()) return false; return true; } async function retire(item) { const staged=controller.retire?.(todayWork.identity({...item,kind:'issue'})); if(!staged)return false; const title=String(item?.title||'Completed work'); const refreshOverview=message=>{ if(overviewing||reviewing)renderReview(); qs('#week-review-status').textContent=message; qs('#edit-week-plan')?.focus?.(); }; try { await controller.flush(); refreshOverview(title+' removed and Week Ahead saved.'); return 'saved'; } catch(_error) { refreshOverview(title+' removed · sync pending.'); return 'pending'; } } function adoptPull(value,message) { if(!value?.today)return false; t(value.today);todayWork.replace(value.today.ids); todayWork.replacePlanning({capacity_minutes:value.today.capacity_minutes??null,estimates:value.today.estimates||{}}); refresh();warm(); qs('#mobile-week-summary').textContent=controller.summary(); qs('#my-work-action-status').textContent=message; return value; } function resumePull() { const restored=controller.resumePull?.(); return restored?adoptPull(restored,'Week-to-Today pull saved on this phone · sync pending.'):false; } async function flushPull() { const pendingPull=controller.pendingPull?.(); if(!pendingPull)return false; if(pendingPull.conflict)return adoptPull(pendingPull.current, 'Today or Week Ahead changed on another device. Saved pull needs review.'); try { const result=await controller.flushPull(); return adoptPull(result,result.conflict?'Today or Week Ahead changed on another device. Saved pull needs review.': 'Week Ahead work added to Today.'); } catch(error) { qs('#my-work-action-status').textContent=(error.message||'Week-to-Today sync is unavailable.')+' Saved on this phone · sync pending.'; return false; } } function finish(){reviewing=false;overviewing=false;setReviewMode(false);closeReflow();qs('#week-review').hidden=true;return true;} qs('#back-to-week-review')?.addEventListener('click',returnToReview); qs('#edit-week-plan')?.addEventListener('click',editWeek); qs('#open-week-reflow')?.addEventListener('click',openReflow); qs('#cancel-week-reflow')?.addEventListener('click',closeReflow); qs('#apply-week-reflow')?.addEventListener('click',applyReflow); qs('#retry-week-live')?.addEventListener('click',event=>open(event.currentTarget)); async function promote(plan){ try{await controller.load();const promoted=await controller.promote(plan.revision);if(!promoted)return false;blockedReviewOpen=false;todayWork.replace(promoted.ids);todayWork.replacePlanning({capacity_minutes:promoted.capacity_minutes??null,estimates:promoted.estimates||{}});refresh();warm();qs('#mobile-week-summary').textContent=controller.summary();qs('#my-work-action-status').textContent='Your saved Week Ahead plan is now Today.';return true;} catch(error){ if(error?.code==='week_today_in_progress'){ reconciliation={today:plan,week:controller.state()}; qs('#my-work-action-status').textContent='Review unfinished Today with the due Week Ahead work before starting.'; if(!blockedReviewOpen){blockedReviewOpen=true;openPlanner(null,false);} return false; } blockedReviewOpen=false; qs('#my-work-action-status').textContent=(error.message||'Week Ahead needs review before promotion.')+' Open Week Ahead to review.';return false; } } const workflow={open,save,advance,confirm,finish,promote,retire,resumePull,flushPull,editWeek,renderDates,renderReview,renderConflict,active:()=>Boolean(selectedDate)||reviewing||Boolean(reconciliation), reviewing:()=>reviewing,overviewing:()=>overviewing,selectedDate:()=>selectedDate, day:()=>reconciliationDay()||(selectedDate?controller.day(selectedDate):(overviewing?{ids:[]}:null)), copy:()=>reconciliation?{title:"Start today's plan",heading:'Unfinished Today + due Week Ahead',available:'Available today',build:'Build combined Today'}: (overviewing?{title:'Week Ahead',heading:'Seven-day overview',available:'Planned work',build:'Edit week'}: (selectedDate?{title:'Plan Week Ahead',heading:selectedDate+', in order',available:'Available this day',build:'Build this day'}:null)), clear(){selectedDate=null;reviewing=false;overviewing=false;editingFromReview=false;reconciliation=null;setReviewMode(false);qs('#week-review').hidden=true; const back=qs('#back-to-week-review');if(back)back.hidden=true;renderDates();}}; globalThis.addEventListener?.('stackchain:issue-closed',event=>retire(event.detail)); if(typeof weekCalendarImport!=='undefined')weekCalendarImport.mount(controller,workflow,qs); if(x)mountTodayWeekReschedule({ qs,week:controller,getToday:t,refresh:r,warm:w,api:controller.request,currentTarget:x,...controller.reschedule(), closeActions:()=>qs('#mobile-today-actions').close(), continueToday:()=>qs('[data-work-session-next]').click(), announce:message=>{qs('#my-work-action-status').textContent=message;}, adoptToday:value=>{ t(value);todayWork.replace(value.ids); todayWork.replacePlanning({capacity_minutes:value.capacity_minutes??null,estimates:value.estimates||{}}); }, }); return workflow; } if(typeof module!=='undefined'&&module.exports){ module.exports=createWeekPlan; module.exports.Workflow=createWeekPlanWorkflow; }