diff --git a/frontend/dashboard.css b/frontend/dashboard.css
index 08967f8..d635179 100644
--- a/frontend/dashboard.css
+++ b/frontend/dashboard.css
@@ -153,6 +153,12 @@ textarea { resize: vertical; min-height: 120px; }
.my-work-card-main.review-trigger { width:100%; text-align:left; font:inherit; }
.my-work-card:hover { border-color:var(--accent); }
.my-work-card-title { display:block; margin:5px 0; font-weight:650; }
+.update-selection-controls { display:flex; align-items:center; gap:8px; flex-wrap:wrap; }
+.update-selection-controls button { min-height:44px; }
+.update-selector { min-height:44px; display:flex; align-items:center; gap:10px; padding:6px; border:1px solid #31577f; border-radius:8px; cursor:pointer; }
+.update-selector input { width:22px; height:22px; flex:0 0 auto; }
+.my-work-card.selection-active { border-color:#31577f; }
+.my-work-card.selected { border-color:#60a5fa; box-shadow:inset 4px 0 #60a5fa; }
.later-actions, .today-actions { display:grid; grid-template-columns:repeat(auto-fit,minmax(120px,1fr)); gap:8px; }
.later-actions button { min-height:44px; width:100%; }
.today-actions button { min-height:44px; width:100%; }
@@ -464,6 +470,7 @@ textarea { resize: vertical; min-height: 120px; }
.work-settings:not([open]) > .work-settings-panel { display:none; }
.work-settings-panel { display:grid; gap:10px; margin-top:8px; }
.my-work-list { grid-template-columns:1fr; }
+ .my-work-bulk { bottom:calc(56px + env(safe-area-inset-bottom)); }
.my-work-card { min-width:0; overflow-x:hidden; }
.card-planning > summary { min-height:44px; display:flex; align-items:center; justify-content:center; cursor:pointer; border:1px solid #60a5fa; border-radius:10px; font-weight:700; list-style:none; }
.card-planning > summary::-webkit-details-marker { display:none; }
diff --git a/frontend/dashboard.js b/frontend/dashboard.js
index fc334d6..0f1261e 100644
--- a/frontend/dashboard.js
+++ b/frontend/dashboard.js
@@ -616,6 +616,13 @@
},
onStatus: message => { qs('#my-work-action-status').textContent = message; },
});
+ const notificationSelection = createNotificationSelection({
+ limit: 50,
+ onChange: () => {
+ bulkConfirmationPending = false;
+ renderMyWork();
+ },
+ });
const notificationPager = createNotificationPager({
load: fetchNotificationPage,
onNotifications: items => {
@@ -2086,6 +2093,8 @@
filterMyWork(laterMyWork, 'all', selectedWorkMilestone) :
filterMyWork(activeMyWork, selectedWorkFilter, selectedWorkMilestone);
const incomplete = activeWorkStreams().some(stream => workPagination[stream]?.has_more);
+ const selection = notificationSelection.snapshot();
+ const selectedIds = new Set(selection.ids);
qs('#my-work-list').innerHTML = visible.length ? visible.map(item => {
const index = lastMyWork.findIndex(candidate => candidate.key === item.key && candidate.kind === item.kind);
const routeItem = routedWorkItem(item);
@@ -2099,7 +2108,13 @@
(item.has_update ? ' Unread update' : '') +
(item.deferred_until ? 'Deferred until ' + escapeHtml(fmt(item.deferred_until)) + '' : '') +
(item.updated_at ? ' ยท Updated ' + escapeHtml(fmt(item.updated_at)) + '' : '');
- const markRead = item.has_update && Number.isInteger(item.notification_id) ?
+ const selectable = selectedWorkFilter === 'update' && selection.active &&
+ item.has_update && Number.isInteger(item.notification_id);
+ const selector = selectable ?
+ '' : '';
+ const cardClasses = 'my-work-card' + (selectable ? ' selection-active' : '') +
+ (selectedIds.has(item.notification_id) ? ' selected' : '');
+ const markRead = item.has_update && Number.isInteger(item.notification_id) && !selection.active ?
'' : '';
const readUpdate = item.has_update && Number.isInteger(item.notification_id) ?
'Read update' : '';
@@ -2115,19 +2130,30 @@
const planningActions = selectedWorkFilter === 'later' ? laterActions :
'Plan or defer
' + todayActions + laterActions + '
';
if (item.is_review) {
- return '' + contents + '' + readUpdate + markRead + planningActions + '';
+ return '' + selector + '' + contents + '' + readUpdate + markRead + planningActions + '';
}
if (item.kind === 'issue') {
- return '' + contents + '' + readUpdate + markRead + planningActions + '';
+ return '' + selector + '' + contents + '' + readUpdate + markRead + planningActions + '';
}
if (item.kind === 'pull') {
- return '' + contents + '' + readUpdate + markRead + planningActions + '';
+ return '' + selector + '' + contents + '' + readUpdate + markRead + planningActions + '';
}
- return '' + contents + '' + markRead + planningActions + '';
+ return '' + selector + '' + contents + '' + markRead + planningActions + '';
}).join('') : '
' + (incomplete ?
'More work is available. Load the next page.' :
'No ' + (selectedWorkFilter === 'attention' ? 'items need attention' : (selectedWorkFilter === 'review' ? 'reviews' : (selectedWorkFilter === 'update' ? 'unread updates' : (selectedWorkFilter === 'later' ? 'deferred work' : (selectedWorkFilter === 'all' ? 'work' : selectedWorkFilter + ' items'))))) + '.') + '
';
cardPlanning.wire();
+ document.querySelectorAll('[data-select-notification-id]').forEach(input => {
+ input.addEventListener('change', () => {
+ const notificationId = Number(input.dataset.selectNotificationId);
+ const result = input.checked ? notificationSelection.select(notificationId) :
+ notificationSelection.toggle(notificationId);
+ if (result === 'limit') {
+ input.checked = false;
+ qs('#my-work-action-status').textContent = 'Select up to 50 updates per batch.';
+ }
+ });
+ });
document.querySelectorAll('[data-review-index]').forEach(button => {
button.addEventListener('click', event => { event.preventDefault(); openRoutedWork(lastMyWork[Number(button.dataset.reviewIndex)], button); });
});
@@ -2246,20 +2272,22 @@
document.querySelector('[data-today-move="' + button.dataset.todayMove + '"][data-work-index="' + button.dataset.workIndex + '"]')?.focus();
});
});
- const allIds = notificationIds(visible);
- const ids = allIds.slice(0, Math.min(lastNotifications.length, 50));
const bulkBar = qs('#bulk-mark-read-bar');
const bulkButton = qs('#bulk-mark-read');
- bulkBar.hidden = selectedWorkFilter !== 'update' || ids.length === 0;
+ const updateIds = notificationIds(visible);
+ const selectionControls = qs('#update-selection-controls');
+ selectionControls.hidden = selectedWorkFilter !== 'update' || updateIds.length === 0;
+ qs('#select-updates').hidden = selection.active;
+ qs('#cancel-update-selection').hidden = !selection.active;
+ qs('#update-selection-status').textContent = selection.active ?
+ selection.count + ' of 50 selected' : 'Choose specific updates to keep important work unread.';
+ bulkBar.hidden = selectedWorkFilter !== 'update' || !selection.active;
qs('#load-more-notifications').hidden =
selectedWorkFilter !== 'update' || !notificationPagination.has_more;
- bulkButton.disabled = bulkMarkPending;
- const bulkLabel = allIds.length > ids.length ?
- 'next ' + ids.length + ' of ' + allIds.length + ' loaded updates' :
- 'all ' + ids.length + ' updates';
+ bulkButton.disabled = bulkMarkPending || selection.count === 0;
bulkButton.textContent = bulkConfirmationPending ?
- 'Confirm marking ' + bulkLabel + ' read' :
- 'Mark ' + bulkLabel + ' read';
+ 'Confirm marking ' + selection.count + ' selected read' :
+ 'Mark ' + selection.count + ' selected read';
workRoute.setItems(lastMyWork);
}
@@ -5161,32 +5189,44 @@
button.disabled = false;
}
});
+ qs('#select-updates').addEventListener('click', () => {
+ notificationSelection.start();
+ qs('#update-selection-status').textContent = '0 of 50 selected';
+ document.querySelector('[data-select-notification-id]')?.focus();
+ });
+ qs('#cancel-update-selection').addEventListener('click', () => {
+ notificationSelection.cancel();
+ qs('#select-updates').focus();
+ });
qs('#bulk-mark-read').addEventListener('click', async () => {
- const allIds = notificationIds(filterMyWork(lastMyWork, 'update'));
- const ids = allIds.slice(0, 50);
- if (!ids.length || bulkMarkPending) return;
+ const selection = notificationSelection.snapshot();
+ if (!selection.ids.length || bulkMarkPending) return;
if (!bulkConfirmationPending) {
bulkConfirmationPending = true;
- qs('#my-work-action-status').textContent = 'Confirm to mark all visible updates read.';
+ qs('#my-work-action-status').textContent = 'Confirm to mark only the selected updates read.';
renderMyWork();
return;
}
bulkConfirmationPending = false;
bulkMarkPending = true;
renderMyWork();
- const result = await bulkNotificationAcknowledger.acknowledge(lastMyWork, ids);
+ const result = await bulkNotificationAcknowledger.acknowledge(lastMyWork, selection.ids);
if (result) {
const marked = new Set(result.marked);
lastNotifications = lastNotifications.filter(item => !marked.has(item.id));
+ notificationSelection.retain(result.failed);
+ if (!result.failed.length) notificationSelection.cancel();
}
bulkMarkPending = false;
renderMyWork();
- (document.querySelector('[data-notification-id]') || qs('[data-work-filter="update"]'))?.focus();
+ (document.querySelector('[data-select-notification-id]') || qs('#select-updates'))?.focus();
});
document.querySelectorAll('[data-work-filter]').forEach(button => {
button.setAttribute('aria-pressed', String(button.dataset.workFilter === selectedWorkFilter));
button.addEventListener('click', () => {
+ const leavingUpdates = selectedWorkFilter === 'update' && button.dataset.workFilter !== 'update';
selectedWorkFilter = button.dataset.workFilter;
+ if (leavingUpdates && notificationSelection.snapshot().active) notificationSelection.cancel();
savedWorkFilter = selectedWorkFilter;
launchFilterResolved = true;
try {
diff --git a/frontend/index.html b/frontend/index.html
index 5677ca4..89c10ef 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -116,6 +116,11 @@
+
+
+
+
+
diff --git a/frontend/my-work.js b/frontend/my-work.js
index 879b118..85d5dc0 100644
--- a/frontend/my-work.js
+++ b/frontend/my-work.js
@@ -178,6 +178,62 @@ function createBulkNotificationAcknowledger({ markRead, onItems, onStatus }) {
};
}
+function createNotificationSelection({ limit = 50, onChange = () => {} } = {}) {
+ const maximum = Number.isInteger(limit) && limit > 0 ? limit : 50;
+ let active = false;
+ const selected = new Set();
+ const snapshot = () => ({
+ active,
+ ids: Array.from(selected),
+ count: selected.size,
+ limit: maximum,
+ at_limit: selected.size >= maximum,
+ });
+ const publish = () => onChange(snapshot());
+ const select = notificationId => {
+ if (!active || !Number.isInteger(notificationId)) return 'inactive';
+ if (selected.has(notificationId)) return 'already-selected';
+ if (selected.size >= maximum) return 'limit';
+ selected.add(notificationId);
+ publish();
+ return 'selected';
+ };
+ return {
+ start() {
+ if (active) return snapshot();
+ active = true;
+ publish();
+ return snapshot();
+ },
+ select,
+ toggle(notificationId) {
+ if (!selected.has(notificationId)) return select(notificationId);
+ selected.delete(notificationId);
+ publish();
+ return 'deselected';
+ },
+ retain(notificationIds) {
+ const allowed = new Set((notificationIds || []).filter(Number.isInteger));
+ let changed = false;
+ selected.forEach(id => {
+ if (!allowed.has(id)) {
+ selected.delete(id);
+ changed = true;
+ }
+ });
+ if (changed) publish();
+ return snapshot();
+ },
+ cancel() {
+ active = false;
+ selected.clear();
+ publish();
+ return snapshot();
+ },
+ snapshot,
+ };
+}
+
function createNotificationPager({ load, onNotifications, onPagination, onStatus }) {
let pagination = { page: 1, total: 0, has_more: false };
let pending = false;
@@ -782,6 +838,7 @@ if (typeof module !== 'undefined' && module.exports) {
buildMyWork.createNotificationAcknowledger = createNotificationAcknowledger;
buildMyWork.notificationIds = notificationIds;
buildMyWork.createBulkNotificationAcknowledger = createBulkNotificationAcknowledger;
+ buildMyWork.createNotificationSelection = createNotificationSelection;
buildMyWork.createNotificationPager = createNotificationPager;
buildMyWork.createWorkPager = createWorkPager;
buildMyWork.createNotificationReader = createNotificationReader;
diff --git a/tests/test_my_work.py b/tests/test_my_work.py
index ecacc41..1c14394 100644
--- a/tests/test_my_work.py
+++ b/tests/test_my_work.py
@@ -3312,6 +3312,68 @@ Promise.all([first, duplicate]).then(results => process.stdout.write(JSON.string
]
+def test_notification_selection_tracks_a_bounded_subset_and_clears_on_cancel():
+ script = f"""
+const buildMyWork = require({json.dumps(str(MY_WORK))});
+const states = [];
+const selection = buildMyWork.createNotificationSelection({{
+ limit: 2,
+ onChange: state => states.push(state),
+}});
+selection.start();
+const first = selection.select(42);
+const duplicate = selection.select(42);
+selection.toggle(42);
+selection.select(43);
+selection.select(44);
+const second = selection.select(45);
+selection.cancel();
+process.stdout.write(JSON.stringify({{first, duplicate, second, states, snapshot:selection.snapshot()}}));
+"""
+ result = subprocess.run(
+ ["node", "-e", script], check=True, capture_output=True, text=True
+ )
+ output = json.loads(result.stdout)
+
+ assert output["first"] == "selected"
+ assert output["duplicate"] == "already-selected"
+ assert output["second"] == "limit"
+ assert output["states"] == [
+ {"active": True, "ids": [], "count": 0, "limit": 2, "at_limit": False},
+ {"active": True, "ids": [42], "count": 1, "limit": 2, "at_limit": False},
+ {"active": True, "ids": [], "count": 0, "limit": 2, "at_limit": False},
+ {"active": True, "ids": [43], "count": 1, "limit": 2, "at_limit": False},
+ {"active": True, "ids": [43, 44], "count": 2, "limit": 2, "at_limit": True},
+ {"active": False, "ids": [], "count": 0, "limit": 2, "at_limit": False},
+ ]
+ assert output["snapshot"] == output["states"][-1]
+
+
+def test_notification_selection_retains_only_failed_ids_after_partial_acknowledgement():
+ script = f"""
+const buildMyWork = require({json.dumps(str(MY_WORK))});
+const states = [];
+const selection = buildMyWork.createNotificationSelection({{
+ onChange: state => states.push(state),
+}});
+selection.start();
+selection.select(42);
+selection.select(43);
+selection.select(44);
+const retained = selection.retain([44, 44, 99]);
+process.stdout.write(JSON.stringify({{retained, states}}));
+"""
+ result = subprocess.run(
+ ["node", "-e", script], check=True, capture_output=True, text=True
+ )
+ output = json.loads(result.stdout)
+
+ assert output["retained"] == {
+ "active": True, "ids": [44], "count": 1, "limit": 50, "at_limit": False,
+ }
+ assert output["states"][-1] == output["retained"]
+
+
def test_notification_pager_is_single_flight_and_merges_unique_updates():
script = f"""
const buildMyWork = require({json.dumps(str(MY_WORK))});
@@ -4555,18 +4617,26 @@ async def test_mobile_update_sheet_has_persistent_accessible_reply_composer():
@pytest.mark.anyio
-async def test_updates_view_offers_confirmed_sticky_mobile_bulk_acknowledgement():
+async def test_updates_view_offers_accessible_sticky_mobile_subset_selection():
html = await dashboard()
- assert 'id="bulk-mark-read"' in html
- assert 'id="bulk-mark-read-bar"' in html
- assert 'class="my-work-bulk"' in html
+ assert 'id="select-updates"' in html
+ assert 'id="cancel-update-selection"' in html
+ assert 'id="update-selection-status"' in html
+ assert 'aria-live="polite"' in html
+ assert 'class="update-selector"' in html
+ assert 'type="checkbox"' in html
+ assert 'Select update ' in html
+ assert '.update-selector { min-height:44px;' in html
assert '.my-work-bulk { position:sticky;' in html
assert 'padding-bottom:calc(10px + env(safe-area-inset-bottom));' in html
assert '.my-work-bulk button { min-height:44px; width:100%; }' in html
- assert "'next ' + ids.length + ' of ' + allIds.length + ' loaded updates'" in html
- assert "'Confirm marking ' + bulkLabel + ' read'" in html
- assert "const ids = allIds.slice(0, 50)" in html
+ assert '.my-work-bulk { bottom:calc(56px + env(safe-area-inset-bottom)); }' in html
+ assert "'Mark ' + selection.count + ' selected read'" in html
+ assert "'Confirm marking ' + selection.count + ' selected read'" in html
+ assert 'bulkNotificationAcknowledger.acknowledge(lastMyWork, selection.ids)' in html
+ assert 'notificationSelection.retain(result.failed)' in html
+ assert "notificationSelection.cancel()" in html
assert "createBulkNotificationAcknowledger" in html
assert "api/v1/notifications/read" in html
assert "body: JSON.stringify({ ids })" in html
@@ -4583,7 +4653,8 @@ async def test_updates_view_discloses_incomplete_inbox_and_loads_more_on_mobile(
assert "api/v1/notifications?page=" in html
assert "snapshot.notification_pagination" in html
assert "notificationPager.loadMore(lastNotifications)" in html
- assert "Math.min(lastNotifications.length, 50)" in html
+ assert "createNotificationSelection({" in html
+ assert "limit: 50" in html
@pytest.mark.anyio