From e234d6b6c37f032e3426ac29d9f353dee4d92403 Mon Sep 17 00:00:00 2001 From: timmy Date: Fri, 21 Aug 2026 18:18:14 +0000 Subject: [PATCH] feat: keep Today-to-Week moves through offline reloads (Closes #1231) --- .gitea/workflows/ci.yml | 2 +- frontend/today-week-reschedule.js | 193 ++++++++++++++++++++++++---- frontend/week-plan.js | 4 +- tests/test_ci_workflow.py | 8 ++ tests/test_today_week_reschedule.py | 120 ++++++++++++++++- 5 files changed, 298 insertions(+), 29 deletions(-) diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 5cfa4af..dc3407e 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -57,7 +57,7 @@ jobs: pip install -r requirements-e2e.txt python3 -m playwright install --with-deps chromium - name: Exercise packaged mobile work journeys - run: python3 -m pytest tests/e2e/test_mobile_offline_issue_release.py tests/e2e/test_mobile_search_preview_navigation.py tests/e2e/test_mobile_search_week_plan.py tests/e2e/test_mobile_find_work_release.py tests/e2e/test_mobile_home_bootstrap_release.py tests/e2e/test_mobile_sign_out_release.py tests/e2e/test_mobile_today_handoff_release.py tests/e2e/test_mobile_today_wrap_up_release.py tests/e2e/test_mobile_today_summary_release.py tests/e2e/test_mobile_tomorrow_conflict_release.py tests/e2e/test_mobile_week_ahead_release.py tests/e2e/test_mobile_wrap_up_handoff_release.py -q + run: python3 -m pytest tests/e2e/test_mobile_offline_issue_release.py tests/e2e/test_mobile_search_preview_navigation.py tests/e2e/test_mobile_search_week_plan.py tests/e2e/test_mobile_find_work_release.py tests/e2e/test_mobile_home_bootstrap_release.py tests/e2e/test_mobile_sign_out_release.py tests/e2e/test_mobile_today_handoff_release.py tests/e2e/test_mobile_today_wrap_up_release.py tests/e2e/test_mobile_today_summary_release.py tests/e2e/test_mobile_tomorrow_conflict_release.py tests/e2e/test_mobile_week_ahead_release.py tests/e2e/test_mobile_today_week_reschedule_release.py tests/e2e/test_mobile_wrap_up_handoff_release.py -q release-candidate: runs-on: ubuntu-latest diff --git a/frontend/today-week-reschedule.js b/frontend/today-week-reschedule.js index 7901b49..53ab4be 100644 --- a/frontend/today-week-reschedule.js +++ b/frontend/today-week-reschedule.js @@ -4,18 +4,108 @@ function createTodayWeekReschedule({ getToday, adoptToday = () => {}, operationId = () => globalThis.crypto?.randomUUID?.() || String(Date.now()), + storage = null, + getLogin = () => '', + now = () => Date.now(), } = {}) { let current = null; + let flushing = null; + const storagePrefix = 'stackchain.today-week-reschedule.v1.'; + + function storageKey() { + const login = String(getLogin?.() || '').trim().toLowerCase(); + return login ? storagePrefix + encodeURIComponent(login) : ''; + } + + function persist(record) { + const key = storageKey(); + if (!key || !storage) return false; + try { storage.setItem(key, JSON.stringify(record)); return true; } + catch (_error) { return false; } + } + + function pending() { + const key = storageKey(); + if (!key || !storage) return null; + try { + const value = JSON.parse(storage.getItem(key) || 'null'); + return typeof value?.body?.operation_id === 'string' && + Array.isArray(value?.today?.ids) && Array.isArray(value?.week?.days) ? value : null; + } catch (_error) { return null; } + } + + function resume() { + const record = pending(); + if (!record) return null; + adoptToday(record.today); + week.adopt(record.week); + return {...record, sync_pending:true}; + } + + async function restoreConflict(error) { + if (error?.status !== 409) throw error; + const [today, weekState] = await Promise.all([api('api/v1/today'), api('api/v1/week')]); + adoptToday(today); + week.adopt(weekState); + const conflict = new Error('Plans changed on another device. Review and retry this saved move.'); + conflict.status = 409; + throw conflict; + } + + function flush() { + if (flushing) return flushing; + const record = pending(); + if (!record) return Promise.resolve(false); + const key = storageKey(); + flushing = api('api/v1/week/reschedule', { + method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(record.body), + }).then(result => { + adoptToday(result.today); + week.adopt(result.week); + const latest = pending(); + if (latest?.body?.operation_id === record.body.operation_id) storage.removeItem(key); + return {...result, sync_pending:false}; + }).catch(restoreConflict).finally(() => { flushing = null; }); + return flushing; + } + + function optimisticSnapshots(planDate, estimate) { + const today = { + ...current.today, + ids: current.today.ids.filter(id => id !== current.identity), + estimates: {...(current.today.estimates || {})}, + }; + delete today.estimates[current.identity]; + const days = (current.week.days || []).map(day => { + const estimates = {...(day.estimates || {})}; delete estimates[current.identity]; + return {...day, ids:(day.ids || []).filter(id => id !== current.identity), estimates}; + }); + let destination = days.find(day => day.plan_date === planDate); + if (!destination) { + destination = {plan_date:planDate, ids:[], capacity_minutes:null, estimates:{}}; + days.push(destination); + } + destination.ids.push(current.identity); + destination.estimates[current.identity] = estimate; + return {today, week:{revision:current.week_revision, timezone:current.week.timezone || null, days}}; + } async function open(identity) { - const today = await api('api/v1/today'); + let today; + let offline = false; + try { today = await api('api/v1/today'); } + catch (error) { + if (error?.status === 401 || error?.status === 403) throw error; + today = getToday?.(); offline = true; + } if (!identity || !today?.ids?.includes(identity)) { throw new Error('The active Today item changed. Reopen rescheduling.'); } const loaded = await week.load(); - if (loaded?.offline_snapshot || loaded?.sync_pending) { + if (loaded?.sync_pending) { throw new Error('Reconnect before rescheduling Today into Week Ahead.'); } + offline = offline || Boolean(loaded?.offline_snapshot); const estimate = Number(today.estimates?.[identity]); const estimateMinutes = Number.isFinite(estimate) && estimate > 0 ? estimate : null; const days = week.review().days.map(day => { @@ -37,6 +127,9 @@ function createTodayWeekReschedule({ operation_id: operationId(), estimate_minutes: estimateMinutes, days, + today, + week: loaded, + offline, }; return {...current, days:days.map(day => ({...day, ids:[...day.ids]}))}; } @@ -54,38 +147,89 @@ function createTodayWeekReschedule({ if (capacity && day.planned_minutes + estimate > capacity && !allowOverload) { throw new Error("That move exceeds the day's capacity. Confirm overload before rescheduling."); } - const result = await api('api/v1/week/reschedule', { - method:'POST', - headers:{'Content-Type':'application/json'}, - body:JSON.stringify({ - operation_id:current.operation_id, - identity:current.identity, - estimate_minutes:estimate, - plan_date:planDate, - today_revision:current.today_revision, - week_revision:current.week_revision, - allow_over_capacity:allowOverload, - }), - }); + const body = { + operation_id:current.operation_id, + identity:current.identity, + estimate_minutes:estimate, + plan_date:planDate, + today_revision:current.today_revision, + week_revision:current.week_revision, + allow_over_capacity:allowOverload, + }; + const optimistic = optimisticSnapshots(planDate, estimate); + const durable = Boolean(storageKey() && storage); + const admitted = durable && persist({body, ...optimistic, queued_at:now()}); + if (durable && !admitted) { + throw new Error('Could not save this move on this device. Nothing changed.'); + } + if (admitted) { + adoptToday(optimistic.today); + week.adopt(optimistic.week); + } + if (current.offline) { + current = null; + return {...optimistic, sync_pending:true}; + } + let result; + try { + result = await api('api/v1/week/reschedule', { + method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(body), + }); + } catch(error) { + if (admitted && error?.status === 409) await restoreConflict(error); + if (admitted && error?.status !== 401 && error?.status !== 403) { + current = null; + return {...optimistic, sync_pending:true}; + } + throw error; + } adoptToday(result.today); week.adopt(result.week); + if (admitted && pending()?.body?.operation_id === body.operation_id) storage.removeItem(storageKey()); current = null; return result; } function cancel() { current = null; } - return {open, confirm, cancel, state:() => current}; + return {open, confirm, cancel, state:() => current, pending, resume, flush}; } function mountTodayWeekReschedule({ - qs, document=globalThis.document, week, api, getToday, adoptToday, currentTarget, closeActions, - refresh, warm, continueToday, announce, schedule=callback=>requestAnimationFrame(callback), + qs, document=globalThis.document, window=globalThis.window, week, api, getToday, adoptToday, currentTarget, closeActions, + refresh, warm, continueToday, announce, storage=null, getLogin=()=>'', schedule=callback=>requestAnimationFrame(callback), }={}) { const dialog=qs('#today-week-reschedule'),daysRoot=qs('#today-week-reschedule-days'); const estimate=qs('#today-week-reschedule-estimate'),status=qs('#today-week-reschedule-status'); const confirm=qs('#confirm-today-week-reschedule'),launcher=qs('[data-work-session-reschedule-week]'); let selectedDate=null,allowOverload=false; - const controller=createTodayWeekReschedule({week,api,getToday,adoptToday}); + const controller=createTodayWeekReschedule({week,api,getToday,adoptToday,storage,getLogin}); + async function flushPending() { + if(!controller.pending())return false; + try { + const result=await controller.flush(); + announce('Today-to-Week move synced to your account.'); + await refresh();warm(); + return result; + } catch(error) { + announce(`${error.message||'Sync unavailable.'} Move saved on this device · sync pending.`); + return false; + } + } + function resumePending() { + const restored=controller.resume(); + if(!restored)return false; + announce('Today-to-Week move saved on this device · sync pending.'); + flushPending(); + return restored; + } + window?.addEventListener('online', flushPending); + document?.addEventListener('visibilitychange',()=>document.hidden?false:flushPending()); + let restoreAttempts=0; + function restoreWhenOwned(){ + if(resumePending()||getLogin()||restoreAttempts++>=20)return; + window?.setTimeout(restoreWhenOwned,250); + } + restoreWhenOwned(); function close() { controller.cancel();selectedDate=null;allowOverload=false; if(dialog.open)dialog.close();schedule(()=>{ @@ -127,8 +271,13 @@ function mountTodayWeekReschedule({ if(!selectedDate)return; confirm.disabled=true;status.textContent='Moving Today into Week Ahead…'; try{ - await controller.confirm(selectedDate,Number(estimate.value),{allowOverload}); - await refresh();warm();dialog.close();announce('Moved to Week Ahead. Continuing Today.'); + const result=await controller.confirm(selectedDate,Number(estimate.value),{allowOverload}); + dialog.close(); + if(result.sync_pending){ + announce('Moved locally. Saved on this device · sync pending.');warm(); + }else{ + await refresh();warm();announce('Moved to Week Ahead. Continuing Today.'); + } await continueToday(); }catch(error){ const overload=error.message.includes('Confirm overload');allowOverload=overload; @@ -137,7 +286,7 @@ function mountTodayWeekReschedule({ confirm.disabled=false; } }); - return {controller,close}; + return {controller,close,flushPending,resumePending}; } if (typeof module !== 'undefined' && module.exports) { diff --git a/frontend/week-plan.js b/frontend/week-plan.js index 3b7899f..68d6d7b 100644 --- a/frontend/week-plan.js +++ b/frontend/week-plan.js @@ -468,7 +468,7 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin,now=()=>D return {adopt,state,dates,day,pass,load,saveDay,stageDay,stageCapacities,review,previewReflow,applyReflow,move,placement,place,pending,flush,conflict,chooseDay,saveMerged, keepLocal,useRemote,promote,startEarly,reconcile:reconcilePromotion,summary,rememberItems,rememberPendingItem, item:id=>pendingItems[id]||confirmedItems[id]||null, - offline:()=>offlineSnapshot,request:fetchJson}; + offline:()=>offlineSnapshot,request:fetchJson,reschedule:()=>({storage,getLogin})}; } function createWeekPlanWorkflow({controller,qs,getItem=()=>null,openItem,openPlanner,setReviewMode=()=>{},escapeHtml,escapeAttribute, todayWork,refresh:r=()=>{},warm:w=()=>{},today:t=()=>null,r:refresh=r,w:warm=w,t:getTodayPlan=t, @@ -802,7 +802,7 @@ function createWeekPlanWorkflow({controller,qs,getItem=()=>null,openItem,openPla const back=qs('#back-to-week-review');if(back)back.hidden=true;renderDates();}}; if(typeof weekCalendarImport!=='undefined')weekCalendarImport.mount(controller,workflow,qs); if(x)mountTodayWeekReschedule({ - qs,week:controller,getToday:t,refresh:r,warm:w,api:controller.request,currentTarget:x, + qs,week:controller,getToday:t,refresh:r,warm:w,api:controller.request,currentTarget:x,...controller.reschedule(), closeActions:()=>qs('#mobile-today-actions').close(), continueToday:()=>qs('[data-work-session-next]').click(), announce:message=>{qs('#my-work-action-status').textContent=message;}, diff --git a/tests/test_ci_workflow.py b/tests/test_ci_workflow.py index 409a2db..f51501f 100644 --- a/tests/test_ci_workflow.py +++ b/tests/test_ci_workflow.py @@ -70,6 +70,14 @@ def test_release_promotion_waits_for_packaged_mobile_journeys(): "tests/e2e/test_mobile_today_summary_release.py " "tests/e2e/test_mobile_tomorrow_conflict_release.py " "tests/e2e/test_mobile_week_ahead_release.py " + "tests/e2e/test_mobile_today_week_reschedule_release.py " "tests/e2e/test_mobile_wrap_up_handoff_release.py -q" ) in browser assert "needs: [lint, build-release, browser-journey]" in release + + +def test_browser_job_runs_packaged_today_week_reschedule_journey(): + text = WORKFLOW.read_text() + browser = text[text.index(" browser-journey:") : text.index(" release-candidate:")] + + assert "tests/e2e/test_mobile_today_week_reschedule_release.py" in browser diff --git a/tests/test_today_week_reschedule.py b/tests/test_today_week_reschedule.py index 8333d68..4a4e683 100644 --- a/tests/test_today_week_reschedule.py +++ b/tests/test_today_week_reschedule.py @@ -78,18 +78,127 @@ const opened=await controller.open('active'); let fullError='',overError=''; try{await controller.confirm('2026-08-24',30);}catch(error){fullError=error.message;} try{await controller.confirm('2026-08-25',30);}catch(error){overError=error.message;} -const offline=createReschedule({week:{load:async()=>({revision:2,offline_snapshot:true}),review:()=>({days})},api,getToday:()=>({revision:3,ids:['active'],estimates:{active:30}})}); -let offlineError='';try{await offline.open('active');}catch(error){offlineError=error.message;} -console.log(JSON.stringify({opened,fullError,overError,offlineError,calls})); +const offline=createReschedule({week:{load:async()=>({revision:2,offline_snapshot:true,days:[]}),review:()=>({days})},api,getToday:()=>({revision:3,ids:['active'],estimates:{active:30}})}); +let offlineOpened=null;try{offlineOpened=await offline.open('active');}catch(_error){} +console.log(JSON.stringify({opened,fullError,overError,offlineOpened,calls})); """) assert result["opened"]["days"][0]["eligible"] is False assert result["fullError"] == "That Week Ahead day already has five items." assert result["overError"] == "That move exceeds the day's capacity. Confirm overload before rescheduling." - assert result["offlineError"] == "Reconnect before rescheduling Today into Week Ahead." + assert result["offlineOpened"]["offline"] is True assert result["calls"] == 0 +def test_reschedule_controller_persists_offline_move_before_optimistic_adoption(): + result = run_controller(""" +const values=new Map(); +const events=[]; +const storage={ + getItem:key=>values.get(key)||null, + setItem:(key,value)=>{events.push('persist');values.set(key,value);}, + removeItem:key=>values.delete(key), +}; +const today={revision:4,ids:['active','other'],capacity_minutes:120,estimates:{active:45,other:20}}; +const weekState={revision:7,timezone:'UTC',offline_snapshot:true,days:[ + {plan_date:'2026-08-25',ids:[],capacity_minutes:90,estimates:{}} +]}; +const week={load:async()=>weekState,review:()=>({days:[ + {plan_date:'2026-08-25',label:'Tue',ids:[],planned_minutes:0,capacity_minutes:90,estimates:{}} +]}),adopt:value=>events.push(['week',value])}; +const api=async()=>{const error=new Error('offline');error.status=0;throw error;}; +const controller=createReschedule({week,api,getToday:()=>today,adoptToday:value=>events.push(['today',value]), + storage,getLogin:()=> 'Timmy',operationId:()=> 'move-active',now:()=>123}); +const opened=await controller.open('active'); +const confirmed=await controller.confirm('2026-08-25',45); +console.log(JSON.stringify({opened,confirmed,events,keys:[...values.keys()],record:JSON.parse([...values.values()][0])})); +""") + + assert result["opened"]["offline"] is True + assert result["confirmed"]["sync_pending"] is True + assert result["events"][0] == "persist" + assert result["events"][1][0] == "today" + assert result["events"][1][1]["ids"] == ["other"] + assert result["events"][2][0] == "week" + assert result["events"][2][1]["days"][0]["ids"] == ["active"] + assert result["keys"] == ["stackchain.today-week-reschedule.v1.timmy"] + assert result["record"]["body"]["operation_id"] == "move-active" + assert result["record"]["queued_at"] == 123 + + +def test_reschedule_controller_restores_and_delivers_same_operation_after_reload(): + result = run_controller(""" +const key='stackchain.today-week-reschedule.v1.timmy'; +const record={queued_at:123,body:{operation_id:'move-active',identity:'active',estimate_minutes:45, + plan_date:'2026-08-25',today_revision:4,week_revision:7,allow_over_capacity:false}, + today:{revision:4,ids:['other'],estimates:{other:20}}, + week:{revision:7,timezone:'UTC',days:[{plan_date:'2026-08-25',ids:['active'],capacity_minutes:90,estimates:{active:45}}]}}; +const values=new Map([[key,JSON.stringify(record)]]);const adopted=[];const requests=[]; +const storage={getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)}; +const api=async(url,options)=>{requests.push({url,body:JSON.parse(options.body)});return { + today:{revision:5,ids:['other'],estimates:{other:20}}, + week:{revision:8,timezone:'UTC',days:[{plan_date:'2026-08-25',ids:['active'],capacity_minutes:90,estimates:{active:45}}]}};}; +const controller=createReschedule({week:{adopt:value=>adopted.push(['week',value])},api, + getToday:()=>null,adoptToday:value=>adopted.push(['today',value]),storage,getLogin:()=> 'timmy'}); +const restored=controller.resume(); +const delivered=await controller.flush(); +console.log(JSON.stringify({restored,delivered,adopted,requests,remaining:values.size})); +""") + + assert result["restored"]["sync_pending"] is True + assert result["adopted"][0][0] == "today" + assert result["adopted"][1][0] == "week" + assert result["requests"][0]["body"]["operation_id"] == "move-active" + assert result["delivered"]["sync_pending"] is False + assert result["remaining"] == 0 + + +def test_reschedule_controller_keeps_admitted_operation_when_connection_drops_on_confirm(): + result = run_controller(""" +const values=new Map();const events=[]; +const storage={getItem:key=>values.get(key)||null,setItem:(key,value)=>{events.push('persist');values.set(key,value);},removeItem:key=>values.delete(key)}; +const weekState={revision:7,timezone:'UTC',days:[{plan_date:'2026-08-25',ids:[],capacity_minutes:90,estimates:{}}]}; +const week={load:async()=>weekState,review:()=>({days:[{...weekState.days[0],label:'Tue',planned_minutes:0}]}), + adopt:value=>events.push(['week',value])}; +let calls=0;const api=async url=>{if(url==='api/v1/today')return {revision:4,ids:['active'],estimates:{active:45}}; + calls++;const error=new Error('connection dropped');error.status=0;throw error;}; +const controller=createReschedule({week,api,getToday:()=>null,adoptToday:value=>events.push(['today',value]), + storage,getLogin:()=> 'timmy',operationId:()=> 'stable-operation'}); +await controller.open('active'); +const result=await controller.confirm('2026-08-25',45); +console.log(JSON.stringify({result,events,calls,pending:controller.pending()})); +""") + + assert result["result"]["sync_pending"] is True + assert result["events"][0] == "persist" + assert result["calls"] == 1 + assert result["pending"]["body"]["operation_id"] == "stable-operation" + + +def test_reschedule_controller_restores_server_truth_and_keeps_intent_on_conflict(): + result = run_controller(""" +const key='stackchain.today-week-reschedule.v1.timmy'; +const record={queued_at:123,body:{operation_id:'move-active'},today:{revision:4,ids:[]},week:{revision:7,days:[]}}; +const values=new Map([[key,JSON.stringify(record)]]);const adopted=[]; +const storage={getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)}; +const api=async url=>{ + if(url==='api/v1/week/reschedule'){const error=new Error('conflict');error.status=409;throw error;} + if(url==='api/v1/today')return {revision:9,ids:['active'],estimates:{active:45}}; + return {revision:12,timezone:'UTC',days:[]}; +}; +const controller=createReschedule({week:{adopt:value=>adopted.push(['week',value])},api, + adoptToday:value=>adopted.push(['today',value]),storage,getLogin:()=> 'timmy'}); +let message='';try{await controller.flush();}catch(error){message=error.message;} +console.log(JSON.stringify({message,adopted,pending:controller.pending(),remaining:values.size})); +""") + + assert result["message"] == "Plans changed on another device. Review and retry this saved move." + assert result["adopted"][0][1]["revision"] == 9 + assert result["adopted"][1][1]["revision"] == 12 + assert result["pending"]["body"]["operation_id"] == "move-active" + assert result["remaining"] == 1 + + def test_mobile_active_today_reschedule_dialog_is_touch_safe_and_wired_into_release_bundle(): index = INDEX.read_text() css = CSS.read_text() @@ -105,5 +214,8 @@ def test_mobile_active_today_reschedule_dialog_is_touch_safe_and_wired_into_rele assert '.today-week-reschedule-days button { min-height:44px;' in css assert '.today-week-reschedule-panel { width:min(100%,560px);' in css assert 'x:currentTodayProgressTarget' in dashboard + assert 'restoreWhenOwned()' in CONTROLLER.read_text() assert "runTodayTransition('next')" in dashboard + assert 'reschedule:()=>({storage,getLogin})' in (CONTROLLER.parent / "week-plan.js").read_text() + assert "addEventListener('online', flushPending)" in CONTROLLER.read_text() assert '"static/today-week-reschedule.js"' in bundle -- 2.43.0