From 163bfad30a9ac71d356237dddaabf519aa0b7ee9 Mon Sep 17 00:00:00 2001 From: timmy Date: Tue, 11 Aug 2026 06:00:48 +0000 Subject: [PATCH] feat: select mobile updates to mark read (Closes #545) --- frontend/dashboard.css | 7 ++++ frontend/dashboard.js | 80 ++++++++++++++++++++++++++++---------- frontend/index.html | 5 +++ frontend/my-work.js | 57 +++++++++++++++++++++++++++ tests/test_my_work.py | 87 ++++++++++++++++++++++++++++++++++++++---- 5 files changed, 208 insertions(+), 28 deletions(-) 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 : '
' + 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 -- 2.43.0