function createWeekPlan({fetchJson,localDate,timeZone}={}) { let week={revision:0,timezone:null,days:[]}; let lastConflict=null; 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)}); 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() { return adopt(await fetchJson('api/v1/week')); } 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() { return lastConflict?{ local:{...lastConflict.local,days:lastConflict.local.days.map(cloneDay)}, remote:{...lastConflict.remote,days:lastConflict.remote.days.map(cloneDay)}, }:null; } async function promote(todayRevision) { 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); return planned.length?`${items} item${items===1?'':'s'} across ${planned.length} day${planned.length===1?'':'s'}`:'Nothing planned'; } return {adopt,state,dates,day,load,saveDay,conflict,promote,summary}; } function createWeekPlanWorkflow({controller,qs,getLogin,openPlanner,escapeHtml,escapeAttribute, todayWork,refresh,warm}={}) { let selectedDate=null; function renderDates() { const root=qs('#week-plan-dates'); root.hidden=!selectedDate; 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); })); } async function open(trigger) { if(!getLogin()){qs('#my-work-action-status').textContent='Planning is unavailable until your operator identity is restored.';return false;} trigger.disabled=true;qs('#mobile-week-summary').textContent='Loading Week Ahead…'; try{await controller.load();selectedDate=controller.dates()[0].date;qs('#mobile-week-summary').textContent=controller.summary();openPlanner(trigger);return true;} catch(error){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{trigger.disabled=false;} } function save(plan){ const date=selectedDate,normalized=Array.isArray(plan)?{ids:plan,capacity_minutes:null,estimates:{}}:plan; controller.saveDay(date,normalized).then(()=>{qs('#mobile-week-summary').textContent=controller.summary();qs('#my-work-action-status').textContent=(normalized.ids.length?'Week Ahead saved for ':'Week Ahead cleared for ')+date+'.';}) .catch(error=>{qs('#mobile-week-summary').textContent=controller.conflict()?'Conflict · review required':'Unavailable · tap to retry';qs('#my-work-action-status').textContent=controller.conflict()?'Another device changed Week Ahead. Both versions are preserved; reopen Week Ahead to review.':(error.message||'Week Ahead could not be saved.')+' Retry when connected.';}); return true; } async function promote(plan){ try{await controller.load();const promoted=await controller.promote(plan.revision);if(!promoted)return 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){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, 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();}}; } if(typeof module!=='undefined'&&module.exports)module.exports=createWeekPlan;