From 1132075318c90a80de8cd52cae88975aecbe4d56 Mon Sep 17 00:00:00 2001 From: timmy Date: Wed, 12 Aug 2026 22:28:36 +0000 Subject: [PATCH] feat: add mobile deadline Agenda (Closes #685) --- frontend/dashboard.js | 9 ++++++-- frontend/index.html | 2 ++ frontend/mobile-task-dock.js | 2 +- frontend/my-work.js | 20 +++++++++++++++++ src/frontend_bundle.py | 2 +- tests/test_mobile_task_dock.py | 6 +++++ tests/test_my_work.py | 41 +++++++++++++++++++++++++++++++++- 7 files changed, 77 insertions(+), 5 deletions(-) diff --git a/frontend/dashboard.js b/frontend/dashboard.js index 78084ab..f4502f0 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -133,7 +133,7 @@ let launchFilterResolved = false; try { const savedFilter = sessionStorage.getItem(WORK_FILTER_KEY); - if (['all', 'today', 'attention', 'issue', 'pull', 'review', 'update', 'later', 'draft'].includes(savedFilter)) { + if (['all', 'today', 'agenda', 'attention', 'issue', 'pull', 'review', 'update', 'later', 'draft'].includes(savedFilter)) { selectedWorkFilter = savedFilter; savedWorkFilter = savedFilter; launchFilterResolved = true; @@ -2158,6 +2158,8 @@ }, }); const counts = countMyWork(activeMyWork); + const agendaItems = agendaMyWork(activeMyWork); + counts.agenda = agendaItems.length; counts.today = todayMyWork.length; counts.later = laterMyWork.length; counts.draft = lastDrafts.length; @@ -2207,6 +2209,7 @@ function activeWorkStreams() { if (selectedWorkFilter === 'today') return ['issue', 'pull', 'review']; + if (selectedWorkFilter === 'agenda') return ['issue']; if (selectedWorkFilter === 'attention') return ['issue', 'pull', 'review']; if (selectedWorkFilter === 'issue') return ['issue']; if (selectedWorkFilter === 'pull') return ['pull']; @@ -2425,7 +2428,8 @@ } const queueItems = selectedWorkFilter === 'today' ? filterMyWork(todayMyWork, 'all', selectedWorkMilestone) : selectedWorkFilter === 'later' ? - filterMyWork(laterMyWork, 'all', selectedWorkMilestone) : + filterMyWork(laterMyWork, 'all', selectedWorkMilestone) : selectedWorkFilter === 'agenda' ? + agendaMyWork(activeMyWork) : filterMyWork(activeMyWork, selectedWorkFilter, selectedWorkMilestone); const incomplete = activeWorkStreams().some(stream => workPagination[stream]?.has_more); const visible = findQueueItems(queueItems, queueFindQuery); @@ -2441,6 +2445,7 @@ const contents = '' + escapeHtml(item.key) + ' ยท ' + escapeHtml(item.kind === 'pull' ? 'PR' : (item.kind === 'update' ? 'Update' : 'Issue')) + '' + '' + escapeHtml(item.title) + '' + + (item.agenda_group ? '' + escapeHtml(item.agenda_group) + '' : '') + '' + escapeHtml(item.reason) + '' + (item.milestone?.title ? ' ' + escapeHtml(item.milestone.title) + '' : '') + (item.due_label ? ' ' + escapeHtml(item.due_label) + '' : '') + diff --git a/frontend/index.html b/frontend/index.html index 0d5850d..3ad3071 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -109,6 +109,7 @@
+ @@ -921,6 +922,7 @@

Choose what to work through next.

+ diff --git a/frontend/mobile-task-dock.js b/frontend/mobile-task-dock.js index 34efa04..3d43982 100644 --- a/frontend/mobile-task-dock.js +++ b/frontend/mobile-task-dock.js @@ -94,7 +94,7 @@ } function updateQueues(counts) { - const names = 'today attention update later draft'.split(' '); + const names = 'today agenda attention update later draft'.split(' '); const normalized = Object.fromEntries(names.map(name => [name, Math.max(0, Number(counts?.[name]) || 0)])); const total = ['today', 'attention', 'later', 'draft'].reduce((sum, name) => sum + normalized[name], 0); Object.entries(options.queueCounts || {}).forEach(([name, element]) => { diff --git a/frontend/my-work.js b/frontend/my-work.js index 2c19551..06a72a3 100644 --- a/frontend/my-work.js +++ b/frontend/my-work.js @@ -553,6 +553,25 @@ function filterMyWork(items, selectedFilter, selectedMilestone = 'all') { ); } +function agendaMyWork(items, now = new Date()) { + const start = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + const tomorrow = new Date(start); tomorrow.setDate(tomorrow.getDate() + 1); + const afterTomorrow = new Date(start); afterTomorrow.setDate(afterTomorrow.getDate() + 2); + const horizon = new Date(start); horizon.setDate(horizon.getDate() + 7); + return (items || []).flatMap(item => { + if (item?.kind !== 'issue' || !item.is_assigned || !item.due_date) return []; + const due = new Date(item.due_date); + if (Number.isNaN(due.getTime()) || due >= horizon) return []; + const group = due < start ? 'Overdue' : due < tomorrow ? 'Today' : + due < afterTomorrow ? 'Tomorrow' : 'Next 7 days'; + return [{ ...item, agenda_group: group, _agenda_due: due.getTime() }]; + }).sort((left, right) => + left._agenda_due - right._agenda_due || + String(left.repository || '').localeCompare(String(right.repository || '')) || + Number(left.number || 0) - Number(right.number || 0) + ).map(({ _agenda_due, ...item }) => item); +} + function milestoneLanes(items) { const lanes = new Map(); (items || []).forEach(item => { @@ -837,6 +856,7 @@ function countMyWork(items) { if (typeof module !== 'undefined' && module.exports) { buildMyWork.filterMyWork = filterMyWork; + buildMyWork.agendaMyWork = agendaMyWork; buildMyWork.milestoneLanes = milestoneLanes; buildMyWork.createWorkSession = createWorkSession; buildMyWork.createWorkSessionCheckpoint = createWorkSessionCheckpoint; diff --git a/src/frontend_bundle.py b/src/frontend_bundle.py index d8b53ac..de621f1 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/mobile-queue-launcher.js", "static/notification-undo.js", "static/today-timer.js", "static/today-recap.js", + "static/my-work.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 1843bc8..1d09bcf 100644 --- a/tests/test_mobile_task_dock.py +++ b/tests/test_mobile_task_dock.py @@ -420,6 +420,12 @@ async def test_dashboard_renders_and_wires_mobile_queue_switcher(): assert 'id="mobile-queue-sheet"' in html assert 'aria-labelledby="mobile-queue-heading"' in html assert 'data-mobile-queue="today"' in html + assert 'data-mobile-queue="agenda"' in html + assert 'AgendaUpcoming deadlines' in html + assert 'data-work-filter="agenda"' in html + assert "const agendaItems = agendaMyWork(activeMyWork);" in html + assert "counts.agenda = agendaItems.length;" in html + assert 'data-agenda-group' in html assert 'data-mobile-queue="attention"' in html assert 'data-mobile-queue="update"' in html assert 'UpdatesUnread conversations' in html diff --git a/tests/test_my_work.py b/tests/test_my_work.py index 5a691ca..3c52a4e 100644 --- a/tests/test_my_work.py +++ b/tests/test_my_work.py @@ -118,6 +118,45 @@ process.stdout.write(JSON.stringify({{ } +def test_mobile_agenda_groups_assigned_deadlines_in_local_seven_day_horizon(): + script = f""" +const work = require({json.dumps(str(MY_WORK))}); +const now = new Date('2026-08-12T12:00:00'); +const items = [ + {{kind:'issue', key:'o/r#5', repository:'o/r', number:5, is_assigned:true, due_date:'2026-08-11T18:00:00'}}, + {{kind:'issue', key:'o/r#3', repository:'o/r', number:3, is_assigned:true, due_date:'2026-08-12T17:00:00'}}, + {{kind:'issue', key:'a/r#9', repository:'a/r', number:9, is_assigned:true, due_date:'2026-08-13T09:00:00'}}, + {{kind:'issue', key:'a/r#2', repository:'a/r', number:2, is_assigned:true, due_date:'2026-08-18T09:00:00'}}, + {{kind:'issue', key:'a/r#1', repository:'a/r', number:1, is_assigned:true, due_date:'2026-08-20T09:00:00'}}, + {{kind:'issue', key:'a/r#7', repository:'a/r', number:7, is_assigned:false, due_date:'2026-08-13T08:00:00'}}, + {{kind:'pull', key:'a/r#8', repository:'a/r', number:8, is_assigned:true, due_date:'2026-08-13T08:00:00'}}, + {{kind:'issue', key:'a/r#6', repository:'a/r', number:6, is_assigned:true}}, +]; +process.stdout.write(JSON.stringify(work.agendaMyWork(items, now).map(item => [item.key, item.agenda_group]))); +""" + result = subprocess.run(["node", "-e", script], capture_output=True, text=True) + assert result.returncode == 0, result.stderr + assert json.loads(result.stdout) == [ + ["o/r#5", "Overdue"], ["o/r#3", "Today"], + ["a/r#9", "Tomorrow"], ["a/r#2", "Next 7 days"], + ] + + +def test_mobile_agenda_sorts_equal_deadlines_by_repository_and_number(): + script = f""" +const work = require({json.dumps(str(MY_WORK))}); +const items = [ + {{kind:'issue', key:'z/r#1', repository:'z/r', number:1, is_assigned:true, due_date:'2026-08-14T09:00:00'}}, + {{kind:'issue', key:'a/r#10', repository:'a/r', number:10, is_assigned:true, due_date:'2026-08-14T09:00:00'}}, + {{kind:'issue', key:'a/r#2', repository:'a/r', number:2, is_assigned:true, due_date:'2026-08-14T09:00:00'}}, +]; +process.stdout.write(JSON.stringify(work.agendaMyWork(items, new Date('2026-08-12T12:00:00')).map(item => item.key))); +""" + result = subprocess.run(["node", "-e", script], capture_output=True, text=True) + assert result.returncode == 0, result.stderr + assert json.loads(result.stdout) == ["a/r#2", "a/r#10", "z/r#1"] + + @pytest.mark.anyio async def test_mobile_queue_finder_is_labeled_thumb_safe_and_offers_older_search(): html = await dashboard() @@ -5861,7 +5900,7 @@ async def test_mobile_filters_wrap_show_counts_and_persist_for_the_session(): assert 'data-work-count="review"' in html assert 'data-work-count="update"' in html assert 'data-work-count="later"' in html - assert "['all', 'today', 'attention', 'issue', 'pull', 'review', 'update', 'later', 'draft'].includes(savedFilter)" in html + assert "['all', 'today', 'agenda', 'attention', 'issue', 'pull', 'review', 'update', 'later', 'draft'].includes(savedFilter)" in html assert 'data-work-count="draft"' in html assert 'sessionStorage.getItem(WORK_FILTER_KEY)' in html assert 'sessionStorage.setItem(WORK_FILTER_KEY, selectedWorkFilter)' in html -- 2.43.0