feat: converge Today sync across active devices (#361)
All checks were successful
CI / lint (pull_request) Successful in 42s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy 2026-08-09 02:01:55 +00:00
parent 7ec3980713
commit 3c743a2b71
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
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

View File

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

View File

@ -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 = [

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 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;

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 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

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]))
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():
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

View File

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

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