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",