feat: keep Week Ahead available offline (Closes #1218)
This commit is contained in:
parent
bb6a4c4305
commit
37950bd0ed
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
|
|
|
|||
|
|
@ -287,3 +287,72 @@ def test_release_artifact_reconciles_only_the_week_day_changed_on_both_devices(
|
|||
fake.shutdown()
|
||||
fake.server_close()
|
||||
thread.join(timeout=5)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("width", "height"), [(320, 568), (390, 844)])
|
||||
def test_release_artifact_reads_the_confirmed_week_offline_then_retries_live(
|
||||
tmp_path: Path, width: int, height: int
|
||||
):
|
||||
archives = sorted((ROOT / "dist").glob("stackchain-dashboard-*.tar.gz"))
|
||||
assert len(archives) == 1, "browser job must download exactly one assembled release archive"
|
||||
fake = FakeGiteaServer(("127.0.0.1", 0))
|
||||
thread = threading.Thread(target=fake.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
online = True
|
||||
try:
|
||||
with release_server(archives[0], tmp_path, f"http://127.0.0.1:{fake.server_port}") as origin, sync_playwright() as playwright:
|
||||
browser = playwright.chromium.launch(args=["--ignore-certificate-errors"])
|
||||
page = browser.new_page(viewport={"width": width, "height": height}, timezone_id="UTC")
|
||||
page_errors: list[str] = []
|
||||
page.on("pageerror", lambda error: page_errors.append(str(error)))
|
||||
|
||||
def week_route(route):
|
||||
if not online:
|
||||
route.fulfill(status=503, content_type="application/json", body='{"message":"offline"}')
|
||||
return
|
||||
route.fulfill(status=200, content_type="application/json", body=json.dumps({
|
||||
"revision": 7, "timezone": "UTC", "days": [{
|
||||
"plan_date": (date.today() + timedelta(days=1)).isoformat(),
|
||||
"ids": ["issue:acme/mobile:41:"], "capacity_minutes": 60,
|
||||
"estimates": {"issue:acme/mobile:41:": 30},
|
||||
}],
|
||||
}))
|
||||
|
||||
page.route("**/api/v1/week", week_route)
|
||||
page.goto(origin + "/", wait_until="networkidle")
|
||||
page.locator('input[name="device_label"]').fill("Offline Week Ahead phone")
|
||||
page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
|
||||
page.locator("#submit-sign-in").click()
|
||||
page.wait_for_url(origin + "/", wait_until="networkidle")
|
||||
page.locator('[data-mobile-task="queues"]').click()
|
||||
page.locator('[data-mobile-queue="week"]').click()
|
||||
expect(page.locator("#week-review-days")).to_contain_text("Ship mobile capture")
|
||||
page.locator("#cancel-plan-today").click()
|
||||
|
||||
online = False
|
||||
page.locator('[data-mobile-task="queues"]').click()
|
||||
page.locator('[data-mobile-queue="week"]').click()
|
||||
notice = page.locator("#week-offline-snapshot")
|
||||
expect(notice).to_be_visible()
|
||||
expect(notice).to_contain_text("Offline snapshot")
|
||||
expect(page.locator("#week-review-days")).to_contain_text("Ship mobile capture")
|
||||
expect(page.locator("[data-week-edit-day]")).to_have_count(0)
|
||||
expect(page.locator("#edit-week-plan")).to_be_hidden()
|
||||
expect(page.locator("#open-week-capacity-import")).to_be_hidden()
|
||||
retry = page.locator("#retry-week-live")
|
||||
expect(retry).to_be_visible()
|
||||
bounds = retry.bounding_box()
|
||||
assert bounds and bounds["height"] >= 44
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
|
||||
online = True
|
||||
retry.click()
|
||||
expect(notice).to_be_hidden()
|
||||
expect(page.locator("#edit-week-plan")).to_be_visible()
|
||||
expect(page.locator("[data-week-edit-day]")).to_have_count(7)
|
||||
assert not page_errors, f"offline Week Ahead recovery raised: {page_errors}"
|
||||
browser.close()
|
||||
finally:
|
||||
fake.shutdown()
|
||||
fake.server_close()
|
||||
thread.join(timeout=5)
|
||||
|
|
|
|||
|
|
@ -260,6 +260,53 @@ console.log(JSON.stringify({reviewing:workflow.reviewing(),reviewMode,writes,ope
|
|||
assert result["editWeekLabel"] == "Edit week"
|
||||
|
||||
|
||||
def test_week_workflow_marks_a_confirmed_fallback_read_only_and_retries_live_data():
|
||||
result = run_controller("""
|
||||
const createWorkflow=createWeekPlan.Workflow;
|
||||
const dates=['2026-08-21','2026-08-22','2026-08-23','2026-08-24','2026-08-25','2026-08-26','2026-08-27'];
|
||||
const elements=new Map();
|
||||
const makeElement=()=>({hidden:false,textContent:'',disabled:false,innerHTML:'',dataset:{},listeners:{},
|
||||
addEventListener(name,listener){this.listeners[name]=listener;},focus:()=>{},querySelectorAll:()=>[],querySelector:()=>null});
|
||||
const dateRoot=makeElement();Object.defineProperty(dateRoot,'innerHTML',{set(value){this.value=value;this.buttons=[];},get(){return this.value||'';}});
|
||||
elements.set('#week-plan-dates',dateRoot);
|
||||
const reviewDays=makeElement();Object.defineProperty(reviewDays,'innerHTML',{set(value){this.value=value;this.editButtons=[...value.matchAll(/data-week-edit-day/g)];},get(){return this.value||'';}});
|
||||
elements.set('#week-review-days',reviewDays);
|
||||
for(const selector of ['#mobile-week-summary','#my-work-action-status','#week-plan-progress','#save-today-plan','#week-review',
|
||||
'#week-review-duplicates','#confirm-week-plan','#week-review-status','#back-to-week-review','#edit-week-plan',
|
||||
'#week-offline-snapshot','#retry-week-live','#open-week-capacity-import']) if(!elements.has(selector))elements.set(selector,makeElement());
|
||||
let offline=true,loads=0,remembered=null;
|
||||
const review={days:dates.map((date,index)=>({plan_date:date,label:'Day '+(index+1),ids:index===0?['issue:r:42:']:[],
|
||||
capacity_minutes:60,estimates:index===0?{'issue:r:42:':30}:{},planned_minutes:index===0?30:0,overloaded:false})),
|
||||
duplicates:[],blockers:[],can_confirm:true};
|
||||
const controller={dates:()=>dates.map((date,index)=>({date,label:'Day '+(index+1)})),day:date=>review.days.find(day=>day.plan_date===date),
|
||||
pass:()=>({}),load:async()=>{loads+=1;const value={offline_snapshot:offline,refreshed_at:'2026-08-20T12:34:00.000Z'};offline=false;return value;},
|
||||
summary:()=> '1 item across 1 day',review:()=>review,pending:()=>false,offline:()=>loads===1,
|
||||
item:id=>id?{kind:'issue',title:'Cached work title',repository:'r',number:42}:null,
|
||||
rememberItems:value=>{remembered=value;},move:()=>true,flush:async()=>({})};
|
||||
const workflow=createWorkflow({controller,qs:selector=>elements.get(selector),getLogin:()=> 'timmy',getItem:()=>null,
|
||||
openPlanner:()=>{},setReviewMode:()=>{},escapeHtml:value=>value,escapeAttribute:value=>value,
|
||||
todayWork:{replace:()=>{},replacePlanning:()=>{}},refresh:()=>{},warm:()=>{}});
|
||||
await workflow.open({disabled:false});
|
||||
const fallback={noticeHidden:elements.get('#week-offline-snapshot').hidden,
|
||||
notice:elements.get('#week-offline-snapshot').textContent,markup:reviewDays.innerHTML,
|
||||
editWeekHidden:elements.get('#edit-week-plan').hidden,capacityHidden:elements.get('#open-week-capacity-import').hidden};
|
||||
await elements.get('#retry-week-live').listeners.click({currentTarget:elements.get('#retry-week-live')});
|
||||
console.log(JSON.stringify({fallback,loads,live:{noticeHidden:elements.get('#week-offline-snapshot').hidden,
|
||||
editWeekHidden:elements.get('#edit-week-plan').hidden,capacityHidden:elements.get('#open-week-capacity-import').hidden},remembered}));
|
||||
""")
|
||||
|
||||
assert result["fallback"]["noticeHidden"] is False
|
||||
assert "Offline snapshot" in result["fallback"]["notice"]
|
||||
assert "Aug" in result["fallback"]["notice"]
|
||||
assert "Cached work title" in result["fallback"]["markup"]
|
||||
assert "data-week-edit-day" not in result["fallback"]["markup"]
|
||||
assert result["fallback"]["editWeekHidden"] is True
|
||||
assert result["fallback"]["capacityHidden"] is True
|
||||
assert result["loads"] == 2
|
||||
assert result["live"] == {"noticeHidden": True, "editWeekHidden": False, "capacityHidden": False}
|
||||
assert result["remembered"] == {}
|
||||
|
||||
|
||||
def test_week_workflow_opens_review_after_day_seven_and_blocks_duplicate_confirmation():
|
||||
result = run_controller("""
|
||||
const createWorkflow=createWeekPlan.Workflow;
|
||||
|
|
@ -492,6 +539,105 @@ console.log(JSON.stringify({queued,restored,requests,otherError,keys:[...values.
|
|||
assert result["keys"] == ["stackchain.week-sync.v1.timmy"]
|
||||
|
||||
|
||||
def test_week_controller_falls_back_to_an_account_bound_confirmed_read_snapshot():
|
||||
result = run_controller("""
|
||||
const values=new Map();
|
||||
const storage={getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)};
|
||||
let login='Timmy',online=true,revision=7;
|
||||
const options={storage,getLogin:()=>login,now:()=>Date.parse('2026-08-20T12:34:00Z'),
|
||||
fetchJson:async()=>{if(!online)throw new Error('offline');return {revision,timezone:'UTC',days:[
|
||||
{plan_date:'2026-08-21',ids:['issue:r:42:'],capacity_minutes:60,estimates:{'issue:r:42:':30}}
|
||||
]};},localDate:()=> '2026-08-20',timeZone:()=> 'UTC'};
|
||||
const live=createWeekPlan(options);
|
||||
const loaded=await live.load();
|
||||
live.rememberItems({'issue:r:42:':{kind:'issue',title:'Ship offline overview',repository:'r',number:42,secret:'drop'}});
|
||||
online=false;
|
||||
const fallback=createWeekPlan(options);
|
||||
const restored=await fallback.load();
|
||||
login='alexander';
|
||||
let otherError='';
|
||||
try { await createWeekPlan(options).load(); } catch(error) { otherError=error.message; }
|
||||
login='timmy';online=true;revision=8;
|
||||
const refreshed=await fallback.load();
|
||||
console.log(JSON.stringify({loaded,restored,item:fallback.item('issue:r:42:'),otherError,refreshed,
|
||||
keys:[...values.keys()],snapshot:JSON.parse(values.get('stackchain.week-confirmed.v1.timmy'))}));
|
||||
""")
|
||||
|
||||
assert result["loaded"]["offline_snapshot"] is False
|
||||
assert result["restored"]["offline_snapshot"] is True
|
||||
assert result["restored"]["refreshed_at"] == "2026-08-20T12:34:00.000Z"
|
||||
assert result["restored"]["days"][0]["ids"] == ["issue:r:42:"]
|
||||
assert result["item"] == {
|
||||
"kind": "issue", "title": "Ship offline overview", "repository": "r", "number": 42,
|
||||
}
|
||||
assert result["otherError"] == "offline"
|
||||
assert result["refreshed"]["revision"] == 8
|
||||
assert result["refreshed"]["offline_snapshot"] is False
|
||||
assert result["keys"] == ["stackchain.week-confirmed.v1.timmy"]
|
||||
assert "secret" not in result["snapshot"]["items"]["issue:r:42:"]
|
||||
|
||||
|
||||
def test_week_controller_does_not_use_a_confirmed_snapshot_after_authentication_fails():
|
||||
result = run_controller("""
|
||||
const values=new Map();
|
||||
const storage={getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)};
|
||||
let denied=false;
|
||||
const options={storage,getLogin:()=> 'timmy',now:()=>1,localDate:()=> '2026-08-20',timeZone:()=> 'UTC',fetchJson:async()=>{
|
||||
if(denied){const error=new Error('sign in');error.status=401;throw error;}
|
||||
return {revision:7,timezone:'UTC',days:[]};}};
|
||||
await createWeekPlan(options).load();denied=true;
|
||||
let status=0;
|
||||
try { await createWeekPlan(options).load(); } catch(error) { status=error.status; }
|
||||
console.log(JSON.stringify({status}));
|
||||
""")
|
||||
|
||||
assert result["status"] == 401
|
||||
|
||||
|
||||
def test_week_controller_never_mutates_an_offline_confirmed_snapshot():
|
||||
result = run_controller("""
|
||||
const values=new Map();
|
||||
const storage={getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)};
|
||||
let online=true,requests=[];
|
||||
const options={storage,getLogin:()=> 'timmy',now:()=>1,localDate:()=> '2026-08-20',timeZone:()=> 'UTC',
|
||||
fetchJson:async(url,request={})=>{requests.push(request.method||'GET');if(!online)throw new Error('offline');return {
|
||||
revision:7,timezone:'UTC',days:[{plan_date:'2026-08-21',ids:['one'],capacity_minutes:60,estimates:{one:30}}]};}};
|
||||
await createWeekPlan(options).load();online=false;
|
||||
const week=createWeekPlan(options);await week.load();
|
||||
const staged=week.stageDay('2026-08-22',{ids:['two'],capacity_minutes:60,estimates:{two:30}});
|
||||
let saved=await week.saveDay('2026-08-22',{ids:['two'],capacity_minutes:60,estimates:{two:30}});
|
||||
const promoted=await week.promote(3);
|
||||
console.log(JSON.stringify({staged,saved,promoted,requests,pending:week.pending(),state:week.state()}));
|
||||
""")
|
||||
|
||||
assert result["staged"] is False
|
||||
assert result["saved"] is False
|
||||
assert result["promoted"] is False
|
||||
assert result["requests"] == ["GET", "GET"]
|
||||
assert result["pending"] is False
|
||||
assert result["state"]["days"][0]["ids"] == ["one"]
|
||||
|
||||
|
||||
def test_week_controller_refreshes_the_confirmed_snapshot_after_a_successful_save():
|
||||
result = run_controller("""
|
||||
const values=new Map();
|
||||
const storage={getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)};
|
||||
let tick=0;
|
||||
const fetchJson=async(_url,options={})=>options.method==='PUT'?{revision:8,timezone:'UTC',days:JSON.parse(options.body).days}:
|
||||
{revision:7,timezone:'UTC',days:[{plan_date:'2026-08-21',ids:['one'],capacity_minutes:60,estimates:{one:30}}]};
|
||||
const week=createWeekPlan({storage,getLogin:()=> 'timmy',now:()=>++tick,fetchJson,
|
||||
localDate:()=> '2026-08-20',timeZone:()=> 'UTC'});
|
||||
await week.load();week.rememberItems({one:{kind:'issue',title:'One',repository:'r',number:1}});
|
||||
week.stageDay('2026-08-22',{ids:['two'],capacity_minutes:90,estimates:{two:45}});
|
||||
await week.flush();
|
||||
console.log(values.get('stackchain.week-confirmed.v1.timmy'));
|
||||
""")
|
||||
|
||||
assert result["week"]["revision"] == 8
|
||||
assert [day["ids"] for day in result["week"]["days"]] == [["one"], ["two"]]
|
||||
assert result["items"]["one"]["title"] == "One"
|
||||
|
||||
|
||||
def test_week_controller_flushes_once_and_removes_only_the_matching_pending_week():
|
||||
result = run_controller("""
|
||||
const values=new Map();
|
||||
|
|
@ -756,6 +902,17 @@ def test_mobile_week_ahead_review_is_rendered_touch_safe_and_confirmed_explicitl
|
|||
assert ".week-review-mode .mobile-plan-today-nav" in css
|
||||
|
||||
|
||||
def test_mobile_week_ahead_has_a_touch_safe_offline_snapshot_notice_and_retry():
|
||||
index = (FRONTEND / "index.html").read_text()
|
||||
css = (FRONTEND / "dashboard.css").read_text()
|
||||
|
||||
assert 'id="week-offline-snapshot"' in index
|
||||
assert 'id="retry-week-live"' in index
|
||||
assert 'role="status"' in index[index.index('id="week-offline-snapshot"') - 100:index.index('id="week-offline-snapshot"') + 300]
|
||||
assert ".week-offline-snapshot" in css
|
||||
assert "#retry-week-live { min-height:44px;" in css
|
||||
|
||||
|
||||
def test_mobile_week_ahead_exposes_durable_save_and_touch_safe_conflict_choices():
|
||||
index = (FRONTEND / "index.html").read_text()
|
||||
css = (FRONTEND / "dashboard.css").read_text()
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user