function createTomorrowPlan({fetchJson,localDate,timeZone,storage,getLogin}={}) { let plan={revision:0,ids:[],capacity_minutes:null,estimates:{}}; let flushing=null; let lastConflict=null; const storagePrefix='stackchain.tomorrow-sync.v1.'; const state=()=>({...plan,ids:[...plan.ids],estimates:{...plan.estimates}}); 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?.ids)? {...value,ids:[...value.ids],estimates:{...(value.estimates||{})},sync_pending:true}:false; } catch (_error) { return false; } } function adopt(value) { if (!Number.isInteger(value?.revision)||!Array.isArray(value?.ids)) return false; plan={revision:value.revision,ids:[...value.ids],capacity_minutes:value.capacity_minutes??null, estimates:{...(value.estimates||{})},...(value.plan_date?{plan_date:value.plan_date,timezone:value.timezone||null}:{})}; return state(); } function nextLocalDate() { const [y,m,d]=localDate().split('-').map(Number); return new Date(Date.UTC(y,m-1,d+1)).toISOString().slice(0,10); } async function load(){ const queued=pending(); return queued?(plan={...queued},state()):adopt(await fetchJson('api/v1/tomorrow')); } function stage(value) { const key=storageKey(); if(!key||!storage) return false; const queued={base_revision:plan.revision,revision:plan.revision,ids:[...(value.ids||[])], capacity_minutes:value.capacity_minutes??null,estimates:{...(value.estimates||{})}, plan_date:nextLocalDate(),timezone:timeZone(),sync_pending:true}; try { storage.setItem(key,JSON.stringify(queued)); } catch (_error) { return false; } lastConflict=null; plan=queued; return state(); } function deliveryBody(value) { return {base_revision:value.base_revision,ids:[...value.ids], capacity_minutes:value.capacity_minutes??null,estimates:{...(value.estimates||{})}, plan_date:value.plan_date,timezone:value.timezone}; } 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/tomorrow',{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/tomorrow'); lastConflict={key,local:pending(),remote:{...remote,ids:[...remote.ids],estimates:{...(remote.estimates||{})}}}; } throw error; }).finally(()=>{flushing=null;}); return flushing; } async function keepLocal() { if(!lastConflict||lastConflict.key!==storageKey()) return false; const conflictKey=lastConflict.key; const local={...lastConflict.local,ids:[...lastConflict.local.ids],estimates:{...lastConflict.local.estimates}}; const body=deliveryBody({...local,base_revision:lastConflict.remote.revision}); try { const saved=await fetchJson('api/v1/tomorrow',{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/tomorrow'); lastConflict={key:conflictKey,local,remote:{...remote,ids:[...remote.ids],estimates:{...(remote.estimates||{})}}}; } throw error; } } function useRemote() { if(!lastConflict||lastConflict.key!==storageKey()) return false; const remote=lastConflict.remote; const key=storageKey(); if(key&&storage) storage.removeItem(key); const adopted=adopt(remote); lastConflict=null; return adopted; } async function save(value) { const body={base_revision:plan.revision,ids:[...(value.ids||[])], capacity_minutes:value.capacity_minutes??null,estimates:{...(value.estimates||{})}, plan_date:nextLocalDate(),timezone:timeZone()}; return adopt(await fetchJson('api/v1/tomorrow',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)})); } async function promote(today_revision) { if (pending()||!plan.plan_date||plan.plan_date>localDate()||!plan.ids.length) return false; const promotion_id=`tomorrow-${plan.plan_date}-r${plan.revision}`; return fetchJson('api/v1/tomorrow/promote',{method:'POST',headers:{'Content-Type':'application/json'}, body:JSON.stringify({promotion_id,tomorrow_revision:plan.revision,today_revision})}); } function millisecondsUntilNextDay() { const now=Date.now(); const zone=timeZone(); let formatter; try { formatter=new Intl.DateTimeFormat('en-CA',{timeZone:zone,year:'numeric',month:'2-digit',day:'2-digit', hour:'2-digit',minute:'2-digit',second:'2-digit',hourCycle:'h23'}); } catch (_error) { const next=new Date(now); next.setHours(24,0,0,250); return Math.max(1,next.getTime()-now); } const zoned=value=>Object.fromEntries(formatter.formatToParts(new Date(value)) .filter(part=>part.type!=='literal').map(part=>[part.type,Number(part.value)])); const current=zoned(now); const targetWall=Date.UTC(current.year,current.month-1,current.day+1); let candidate=targetWall; for(let attempt=0;attempt<2;attempt+=1){ const parts=zoned(candidate); const offset=Date.UTC(parts.year,parts.month-1,parts.day,parts.hour,parts.minute,parts.second)-candidate; candidate=targetWall-offset; } return Math.max(1,candidate-now+250); } function summary(value=plan) { const ids=Array.isArray(value?.ids)?value.ids:[]; const suffix=value?.sync_pending?' · sync pending':''; if(!ids.length) return 'Nothing planned'+suffix; const label=`${ids.length} planned`; const estimates=value.estimates||{}; const estimated=ids.reduce((total,id)=>total+(Number(estimates[id])||0),0); const capacity=Number(value.capacity_minutes)||0; return (estimated&&capacity?`${label} · ${estimated} of ${capacity} min`:label)+suffix; } function startLifecycle({windowObject,documentObject,check,setTimer=setTimeout,clearTimer=clearTimeout, nextDelay=millisecondsUntilNextDay}={}) { let flight=null; let timer=null; const schedule=()=>{ if(timer!==null) clearTimer(timer); timer=setTimer(run,nextDelay()); }; function run(){ if(!flight) flight=Promise.resolve().then(check).finally(()=>{flight=null;schedule();}); return flight; } windowObject?.addEventListener?.('online',run); documentObject?.addEventListener?.('visibilitychange',()=>documentObject.hidden?false:run()); run(); return {run,stop(){if(timer!==null)clearTimer(timer);timer=null;}}; } function conflict() { return lastConflict?.key===storageKey()?{local:{...lastConflict.local,ids:[...lastConflict.local.ids],estimates:{...lastConflict.local.estimates}}, remote:{...lastConflict.remote,ids:[...lastConflict.remote.ids],estimates:{...lastConflict.remote.estimates}}}:null; } return {adopt,load,save,stage,pending,flush,conflict,keepLocal,useRemote,promote,state,summary,nextLocalDate,startLifecycle}; } if(typeof module!=='undefined'&&module.exports)module.exports=createTomorrowPlan;