From 982c2a1fb75bd4944d80146a69faa07acb78a77d Mon Sep 17 00:00:00 2001 From: timmy Date: Wed, 12 Aug 2026 14:29:07 +0000 Subject: [PATCH] feat: make mobile work queues addressable (Closes #657) --- frontend/dashboard.js | 14 +----- frontend/work-route.js | 15 +++++- tests/test_background_issue_sync.py | 5 +- tests/test_my_work.py | 72 ++++++++++++++++++++++++++++- 4 files changed, 87 insertions(+), 19 deletions(-) diff --git a/frontend/dashboard.js b/frontend/dashboard.js index 18e0502..104cd32 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -5563,11 +5563,7 @@ qs('#active-work-queue').textContent = button.firstChild.textContent.trim() + ' (' + selectedCount + ')'; renderMyWork(); updateWorkPaginationControls(); - if (!preserveRoute && filter === 'update' && window.location.hash !== '#/my-work/updates') { - window.history.pushState({ workQueue:'update' }, '', '#/my-work/updates'); - } else if (!preserveRoute && filter !== 'update' && window.location.hash === '#/my-work/updates') { - window.history.replaceState(null, '', window.location.pathname + window.location.search); - } + if (!preserveRoute) workRoute.queue(filter); return true; } function openWorkQueueRoute(filter) { @@ -5575,14 +5571,6 @@ qs('#my-work').scrollIntoView({block:'start'}); qs('#my-work').focus(); } - function openDeliveryReceiptRoute() { - if (window.location.hash !== '#/my-work/drafts') return; - qs('[data-work-filter="draft"]').click(); - qs('#my-work').scrollIntoView({block:'start'}); - qs('#my-work').focus(); - } - window.addEventListener('hashchange', openDeliveryReceiptRoute); - openDeliveryReceiptRoute(); qs('#work-milestone-filter').addEventListener('change', event => { selectedWorkMilestone = event.target.value; try { sessionStorage.setItem(WORK_MILESTONE_KEY, selectedWorkMilestone); } diff --git a/frontend/work-route.js b/frontend/work-route.js index 99b2f03..bd648e2 100644 --- a/frontend/work-route.js +++ b/frontend/work-route.js @@ -6,6 +6,7 @@ 'use strict'; const repositoryPart = /^[A-Za-z0-9_.-]+$/; + const queueFilters = ['today', 'attention', 'update', 'later', 'draft']; function positiveInteger(value) { const number = Number(value); @@ -15,8 +16,9 @@ function parse(fragment) { const parts = String(fragment || '').split('/'); if (parts[0] !== '#' || parts[1] !== 'my-work') return null; - if (parts[2] === 'updates' && parts.length === 3) { - return { kind: 'queue', filter: 'update' }; + const queue = parts[2]?.replace(/s$/, ''); + if (queueFilters.includes(queue) && parts.length === 3) { + return { kind: 'queue', filter: queue }; } if (parts[2] === 'update' && parts.length === 4) { const notificationId = positiveInteger(parts[3]); @@ -149,6 +151,15 @@ onOpen(item); return true; }, + queue(filter) { + if (!queueFilters.includes(filter)) return false; + const name = filter + (['update', 'draft'].includes(filter) ? 's' : ''); + const fragment = '#/my-work/' + name; + if (location.hash !== fragment) history.pushState(null, '', fragment); + active = fragment; + onQueue(filter); + return true; + }, close() { if (parse(location.hash)) history.back(); else { diff --git a/tests/test_background_issue_sync.py b/tests/test_background_issue_sync.py index cc32789..108a039 100644 --- a/tests/test_background_issue_sync.py +++ b/tests/test_background_issue_sync.py @@ -1420,5 +1420,6 @@ async def test_dashboard_offers_explicit_account_bound_delivery_receipt_opt_in() assert "await Notification.requestPermission()" in html assert "backgroundIssueSync.setReceiptPreference(confirmedOwnerLogin, enabled)" in html assert "await backgroundIssueSync.getReceiptPreference(confirmedOwnerLogin)" in html - assert "window.addEventListener('hashchange', openDeliveryReceiptRoute);" in html - assert "if (window.location.hash !== '#/my-work/drafts') return;" in html + assert "onQueue: openWorkQueueRoute" in html + assert "workRoute.queue(filter)" in html + assert "openDeliveryReceiptRoute" not in html diff --git a/tests/test_my_work.py b/tests/test_my_work.py index 73fd3dc..fe85598 100644 --- a/tests/test_my_work.py +++ b/tests/test_my_work.py @@ -439,6 +439,74 @@ process.stdout.write(JSON.stringify({{calls, hash:location.hash, parsed:routes.p } +def test_every_mobile_queue_route_survives_hydration_and_detail_back_navigation(): + script = f""" +const routes = require({json.dumps(str(WORK_ROUTE))}); +const canonical = ['today', 'attention', 'updates', 'later', 'drafts']; +const parsed = canonical.map(name => [name, routes.parse('#/my-work/' + name)]); +const listeners = {{}}; +const location = {{hash:'#/my-work/later'}}; +const calls = []; +const stack = ['#/my-work/later']; +let cursor = 0; +const history = {{ + pushState(state, _, hash) {{ stack.splice(cursor + 1); stack.push(hash); cursor += 1; location.hash = hash; }}, + replaceState(state, _, hash) {{ stack[cursor] = hash; location.hash = hash; }}, + back() {{ cursor -= 1; location.hash = stack[cursor]; listeners.popstate(); }}, +}}; +const controller = routes.createController({{ + location, history, + eventTarget: {{addEventListener(name, fn) {{ listeners[name] = fn; }}}}, + onQueue: queue => calls.push(['queue', queue]), + onOpen: item => calls.push(['open', item.number]), + onClose: () => calls.push(['close']), + onInvalid: () => calls.push(['invalid']), +}}); +controller.start(); +controller.setItems([{{kind:'issue', repository:'stackchain/dashboard', number:42}}]); +controller.open({{kind:'issue', repository:'stackchain/dashboard', number:42}}); +controller.close(); +controller.queue('draft'); +process.stdout.write(JSON.stringify({{parsed, calls, hash:location.hash}})); +""" + result = subprocess.run( + ["node", "-e", script], check=True, capture_output=True, text=True + ) + + assert json.loads(result.stdout) == { + "parsed": [ + ["today", {"kind": "queue", "filter": "today"}], + ["attention", {"kind": "queue", "filter": "attention"}], + ["updates", {"kind": "queue", "filter": "update"}], + ["later", {"kind": "queue", "filter": "later"}], + ["drafts", {"kind": "queue", "filter": "draft"}], + ], + "calls": [ + ["queue", "later"], + ["open", 42], + ["close"], + ["queue", "later"], + ["queue", "draft"], + ], + "hash": "#/my-work/drafts", + } + + +def test_mobile_queue_routes_reject_unknown_or_nested_fragments(): + script = f""" +const routes = require({json.dumps(str(WORK_ROUTE))}); +process.stdout.write(JSON.stringify([ + routes.parse('#/my-work/tomorrow'), + routes.parse('#/my-work/today/extra'), + routes.parse('#/my-work/drafts/1'), +])); +""" + result = subprocess.run( + ["node", "-e", script], check=True, capture_output=True, text=True + ) + assert json.loads(result.stdout) == [None, None, None] + + def test_work_route_controller_resolves_cold_routes_without_erasing_the_fragment(): script = f""" const routes = require({json.dumps(str(WORK_ROUTE))}); @@ -553,8 +621,8 @@ async def test_dashboard_wires_addressable_work_sheets_back_navigation_and_share assert 'Route unavailable ยท this item is no longer in My Work.' in html assert "onQueue: openWorkQueueRoute" in html assert "selectWorkQueue(filter, { preserveRoute:true })" in html - assert "filter === 'update' && window.location.hash !== '#/my-work/updates'" in html - assert "window.history.pushState({ workQueue:'update' }, '', '#/my-work/updates')" in html + assert "workRoute.queue(filter)" in html + assert "openDeliveryReceiptRoute" not in html def test_my_work_queue_prioritizes_labels_then_reviews_and_keeps_repo_identity():