From 37950bd0eda98e74dbd0c004bb7753760726caed Mon Sep 17 00:00:00 2001 From: timmy Date: Fri, 21 Aug 2026 10:35:21 +0000 Subject: [PATCH] feat: keep Week Ahead available offline (Closes #1218) --- frontend/dashboard.css | 2 + frontend/index.html | 2 + frontend/week-plan.js | 133 +++++++++++++---- tests/e2e/test_mobile_week_ahead_release.py | 69 +++++++++ tests/test_week_plan_frontend.py | 157 ++++++++++++++++++++ 5 files changed, 337 insertions(+), 26 deletions(-) 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 @@
Seven-day overview

Week Ahead

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 '
Work details unavailable'+escapeHtml(String(id).slice(0,96))+'
'; 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 ''; const items=day.ids.length?'': '

Nothing planned.

'; - return '

'+escapeHtml(day.label)+(day===nextUp?' Next up':'')+'

'+ + const edit=readOnly?'':''; + return '

'+escapeHtml(day.label)+(day===nextUp?' Next up':'')+'

'+edit+'

'+ escapeHtml(load)+'

'+items+'
'; }).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)), diff --git a/tests/e2e/test_mobile_week_ahead_release.py b/tests/e2e/test_mobile_week_ahead_release.py index d0bce13..786265c 100644 --- a/tests/e2e/test_mobile_week_ahead_release.py +++ b/tests/e2e/test_mobile_week_ahead_release.py @@ -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) diff --git a/tests/test_week_plan_frontend.py b/tests/test_week_plan_frontend.py index e3b2f21..6013d1d 100644 --- a/tests/test_week_plan_frontend.py +++ b/tests/test_week_plan_frontend.py @@ -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()