'+escapeHtml(day.label)+(day===nextUp?' ':'')+'
'+
+ const edit=readOnly?'':'';
+ return ' '+
escapeHtml(load)+''+escapeHtml(day.label)+(day===nextUp?' ':'')+'
'+edit+'
diff --git a/frontend/dashboard.css b/frontend/dashboard.css index 0b278c2..e1e0f6a 100644 --- a/frontend/dashboard.css +++ b/frontend/dashboard.css @@ -338,6 +338,8 @@ textarea { resize: vertical; min-height: 120px; } .week-review-day button { min-height:44px; } .week-review-duplicates { margin:12px 0; padding:12px; border:1px solid #f59e0b; border-radius:10px; background:#2a1c12; } .week-review-status { min-height:1.4em; margin:10px 0; } +.week-offline-snapshot { margin:10px 0 8px; padding:10px 12px; border:1px solid #f59e0b; border-radius:10px; background:#2a1c12; color:#fde68a; overflow-wrap:anywhere; } +#retry-week-live { min-height:44px; width:100%; margin-bottom:8px; border-color:#f59e0b; } #confirm-week-plan { width:100%; min-height:48px; position:sticky; bottom:0; } #edit-week-plan { width:100%; min-height:48px; position:sticky; bottom:0; } #open-week-capacity-import { width:100%; min-height:44px; margin:8px 0 12px; } diff --git a/frontend/index.html b/frontend/index.html index 7be1c29..d7acd3d 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -509,6 +509,8 @@
See what is next and check each day’s load. Edit only when you are ready.
+ + diff --git a/frontend/week-plan.js b/frontend/week-plan.js index d1b0fa1..cacbba0 100644 --- a/frontend/week-plan.js +++ b/frontend/week-plan.js @@ -1,18 +1,56 @@ -function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin}={}) { +function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin,now=()=>Date.now()}={}) { let week={revision:0,timezone:null,days:[]}; let flushing=null; let lastConflict=null; + let offlineSnapshot=false; + let refreshedAt=null; + let confirmedItems={}; const storagePrefix='stackchain.week-sync.v1.'; + const confirmedPrefix='stackchain.week-confirmed.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), + 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 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={}) { + const ids=new Set(week.days.flatMap(day=>day.ids||[])),sanitized={}; + ids.forEach(id=>{ + const value=items[id]; + if(!value)return; + sanitized[id]={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}; + }); + confirmedItems=sanitized; + return persistConfirmed(); + } function pending() { const key=storageKey(); if(!key||!storage) return false; @@ -31,9 +69,15 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin}={}) { 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; lastConflict=null; return state(); } + function adoptConfirmed(value) { + const adopted=adopt(value); + if(adopted)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); @@ -57,10 +101,21 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin}={}) { } async function load() { const queued=pending(); - if(queued){week=queued;return state();} - return adopt(await fetchJson('api/v1/week')); + if(queued){week=queued;offlineSnapshot=false;return state();} + try { + adopt(await fetchJson('api/v1/week')); + const cached=readConfirmed();confirmedItems=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=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, @@ -189,7 +244,7 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin}={}) { 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())adopt(saved); + if(!pending())adoptConfirmed(saved); return saved; } lastConflict={key,local,remote:{revision:remote.revision,timezone:remote.timezone||null, @@ -202,7 +257,7 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin}={}) { } const current=pending(); if(current&&sameBody(current,body)){ - storage.removeItem(key);adopt(saved);return saved; + storage.removeItem(key);adoptConfirmed(saved);return saved; } if(current){ try { @@ -213,12 +268,13 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin}={}) { } catch(_error){return saved;} return deliver(); } - adopt(saved);return saved; + adoptConfirmed(saved);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, @@ -226,7 +282,7 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin}={}) { }]).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)})); + 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'); @@ -273,7 +329,7 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin}={}) { 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); + if(!pending()) adoptConfirmed(saved); lastConflict=null; return saved; } catch(error) { @@ -294,7 +350,7 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin}={}) { 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); + if(!pending()) adoptConfirmed(saved); lastConflict=null; return saved; } catch(error) { @@ -310,12 +366,12 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin}={}) { if(!lastConflict||lastConflict.key!==storageKey()) return false; const remote=lastConflict.remote; storage.removeItem(storageKey()); - const adopted=adopt(remote); + const adopted=adoptConfirmed(remote); lastConflict=null; return adopted; } async function promote(todayRevision) { - if(pending()||conflict()) return false; + 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({ @@ -324,7 +380,7 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin}={}) { })}); } async function reconcilePromotion(preserved,selection) { - if(pending()||conflict()) return false; + 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({ @@ -332,7 +388,7 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin}={}) { 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||{})}, })}); - adopt(result.week); + adoptConfirmed(result.week); return result; } function summary() { @@ -342,7 +398,8 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin}={}) { return label+(pending()?' · sync pending':''); } return {adopt,state,dates,day,pass,load,saveDay,stageDay,stageCapacities,review,move,placement,place,pending,flush,conflict,chooseDay,saveMerged, - keepLocal,useRemote,promote,reconcile:reconcilePromotion,summary}; + keepLocal,useRemote,promote,reconcile:reconcilePromotion,summary,rememberItems,item:id=>confirmedItems[id]||null, + offline:()=>offlineSnapshot}; } function createWeekPlanWorkflow({controller,qs,getLogin,getItem=()=>null,openPlanner,setReviewMode=()=>{},escapeHtml,escapeAttribute, todayWork,refresh,warm}={}) { @@ -352,6 +409,21 @@ function createWeekPlanWorkflow({controller,qs,getLogin,getItem=()=>null,openPla let editingFromReview=false; let blockedReviewOpen=false; let reconciliation=null; + let loadState=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); @@ -420,10 +492,11 @@ function createWeekPlanWorkflow({controller,qs,getLogin,getItem=()=>null,openPla } function renderReview() { const value=controller.review(),root=qs('#week-review-days'),duplicates=qs('#week-review-duplicates'); + const readOnly=Boolean(controller.offline?.()); const nextUp=overviewing?value.days.find(day=>day.ids.length):null; const destinations=value.days.map(day=>'').join(''); const itemMarkup=(id,day)=>{ - const item=getItem(id),estimate=Number(day.estimates?.[id])||0; + const item=getItem(id)||controller.item?.(id),estimate=Number(day.estimates?.[id])||0; if(!item)return 'Nothing planned.
'; - return ''+
+ const edit=readOnly?'':'';
+ return ' '+
escapeHtml(load)+''+escapeHtml(day.label)+(day===nextUp?' ':'')+'
'+edit+'