diff --git a/frontend/agenda-session-launcher.js b/frontend/agenda-session-launcher.js new file mode 100644 index 0000000..76c17e3 --- /dev/null +++ b/frontend/agenda-session-launcher.js @@ -0,0 +1,33 @@ +(function (root, factory) { + if (typeof module === 'object' && module.exports) module.exports = factory; + else root.createAgendaSessionLauncher = factory; +})(typeof self !== 'undefined' ? self : this, function createAgendaSessionLauncher(options) { + let pending = null; + + function open() { + if (pending) return pending; + options.selectAgenda(); + pending = Promise.resolve() + .then(() => options.discover()) + .then(complete => { + if (!options.isAgendaSelected()) return 'cancelled'; + if (complete === false || options.hasMore()) { + options.announce('Agenda check paused. Retry to check older assigned deadlines.'); + return 'incomplete'; + } + const opened = options.hasCheckpoint() ? options.resume() : options.start(); + if (!opened) options.announce('No deadlines are ready in Agenda.'); + return opened ? 'opened' : 'empty'; + }) + .catch(() => { + if (options.isAgendaSelected()) { + options.announce('Agenda check paused. Retry to check older assigned deadlines.'); + } + return 'incomplete'; + }) + .finally(() => { pending = null; }); + return pending; + } + + return {open}; +}); diff --git a/frontend/dashboard.js b/frontend/dashboard.js index cb2b311..167b4af 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -1210,11 +1210,18 @@ qs('[data-work-filter="today"]').click(); } + const agendaSessionLauncher = createAgendaSessionLauncher({ + selectAgenda: () => selectMobileQueue('agenda'), + discover: completeAgendaIssues, + isAgendaSelected: () => selectedWorkFilter === 'agenda', + hasMore: () => Boolean(workPagination.issue?.has_more), + hasCheckpoint: () => Boolean(agendaSessionCheckpoint.read()), + resume: () => workSession.resume(), + start: () => workSession.start(), + announce: message => { qs('#my-work-action-status').textContent = message; }, + }); 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'; + return agendaSessionLauncher.open(); } let todayReadinessTrigger = null; @@ -2340,17 +2347,20 @@ } async function completeAgendaIssues() { - if (selectedWorkFilter !== 'agenda' || !workPagination.issue?.has_more || !lastContextSnapshot) return; + if (selectedWorkFilter !== 'agenda') return false; + if (!workPagination.issue?.has_more) return true; + if (!lastContextSnapshot) return false; agendaChecking = true; qs('#my-work-action-status').textContent = 'Checking all assigned deadlines…'; renderMyWork(); const complete = await workPager.loadAll('issue', () => lastContextSnapshot?.issues || []); agendaChecking = false; - if (selectedWorkFilter !== 'agenda') return; + if (selectedWorkFilter !== 'agenda') return false; qs('#my-work-action-status').textContent = complete ? 'All assigned deadlines checked.' : 'Agenda check paused. Retry to check older assigned deadlines.'; renderMyWork(); + return complete && !workPagination.issue?.has_more; } function renderDrafts() { diff --git a/frontend/index.html b/frontend/index.html index e663395..1d9f5cf 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1055,6 +1055,7 @@ + diff --git a/frontend/service-worker.js b/frontend/service-worker.js index 2061ec2..b37330a 100644 --- a/frontend/service-worker.js +++ b/frontend/service-worker.js @@ -74,6 +74,7 @@ const SHELL = [ BASE + 'static/mobile-task-dock.js', BASE + 'static/mobile-work-entry.js', BASE + 'static/mobile-queue-launcher.js', + BASE + 'static/agenda-session-launcher.js', BASE + 'static/mobile-launch.js', BASE + 'static/install-app.js', BASE + 'static/mobile-device-setup.js', diff --git a/tests/test_my_work.py b/tests/test_my_work.py index 8fdd5dc..a73c3b0 100644 --- a/tests/test_my_work.py +++ b/tests/test_my_work.py @@ -40,6 +40,89 @@ COMMENT_ACTIONS = Path(__file__).parents[1] / "frontend" / "comment-actions.js" PICK_WORK = Path(__file__).parents[1] / "frontend" / "pick-work.js" WORK_ROUTE = Path(__file__).parents[1] / "frontend" / "work-route.js" UPDATE_OWNERSHIP = Path(__file__).parents[1] / "frontend" / "update-ownership.js" +AGENDA_SESSION_LAUNCHER = Path(__file__).parents[1] / "frontend" / "agenda-session-launcher.js" + + +def test_agenda_session_launch_waits_for_complete_single_flight_discovery(): + script = f""" +const createLauncher = require({json.dumps(str(AGENDA_SESSION_LAUNCHER))}); +let finishDiscovery; +let discoveries = 0; +let opens = 0; +let queue = 'agenda'; +const launcher = createLauncher({{ + selectAgenda: () => {{ queue = 'agenda'; }}, + discover: () => {{ + discoveries += 1; + return new Promise(resolve => {{ finishDiscovery = resolve; }}); + }}, + isAgendaSelected: () => queue === 'agenda', + hasMore: () => false, + hasCheckpoint: () => true, + resume: () => {{ opens += 1; return true; }}, + start: () => {{ throw new Error('must resume'); }}, + announce: () => {{}}, +}}); +const first = launcher.open(); +const second = launcher.open(); +Promise.resolve().then(() => {{ + const before = {{discoveries, opens, same:first === second}}; + finishDiscovery(true); + return Promise.all([first, second]).then(results => {{ + process.stdout.write(JSON.stringify({{before, results, discoveries, opens}})); + }}); +}}); +""" + result = subprocess.run(["node", "-e", script], capture_output=True, text=True) + + assert result.returncode == 0, result.stderr + assert json.loads(result.stdout) == { + "before": {"discoveries": 1, "opens": 0, "same": True}, + "results": ["opened", "opened"], + "discoveries": 1, + "opens": 1, + } + + +def test_agenda_session_launch_preserves_progress_when_discovery_fails_or_queue_changes(): + script = f""" +const createLauncher = require({json.dumps(str(AGENDA_SESSION_LAUNCHER))}); +async function scenario(mode) {{ + let queue = 'agenda'; + let opens = 0; + const messages = []; + const launcher = createLauncher({{ + selectAgenda: () => {{ queue = 'agenda'; }}, + discover: async () => {{ + if (mode === 'leave') queue = 'today'; + if (mode === 'throw') throw new Error('offline'); + return mode !== 'incomplete'; + }}, + isAgendaSelected: () => queue === 'agenda', + hasMore: () => mode === 'incomplete', + hasCheckpoint: () => true, + resume: () => {{ opens += 1; return true; }}, + start: () => {{ opens += 1; return true; }}, + announce: message => messages.push(message), + }}); + return {{result:await launcher.open(), opens, messages}}; +}} +Promise.all(['incomplete', 'throw', 'leave'].map(scenario)).then(results => + process.stdout.write(JSON.stringify(results)) +); +""" + result = subprocess.run(["node", "-e", script], capture_output=True, text=True) + + assert result.returncode == 0, result.stderr + assert json.loads(result.stdout) == [ + {"result": "incomplete", "opens": 0, "messages": [ + "Agenda check paused. Retry to check older assigned deadlines." + ]}, + {"result": "incomplete", "opens": 0, "messages": [ + "Agenda check paused. Retry to check older assigned deadlines." + ]}, + {"result": "cancelled", "opens": 0, "messages": []}, + ] def test_protect_today_route_is_an_explicit_agenda_action(): @@ -305,7 +388,11 @@ process.stdout.write(JSON.stringify({{ async def test_mobile_agenda_launcher_starts_or_resumes_the_durable_agenda_session(): html = await dashboard() + assert '' in html assert "openAgenda: openAgendaSession" in html + assert "const agendaSessionLauncher = createAgendaSessionLauncher({" in html + assert "discover: completeAgendaIssues" in html + assert "hasMore: () => Boolean(workPagination.issue?.has_more)" 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 diff --git a/tests/test_service_worker.py b/tests/test_service_worker.py index 6309b0c..a284c39 100644 --- a/tests/test_service_worker.py +++ b/tests/test_service_worker.py @@ -786,6 +786,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell(): "/dashboard/static/mobile-task-dock.js", "/dashboard/static/mobile-work-entry.js", "/dashboard/static/mobile-queue-launcher.js", + "/dashboard/static/agenda-session-launcher.js", "/dashboard/static/mobile-launch.js", "/dashboard/static/install-app.js", "/dashboard/static/mobile-device-setup.js",