From 80a409611895aac4c8d25c068068f7dea637cba0 Mon Sep 17 00:00:00 2001 From: timmy Date: Wed, 12 Aug 2026 16:03:04 +0000 Subject: [PATCH] feat: batch-plan mobile work queues (Closes #661) --- frontend/dashboard.css | 6 ++- frontend/dashboard.js | 85 ++++++++++++++++++++++++++++++++---- frontend/index.html | 6 +++ frontend/service-worker.js | 1 + frontend/today-work.js | 17 +++++++- frontend/work-selection.js | 68 +++++++++++++++++++++++++++++ src/frontend_bundle.py | 3 +- tests/test_my_work.py | 76 ++++++++++++++++++++++++++++++++ tests/test_service_worker.py | 1 + 9 files changed, 250 insertions(+), 13 deletions(-) create mode 100644 frontend/work-selection.js diff --git a/frontend/dashboard.css b/frontend/dashboard.css index b0dc904..12a2f7d 100644 --- a/frontend/dashboard.css +++ b/frontend/dashboard.css @@ -234,8 +234,10 @@ textarea { resize: vertical; min-height: 120px; } .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; } +.update-selector { min-height:44px; } +.work-selector { min-height:44px; } +.update-selector, .work-selector { display:flex; align-items:center; gap:10px; padding:6px; border:1px solid #31577f; border-radius:8px; cursor:pointer; } +.update-selector input, .work-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; } diff --git a/frontend/dashboard.js b/frontend/dashboard.js index 9a10e9e..3d9d3af 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -702,6 +702,7 @@ renderMyWork(); }, }); + const workSelection = createWorkSelection({ limit: 50, onChange: () => renderMyWork() }); const notificationPager = createNotificationPager({ load: fetchNotificationPage, onNotifications: items => { @@ -2325,6 +2326,8 @@ const incomplete = activeWorkStreams().some(stream => workPagination[stream]?.has_more); const selection = notificationSelection.snapshot(); const selectedIds = new Set(selection.ids); + const workSelectionState = workSelection.snapshot(); + const selectedWorkIds = new Set(workSelectionState.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); @@ -2340,10 +2343,13 @@ (item.updated_at ? ' ยท Updated ' + escapeHtml(fmt(item.updated_at)) + '' : ''); 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 workSelectable = workSelectionState.active; + const workId = workSelection.identity(item); + const selector = workSelectable ? + '' : (selectable ? + '' : ''); + const cardClasses = 'my-work-card' + ((selectable || workSelectable) ? ' selection-active' : '') + + ((selectedIds.has(item.notification_id) || selectedWorkIds.has(workId)) ? ' selected' : ''); const markRead = item.has_update && Number.isInteger(item.notification_id) && !selection.active ? '' : ''; const readUpdate = item.has_update && Number.isInteger(item.notification_id) ? @@ -2384,6 +2390,16 @@ } }); }); + document.querySelectorAll('[data-select-work-id]').forEach(input => { + input.addEventListener('change', () => { + const item = lastMyWork[Number(input.dataset.workIndex)]; + const result = input.checked ? workSelection.select(item) : workSelection.toggle(item); + if (result === 'limit') { + input.checked = false; + qs('#my-work-action-status').textContent = 'Select up to 50 work items per batch.'; + } + }); + }); document.querySelectorAll('[data-review-index]').forEach(button => { button.addEventListener('click', event => { event.preventDefault(); openRoutedWork(lastMyWork[Number(button.dataset.reviewIndex)], button); }); }); @@ -2507,18 +2523,30 @@ const updateIds = notificationIds(visible); const selectionControls = qs('#update-selection-controls'); - selectionControls.hidden = selectedWorkFilter !== 'update' || updateIds.length === 0; - qs('#select-updates').hidden = selection.active; + const planningQueue = !['today', 'later', 'draft'].includes(selectedWorkFilter) && visible.length > 0; + selectionControls.hidden = !planningQueue && (selectedWorkFilter !== 'update' || updateIds.length === 0); + qs('#select-work').hidden = !planningQueue || workSelectionState.active || selection.active; + qs('#cancel-work-selection').hidden = !workSelectionState.active; + qs('#select-updates').hidden = selectedWorkFilter !== 'update' || selection.active || workSelectionState.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('#update-selection-status').textContent = workSelectionState.active ? + workSelectionState.count + ' selected' : (selection.active ? + selection.count + ' of 50 selected' : 'Choose work to plan together or specific updates to mark read.'); + bulkBar.hidden = !workSelectionState.active && (selectedWorkFilter !== 'update' || !selection.active); qs('#load-more-notifications').hidden = selectedWorkFilter !== 'update' || !notificationPagination.has_more; bulkButton.disabled = bulkMarkPending || selection.count === 0; document.querySelectorAll('[data-defer-selected]').forEach(button => { + button.hidden = workSelectionState.active; button.disabled = bulkMarkPending || selection.count === 0 || !planningOwnerLogin; }); + qs('#batch-add-today').hidden = !workSelectionState.active; + qs('#batch-add-today').disabled = workSelectionState.count === 0 || !planningOwnerLogin; + document.querySelectorAll('[data-batch-defer]').forEach(button => { + button.hidden = !workSelectionState.active; + button.disabled = workSelectionState.count === 0 || !planningOwnerLogin; + }); + bulkButton.hidden = workSelectionState.active; bulkButton.textContent = bulkConfirmationPending ? 'Confirm marking ' + selection.count + ' selected read' : 'Mark ' + selection.count + ' selected read'; @@ -5509,6 +5537,44 @@ button.disabled = false; } }); + qs('#select-work').addEventListener('click', () => { + if (notificationSelection.snapshot().active) notificationSelection.cancel(); + workSelection.start(); + document.querySelector('[data-select-work-id]')?.focus(); + }); + qs('#cancel-work-selection').addEventListener('click', () => { + workSelection.cancel(); + qs('#select-work').focus(); + }); + qs('#batch-add-today').addEventListener('click', () => { + const ids = new Set(workSelection.snapshot().ids); + const selectedItems = lastMyWork.filter(item => ids.has(workSelection.identity(item))); + const result = todayWork.addMany(selectedItems); + if (result.status === 'added') { + result.ids.forEach(id => todaySync.enqueue('add', id)); + todaySync.flush(); + warmTodayOffline(); + qs('#my-work-action-status').textContent = result.ids.length + ' items added to Today without changing Gitea.'; + workSelection.cancel(); + } else { + qs('#my-work-action-status').textContent = result.status === 'full' ? + 'The full selection will not fit in Today. Nothing was added; selection unchanged.' : + (result.status === 'exists' ? 'Every selected item is already in Today.' : + 'Could not save Today on this device. Nothing was added; selection unchanged.'); + } + refreshMyWorkView(); + }); + document.querySelectorAll('[data-batch-defer]').forEach(button => button.addEventListener('click', () => { + const ids = new Set(workSelection.snapshot().ids); + const selectedItems = lastMyWork.filter(item => ids.has(workSelection.identity(item))); + const until = laterWork.presetUntil(button.dataset.batchDefer); + const result = laterWork.deferMany(selectedItems, until); + qs('#my-work-action-status').textContent = result === 'deferred' ? + selectedItems.length + ' items deferred until ' + fmt(until) + '; updates stay unread and Gitea is unchanged.' : + 'Could not save Later on this device. Selection unchanged.'; + if (result === 'deferred') workSelection.cancel(); + refreshMyWorkView(); + })); qs('#select-updates').addEventListener('click', () => { notificationSelection.start(); qs('#update-selection-status').textContent = '0 of 50 selected'; @@ -5565,6 +5631,7 @@ const leavingUpdates = selectedWorkFilter === 'update' && filter !== 'update'; selectedWorkFilter = filter; if (leavingUpdates && notificationSelection.snapshot().active) notificationSelection.cancel(); + if (workSelection.snapshot().active) workSelection.cancel(); savedWorkFilter = selectedWorkFilter; launchFilterResolved = true; try { diff --git a/frontend/index.html b/frontend/index.html index 9eb550d..7bc97d4 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -163,6 +163,8 @@