From 715df380e04568b29f624500959ef8dd28c898af Mon Sep 17 00:00:00 2001 From: timmy Date: Thu, 13 Aug 2026 11:27:13 +0000 Subject: [PATCH] feat: resume mobile Agenda sessions (Closes #733) --- frontend/dashboard.js | 30 +++++++++-- frontend/mobile-queue-launcher.js | 1 + frontend/my-work.js | 19 +++---- tests/test_mobile_task_dock.py | 3 +- tests/test_my_work.py | 83 +++++++++++++++++++++++++++++-- 5 files changed, 118 insertions(+), 18 deletions(-) diff --git a/frontend/dashboard.js b/frontend/dashboard.js index 52e8e16..cb2b311 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -72,6 +72,7 @@ let mobileQueueCounts = {}; const mobileQueueLauncher = createMobileQueueLauncher({ openToday: () => mobileWorkEntry.open(), + openAgenda: openAgendaSession, selectFilter: selectMobileQueue, firstAction: name => qs('#my-work-list .my-work-card-main, #my-work-list .draft-resume, #my-work-list .draft-continue, #my-work-list .draft-edit'), announce: message => { qs('#my-work-action-status').textContent = message; }, @@ -1082,6 +1083,15 @@ 'Session recovery could not be saved on this device. You can keep working.'; }, }); + const agendaSessionCheckpoint = createWorkSessionCheckpoint({ + storage: localStorage, + getLogin: () => confirmedOwnerLogin, + key: 'stackchain.agenda-session.v1', + onError: () => { + qs('#my-work-action-status').textContent = + 'Agenda progress could not be saved on this device. You can keep working.'; + }, + }); const timer = createTodayTimer({ storage: localStorage, getLogin: () => confirmedOwnerLogin, @@ -1150,8 +1160,10 @@ const active = workSession.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(); + startWorkSession.textContent = selectedWorkFilter === 'agenda' ? + (agendaSessionCheckpoint.read() ? 'Resume Agenda' : 'Start Agenda') : 'Start work'; + qs('#resume-today-session').hidden = active || selectedWorkFilter !== 'today' || + !todayMyWork.length || !workSession.resumable(); qs('#end-today-session').hidden = !active; updateDetailDeferLabels(active); mobileTaskDock.updateWork(mobileWorkEntry.mode()); @@ -1164,8 +1176,9 @@ getFilter: () => selectedWorkFilter === 'today' ? 'all' : selectedWorkFilter === 'agenda' ? 'all' : selectedWorkFilter, getMilestone: () => selectedWorkFilter === 'agenda' ? 'all' : selectedWorkMilestone, - checkpoint: sessionCheckpoint, - checkpointEnabled: () => selectedWorkFilter === 'today', + checkpoint: () => selectedWorkFilter === 'agenda' ? agendaSessionCheckpoint : sessionCheckpoint, + checkpointEnabled: () => ['today', 'agenda'].includes(selectedWorkFilter), + checkpointedEnabled: () => selectedWorkFilter === 'today', onOpen: openWorkSessionItem, onProgress: state => { updateWorkSessionActions(); @@ -1186,6 +1199,7 @@ updateWorkSessionActions(); if (selectedWorkFilter === 'agenda') { closeOpenWorkSheets(); + window.location.hash = '#/my-work/agenda'; qs('#my-work-action-status').textContent = 'Agenda complete.'; return; } @@ -1196,6 +1210,13 @@ qs('[data-work-filter="today"]').click(); } + function openAgendaSession() { + selectMobileQueue('agenda'); + const opened = agendaSessionCheckpoint.read() ? workSession.resume() : workSession.start(); + if (!opened) qs('#my-work-action-status').textContent = 'No deadlines are ready in Agenda.'; + return opened ? 'opened' : 'empty'; + } + let todayReadinessTrigger = null; let todayReadinessBlockerFocus = null; let searchPreviewReturnKind = null; @@ -5886,6 +5907,7 @@ return; } if (selectedWorkFilter === 'today') runTodayTransition('start'); + else if (selectedWorkFilter === 'agenda' && agendaSessionCheckpoint.read()) workSession.resume(); else workSession.start(); }); qs('#resume-today-session').addEventListener('click', () => resumeTodaySession()); diff --git a/frontend/mobile-queue-launcher.js b/frontend/mobile-queue-launcher.js index 35b352a..e3ea208 100644 --- a/frontend/mobile-queue-launcher.js +++ b/frontend/mobile-queue-launcher.js @@ -27,6 +27,7 @@ function open(name) { if (name === 'today') return options.openToday(); + if (name === 'agenda') return options.openAgenda(); options.selectFilter(name); const action = options.firstAction(name); if (!action) { diff --git a/frontend/my-work.js b/frontend/my-work.js index cc3aabc..8da3c42 100644 --- a/frontend/my-work.js +++ b/frontend/my-work.js @@ -675,13 +675,14 @@ function createWorkSessionCheckpoint({ function createWorkSession({ getItems, getFilter, getMilestone = () => 'all', checkpoint = null, - checkpointEnabled = () => true, + checkpointEnabled = () => true, checkpointedEnabled = checkpointEnabled, onOpen, onProgress, onFinish, }) { let currentIdentity = ''; let currentIndex = -1; let running = false; let durable = false; + const activeCheckpoint = () => typeof checkpoint === 'function' ? checkpoint() : checkpoint; const queue = () => filterMyWork(getItems() || [], getFilter(), getMilestone()); const report = (items, index) => onProgress({ @@ -696,7 +697,7 @@ function createWorkSession({ durable = false; currentIdentity = ''; currentIndex = -1; - if (clearCheckpoint) checkpoint?.clear(); + if (clearCheckpoint) activeCheckpoint()?.clear(); onFinish(); return false; }; @@ -704,7 +705,7 @@ function createWorkSession({ if (!items.length || index < 0 || index >= items.length) return finish(); currentIndex = index; currentIdentity = workIdentity(items[index]); - if (durable) checkpoint?.save(currentIdentity, currentIndex); + if (durable) activeCheckpoint()?.save(currentIdentity, currentIndex); report(items, index); onOpen(items[index]); return true; @@ -712,9 +713,9 @@ function createWorkSession({ return { active: () => running, - checkpointed: item => running && durable && (!item || workIdentity(item) === currentIdentity), + checkpointed: item => running && durable && checkpointedEnabled() && (!item || workIdentity(item) === currentIdentity), end: () => finish(), - resumable: () => Boolean(checkpoint?.read()), + resumable: () => Boolean(activeCheckpoint()?.read()), reopen(requested = null) { if (!running) return false; const items = queue(); @@ -723,13 +724,13 @@ function createWorkSession({ if (index < 0) return false; currentIdentity = requestedIdentity; currentIndex = index; - if (durable && requested) checkpoint?.save(currentIdentity, currentIndex); + if (durable && requested) activeCheckpoint()?.save(currentIdentity, currentIndex); report(items, index); onOpen(items[index]); return true; }, resume(requested = null) { - const saved = checkpoint?.read(); + const saved = activeCheckpoint()?.read(); if (!saved) return false; durable = true; const items = queue(); @@ -743,7 +744,7 @@ function createWorkSession({ const items = queue(); if (!items.length) return finish(); running = true; - durable = Boolean(checkpoint && checkpointEnabled()); + durable = Boolean(activeCheckpoint() && checkpointEnabled()); const requested = item ? items.findIndex(candidate => workIdentity(candidate) === workIdentity(item)) : 0; return openAt(items, requested >= 0 ? requested : 0); }, @@ -791,7 +792,7 @@ function createWorkSession({ if (!items.length) return null; if (action === 'start') return items[0]; if (action === 'resume') { - const saved = checkpoint?.read(); + const saved = activeCheckpoint()?.read(); if (!saved) return null; const exact = items.findIndex(item => workIdentity(item) === saved.identity); return items[exact >= 0 ? exact : Math.min(saved.index, items.length - 1)]; diff --git a/tests/test_mobile_task_dock.py b/tests/test_mobile_task_dock.py index d9c0e72..2eb40e6 100644 --- a/tests/test_mobile_task_dock.py +++ b/tests/test_mobile_task_dock.py @@ -388,6 +388,7 @@ const calls = []; let counts = {{attention: 2, today: 1, agenda: 5, later: 4, draft: 3}}; const launcher = createLauncher({{ openToday: () => calls.push('today'), + openAgenda: () => {{ calls.push('agenda'); return 'opened'; }}, selectFilter: name => calls.push('filter:' + name), firstAction: name => (counts[name] || 0) ? {{click() {{ calls.push('open:' + name); }}}} : null, announce: message => calls.push('announce:' + message), @@ -411,7 +412,7 @@ process.stdout.write(JSON.stringify({{first, agenda, opened, fallback, calls}})) "agenda": {"name": "agenda", "count": 5, "label": "Open Agenda (5)"}, "opened": "opened", "fallback": {"name": "find", "count": 0, "label": "Find Work"}, - "calls": ["filter:agenda", "open:agenda", "find"], + "calls": ["agenda", "find"], } diff --git a/tests/test_my_work.py b/tests/test_my_work.py index 09d2624..8fdd5dc 100644 --- a/tests/test_my_work.py +++ b/tests/test_my_work.py @@ -239,16 +239,91 @@ process.stdout.write(JSON.stringify({{ } +def test_mobile_agenda_checkpoint_is_account_bound_resumable_and_separate_from_today(): + script = f""" +const buildMyWork = require({json.dumps(str(MY_WORK))}); +const values = new Map([['stackchain.today-session.v1', 'preserve-today']]); +const storage = {{ + getItem:key => values.get(key) || null, + setItem:(key, value) => values.set(key, value), + removeItem:key => values.delete(key), +}}; +let login = 'timmy'; +const agendaCheckpoint = buildMyWork.createWorkSessionCheckpoint({{ + storage, getLogin:() => login, key:'stackchain.agenda-session.v1', +}}); +const todayCheckpoint = buildMyWork.createWorkSessionCheckpoint({{ + storage, getLogin:() => login, key:'stackchain.today-session.v1', +}}); +let mode = 'agenda'; +let items = [ + {{kind:'issue',repository:'o/r',number:1,title:'Overdue'}}, + {{kind:'issue',repository:'o/r',number:2,title:'Today'}}, + {{kind:'issue',repository:'o/r',number:3,title:'Tomorrow'}}, +]; +const opened = []; +const makeSession = () => buildMyWork.createWorkSession({{ + getItems:() => items, getFilter:() => 'all', + checkpoint:() => mode === 'agenda' ? agendaCheckpoint : todayCheckpoint, + checkpointEnabled:() => true, checkpointedEnabled:() => mode === 'today', + onOpen:item => opened.push(item.title), onProgress:() => {{}}, onFinish:() => {{}}, +}}); +const first = makeSession(); +first.start(); +first.next(); +const saved = JSON.parse(values.get('stackchain.agenda-session.v1')); +const todayWhileRunning = first.checkpointed(); +login = 'alexander'; +const hiddenFromOtherAccount = makeSession().resumable(); +login = 'timmy'; +items = items.filter(item => item.number !== 2); +const resumedSession = makeSession(); +const resumed = resumedSession.resume(); +resumedSession.end(); +process.stdout.write(JSON.stringify({{ + saved, todayWhileRunning, hiddenFromOtherAccount, resumed, opened, + agendaCleared:!values.has('stackchain.agenda-session.v1'), + today:values.get('stackchain.today-session.v1'), +}})); +""" + result = subprocess.run(["node", "-e", script], capture_output=True, text=True) + + assert result.returncode == 0, result.stderr + state = json.loads(result.stdout) + assert state["saved"] == { + "version": 1, "login": "timmy", "identity": "issue:o/r:2:", "index": 1, + } + assert state["todayWhileRunning"] is False + assert state["hiddenFromOtherAccount"] is False + assert state["resumed"] is True + assert state["opened"] == ["Overdue", "Today", "Tomorrow"] + assert state["agendaCleared"] is True + assert state["today"] == "preserve-today" + + +@pytest.mark.anyio +async def test_mobile_agenda_launcher_starts_or_resumes_the_durable_agenda_session(): + html = await dashboard() + + assert "openAgenda: openAgendaSession" in html + assert "key: 'stackchain.agenda-session.v1'" in html + assert "checkpoint: () => selectedWorkFilter === 'agenda' ? agendaSessionCheckpoint : sessionCheckpoint" in html + assert "checkpointEnabled: () => ['today', 'agenda'].includes(selectedWorkFilter)" in html + assert "checkpointedEnabled: () => selectedWorkFilter === 'today'" in html + assert "agendaSessionCheckpoint.read() ? 'Resume Agenda' : 'Start Agenda'" in html + assert "window.location.hash = '#/my-work/agenda'" in html + + @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 "agendaSessionCheckpoint.read() ? 'Resume Agenda' : 'Start Agenda'" 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 + assert "checkpointEnabled: () => ['today', 'agenda'].includes(selectedWorkFilter)" in html @pytest.mark.parametrize("timezone_name", ["Asia/Tokyo", "America/Los_Angeles"]) @@ -3220,8 +3295,8 @@ async def test_dashboard_offers_account_safe_resume_and_end_today_controls(): assert 'id="end-today-session"' in html assert 'const sessionCheckpoint = createWorkSessionCheckpoint({' in html assert 'getLogin: () => confirmedOwnerLogin' in html - assert 'checkpoint: sessionCheckpoint' in html - assert 'checkpointEnabled: () => selectedWorkFilter === \'today\'' in html + assert "checkpoint: () => selectedWorkFilter === 'agenda' ? agendaSessionCheckpoint : sessionCheckpoint" in html + assert "checkpointedEnabled: () => selectedWorkFilter === 'today'" in html assert "qs('#resume-today-session').addEventListener('click'" in html assert "qs('#end-today-session').addEventListener('click'" in html assert "workSession.resume(item)" in html -- 2.43.0