Merge pull request 'Keep Today plans convergent across tabs and devices' (#362) from timmy/361-convergent-today-sync into main
Merge pull request 'Keep Today plans convergent across tabs and devices' (#362) from timmy/361-convergent-today-sync into main
This commit is contained in:
commit
4bc2881a4b
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -156,6 +156,7 @@
|
|||
'Today sync unavailable · changes stay on this device.'));
|
||||
},
|
||||
});
|
||||
todaySync.startLifecycle({ window, document });
|
||||
const laterWork = createLaterWork({
|
||||
storage: localStorage,
|
||||
getLogin: () => planningOwnerLogin,
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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"]]
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user