diff --git a/frontend/dashboard.js b/frontend/dashboard.js index bbcdc66..068d193 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -992,9 +992,19 @@ }, }); + const updateTriageLauncher = createUpdateTriageLauncher({ + selectUpdates: () => selectMobileQueue('update'), + discover: () => notificationPager.loadAll(() => lastNotifications), + isUpdatesSelected: () => selectedWorkFilter === 'update', + hasMore: () => Boolean(notificationPagination.has_more), + hasCheckpoint: () => updateTriage.resumable(), + resume: () => updateTriage.resume(), + start: () => updateTriage.start(), + announce: message => { qs('#my-work-action-status').textContent = message; }, + }); + function openUpdateTriage() { - selectMobileQueue('update'); - return updateTriage.resumable() ? updateTriage.resume() : updateTriage.start(); + return updateTriageLauncher.open(); } function routedWorkItem(item) { diff --git a/frontend/index.html b/frontend/index.html index 53da8b4..7bc9300 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1073,6 +1073,7 @@ + diff --git a/frontend/my-work.js b/frontend/my-work.js index 8da3c42..d33001b 100644 --- a/frontend/my-work.js +++ b/frontend/my-work.js @@ -248,7 +248,8 @@ function createNotificationSelection({ limit = 50, onChange = () => {} } = {}) { function createNotificationPager({ load, onNotifications, onPagination, onStatus }) { let pagination = { page: 1, total: 0, has_more: false }; let pending = false; - return { + let completing = null; + const pager = { reset(next) { pagination = { ...pagination, ...(next || {}) }; onPagination(pagination); @@ -282,7 +283,19 @@ function createNotificationPager({ load, onNotifications, onPagination, onStatus pending = false; } }, + loadAll(getExisting) { + if (completing) return completing; + completing = (async () => { + while (pagination.has_more) { + const loaded = await pager.loadMore(getExisting()); + if (!loaded) return false; + } + return true; + })().finally(() => { completing = null; }); + return completing; + }, }; + return pager; } function createWorkPager({ load, onItems, onPagination, onStatus }) { diff --git a/frontend/service-worker.js b/frontend/service-worker.js index bfdd1db..c389342 100644 --- a/frontend/service-worker.js +++ b/frontend/service-worker.js @@ -77,6 +77,7 @@ const SHELL = [ BASE + 'static/mobile-work-entry.js', BASE + 'static/mobile-queue-launcher.js', BASE + 'static/update-triage-session.js', + BASE + 'static/update-triage-launcher.js', BASE + 'static/agenda-session-launcher.js', BASE + 'static/mobile-launch.js', BASE + 'static/mobile-app-shortcuts.js', diff --git a/frontend/update-triage-launcher.js b/frontend/update-triage-launcher.js new file mode 100644 index 0000000..17924da --- /dev/null +++ b/frontend/update-triage-launcher.js @@ -0,0 +1,34 @@ +(function (root, factory) { + if (typeof module === 'object' && module.exports) module.exports = factory; + else root.createUpdateTriageLauncher = factory; +})(typeof self !== 'undefined' ? self : this, function createUpdateTriageLauncher(options) { + let pending = null; + + function open() { + if (pending) return pending; + options.selectUpdates(); + options.announce('Checking all unread updates…'); + pending = Promise.resolve() + .then(() => options.discover()) + .then(complete => { + if (!options.isUpdatesSelected()) return 'cancelled'; + if (complete === false || options.hasMore()) { + options.announce('Updates check paused. Retry to check older unread updates.'); + return 'incomplete'; + } + const opened = options.hasCheckpoint() ? options.resume() : options.start(); + if (!opened) options.announce('No unread updates are ready.'); + return opened ? 'opened' : 'empty'; + }) + .catch(() => { + if (options.isUpdatesSelected()) { + options.announce('Updates check paused. Retry to check older unread updates.'); + } + return 'incomplete'; + }) + .finally(() => { pending = null; }); + return pending; + } + + return {open}; +}); diff --git a/src/frontend_bundle.py b/src/frontend_bundle.py index 4857bb9..affdaaf 100644 --- a/src/frontend_bundle.py +++ b/src/frontend_bundle.py @@ -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/my-work.js", "static/protect-today.js", "static/mobile-task-dock.js", "static/mobile-queue-launcher.js", "static/update-triage-session.js", "static/notification-undo.js", "static/today-timer.js", "static/today-recap.js", + "static/my-work.js", "static/protect-today.js", "static/mobile-task-dock.js", "static/mobile-queue-launcher.js", "static/update-triage-session.js", "static/update-triage-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", diff --git a/tests/test_my_work.py b/tests/test_my_work.py index a73c3b0..14e9b6a 100644 --- a/tests/test_my_work.py +++ b/tests/test_my_work.py @@ -41,6 +41,109 @@ 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" +UPDATE_TRIAGE_LAUNCHER = Path(__file__).parents[1] / "frontend" / "update-triage-launcher.js" + + +def test_update_triage_launch_waits_for_complete_single_flight_discovery(): + script = f""" +const createLauncher = require({json.dumps(str(UPDATE_TRIAGE_LAUNCHER))}); +let release; +let discoveries = 0; +let opens = 0; +const messages = []; +const launcher = createLauncher({{ + selectUpdates: () => {{}}, + discover: () => {{ discoveries += 1; return new Promise(resolve => {{ release = resolve; }}); }}, + isUpdatesSelected: () => true, + hasMore: () => false, + hasCheckpoint: () => false, + resume: () => {{ throw new Error('must start'); }}, + start: () => {{ opens += 1; return true; }}, + announce: message => messages.push(message), +}}); +const first = launcher.open(); +const second = launcher.open(); +Promise.resolve().then(() => {{ + const before = {{discoveries, opens, same:first === second}}; + release(true); + return Promise.all([first, second]).then(results => + process.stdout.write(JSON.stringify({{before, discoveries, opens, results, messages}})) + ); +}}); +""" + 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}, + "discoveries": 1, + "opens": 1, + "results": ["opened", "opened"], + "messages": ["Checking all unread updates…"], + } + + +def test_update_triage_launch_preserves_checkpoint_when_discovery_fails(): + script = f""" +const createLauncher = require({json.dumps(str(UPDATE_TRIAGE_LAUNCHER))}); +const messages = []; +let resumed = 0; +const launcher = createLauncher({{ + selectUpdates: () => {{}}, + discover: async () => false, + isUpdatesSelected: () => true, + hasMore: () => true, + hasCheckpoint: () => true, + resume: () => {{ resumed += 1; return true; }}, + start: () => true, + announce: message => messages.push(message), +}}); +launcher.open().then(result => process.stdout.write(JSON.stringify({{result, resumed, messages}}))); +""" + result = subprocess.run(["node", "-e", script], capture_output=True, text=True) + + assert result.returncode == 0, result.stderr + assert json.loads(result.stdout) == { + "result": "incomplete", + "resumed": 0, + "messages": [ + "Checking all unread updates…", + "Updates check paused. Retry to check older unread updates.", + ], + } + + +def test_notification_pager_load_all_retries_from_failed_page_without_duplicates(): + script = f""" +const buildMyWork = require({json.dumps(str(MY_WORK))}); +let items = [{{id:1}}]; +let fail = true; +const requested = []; +const pager = buildMyWork.createNotificationPager({{ + load: async page => {{ + requested.push(page); + if (page === 2 && fail) {{ fail = false; throw new Error('offline'); }} + return page === 2 ? {{page:2,total:101,has_more:true,items:[{{id:1}},{{id:51}}]}} : + {{page:3,total:101,has_more:false,items:[{{id:101}}]}}; + }}, + onNotifications: value => {{ items = value; }}, + onPagination: () => {{}}, + onStatus: () => {{}}, +}}); +pager.reset({{page:1,total:101,has_more:true}}); +pager.loadAll(() => items).then(firstResult => pager.loadAll(() => items).then(secondResult => + process.stdout.write(JSON.stringify({{firstResult, secondResult, requested, ids:items.map(item => item.id)}})) +)); +""" + result = subprocess.run(["node", "-e", script], capture_output=True, text=True) + + assert result.returncode == 0, result.stderr + assert json.loads(result.stdout) == { + "firstResult": False, + "secondResult": True, + "requested": [2, 2, 3], + "ids": [1, 51, 101], + } def test_agenda_session_launch_waits_for_complete_single_flight_discovery(): diff --git a/tests/test_service_worker.py b/tests/test_service_worker.py index 53a419d..8bbdb1e 100644 --- a/tests/test_service_worker.py +++ b/tests/test_service_worker.py @@ -847,6 +847,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell(): "/dashboard/static/mobile-work-entry.js", "/dashboard/static/mobile-queue-launcher.js", "/dashboard/static/update-triage-session.js", + "/dashboard/static/update-triage-launcher.js", "/dashboard/static/agenda-session-launcher.js", "/dashboard/static/mobile-launch.js", "/dashboard/static/mobile-app-shortcuts.js",