feat: add mobile deadline Agenda (Closes #685)
This commit is contained in:
parent
16b1d8670f
commit
1132075318
|
|
@ -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>' : '') +
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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]) => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user