From d940e0b37b6f24eda7415224cd3915fafdaf8493 Mon Sep 17 00:00:00 2001 From: timmy Date: Sun, 9 Aug 2026 01:16:26 +0000 Subject: [PATCH] fix: retire completed Today work across devices (#359) --- README.md | 6 ++- frontend/dashboard.js | 10 ++++- frontend/service-worker.js | 2 +- frontend/today-sync.js | 6 +++ frontend/today-work.js | 8 +++- tests/test_markdown_renderer.py | 2 +- tests/test_mobile_composer_integration.py | 2 +- tests/test_service_worker.py | 6 +-- tests/test_today_sync.py | 31 +++++++++++++ tests/test_today_work.py | 54 +++++++++++++++++++++++ 10 files changed, 117 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 456cb2f..38c5d82 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,11 @@ and durable delivery flow. A different or unconfirmed account can only copy or d 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. When +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 +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 **Keep My Work available offline** is enabled, every issue or pull request in the bounded Today queue is warmed automatically after a healthy authenticated refresh and as soon as it is added. The readiness indicator reports saved, pending, and retryable items; unchanged diff --git a/frontend/dashboard.js b/frontend/dashboard.js index e86a1e9..eb94aa9 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -811,8 +811,16 @@ }); activeMyWork = partitioned.active; laterMyWork = partitioned.later; + const authoritativeTodayReconciliation = liveMode && hasContextSnapshot && + !lastContextSnapshot?.error && + !Object.values(workPagination).some(page => page?.has_more); todayMyWork = todayWork.reconcile(lastMyWork, { - pruneMissing: !Object.values(workPagination).some(page => page?.has_more), + pruneMissing: authoritativeTodayReconciliation, + onPrune: retiredIds => { + const queued = retiredIds.map(id => todaySync.enqueue('remove', id)).every(Boolean); + if (queued) todaySync.flush(); + return queued; + }, }); const counts = countMyWork(activeMyWork); counts.today = todayMyWork.length; diff --git a/frontend/service-worker.js b/frontend/service-worker.js index d95dc16..6a5a8d5 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-v45'; +const CACHE = 'stackchain-dashboard-shell-v46'; 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 71a5093..53bf5d3 100644 --- a/frontend/today-sync.js +++ b/frontend/today-sync.js @@ -43,6 +43,12 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onStatus, function enqueue(action, itemId, direction = null) { const operations = pending(); + if (action === 'remove' && operations.some(operation => + operation.action === 'remove' && operation.item_id === itemId + )) { + onStatus?.('pending'); + return true; + } operations.push({ operation_id: operationId(), action, item_id: itemId, direction }); const saved = save(operations); onStatus?.(saved ? 'pending' : 'error'); diff --git a/frontend/today-work.js b/frontend/today-work.js index 2c1ec0f..286ba68 100644 --- a/frontend/today-work.js +++ b/frontend/today-work.js @@ -74,11 +74,15 @@ function createTodayWork({ storage, getLogin, limit = 5 }) { return write(ids); } - function reconcile(items, { pruneMissing = false } = {}) { + function reconcile(items, { pruneMissing = false, onPrune } = {}) { const available = new Map((items || []).map(item => [identity(item), item])); const ids = read(); const retained = pruneMissing ? ids.filter(id => available.has(id)) : ids; - if (retained.length !== ids.length) write(retained); + const retired = ids.filter(id => !retained.includes(id)); + if (retired.length) { + const reported = onPrune?.(retired); + if (reported !== false) write(retained); + } return retained.flatMap(id => available.has(id) ? [available.get(id)] : []); } diff --git a/tests/test_markdown_renderer.py b/tests/test_markdown_renderer.py index 035be56..704d6f1 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-v45" in worker + assert "stackchain-dashboard-shell-v46" in worker diff --git a/tests/test_mobile_composer_integration.py b/tests/test_mobile_composer_integration.py index 97dce9a..a5204d6 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-v45" in worker + assert "stackchain-dashboard-shell-v46" in worker diff --git a/tests/test_service_worker.py b/tests/test_service_worker.py index 7e3fca6..ca4a39e 100644 --- a/tests/test_service_worker.py +++ b/tests/test_service_worker.py @@ -108,7 +108,7 @@ async function dispatchNotificationClick(route) {{ def test_navigation_deadline_ships_in_a_new_shell_cache(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v45" in source + assert "stackchain-dashboard-shell-v46" in source assert "BASE + 'static/dashboard.css'" in source assert "BASE + 'static/dashboard.js'" in source assert "BASE + 'static/install-app.js'" in source @@ -117,14 +117,14 @@ def test_navigation_deadline_ships_in_a_new_shell_cache(): def test_mobile_search_viewport_ships_in_a_new_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v45" in source + assert "stackchain-dashboard-shell-v46" 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-v45" in source + assert "stackchain-dashboard-shell-v46" 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 cd4a5ac..8eb2e2b 100644 --- a/tests/test_today_sync.py +++ b/tests/test_today_sync.py @@ -87,6 +87,37 @@ sync.enqueue('move', 'issue:r:2:', 'up'); } +def test_duplicate_pending_retirements_collapse_to_one_effective_remove(): + script = f""" +const createTodaySync = require({json.dumps(str(TODAY_SYNC))}); +const values = new Map(); +let sequence = 0; +const statuses = []; +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: () => 'remove-' + (++sequence), + fetchJson: async () => ({{revision:0,ids:[]}}), onRemoteIds:()=>{{}}, onStatus:value=>statuses.push(value), +}}); +const first = sync.enqueue('remove', 'issue:r:1:'); +const duplicate = sync.enqueue('remove', 'issue:r:1:'); +process.stdout.write(JSON.stringify({{first, duplicate, pending:sync.pending(), statuses}})); +""" + result = json.loads( + subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True).stdout + ) + assert result == { + "first": True, + "duplicate": True, + "pending": [{ + "operation_id": "remove-1", + "action": "remove", + "item_id": "issue:r:1:", + "direction": None, + }], + "statuses": ["pending", "pending"], + } + + def test_existing_device_queue_is_migrated_only_once(): script = f""" const createTodaySync = require({json.dumps(str(TODAY_SYNC))}); diff --git a/tests/test_today_work.py b/tests/test_today_work.py index 082ffbe..5ca4110 100644 --- a/tests/test_today_work.py +++ b/tests/test_today_work.py @@ -78,6 +78,55 @@ process.stdout.write(JSON.stringify([ ] +def test_authoritative_reconciliation_reports_retired_ids_before_local_prune(): + script = f""" +const createTodayWork = require({json.dumps(str(TODAY_WORK))}); +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 queue = createTodayWork({{storage, getLogin: () => 'timmy'}}); +const item = number => ({{kind:'issue', repository:'r', number}}); +queue.add(item(1)); +queue.add(item(2)); +const reports = []; +const visible = queue.reconcile([item(2)], {{ + pruneMissing: true, + onPrune: retiredIds => reports.push({{retiredIds, idsDuringReport: queue.read()}}), +}}); +queue.reconcile([item(2)], {{ + pruneMissing: true, + onPrune: retiredIds => reports.push({{retiredIds, idsDuringReport: queue.read()}}), +}}); +process.stdout.write(JSON.stringify({{reports, visible:visible.map(entry => entry.number), stored:queue.read()}})); +""" + + assert json.loads(run_node(script)) == { + "reports": [{ + "retiredIds": ["issue:r:1:"], + "idsDuringReport": ["issue:r:1:", "issue:r:2:"], + }], + "visible": [2], + "stored": ["issue:r:2:"], + } + + +def test_failed_retirement_queue_keeps_local_identity_for_retry(): + script = f""" +const createTodayWork = require({json.dumps(str(TODAY_WORK))}); +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 queue = createTodayWork({{storage, getLogin: () => 'timmy'}}); +const item = number => ({{kind:'issue', repository:'r', number}}); +queue.add(item(1)); +const visible = queue.reconcile([], {{pruneMissing:true, onPrune:() => false}}); +process.stdout.write(JSON.stringify({{visible, stored:queue.read()}})); +""" + + assert json.loads(run_node(script)) == { + "visible": [], + "stored": ["issue:r:1:"], + } + + def test_today_queue_adopts_bounded_server_order(): script = f""" const createTodayWork = require({json.dumps(str(TODAY_WORK))}); @@ -115,6 +164,11 @@ async def test_dashboard_runs_the_curated_today_queue_as_a_mobile_work_flow(): assert "todaySync.enqueue('add'" in html assert "todaySync.enqueue('remove'" in html assert "todaySync.enqueue('move'" in html + assert "const authoritativeTodayReconciliation = liveMode && hasContextSnapshot &&" in html + assert "!lastContextSnapshot?.error &&" in html + assert "onPrune: retiredIds =>" in html + assert "retiredIds.map(id => todaySync.enqueue('remove', id)).every(Boolean)" in html + assert "if (queued) todaySync.flush();" in html assert "todaySync.flush();" in html assert "Another device filled Today ยท showing its saved plan." in html -- 2.43.0