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