From fdba0554dfe6bc032add9a08c0cd7c859fd97e04 Mon Sep 17 00:00:00 2001 From: timmy Date: Thu, 13 Aug 2026 16:59:36 +0000 Subject: [PATCH] feat: prioritize the mobile Work action (Closes #755) --- frontend/dashboard.js | 35 +++++------ frontend/mobile-queue-launcher.js | 1 + frontend/mobile-task-dock.js | 22 +++---- frontend/mobile-work-entry.js | 5 +- tests/test_mobile_task_dock.py | 101 ++++++++++++++++++++++++++++++ 5 files changed, 131 insertions(+), 33 deletions(-) diff --git a/frontend/dashboard.js b/frontend/dashboard.js index 6045be4..c729512 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -58,17 +58,6 @@ qs('#my-work').scrollIntoView({block:'start'}); qs('#my-work').focus(); } - const mobileWorkEntry = createMobileWorkEntry({ - isTodayActive: () => workSession.checkpointed(), - isTodayResumable: () => workSession.resumable(), - getTodayCount: () => todayMyWork.length, - getEligibleCount: () => activeMyWork.length, - continueToday: continueTodaySession, - resumeToday: resumeTodaySession, - startToday: startTodaySession, - planToday: () => openPlanToday(mobileTaskButtons.work), - findWork: () => qs('#find-work').click(), - }); let mobileQueueCounts = {}; const mobileQueueLauncher = createMobileQueueLauncher({ openToday: () => mobileWorkEntry.open(), @@ -80,6 +69,19 @@ getCounts: () => mobileQueueCounts, openFindWork: () => qs('#find-work').click(), }); + const mobileWorkEntry = createMobileWorkEntry({ + isTodayActive: () => workSession.checkpointed(), + isTodayResumable: () => workSession.resumable(), + getTodayCount: () => todayMyWork.length, + getEligibleCount: () => activeMyWork.length, + queueLauncher: mobileQueueLauncher, + continueToday: continueTodaySession, + resumeToday: resumeTodaySession, + startToday: startTodaySession, + planToday: () => openPlanToday(mobileTaskButtons.work), + + findWork: () => qs('#find-work').click(), + }); function showMobileQueueCompletion(completedName) { const next = mobileQueueLauncher.recommend(); qs('#mobile-queue-heading').textContent = completedName + ' cleared'; @@ -141,9 +143,7 @@ } const savedMilestone = sessionStorage.getItem(WORK_MILESTONE_KEY); if (savedMilestone) selectedWorkMilestone = savedMilestone; - } catch (e) { - console.warn('Filter restore failed', e); - } + } catch (e) {} let lastMyWork = []; let lastDrafts = []; let lastNotifications = []; @@ -6256,9 +6256,7 @@ launchFilterResolved = true; try { sessionStorage.setItem(WORK_FILTER_KEY, selectedWorkFilter); - } catch (e) { - console.warn('Filter save failed', e); - } + } catch (e) {} document.querySelectorAll('[data-work-filter]').forEach(item => item.setAttribute('aria-pressed', String(item === button)) ); @@ -6296,7 +6294,7 @@ qs('#work-milestone-filter').addEventListener('change', event => { selectedWorkMilestone = event.target.value; try { sessionStorage.setItem(WORK_MILESTONE_KEY, selectedWorkMilestone); } - catch (e) { console.warn('Milestone save failed', e); } + catch (e) {} renderMyWork(); if (workSession.active()) workSession.reconcile(); }); @@ -6347,7 +6345,6 @@ }).catch(error => { qs('#push-updates').disabled = true; qs('#push-update-status').textContent = 'Update notification settings unavailable.'; - console.warn('Push notifications unavailable', error); return null; }); } else { diff --git a/frontend/mobile-queue-launcher.js b/frontend/mobile-queue-launcher.js index a33475b..5c89f46 100644 --- a/frontend/mobile-queue-launcher.js +++ b/frontend/mobile-queue-launcher.js @@ -11,6 +11,7 @@ const continuation = [ ['attention', 'Start Attention'], ['today', 'Continue Today'], + ['update', 'Resume Updates'], ['agenda', 'Open Agenda'], ['later', 'Start Later'], ['draft', 'Open Drafts'], diff --git a/frontend/mobile-task-dock.js b/frontend/mobile-task-dock.js index 6c34588..f5a2e4e 100644 --- a/frontend/mobile-task-dock.js +++ b/frontend/mobile-task-dock.js @@ -64,19 +64,15 @@ } function updateWork(mode) { - const labels = { - continue: ['Continue', 'Continue Today'], - resume: ['Resume', 'Resume Today'], - start: ['Start', 'Start Today'], - plan: ['Plan', 'Plan Today'], - find: ['Find', 'Find work'], - work: ['Work', 'Work'], - }; - const workMode = labels[mode] ? mode : 'work'; - if (options.workLabel) options.workLabel.textContent = labels[workMode][0]; - if (buttons.work) { - buttons.work.setAttribute('aria-label', labels[workMode][1]); - } + const text = { + continue:'Continue', resume:'Resume', start:'Start', plan:'Plan', find:'Find', + attention:'Attention', update:'Updates', agenda:'Agenda', later:'Later', draft:'Drafts', + }[mode] || 'Work'; + const queue = ['Attention', 'Updates', 'Agenda', 'Later', 'Drafts'].includes(text); + if (options.workLabel) options.workLabel.textContent = text; + if (buttons.work) buttons.work.setAttribute('aria-label', queue + ? (mode === 'update' ? 'Resume ' : 'Open ') + text + : mode === 'find' ? 'Find work' : text + (mode === 'work' ? '' : ' Today')); } function updateAttention(count) { diff --git a/frontend/mobile-work-entry.js b/frontend/mobile-work-entry.js index 56a1fa8..622711f 100644 --- a/frontend/mobile-work-entry.js +++ b/frontend/mobile-work-entry.js @@ -4,6 +4,8 @@ })(typeof self !== 'undefined' ? self : this, function createMobileWorkEntry(options) { function mode() { if (options.isTodayActive()) return 'continue'; + const recommended = options.queueLauncher && options.queueLauncher.recommend(); + if (recommended && recommended.name !== 'today') return recommended.name; if (options.getTodayCount() > 0 && options.isTodayResumable()) return 'resume'; if (options.getTodayCount() > 0) return 'start'; if (options.getEligibleCount() > 0) return 'plan'; @@ -16,7 +18,8 @@ else if (current === 'resume') options.resumeToday(); else if (current === 'start') options.startToday(); else if (current === 'plan') options.planToday(); - else options.findWork(); + else if (current === 'find') options.findWork(); + else options.queueLauncher.open(current); return current; } diff --git a/tests/test_mobile_task_dock.py b/tests/test_mobile_task_dock.py index f5d1972..abb6315 100644 --- a/tests/test_mobile_task_dock.py +++ b/tests/test_mobile_task_dock.py @@ -48,6 +48,43 @@ process.stdout.write(JSON.stringify({{modes, calls}})); } +def test_mobile_work_entry_preserves_active_today_then_launches_highest_priority_queue(): + script = f""" +const createEntry = require({json.dumps(str(ENTRY))}); +const calls = []; +const state = {{active:true, today:2, resumable:true, eligible:3, next:{{name:'attention'}}}}; +const entry = createEntry({{ + isTodayActive: () => state.active, + isTodayResumable: () => state.resumable, + getTodayCount: () => state.today, + getEligibleCount: () => state.eligible, + queueLauncher: {{recommend: () => state.next, open: name => calls.push('queue:' + name)}}, + continueToday: () => calls.push('continue'), + resumeToday: () => calls.push('resume'), + startToday: () => calls.push('start'), + planToday: () => calls.push('plan'), + + findWork: () => calls.push('find'), +}}); +const modes = [entry.open()]; +state.active = false; modes.push(entry.open()); +state.next = {{name:'today'}}; modes.push(entry.open()); +state.today = 0; state.eligible = 0; state.next = {{name:'update'}}; modes.push(entry.open()); +state.next = {{name:'agenda'}}; modes.push(entry.open()); +state.next = {{name:'later'}}; modes.push(entry.open()); +state.next = {{name:'draft'}}; modes.push(entry.open()); +state.next = {{name:'find'}}; modes.push(entry.open()); +process.stdout.write(JSON.stringify({{modes, calls}})); +""" + result = subprocess.run(["node", "-e", script], capture_output=True, text=True) + + assert result.returncode == 0, result.stderr + assert json.loads(result.stdout) == { + "modes": ["continue", "attention", "resume", "update", "agenda", "later", "draft", "find"], + "calls": ["continue", "queue:attention", "resume", "queue:update", "queue:agenda", "queue:later", "queue:draft", "find"], + } + + def test_mobile_task_dock_routes_actions_hides_for_overlays_and_restores_focus(): script = f""" const createDock = require({json.dumps(str(DOCK))}); @@ -437,6 +474,59 @@ process.stdout.write(JSON.stringify({{first, agenda, opened, fallback, calls}})) } +def test_mobile_queue_launcher_prioritizes_updates_before_agenda_and_resumes_launcher(): + script = f""" +const createLauncher = require({json.dumps(str(QUEUE_LAUNCHER))}); +const calls = []; +let counts = {{attention:0, today:0, update:6, agenda:2, later:1, draft:1}}; +const launcher = createLauncher({{ + getCounts: () => counts, + openUpdates: () => {{ calls.push('updates'); return 'resumed'; }}, + openAgenda: () => calls.push('agenda'), + selectFilter: name => calls.push('filter:' + name), + firstAction: () => null, + announce: () => {{}}, + openFindWork: () => calls.push('find'), +}}); +const recommendation = launcher.recommend(); +const opened = launcher.continueWork(); +process.stdout.write(JSON.stringify({{recommendation, opened, calls}})); +""" + result = subprocess.run(["node", "-e", script], capture_output=True, text=True) + + assert result.returncode == 0, result.stderr + assert json.loads(result.stdout) == { + "recommendation": {"name": "update", "count": 6, "label": "Resume Updates (6)"}, + "opened": "resumed", + "calls": ["updates"], + } + + +def test_mobile_task_dock_labels_each_recommended_work_destination_accessibly(): + script = f""" +const createDock = require({json.dumps(str(DOCK))}); +const work = {{attributes: {{}}, setAttribute(name, value) {{ this.attributes[name] = value; }}}}; +const workLabel = {{textContent:''}}; +const dock = createDock({{nav:{{}}, buttons:{{work}}, workLabel}}); +const labels = {{}}; +for (const mode of ['attention','update','agenda','later','draft']) {{ + dock.updateWork(mode); + labels[mode] = [workLabel.textContent, work.attributes['aria-label']]; +}} +process.stdout.write(JSON.stringify(labels)); +""" + result = subprocess.run(["node", "-e", script], capture_output=True, text=True) + + assert result.returncode == 0, result.stderr + assert json.loads(result.stdout) == { + "attention": ["Attention", "Open Attention"], + "update": ["Updates", "Resume Updates"], + "agenda": ["Agenda", "Open Agenda"], + "later": ["Later", "Open Later"], + "draft": ["Drafts", "Open Drafts"], + } + + @pytest.mark.anyio async def test_dashboard_renders_accessible_mobile_queue_completion_handoff(): html = await dashboard() @@ -507,6 +597,17 @@ async def test_dashboard_wires_mobile_work_dock_into_today_session_lifecycle(): assert "runTodayTransition('continue')" in html +@pytest.mark.anyio +async def test_dashboard_wires_work_dock_and_app_shortcut_to_live_queue_recommendation(): + html = await dashboard() + + assert "queueLauncher: mobileQueueLauncher" in html + assert "work: () => mobileWorkEntry.open()" in html + assert "continueWork: () => mobileWorkEntry.open()" in html + assert "mobileQueueCounts = counts;" in html + assert "mobileTaskDock.updateWork(mobileWorkEntry.mode())" in html + + @pytest.mark.anyio async def test_dashboard_renders_and_wires_phone_safe_task_dock(): html = await dashboard()