stackchain-dashboard/frontend/week-plan.js
timmy 47cad77312
All checks were successful
CI / lint (pull_request) Successful in 3m20s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 4m19s
CI / release-candidate (pull_request) Has been skipped
feat: keep Week Ahead plans through offline saves (Closes #1180)
2026-08-20 13:40:33 +00:00

203 lines
11 KiB
JavaScript

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,
}: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))};
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 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');
lastConflict={key,local:pending(),remote:{revision:remote.revision,timezone:remote.timezone||null,
days:remote.days.map(cloneDay)}};
}
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() {
return 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;
}
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,keepLocal,useRemote,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=>'<button type="button" data-week-plan-date="'+
escapeAttribute(item.date)+'"'+(item.date===selectedDate?' aria-current="date"':'')+'><strong>'+escapeHtml(item.label)+
'</strong><small>'+escapeHtml(controller.day(item.date).ids.length+' planned')+'</small></button>').join('');
root.querySelectorAll('[data-week-plan-date]').forEach(button=>button.addEventListener('click',()=>{
selectedDate=button.dataset.weekPlanDate;renderDates();openPlanner(null,false);
}));
}
async function open(trigger) {
trigger.disabled=true;selectedDate=controller.dates()[0].date;renderDates();openPlanner(trigger);
qs('#mobile-week-summary').textContent='Loading Week Ahead…';
try{await controller.load();qs('#mobile-week-summary').textContent=controller.summary();openPlanner(null,false);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;
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+'.';})
.catch(error=>{qs('#mobile-week-summary').textContent=controller.conflict()?'Conflict · review required':controller.summary();qs('#my-work-action-status').textContent=controller.conflict()?'Another device changed Week Ahead. Both versions are preserved; open Week Ahead to choose one.':(error.message||'Week Ahead sync is unavailable.')+' Saved on this phone · sync pending.';});
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;