From e5c5c8ec310a0b7ad7d5a43746fd1d90eb703fa8 Mon Sep 17 00:00:00 2001 From: timmy Date: Wed, 12 Aug 2026 21:57:34 +0000 Subject: [PATCH] feat: continue mobile work across queues (Closes #683) --- frontend/dashboard.css | 3 ++ frontend/dashboard.js | 21 ++++++++++++-- frontend/index.html | 1 + frontend/mobile-queue-launcher.js | 26 ++++++++++++++++- src/frontend_bundle.py | 2 +- tests/test_mobile_task_dock.py | 48 +++++++++++++++++++++++++++++-- 6 files changed, 95 insertions(+), 6 deletions(-) diff --git a/frontend/dashboard.css b/frontend/dashboard.css index deaee06..a5655fd 100644 --- a/frontend/dashboard.css +++ b/frontend/dashboard.css @@ -576,6 +576,7 @@ textarea { resize: vertical; min-height: 120px; } .pull-retry { min-height:44px; width:100%; margin-top:10px; } .mobile-task-dock { display:none; } .mobile-task-dock[hidden] { display:none; } + .mobile-today-hud { display:none; } .mobile-today-hud[hidden], .mobile-today-hud[data-overlay-hidden="true"] { display:none; } .mobile-task-action { min-width:0; min-height:44px; padding:6px 2px; border:0; border-radius:8px; background:transparent; display:grid; place-items:center; gap:2px; font-size:12px; } @@ -649,6 +650,7 @@ textarea { resize: vertical; min-height: 120px; } .composer-keyboard-active .update-reply { scroll-margin-block:12px; padding-bottom:env(safe-area-inset-bottom); } .mobile-task-dock { position:fixed; inset:auto 0 0; z-index:45; display:grid; grid-template-columns:repeat(5,minmax(0,1fr)); gap:2px; padding:6px 8px; padding-bottom:env(safe-area-inset-bottom); border-top:1px solid #2a496e; background:rgba(11,21,38,.98); backdrop-filter:blur(12px); } .mobile-queue-sheet { width:100%; max-width:none; margin:auto 0 0; padding:0; border:0; border-radius:18px 18px 0 0; color:var(--text); background:#102641; } + .mobile-queue-sheet::backdrop { background:rgba(3,9,18,.7); } .mobile-queue-panel { padding:16px; padding-bottom:calc(16px + env(safe-area-inset-bottom)); } .mobile-queue-panel header { display:flex; align-items:center; justify-content:space-between; gap:12px; } @@ -658,6 +660,7 @@ textarea { resize: vertical; min-height: 120px; } .mobile-queue-list button > span:first-child { display:grid; gap:2px; } .mobile-queue-list small { color:var(--muted); } .mobile-queue-list [data-mobile-queue-count] { min-width:28px; padding:3px 8px; border-radius:999px; text-align:center; background:#1d426d; } + .mobile-queue-list [data-recommended="true"] { border-color:#60a5fa; box-shadow:0 0 0 2px #60a5fa; } .mobile-today-hud { position:fixed; left:8px; right:8px; bottom:calc(56px + env(safe-area-inset-bottom)); z-index:44; display:grid; grid-template-columns:minmax(0,1fr) minmax(112px,auto); grid-template-areas:"summary complete" "progress toggle"; gap:4px 8px; max-width:100%; padding:8px; border:1px solid #31577f; border-radius:12px 12px 0 0; background:rgba(16,38,65,.98); box-shadow:0 -8px 24px rgba(0,0,0,.28); } .mobile-today-summary { grid-area:summary; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; text-align:left; font-weight:700; } .mobile-today-hud [data-work-session-progress] { grid-area:progress; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } diff --git a/frontend/dashboard.js b/frontend/dashboard.js index 093466e..78084ab 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -73,12 +73,24 @@ planToday: () => openPlanToday(mobileTaskButtons.work), openFallback: openMobileWorkFallback, }); + let mobileQueueCounts = {}; const mobileQueueLauncher = createMobileQueueLauncher({ openToday: () => mobileWorkEntry.open(), selectFilter: selectMobileQueue, firstAction: name => qs('#my-work-list .my-work-card-main, #my-work-list .draft-resume, #my-work-list .draft-continue, #my-work-list .draft-edit'), announce: message => { qs('#my-work-action-status').textContent = message; }, + getCounts: () => mobileQueueCounts, + openFindWork: () => qs('#find-work').click(), }); + function showMobileQueueCompletion(completedName) { + const next = mobileQueueLauncher.recommend(); + qs('#mobile-queue-heading').textContent = completedName + ' cleared'; + document.querySelectorAll('[data-mobile-queue]').forEach(row => row.removeAttribute('data-recommended')); + const row = qs('[data-mobile-queue="' + next.name + '"]'); + row.setAttribute('data-recommended', 'true'); + qs('#mobile-queue-sheet').showModal(); + row.focus(); + } const mobileTaskDock = createMobileTaskDock({ nav: qs('#mobile-task-dock'), sessionHud: qs('[data-mobile-today-hud]'), @@ -93,7 +105,8 @@ Array.from(document.querySelectorAll('[data-mobile-queue-count]')).map(element => [element.dataset.mobileQueueCount, element]) ), queueBadge: qs('#mobile-queue-count'), - onSelectQueue:name => name === 'recaps' ? qs('#open-today-recaps').click() : mobileQueueLauncher.open(name), + onSelectQueue:name => name === 'recaps' ? qs('#open-today-recaps').click() : + name === 'find' ? qs('#find-work').click() : mobileQueueLauncher.open(name), overlays: mobileTaskOverlays, actions: { work: () => mobileWorkEntry.open(), @@ -882,7 +895,10 @@ onStatus: message => { qs('#update-sheet-status').textContent = message; qs('#retry-update-load').hidden = !message.startsWith('Could not load update.'); - if (message === 'Inbox cleared.') qs('#my-work-action-status').textContent = message; + if (message === 'Inbox cleared.') { + qs('#my-work-action-status').textContent = message; + showMobileQueueCompletion('Updates'); + } }, onClose: () => closeUpdateSheet(false), }); @@ -2158,6 +2174,7 @@ const element = qs('[data-work-count="' + filter + '"]'); if (element) element.textContent = count; }); + mobileQueueCounts = counts; mobileTaskDock.updateQueues(counts); mobileTaskDock.updateWork(mobileWorkEntry.mode()); mobileTaskDock.updateAttention(countMyWork(activeMyWork).attention); diff --git a/frontend/index.html b/frontend/index.html index 66fa1b4..0d5850d 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -925,6 +925,7 @@ + diff --git a/frontend/mobile-queue-launcher.js b/frontend/mobile-queue-launcher.js index 8cf7faf..2dadf12 100644 --- a/frontend/mobile-queue-launcher.js +++ b/frontend/mobile-queue-launcher.js @@ -8,6 +8,21 @@ later: 'No deferred work is ready to open.', draft: 'No drafts are ready to open.', }; + const continuation = [ + ['attention', 'Start Attention'], + ['today', 'Continue Today'], + ['later', 'Start Later'], + ['draft', 'Open Drafts'], + ]; + + function recommend() { + const counts = options.getCounts ? options.getCounts() : {}; + const match = continuation.find(([name]) => Number(counts[name]) > 0); + if (!match) return {name: 'find', count: 0, label: 'Find Work'}; + const [name, label] = match; + const count = Math.max(0, Number(counts[name]) || 0); + return {name, count, label: label + ' (' + count + ')'}; + } function open(name) { if (name === 'today') return options.openToday(); @@ -21,5 +36,14 @@ return 'opened'; } - return { open }; + function continueWork() { + const next = recommend(); + if (next.name === 'find') { + options.openFindWork(); + return 'find'; + } + return open(next.name); + } + + return { open, recommend, continueWork }; }); diff --git a/src/frontend_bundle.py b/src/frontend_bundle.py index ce80df8..d8b53ac 100644 --- a/src/frontend_bundle.py +++ b/src/frontend_bundle.py @@ -28,7 +28,7 @@ FEATURE_SOURCES = { "device-setup": ("static/install-app.js", "static/mobile-device-setup.js"), "security-center": ("static/security-center.js",), "today-timer": ( - "static/mobile-task-dock.js", "static/notification-undo.js", "static/today-timer.js", "static/today-recap.js", + "static/mobile-task-dock.js", "static/mobile-queue-launcher.js", "static/notification-undo.js", "static/today-timer.js", "static/today-recap.js", "static/today-rollover.js", "static/later-work.js", "static/drafts.js", "static/unfiled-captures.js", "static/draft-filing-session.js", "static/draft-capacity-dialog.js", "static/work-selection.js", "static/today-work.js", "static/pick-work.js", "static/batch-find-work.js", diff --git a/tests/test_mobile_task_dock.py b/tests/test_mobile_task_dock.py index 2d22ec3..1843bc8 100644 --- a/tests/test_mobile_task_dock.py +++ b/tests/test_mobile_task_dock.py @@ -368,6 +368,50 @@ process.stdout.write(JSON.stringify({{opened, empty, calls}})); } +def test_mobile_queue_launcher_recommends_and_revalidates_cross_queue_continuation(): + script = f""" +const createLauncher = require({json.dumps(str(QUEUE_LAUNCHER))}); +const calls = []; +let counts = {{attention: 2, today: 1, later: 4, draft: 3}}; +const launcher = createLauncher({{ + openToday: () => calls.push('today'), + selectFilter: name => calls.push('filter:' + name), + firstAction: name => (counts[name] || 0) ? {{click() {{ calls.push('open:' + name); }}}} : null, + announce: message => calls.push('announce:' + message), + getCounts: () => counts, + openFindWork: () => calls.push('find'), +}}); +const first = launcher.recommend(); +counts = {{attention: 0, today: 0, later: 4, draft: 3}}; +const opened = launcher.continueWork(); +counts = {{attention: 0, today: 0, later: 0, draft: 0}}; +const fallback = launcher.recommend(); +launcher.continueWork(); +process.stdout.write(JSON.stringify({{first, opened, fallback, calls}})); +""" + result = subprocess.run(["node", "-e", script], capture_output=True, text=True) + + assert result.returncode == 0, result.stderr + assert json.loads(result.stdout) == { + "first": {"name": "attention", "count": 2, "label": "Start Attention (2)"}, + "opened": "opened", + "fallback": {"name": "find", "count": 0, "label": "Find Work"}, + "calls": ["filter:later", "open:later", "find"], + } + + +@pytest.mark.anyio +async def test_dashboard_renders_accessible_mobile_queue_completion_handoff(): + html = await dashboard() + + assert 'id="mobile-queue-heading"' in html + assert 'data-mobile-queue="find"' in html + assert "mobileQueueLauncher.recommend()" in html + assert "showMobileQueueCompletion('Updates')" in html + assert "row.setAttribute('data-recommended', 'true')" in html + assert "row.focus()" in html + + @pytest.mark.anyio async def test_dashboard_renders_and_wires_mobile_queue_switcher(): html = await dashboard() @@ -388,7 +432,7 @@ async def test_dashboard_renders_and_wires_mobile_queue_switcher(): assert '' in html assert "const mobileQueueLauncher = createMobileQueueLauncher({" in html assert "firstAction: name => qs('#my-work-list .my-work-card-main, #my-work-list .draft-resume, #my-work-list .draft-continue, #my-work-list .draft-edit')" in html - assert "onSelectQueue:name => name === 'recaps' ? qs('#open-today-recaps').click() : mobileQueueLauncher.open(name)" in html + assert "name === 'find' ? qs('#find-work').click() : mobileQueueLauncher.open(name)" in html assert '.mobile-queue-sheet' in html assert '.my-work-actions { display:none;' in html assert 'padding-bottom:calc(16px + env(safe-area-inset-bottom))' in html @@ -434,7 +478,7 @@ async def test_dashboard_renders_and_wires_phone_safe_task_dock(): assert '' in html assert "createMobileTaskDock({" in html assert "work: () => mobileWorkEntry.open()" in html - assert "onSelectQueue:name => name === 'recaps' ? qs('#open-today-recaps').click() : mobileQueueLauncher.open(name)" in html + assert "name === 'find' ? qs('#find-work').click() : mobileQueueLauncher.open(name)" in html assert "qs('[data-work-filter=\"' + name + '\"]').click()" in html assert "find: () => qs('#find-work').click()" in html assert "new: () => qs('#new-issue').click()" in html