feat: keep Week Ahead available offline (Closes #1218)
CI / lint (pull_request) Successful in 3m40s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 5m26s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy committed 2026-08-21 10:43:59 +00:00
1 parent bb6a4c4305
commit 37950bd0ed
5 files changed
+337 -26

No files matched your search

+2
View File
@@ -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; }
+2
View File
@@ -509,6 +509,8 @@
<div class="small">Seven-day overview</div>
<h2 id="week-review-title">Week Ahead</h2>
<p class="small muted">See what is next and check each day’s load. Edit only when you are ready.</p>
<p class="week-offline-snapshot" id="week-offline-snapshot" role="status" aria-live="polite" hidden></p>
<button id="retry-week-live" type="button" hidden>Retry live Week Ahead</button>
<button id="open-week-capacity-import" type="button">Set capacity from calendar</button>
<div class="week-review-duplicates" id="week-review-duplicates" role="alert" hidden></div>
<div class="week-review-days" id="week-review-days"></div>
+107 -26
View File
@@ -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=>'<option value="'+escapeAttribute(day.plan_date)+'">'+escapeHtml(day.label)+'</option>').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 '<div class="week-review-item-copy"><strong>Work details unavailable</strong><small>'+escapeHtml(String(id).slice(0,96))+'</small></div>';
const kind=item.kind==='pull'?'Pull request':'Issue';
const reference=item.repository&&item.number!=null?item.repository+' #'+item.number:kind;
@@ -436,8 +509,8 @@ function createWeekPlanWorkflow({controller,qs,getLogin,getItem=()=>null,openPla
'</select></label><button type="button" data-week-move="'+escapeAttribute(id)+'">Move</button></div>';
const items=day.ids.length?'<ul>'+day.ids.map(id=>'<li>'+itemMarkup(id,day)+move(id)+'</li>').join('')+'</ul>':
'<p class="small muted">Nothing planned.</p>';
return '<article class="week-review-day'+(day.overloaded?' is-overloaded':'')+(day===nextUp?' is-next-up':'')+'"><header><h3>'+escapeHtml(day.label)+(day===nextUp?' <span class="week-next-up">Next up</span>':'')+'</h3><button type="button" data-week-edit-day="'+
escapeAttribute(day.plan_date)+'" aria-label="Edit '+escapeAttribute(day.label)+'">Edit day</button></header><p class="week-review-load">'+
const edit=readOnly?'':'<button type="button" data-week-edit-day="'+escapeAttribute(day.plan_date)+'" aria-label="Edit '+escapeAttribute(day.label)+'">Edit day</button>';
return '<article class="week-review-day'+(day.overloaded?' is-overloaded':'')+(day===nextUp?' is-next-up':'')+'"><header><h3>'+escapeHtml(day.label)+(day===nextUp?' <span class="week-next-up">Next up</span>':'')+'</h3>'+edit+'</header><p class="week-review-load">'+
escapeHtml(load)+'</p>'+items+'</article>';
}).join('');
duplicates.hidden=!value.duplicates.length;
@@ -447,14 +520,14 @@ function createWeekPlanWorkflow({controller,qs,getLogin,getItem=()=>null,openPla
const pending=Boolean(controller.pending?.());
confirm.disabled=!value.can_confirm||pending;
const blocker=value.blockers?.[0];
qs('#week-review-status').textContent=overviewing?'Week Ahead overview · '+(pending?'sync pending.':'no changes made.'):(value.duplicates.length?'Duplicate work must be moved to one date.':
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;
const editWeek=qs('#edit-week-plan');if(editWeek){editWeek.hidden=!overviewing;editWeek.textContent=nextUp?'Edit week':'Plan Week Ahead';}
):'Week Ahead is balanced and ready.')));
confirm.hidden=overviewing||readOnly;
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;
@@ -491,14 +564,21 @@ function createWeekPlanWorkflow({controller,qs,getLogin,getItem=()=>null,openPla
return true;
}
async function open(trigger) {
trigger.disabled=true;reviewing=false;overviewing=false;editingFromReview=false;setReviewMode(false);const reviewRoot=qs('#week-review');if(reviewRoot)reviewRoot.hidden=true;
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{await controller.load();qs('#mobile-week-summary').textContent=controller.summary();overviewing=true;reviewing=true;
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){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;}
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;
@@ -549,6 +629,7 @@ function createWeekPlanWorkflow({controller,qs,getLogin,getItem=()=>null,openPla
function finish(){reviewing=false;overviewing=false;setReviewMode(false);qs('#week-review').hidden=true;return true;}
qs('#back-to-week-review')?.addEventListener('click',returnToReview);
qs('#edit-week-plan')?.addEventListener('click',editWeek);
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){
@@ -564,7 +645,7 @@ function createWeekPlanWorkflow({controller,qs,getLogin,getItem=()=>null,openPla
}
const workflow={open,save,advance,confirm,finish,promote,editWeek,renderDates,renderReview,renderConflict,active:()=>Boolean(selectedDate)||reviewing||Boolean(reconciliation),
reviewing:()=>reviewing,overviewing:()=>overviewing,selectedDate:()=>selectedDate,
day:()=>reconciliationDay()||(selectedDate?controller.day(selectedDate):null),
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)),