Keep Today plans convergent across tabs and devices #362

Merged
rockachopa merged 1 commits from timmy/361-convergent-today-sync into main 2026-08-09 02:03:45 +00:00
9 changed files with 217 additions and 11 deletions

View File

@ -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 comments, pull-request comments, notification replies, and reviews) persist per-draft
idempotency keys, so retrying after a timeout, reload, process restart, or handoff to 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, 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 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 queues an idempotent retirement before removing it locally; partial and degraded refreshes
leave the plan unchanged, and offline retirements replay after reconnect. When leave the plan unchanged, and offline retirements replay after reconnect. When

View File

@ -156,6 +156,7 @@
'Today sync unavailable · changes stay on this device.')); 'Today sync unavailable · changes stay on this device.'));
}, },
}); });
todaySync.startLifecycle({ window, document });
const laterWork = createLaterWork({ const laterWork = createLaterWork({
storage: localStorage, storage: localStorage,
getLogin: () => planningOwnerLogin, getLogin: () => planningOwnerLogin,

View File

@ -1,6 +1,6 @@
const BASE = new URL('./', self.location.href).pathname; const BASE = new URL('./', self.location.href).pathname;
importScripts(BASE + 'static/background-issue-sync.js'); 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 OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000; const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000;
const SHELL = [ const SHELL = [

View File

@ -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 prefix = 'stackchain.today-sync.v1.';
const migrationPrefix = 'stackchain.today-sync-migrated.v1.'; const migrationPrefix = 'stackchain.today-sync-migrated.v1.';
const snapshotPrefix = 'stackchain.today-sync-snapshot.v1.';
let flushing = null; let flushing = null;
let channel = null;
let channelKey = '';
function key() { function key() {
const login = String(getLogin?.() || '').trim().toLowerCase(); const login = String(getLogin?.() || '').trim().toLowerCase();
return login ? prefix + encodeURIComponent(login) : ''; 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() { function pending() {
const storageKey = key(); const storageKey = key();
if (!storageKey || !storage) return []; if (!storageKey || !storage) return [];
@ -71,6 +115,7 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onStatus,
async function run() { async function run() {
if (!key()) return false; if (!key()) return false;
ensureChannel();
try { try {
let plan = await fetchJson('api/v1/today'); let plan = await fetchJson('api/v1/today');
const operations = pending(); const operations = pending();
@ -85,14 +130,14 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onStatus,
if (error?.status !== 409) throw error; if (error?.status !== 409) throw error;
const rejected = pending(); const rejected = pending();
save(rejected.filter(candidate => candidate.operation_id !== operation.operation_id)); save(rejected.filter(candidate => candidate.operation_id !== operation.operation_id));
onRemoteIds?.(Array.isArray(plan.ids) ? plan.ids : []); adopt(plan);
onStatus?.('full'); onStatus?.('full');
return false; return false;
} }
const remaining = pending(); const remaining = pending();
if (remaining[0]?.operation_id === operation.operation_id) save(remaining.slice(1)); 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'); onStatus?.(pending().length ? 'pending' : 'saved');
return true; return true;
} catch (_error) { } catch (_error) {
@ -106,7 +151,14 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onStatus,
return flushing; 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; if (typeof module !== 'undefined' && module.exports) module.exports = createTodaySync;

View File

@ -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 { 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 pre { max-width:100%; overflow-x:auto;" in css
assert ".markdown-content a { min-height:44px;" 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

View File

@ -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])) 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 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

View File

@ -108,23 +108,30 @@ async function dispatchNotificationClick(route) {{
def test_navigation_deadline_ships_in_a_new_shell_cache(): def test_navigation_deadline_ships_in_a_new_shell_cache():
source = WORKER.read_text() 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.css'" in source
assert "BASE + 'static/dashboard.js'" in source assert "BASE + 'static/dashboard.js'" in source
assert "BASE + 'static/install-app.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(): def test_mobile_search_viewport_ships_in_a_new_offline_shell():
source = WORKER.read_text() 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 assert "BASE + 'static/mobile-search-viewport.js'" in source
def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell(): def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell():
source = WORKER.read_text() 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 assert "BASE + 'static/update-ownership.js'" in source

View File

@ -171,3 +171,139 @@ sync.enqueue('add', '6');
"ids": ["1", "2", "3", "4", "5"], "ids": ["1", "2", "3", "4", "5"],
"status": "full", "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"]]

View File

@ -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 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 @pytest.mark.anyio
async def test_retained_authenticated_context_keeps_local_planning_separate_from_fresh_delivery(): async def test_retained_authenticated_context_keeps_local_planning_separate_from_fresh_delivery():
html = await dashboard() html = await dashboard()