diff --git a/README.md b/README.md index 38c5d82..f46b198 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,10 @@ the private content. Issue capture and authored mobile actions (issue comments, pull-request comments, notification replies, and reviews) persist per-draft idempotency keys, so retrying after a timeout, reload, process restart, or handoff to another worker replays a confirmed result instead of posting duplicate content. The ordered, -five-item Today plan syncs across the operator's devices. After a healthy, fully paginated +five-item Today plan syncs across the operator's devices. Server revisions prevent delayed +responses from replacing a newer plan; same-account browser tabs exchange fresh snapshots, +and reconnecting or returning to the dashboard refreshes server truth after replaying queued +offline operations. After a healthy, fully paginated My Work refresh proves that an item is complete or otherwise no longer eligible, Stackchain queues an idempotent retirement before removing it locally; partial and degraded refreshes leave the plan unchanged, and offline retirements replay after reconnect. When diff --git a/frontend/dashboard.js b/frontend/dashboard.js index eb94aa9..0312572 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -156,6 +156,7 @@ 'Today sync unavailable · changes stay on this device.')); }, }); + todaySync.startLifecycle({ window, document }); const laterWork = createLaterWork({ storage: localStorage, getLogin: () => planningOwnerLogin, diff --git a/frontend/service-worker.js b/frontend/service-worker.js index 6a5a8d5..792988f 100644 --- a/frontend/service-worker.js +++ b/frontend/service-worker.js @@ -1,6 +1,6 @@ const BASE = new URL('./', self.location.href).pathname; importScripts(BASE + 'static/background-issue-sync.js'); -const CACHE = 'stackchain-dashboard-shell-v46'; +const CACHE = 'stackchain-dashboard-shell-v47'; const OUTAGE_STATUSES = new Set([500, 502, 503, 504]); const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000; const SHELL = [ diff --git a/frontend/today-sync.js b/frontend/today-sync.js index 53bf5d3..e3a567e 100644 --- a/frontend/today-sync.js +++ b/frontend/today-sync.js @@ -1,13 +1,57 @@ -function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onStatus, createOperationId }) { +function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onStatus, createOperationId, createChannel }) { const prefix = 'stackchain.today-sync.v1.'; const migrationPrefix = 'stackchain.today-sync-migrated.v1.'; + const snapshotPrefix = 'stackchain.today-sync-snapshot.v1.'; let flushing = null; + let channel = null; + let channelKey = ''; function key() { const login = String(getLogin?.() || '').trim().toLowerCase(); return login ? prefix + encodeURIComponent(login) : ''; } + function snapshotKey() { + const storageKey = key(); + return storageKey ? snapshotPrefix + storageKey.slice(prefix.length) : ''; + } + + function savedRevision() { + try { + const snapshot = JSON.parse(storage?.getItem(snapshotKey()) || 'null'); + return Number.isInteger(snapshot?.revision) ? snapshot.revision : -1; + } catch (_error) { + return -1; + } + } + + function adopt(plan, broadcast = true) { + if (!Number.isInteger(plan?.revision) || !Array.isArray(plan?.ids)) return false; + if (plan.revision < savedRevision()) return false; + try { + storage?.setItem(snapshotKey(), JSON.stringify({ revision: plan.revision, ids: plan.ids })); + } catch (_error) { + // A storage quota failure must not prevent the current tab from using server truth. + } + onRemoteIds?.(plan.ids); + if (broadcast) channel?.postMessage({ revision: plan.revision, ids: plan.ids }); + return true; + } + + function ensureChannel() { + const storageKey = key(); + if (!storageKey || channelKey === storageKey) return; + channel?.close?.(); + const factory = createChannel || (globalThis.window?.BroadcastChannel + ? name => new globalThis.window.BroadcastChannel(name) + : null); + channelKey = storageKey; + channel = factory?.('stackchain-today-' + storageKey.slice(prefix.length)) || null; + channel?.addEventListener?.('message', event => { + if (key() === storageKey) adopt(event.data, false); + }); + } + function pending() { const storageKey = key(); if (!storageKey || !storage) return []; @@ -71,6 +115,7 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onStatus, async function run() { if (!key()) return false; + ensureChannel(); try { let plan = await fetchJson('api/v1/today'); const operations = pending(); @@ -85,14 +130,14 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onStatus, if (error?.status !== 409) throw error; const rejected = pending(); save(rejected.filter(candidate => candidate.operation_id !== operation.operation_id)); - onRemoteIds?.(Array.isArray(plan.ids) ? plan.ids : []); + adopt(plan); onStatus?.('full'); return false; } const remaining = pending(); if (remaining[0]?.operation_id === operation.operation_id) save(remaining.slice(1)); } - onRemoteIds?.(Array.isArray(plan.ids) ? plan.ids : []); + adopt(plan); onStatus?.(pending().length ? 'pending' : 'saved'); return true; } catch (_error) { @@ -106,7 +151,14 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onStatus, return flushing; } - return { enqueue, migrate, flush, pending }; + function startLifecycle({ window: windowObject, document: documentObject }) { + windowObject?.addEventListener?.('online', flush); + documentObject?.addEventListener?.('visibilitychange', () => + documentObject.hidden ? false : flush() + ); + } + + return { enqueue, migrate, flush, pending, startLifecycle }; } if (typeof module !== 'undefined' && module.exports) module.exports = createTodaySync; diff --git a/tests/test_markdown_renderer.py b/tests/test_markdown_renderer.py index 704d6f1..b6d277e 100644 --- a/tests/test_markdown_renderer.py +++ b/tests/test_markdown_renderer.py @@ -137,4 +137,4 @@ def test_markdown_work_bodies_are_mobile_safe_block_containers(): assert ".markdown-content { min-width:0; max-width:100%; overflow-wrap:anywhere;" in css assert ".markdown-content pre { max-width:100%; overflow-x:auto;" in css assert ".markdown-content a { min-height:44px;" in css - assert "stackchain-dashboard-shell-v46" in worker + assert "stackchain-dashboard-shell-v47" in worker diff --git a/tests/test_mobile_composer_integration.py b/tests/test_mobile_composer_integration.py index a5204d6..143773d 100644 --- a/tests/test_mobile_composer_integration.py +++ b/tests/test_mobile_composer_integration.py @@ -35,4 +35,4 @@ def test_offline_shell_contains_every_local_dashboard_runtime_asset(): shell_assets = set(re.findall(r"BASE \+ '([^']+)'", worker.split("async function sessionCsrf", 1)[0])) assert local_assets <= shell_assets, f"Offline shell is missing: {sorted(local_assets - shell_assets)}" - assert "stackchain-dashboard-shell-v46" in worker + assert "stackchain-dashboard-shell-v47" in worker diff --git a/tests/test_service_worker.py b/tests/test_service_worker.py index ca4a39e..47f0310 100644 --- a/tests/test_service_worker.py +++ b/tests/test_service_worker.py @@ -108,23 +108,30 @@ async function dispatchNotificationClick(route) {{ def test_navigation_deadline_ships_in_a_new_shell_cache(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v46" in source + assert "stackchain-dashboard-shell-v47" in source assert "BASE + 'static/dashboard.css'" in source assert "BASE + 'static/dashboard.js'" in source assert "BASE + 'static/install-app.js'" in source +def test_today_convergence_ships_in_a_new_shell_cache(): + source = WORKER.read_text() + + assert "stackchain-dashboard-shell-v47" in source + assert "BASE + 'static/today-sync.js'" in source + + def test_mobile_search_viewport_ships_in_a_new_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v46" in source + assert "stackchain-dashboard-shell-v47" in source assert "BASE + 'static/mobile-search-viewport.js'" in source def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v46" in source + assert "stackchain-dashboard-shell-v47" in source assert "BASE + 'static/update-ownership.js'" in source diff --git a/tests/test_today_sync.py b/tests/test_today_sync.py index 8eb2e2b..7a0492c 100644 --- a/tests/test_today_sync.py +++ b/tests/test_today_sync.py @@ -171,3 +171,139 @@ sync.enqueue('add', '6'); "ids": ["1", "2", "3", "4", "5"], "status": "full", } + + +def test_tabs_ignore_an_older_response_after_a_newer_revision_is_broadcast(): + script = f""" +const createTodaySync = require({json.dumps(str(TODAY_SYNC))}); +const values = new Map(); +const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}}; +const listeners = []; +const createChannel = () => ({{ + addEventListener: (_name, listener) => listeners.push(listener), + postMessage: data => listeners.forEach(listener => listener({{data}})), +}}); +let resolveOld; +let resolveNew; +const oldHistory = []; +const newHistory = []; +const oldTab = createTodaySync({{ + storage, getLogin:()=> 'timmy', createChannel, + fetchJson:()=>new Promise(resolve=>{{resolveOld=resolve}}), + onRemoteIds:ids=>oldHistory.push(ids), onStatus:()=>{{}}, +}}); +const newTab = createTodaySync({{ + storage, getLogin:()=> 'timmy', createChannel, + fetchJson:()=>new Promise(resolve=>{{resolveNew=resolve}}), + onRemoteIds:ids=>newHistory.push(ids), onStatus:()=>{{}}, +}}); +(async()=>{{ + const oldFlush = oldTab.flush(); + const newFlush = newTab.flush(); + await Promise.resolve(); + resolveNew({{revision:2,ids:['new']}}); + await newFlush; + resolveOld({{revision:1,ids:['old']}}); + await oldFlush; + process.stdout.write(JSON.stringify({{oldHistory,newHistory}})); +}})(); +""" + result = json.loads( + subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True).stdout + ) + + assert result["oldHistory"][-1] == ["new"] + assert result["newHistory"][-1] == ["new"] + + +def test_returning_online_replays_pending_operations(): + script = f""" +const createTodaySync = require({json.dumps(str(TODAY_SYNC))}); +const values = new Map(); +const handlers = {{}}; +const requests = []; +let revision = 1; +const sync = createTodaySync({{ + storage: {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}}, + getLogin:()=> 'timmy', createOperationId:()=> 'offline-add', + fetchJson:async (_url, options={{}})=>{{ + requests.push(options.method || 'GET'); + if (options.method) return {{revision:++revision,ids:['issue:r:1:']}}; + return {{revision,ids:[]}}; + }}, + onRemoteIds:()=>{{}}, onStatus:()=>{{}}, +}}); +sync.enqueue('add', 'issue:r:1:'); +sync.startLifecycle({{ + window: {{addEventListener:(name, handler)=>{{handlers[name]=handler}}}}, + document: {{addEventListener:()=>{{}}, hidden:false}}, +}}); +(async()=>{{ + await handlers.online(); + process.stdout.write(JSON.stringify({{requests,pending:sync.pending()}})); +}})(); +""" + result = json.loads( + subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True).stdout + ) + + assert result == {"requests": ["GET", "PATCH"], "pending": []} + + +def test_foregrounding_a_stale_tab_refreshes_the_saved_plan(): + script = f""" +const createTodaySync = require({json.dumps(str(TODAY_SYNC))}); +const handlers = {{}}; +const adopted = []; +const documentObject = {{ + hidden:true, + addEventListener:(name, handler)=>{{handlers[name]=handler}}, +}}; +const sync = createTodaySync({{ + storage: {{getItem:()=>null,setItem:()=>{{}},removeItem:()=>{{}}}}, + getLogin:()=> 'timmy', + fetchJson:async ()=>({{revision:7,ids:['issue:r:7:']}}), + onRemoteIds:ids=>adopted.push(ids), onStatus:()=>{{}}, +}}); +sync.startLifecycle({{ + window: {{addEventListener:()=>{{}}}}, document:documentObject, +}}); +(async()=>{{ + await handlers.visibilitychange(); + documentObject.hidden = false; + await handlers.visibilitychange(); + process.stdout.write(JSON.stringify(adopted)); +}})(); +""" + result = json.loads( + subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True).stdout + ) + + assert result == [["issue:r:7:"]] + + +def test_old_account_broadcasts_are_ignored_after_identity_changes(): + script = f""" +const createTodaySync = require({json.dumps(str(TODAY_SYNC))}); +let login = 'timmy'; +let listener; +const adopted = []; +const sync = createTodaySync({{ + storage: {{getItem:()=>null,setItem:()=>{{}},removeItem:()=>{{}}}}, + getLogin:()=>login, + createChannel:()=>({{addEventListener:(_name, handler)=>{{listener=handler}},postMessage:()=>{{}},close:()=>{{}}}}), + fetchJson:async ()=>({{revision:1,ids:['timmy-plan']}}), + onRemoteIds:ids=>adopted.push(ids), onStatus:()=>{{}}, +}}); +(async()=>{{ + await sync.flush(); + login = 'alexander'; + listener({{data:{{revision:2,ids:['timmy-secret']}}}}); + process.stdout.write(JSON.stringify(adopted)); +}})(); +""" + result = json.loads( + subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True).stdout + ) + + assert result == [["timmy-plan"]] diff --git a/tests/test_today_work.py b/tests/test_today_work.py index 5ca4110..bd61093 100644 --- a/tests/test_today_work.py +++ b/tests/test_today_work.py @@ -173,6 +173,13 @@ async def test_dashboard_runs_the_curated_today_queue_as_a_mobile_work_flow(): assert "Another device filled Today · showing its saved plan." in html +@pytest.mark.anyio +async def test_dashboard_activates_today_lifecycle_convergence(): + html = await dashboard() + + assert "todaySync.startLifecycle({ window, document });" in html + + @pytest.mark.anyio async def test_retained_authenticated_context_keeps_local_planning_separate_from_fresh_delivery(): html = await dashboard()