Plan upcoming deadlines from a mobile Agenda #686

Merged
timmy merged 1 commits from timmy/685-mobile-agenda into main 2026-08-12 22:30:57 +00:00
7 changed files with 77 additions and 5 deletions

View File

@ -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 =
'<span class="small">' + escapeHtml(item.key) + ' · ' + escapeHtml(item.kind === 'pull' ? 'PR' : (item.kind === 'update' ? 'Update' : 'Issue')) + '</span>' +
'<span class="my-work-card-title">' + escapeHtml(item.title) + '</span>' +
(item.agenda_group ? '<span class="pill agenda-badge" data-agenda-group="' + escAttr(item.agenda_group) + '">' + escapeHtml(item.agenda_group) + '</span>' : '') +
'<span class="pill">' + escapeHtml(item.reason) + '</span>' +
(item.milestone?.title ? ' <span class="pill milestone-badge">' + escapeHtml(item.milestone.title) + '</span>' : '') +
(item.due_label ? ' <span class="pill due-badge">' + escapeHtml(item.due_label) + '</span>' : '') +

View File

@ -109,6 +109,7 @@
<div class="work-filters" aria-label="Filter My Work">
<button class="work-filter" data-work-filter="all" aria-pressed="true">All <span data-work-count="all">0</span></button>
<button class="work-filter" data-work-filter="today" aria-pressed="false">Today <span data-work-count="today">0</span></button>
<button class="work-filter" data-work-filter="agenda" aria-pressed="false">Agenda <span data-work-count="agenda">0</span></button>
<button class="work-filter" data-work-filter="attention" aria-pressed="false">Attention <span data-work-count="attention">0</span></button>
<button class="work-filter" data-work-filter="issue" aria-pressed="false">Issues <span data-work-count="issue">0</span></button>
<button class="work-filter" data-work-filter="pull" aria-pressed="false">PRs <span data-work-count="pull">0</span></button>
@ -921,6 +922,7 @@
<p class="small muted">Choose what to work through next.</p>
<div class="mobile-queue-list">
<button data-mobile-queue="today" type="button"><span><strong>Today</strong><small>Planned work</small></span><span data-mobile-queue-count="today">0</span></button>
<button data-mobile-queue="agenda" type="button"><span><strong>Agenda</strong><small>Upcoming deadlines</small></span><span data-mobile-queue-count="agenda">0</span></button>
<button data-mobile-queue="attention" type="button"><span><strong>Attention</strong><small>Needs a response</small></span><span data-mobile-queue-count="attention">0</span></button>
<button data-mobile-queue="update" type="button" aria-label="Updates, 0 unread conversations"><span><strong>Updates</strong><small>Unread conversations</small></span><span data-mobile-queue-count="update">0</span></button>
<button data-mobile-queue="later" type="button"><span><strong>Later</strong><small>Deferred work</small></span><span data-mobile-queue-count="later">0</span></button>

View File

@ -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]) => {

View File

@ -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;

View File

@ -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",

View File

@ -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 '<strong>Agenda</strong><small>Upcoming deadlines</small>' 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 '<strong>Updates</strong><small>Unread conversations</small>' in html

View File

@ -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