diff --git a/frontend/dashboard.js b/frontend/dashboard.js index 095d85a..a940ce1 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -59,10 +59,23 @@ qs('#my-work').focus(); } let mobileQueueCounts = {}; + function openFiledFollowUp() { + selectMobileQueue('filed'); + const target = filedFollowUpTarget(lastMyWork); + if (!target) { + qs('#my-work-action-status').textContent = 'No filed issues are ready to open.'; + return 'empty'; + } + const index = lastMyWork.indexOf(target.item); + const trigger = qs('#my-work-list [data-' + target.kind + '-index="' + index + '"]'); + openRoutedWork(target.kind === 'update' ? { ...target.item, kind:'update' } : target.item, trigger); + return target.kind === 'update' ? 'opened-update' : 'opened-issue'; + } const mobileQueueLauncher = createMobileQueueLauncher({ openToday: () => mobileWorkEntry.open(), openAgenda: openAgendaSession, openUpdates: openUpdateTriage, + openFiled: openFiledFollowUp, 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; }, diff --git a/frontend/index.html b/frontend/index.html index 812935d..2650779 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1214,6 +1214,7 @@ + diff --git a/frontend/mobile-queue-launcher.js b/frontend/mobile-queue-launcher.js index 5c89f46..3215060 100644 --- a/frontend/mobile-queue-launcher.js +++ b/frontend/mobile-queue-launcher.js @@ -30,6 +30,7 @@ if (name === 'today') return options.openToday(); if (name === 'agenda') return options.openAgenda(); if (name === 'update' && options.openUpdates) return options.openUpdates(); + if (name === 'filed' && options.openFiled) return options.openFiled(); options.selectFilter(name); const action = options.firstAction(name); if (!action) { diff --git a/frontend/mobile-task-dock.js b/frontend/mobile-task-dock.js index f5a2e4e..e6184e2 100644 --- a/frontend/mobile-task-dock.js +++ b/frontend/mobile-task-dock.js @@ -91,9 +91,9 @@ } function updateQueues(counts) { - const names = 'today agenda attention update later draft'.split(' '); + const names = 'today agenda attention update filed later draft'.split(' '); const normalized = Object.fromEntries(names.map(name => [name, Math.max(0, Number(counts?.[name]) || 0)])); - const actionableNames = ['today', 'attention', 'update', 'later', 'draft']; + const actionableNames = ['today', 'attention', 'update', 'filed', 'later', 'draft']; const active = actionableNames.reduce((total, name) => total + (normalized[name] > 0 ? 1 : 0), 0); Object.entries(options.queueCounts || {}).forEach(([name, element]) => { element.textContent = String(normalized[name] || 0); @@ -119,6 +119,7 @@ + ', Agenda ' + normalized.agenda + ' due' + ', Attention ' + normalized.attention + ', Updates ' + normalized.update + + ', Filed ' + normalized.filed + ', Later ' + normalized.later + ', Drafts ' + normalized.draft + '; ' + active + ' active ' + (active === 1 ? 'queue' : 'queues'); diff --git a/frontend/my-work.js b/frontend/my-work.js index f90f3bc..bd6e0aa 100644 --- a/frontend/my-work.js +++ b/frontend/my-work.js @@ -961,6 +961,12 @@ function findQueueItems(items, query) { }); } +function filedFollowUpTarget(items) { + const item = (items || []).find(candidate => candidate?.is_filed); + if (!item) return null; + return { kind:item.has_update ? 'update' : 'issue', item }; +} + function countMyWork(items) { return { all: items.length, @@ -987,6 +993,7 @@ if (typeof module !== 'undefined' && module.exports) { buildMyWork.removeIssue = removeIssue; buildMyWork.summarizeMyWork = summarizeMyWork; buildMyWork.findQueueItems = findQueueItems; + buildMyWork.filedFollowUpTarget = filedFollowUpTarget; buildMyWork.countMyWork = countMyWork; buildMyWork.acknowledgeNotification = acknowledgeNotification; buildMyWork.createNotificationAcknowledger = createNotificationAcknowledger; diff --git a/tests/test_mobile_task_dock.py b/tests/test_mobile_task_dock.py index 727dd83..969557a 100644 --- a/tests/test_mobile_task_dock.py +++ b/tests/test_mobile_task_dock.py @@ -331,7 +331,7 @@ sheet.close = function () {{ this.open = false; this.listeners.close?.(); }}; const close = new FakeElement(); const badge = new FakeElement(); const deadline = new FakeElement(); -const rows = Object.fromEntries(['today','agenda','attention','update','later','draft','recaps'].map(name => [name, new FakeElement()])); +const rows = Object.fromEntries(['today','agenda','attention','update','filed','later','draft','recaps'].map(name => [name, new FakeElement()])); const counts = Object.fromEntries(Object.keys(rows).map(name => [name, new FakeElement()])); const selected = []; const utilities = []; @@ -342,7 +342,7 @@ const dock = createDock({{ observe() {{}}, }}); dock.start(); -dock.updateQueues({{today:2, agenda:5, attention:1, update:5, later:3, draft:4}}); +dock.updateQueues({{today:2, agenda:5, attention:1, update:5, filed:2, later:3, draft:4}}); queues.click(); const opened = sheet.open; rows.update.click(); @@ -387,9 +387,9 @@ process.stdout.write(JSON.stringify({{ "utilities": ["recaps:trigger"], "badge": "0 active", "populated": { - "badge": "5 active", + "badge": "6 active", "badgeHidden": False, - "badgeLabel": "Queues: Today 2, Agenda 5 due, Attention 1, Updates 5, Later 3, Drafts 4; 5 active queues", + "badgeLabel": "Queues: Today 2, Agenda 5 due, Attention 1, Updates 5, Filed 2, Later 3, Drafts 4; 6 active queues", "deadline": "5 due", "deadlineHidden": False, "agendaDue": "true", @@ -398,13 +398,13 @@ process.stdout.write(JSON.stringify({{ "badge": "1 active", "badgeHidden": False, "deadlineHidden": True, - "badgeLabel": "Queues: Today 0, Agenda 0 due, Attention 0, Updates 7, Later 0, Drafts 0; 1 active queue", + "badgeLabel": "Queues: Today 0, Agenda 0 due, Attention 0, Updates 7, Filed 0, Later 0, Drafts 0; 1 active queue", }, "clearedBadgeHidden": True, "clearedDeadlineHidden": True, "clearedAgendaDue": None, "clearedBadgeLabel": "Queues: no active queues; no upcoming deadlines", - "counts": {"today": "0", "agenda": "0", "attention": "0", "update": "0", "later": "0", "draft": "0", "recaps": "0"}, + "counts": {"today": "0", "agenda": "0", "attention": "0", "update": "0", "filed": "0", "later": "0", "draft": "0", "recaps": "0"}, "updateLabel": "Updates, 0 unread conversations", "queueFocuses": 3, "columnState": None, @@ -439,6 +439,28 @@ process.stdout.write(JSON.stringify({{opened, empty, calls}})); } +def test_mobile_queue_launcher_uses_dedicated_filed_follow_up_flow(): + script = f""" +const createLauncher = require({json.dumps(str(QUEUE_LAUNCHER))}); +const calls = []; +const launcher = createLauncher({{ + openFiled: () => {{ calls.push('filed-follow-up'); return 'opened-update'; }}, + selectFilter: name => calls.push('generic-filter:' + name), + firstAction: () => {{ throw new Error('generic card launch must not run'); }}, + announce: message => calls.push('announce:' + message), +}}); +const result = launcher.open('filed'); +process.stdout.write(JSON.stringify({{result, calls}})); +""" + result = subprocess.run(["node", "-e", script], capture_output=True, text=True) + + assert result.returncode == 0, result.stderr + assert json.loads(result.stdout) == { + "result": "opened-update", + "calls": ["filed-follow-up"], + } + + def test_mobile_queue_launcher_recommends_and_revalidates_cross_queue_continuation(): script = f""" const createLauncher = require({json.dumps(str(QUEUE_LAUNCHER))}); @@ -556,6 +578,11 @@ async def test_dashboard_renders_and_wires_mobile_queue_switcher(): assert 'data-mobile-queue="attention"' in html assert 'data-mobile-queue="update"' in html assert 'UpdatesUnread conversations' in html + assert 'data-mobile-queue="filed"' in html + assert 'FiledIssues you delegated' in html + assert 'data-mobile-queue-count="filed"' in html + assert "openFiled: openFiledFollowUp" in html + assert "filedFollowUpTarget(lastMyWork)" in html assert 'data-mobile-queue="later"' in html assert 'data-mobile-queue="draft"' in html assert 'data-mobile-queue="recaps"' in html diff --git a/tests/test_my_work.py b/tests/test_my_work.py index 2d2fa99..95431a4 100644 --- a/tests/test_my_work.py +++ b/tests/test_my_work.py @@ -44,6 +44,34 @@ AGENDA_SESSION_LAUNCHER = Path(__file__).parents[1] / "frontend" / "agenda-sessi UPDATE_TRIAGE_LAUNCHER = Path(__file__).parents[1] / "frontend" / "update-triage-launcher.js" +def test_filed_follow_up_target_preserves_queue_order_and_selects_update_reader(): + script = f""" +const buildMyWork = require({json.dumps(str(MY_WORK))}); +const choose = buildMyWork.filedFollowUpTarget; +const available = typeof choose === 'function'; +const quiet = {{key:'repo#1', is_filed:true, has_update:false}}; +const unread = {{key:'repo#2', is_filed:true, has_update:true, notification_id:22}}; +const unrelated = {{key:'repo#3', is_filed:false, has_update:true}}; +const results = available ? {{ + first:choose([unrelated, unread, quiet]), + fallback:choose([unrelated, quiet, unread]), + empty:choose([unrelated]), +}} : null; +process.stdout.write(JSON.stringify({{available, results}})); +""" + result = subprocess.run(["node", "-e", script], capture_output=True, text=True) + + assert result.returncode == 0, result.stderr + assert json.loads(result.stdout) == { + "available": True, + "results": { + "first": {"kind": "update", "item": {"key": "repo#2", "is_filed": True, "has_update": True, "notification_id": 22}}, + "fallback": {"kind": "issue", "item": {"key": "repo#1", "is_filed": True, "has_update": False}}, + "empty": None, + }, + } + + def test_updates_rank_actionable_work_before_newer_routine_activity(): payload = { "user": {"login": "timmy"},