From 523968f5a3580a8b067c29361e341ac310ae941e Mon Sep 17 00:00:00 2001 From: timmy Date: Thu, 13 Aug 2026 10:54:58 +0000 Subject: [PATCH] feat: run Agenda as a work session (Closes #731) --- frontend/dashboard.js | 25 +++++++++++++---- tests/test_my_work.py | 60 ++++++++++++++++++++++++++++++++++++++-- tests/test_today_work.py | 3 +- 3 files changed, 79 insertions(+), 9 deletions(-) diff --git a/frontend/dashboard.js b/frontend/dashboard.js index 493909f..52e8e16 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -1148,7 +1148,9 @@ } function updateWorkSessionActions() { const active = workSession.active(); - qs('#start-work-session').hidden = active; + const startWorkSession = qs('#start-work-session'); + startWorkSession.hidden = active; + startWorkSession.textContent = selectedWorkFilter === 'agenda' ? 'Start Agenda' : 'Start work'; qs('#resume-today-session').hidden = active || !todayMyWork.length || !workSession.resumable(); qs('#end-today-session').hidden = !active; updateDetailDeferLabels(active); @@ -1157,9 +1159,11 @@ } const workSession = createWorkSession({ - getItems: () => selectedWorkFilter === 'today' ? todayMyWork : activeMyWork, - getFilter: () => selectedWorkFilter === 'today' ? 'all' : selectedWorkFilter, - getMilestone: () => selectedWorkMilestone, + getItems: () => selectedWorkFilter === 'today' ? todayMyWork : + selectedWorkFilter === 'agenda' ? agendaMyWork(activeMyWork) : activeMyWork, + getFilter: () => selectedWorkFilter === 'today' ? 'all' : + selectedWorkFilter === 'agenda' ? 'all' : selectedWorkFilter, + getMilestone: () => selectedWorkFilter === 'agenda' ? 'all' : selectedWorkMilestone, checkpoint: sessionCheckpoint, checkpointEnabled: () => selectedWorkFilter === 'today', onOpen: openWorkSessionItem, @@ -1178,7 +1182,15 @@ button.hidden = !workSession.checkpointed(); }); }, - onFinish: () => todayRecapView.finish(selectedWorkFilter), + onFinish: () => { + updateWorkSessionActions(); + if (selectedWorkFilter === 'agenda') { + closeOpenWorkSheets(); + qs('#my-work-action-status').textContent = 'Agenda complete.'; + return; + } + todayRecapView.finish(selectedWorkFilter); + }, }); function selectTodayWork() { qs('[data-work-filter="today"]').click(); @@ -5868,7 +5880,7 @@ qs('#save-today-plan').addEventListener('click', event => commitPlanToday(false, event.currentTarget)); qs('#save-and-start-today').addEventListener('click', event => commitPlanToday(true, event.currentTarget)); qs('#start-work-session').addEventListener('click', () => { - const sessionItems = selectedWorkFilter === 'today' ? todayMyWork : filterMyWork(lastMyWork, selectedWorkFilter); + const sessionItems = workSession.items(); if (!sessionItems.length) { qs('#my-work-action-status').textContent = 'No visible work to start.'; return; @@ -6053,6 +6065,7 @@ const selectedCount = button.querySelector('[data-work-count]').textContent; qs('#active-work-queue').textContent = button.firstChild.textContent.trim() + ' (' + selectedCount + ')'; renderMyWork(); + updateWorkSessionActions(); updateWorkPaginationControls(); if (selectedWorkFilter === 'agenda') completeAgendaIssues(); if (!preserveRoute) workRoute.queue(filter); diff --git a/tests/test_my_work.py b/tests/test_my_work.py index 1f6b5da..09d2624 100644 --- a/tests/test_my_work.py +++ b/tests/test_my_work.py @@ -195,6 +195,62 @@ process.stdout.write(JSON.stringify(work.agendaMyWork(items, new Date('2026-08-1 assert json.loads(result.stdout) == ["a/r#2", "a/r#10", "z/r#1"] +def test_mobile_agenda_runs_as_an_ordered_non_durable_work_session(): + script = f""" +const buildMyWork = require({json.dumps(str(MY_WORK))}); +const values = new Map([['stackchain.today-session.v1', 'preserve-today']]); +const checkpoint = {{ + read:() => null, + save:() => {{ throw new Error('Agenda must not save Today'); }}, + clear:() => {{ throw new Error('Agenda must not clear Today'); }}, +}}; +const active = [ + {{kind:'issue',repository:'o/r',number:3,title:'Tomorrow',is_assigned:true,due_date:'2026-08-14'}}, + {{kind:'issue',repository:'o/r',number:1,title:'Overdue',is_assigned:true,due_date:'2026-08-12'}}, + {{kind:'issue',repository:'o/r',number:2,title:'Today',is_assigned:true,due_date:'2026-08-13'}}, + {{kind:'issue',repository:'o/r',number:4,title:'No deadline',is_assigned:true}}, +]; +const opened = []; +let finished = 0; +const session = buildMyWork.createWorkSession({{ + getItems:() => buildMyWork.agendaMyWork(active, new Date('2026-08-13T12:00:00')), + getFilter:() => 'all', checkpoint, checkpointEnabled:() => false, + onOpen:item => opened.push(item.title), onProgress:() => {{}}, + onFinish:() => {{ finished += 1; }}, +}}); +const started = session.start(); +session.next(); +session.next(); +const completed = session.next(); +process.stdout.write(JSON.stringify({{ + started, completed, opened, finished, active:session.active(), today:values.get('stackchain.today-session.v1'), +}})); +""" + result = subprocess.run(["node", "-e", script], capture_output=True, text=True) + + assert result.returncode == 0, result.stderr + assert json.loads(result.stdout) == { + "started": True, + "completed": False, + "opened": ["Overdue", "Today", "Tomorrow"], + "finished": 1, + "active": False, + "today": "preserve-today", + } + + +@pytest.mark.anyio +async def test_mobile_agenda_launcher_starts_the_rendered_queue_and_finishes_in_agenda(): + html = await dashboard() + + assert "selectedWorkFilter === 'agenda' ? agendaMyWork(activeMyWork)" in html + assert "selectedWorkFilter === 'agenda' ? 'all'" in html + assert "startWorkSession.textContent = selectedWorkFilter === 'agenda' ? 'Start Agenda' : 'Start work'" in html + assert "const sessionItems = workSession.items()" in html + assert "qs('#my-work-action-status').textContent = 'Agenda complete.'" in html + assert "checkpointEnabled: () => selectedWorkFilter === 'today'" in html + + @pytest.mark.parametrize("timezone_name", ["Asia/Tokyo", "America/Los_Angeles"]) def test_mobile_agenda_preserves_the_gitea_calendar_day_in_every_timezone(timezone_name): script = f""" @@ -1475,7 +1531,7 @@ async def test_mobile_my_work_exposes_touch_safe_milestone_lane_and_issue_editor assert 'id="save-issue-milestone"' in html assert 'class="pill milestone-badge"' in html assert '.work-milestone-filter, .issue-milestone-editor select, .issue-milestone-editor button { min-height:44px;' in html - assert 'getMilestone: () => selectedWorkMilestone' in html + assert "getMilestone: () => selectedWorkFilter === 'agenda' ? 'all' : selectedWorkMilestone" in html assert 'buildMyWork.replaceIssueMilestone(' in html @@ -5821,7 +5877,7 @@ async def test_mobile_dashboard_puts_filterable_my_work_before_auxiliary_panels( assert '' in html assert "buildMyWork(data)" in html assert "markMyWorkStale()" in html - assert "filterMyWork(lastMyWork, selectedWorkFilter)" in html + assert "const sessionItems = workSession.items()" in html @pytest.mark.anyio diff --git a/tests/test_today_work.py b/tests/test_today_work.py index 5414692..75de4aa 100644 --- a/tests/test_today_work.py +++ b/tests/test_today_work.py @@ -344,7 +344,8 @@ async def test_dashboard_runs_the_curated_today_queue_as_a_mobile_work_flow(): assert "const todayWork = createTodayWork({" in html assert "getTodayCount: () => todayMyWork.length" in html assert "startToday: startTodaySession" in html - assert "selectedWorkFilter === 'today' ? todayMyWork : activeMyWork" in html + assert "selectedWorkFilter === 'today' ? todayMyWork :" in html + assert "selectedWorkFilter === 'agenda' ? agendaMyWork(activeMyWork) : activeMyWork" in html assert 'data-today-add' in html assert 'data-today-remove' in html assert 'data-today-move="up"' in html