diff --git a/frontend/dashboard.css b/frontend/dashboard.css index b66242a..70ac167 100644 --- a/frontend/dashboard.css +++ b/frontend/dashboard.css @@ -383,6 +383,7 @@ textarea { resize: vertical; min-height: 120px; } .update-sheet-panel { width:min(560px,100%); height:100%; overflow:auto; padding:18px; background:#0b1526; border-left:1px solid #2a496e; } .update-sheet-header { display:flex; align-items:center; justify-content:space-between; gap:10px; } .update-sheet-header button { min-height:44px; } +.update-triage-progress { margin:10px 0; padding:8px 10px; border:1px solid #2a496e; border-radius:10px; background:#10213a; } .update-sheet-content { overflow-wrap:anywhere; white-space:pre-wrap; } .update-reply { display:grid; gap:8px; margin-top:16px; } .update-reply textarea { width:100%; min-height:112px; resize:vertical; } diff --git a/frontend/dashboard.js b/frontend/dashboard.js index 521d6f5..bbcdc66 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -73,6 +73,7 @@ const mobileQueueLauncher = createMobileQueueLauncher({ openToday: () => mobileWorkEntry.open(), openAgenda: openAgendaSession, + openUpdates: openUpdateTriage, selectFilter: selectMobileQueue, firstAction: name => qs('#my-work-list .my-work-card-main, #my-work-list .draft-resume, #my-work-list .draft-continue, #my-work-list .draft-edit'), announce: message => { qs('#my-work-action-status').textContent = message; }, @@ -276,7 +277,10 @@ document.addEventListener('visibilitychange', () => { if (document.hidden) interruptionPrompt.background(); else interruptionPrompt.foreground(); - if (!document.hidden) refreshMyWorkView(); + if (!document.hidden) { + refreshMyWorkView(); + if (updateTriage.active()) updateTriage.reconcile(); + } }); async function fetchReviewJson(url, options) { @@ -969,6 +973,30 @@ onClose: () => closeUpdateSheet(false), }); + const updateTriage = createUpdateTriageSession({ + storage: localStorage, + getLogin: () => confirmedOwnerLogin, + getItems: () => lastMyWork.filter(item => item?.has_update && Number.isInteger(item.notification_id)), + onOpen: item => notificationReader.open( + item, + offlineWorkMode ? offlineWorkStore.loadDetail(confirmedOwnerLogin, item) : null + ), + onProgress: state => { + const progress = qs('#update-triage-progress'); + progress.hidden = false; + progress.textContent = 'Update ' + state.index + ' of ' + state.total; + }, + onFinish: () => { + qs('#update-triage-progress').hidden = true; + showMobileQueueCompletion('Updates'); + }, + }); + + function openUpdateTriage() { + selectMobileQueue('update'); + return updateTriage.resumable() ? updateTriage.resume() : updateTriage.start(); + } + function routedWorkItem(item) { if (item?.has_update && item.kind === 'update') { return { ...item, kind:'update' }; @@ -5444,7 +5472,10 @@ button.focus(); } }); - qs('#keep-update-unread').addEventListener('click', () => closeUpdateSheet(true)); + qs('#keep-update-unread').addEventListener('click', () => { + if (updateTriage.active()) updateTriage.keepUnreadAndNext(); + else closeUpdateSheet(true); + }); qs('#update-ownership-action').addEventListener('click', () => updateOwnership.act()); qs('#update-ownership-start').addEventListener('click', () => updateOwnership.start()); qs('#retry-update-load').addEventListener('click', () => { @@ -5516,6 +5547,7 @@ 'Reply and read acknowledgement queued for sync.'; if (result?.delivery === 'posted' && result?.next) { notificationUndo.offer(item, result.next.items); + if (updateTriage.active()) updateTriage.acceptCompleted(); } } catch (error) { qs('#update-reply-status').textContent = error.message + ' Your draft is safe; retry.'; @@ -5530,7 +5562,10 @@ qs('#mark-update-read-next').disabled = true; try { const result = await notificationReader.markReadAndNext(lastMyWork); - if (result) notificationUndo.offer(result.item, result.items); + if (result) { + notificationUndo.offer(result.item, result.items); + if (updateTriage.active()) updateTriage.acceptCompleted(); + } } finally { qs('#mark-update-read-next').disabled = false; } @@ -5540,7 +5575,10 @@ button.disabled = true; try { const result = await notificationReader.acknowledgeAndNext(lastMyWork); - if (result) notificationUndo.offer(result.item, result.items); + if (result) { + notificationUndo.offer(result.item, result.items); + if (updateTriage.active()) updateTriage.acceptCompleted(); + } } finally { button.disabled = offlineWorkMode; } diff --git a/frontend/index.html b/frontend/index.html index 96fcad3..53da8b4 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -749,8 +749,9 @@

Unread update

- + +
Choose an update.
@@ -1071,6 +1072,7 @@ + diff --git a/frontend/mobile-queue-launcher.js b/frontend/mobile-queue-launcher.js index e3ea208..a33475b 100644 --- a/frontend/mobile-queue-launcher.js +++ b/frontend/mobile-queue-launcher.js @@ -28,6 +28,7 @@ function open(name) { if (name === 'today') return options.openToday(); if (name === 'agenda') return options.openAgenda(); + if (name === 'update' && options.openUpdates) return options.openUpdates(); options.selectFilter(name); const action = options.firstAction(name); if (!action) { diff --git a/frontend/service-worker.js b/frontend/service-worker.js index 0232c6a..bfdd1db 100644 --- a/frontend/service-worker.js +++ b/frontend/service-worker.js @@ -1,6 +1,6 @@ const BASE = new URL('./', self.location.href).pathname; importScripts(BASE + 'static/background-issue-sync.js'); -const CACHE = 'stackchain-dashboard-shell-v99'; +const CACHE = 'stackchain-dashboard-shell-v100'; const OFFLINE_LEASE_URL = new URL(BASE + '__offline-session-lease', self.location.origin).href; const OUTAGE_STATUSES = new Set([500, 502, 503, 504]); const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000; @@ -76,6 +76,7 @@ const SHELL = [ BASE + 'static/mobile-task-dock.js', BASE + 'static/mobile-work-entry.js', BASE + 'static/mobile-queue-launcher.js', + BASE + 'static/update-triage-session.js', BASE + 'static/agenda-session-launcher.js', BASE + 'static/mobile-launch.js', BASE + 'static/mobile-app-shortcuts.js', diff --git a/frontend/update-triage-session.js b/frontend/update-triage-session.js new file mode 100644 index 0000000..471d1da --- /dev/null +++ b/frontend/update-triage-session.js @@ -0,0 +1,100 @@ +(function (root, factory) { + if (typeof module === 'object' && module.exports) module.exports = factory; + else root.createUpdateTriageSession = factory; +})(typeof self !== 'undefined' ? self : this, function createUpdateTriageSession(options) { + const key = options.key || 'stackchain.update-triage.v1'; + const identity = item => Number.isInteger(item?.notification_id) ? String(item.notification_id) : ''; + const login = () => String(options.getLogin() || '').trim(); + let state = null; + let running = false; + + function read() { + const owner = login(); + if (!owner) return null; + try { + const value = JSON.parse(options.storage.getItem(key) || 'null'); + if (value?.version !== 1 || value.login !== owner || !Array.isArray(value.identities) || + !value.identities.length || typeof value.current !== 'string' || !Array.isArray(value.completed)) return null; + const identities = value.identities.filter(value => typeof value === 'string' && value); + const completed = value.completed.filter(value => identities.includes(value)); + if (!identities.length || !identities.includes(value.current)) return null; + return { version:1, login:owner, identities, current:value.current, completed }; + } catch (_) { + return null; + } + } + + function persist() { + if (!state) return; + options.storage.setItem(key, JSON.stringify(state)); + } + + function available() { + const byIdentity = new Map((options.getItems() || []).map(item => [identity(item), item])); + return state.identities.filter(id => !state.completed.includes(id) && byIdentity.has(id)) + .map(id => byIdentity.get(id)); + } + + function finish() { + running = false; + state = null; + options.storage.removeItem(key); + options.onFinish(); + return false; + } + + function openCurrent(preferredIndex = null) { + const items = available(); + if (!items.length) return finish(); + let index = items.findIndex(item => identity(item) === state.current); + if (index < 0) index = Math.min(preferredIndex ?? 0, items.length - 1); + state.current = identity(items[index]); + persist(); + const originalIndex = state.identities.indexOf(state.current); + options.onProgress({ index:originalIndex + 1, total:state.identities.length }); + options.onOpen(items[index]); + return true; + } + + function advance(openNext = true) { + if (!running || !state) return false; + const originalIndex = state.identities.indexOf(state.current); + if (!state.completed.includes(state.current)) state.completed.push(state.current); + const candidates = available(); + const next = candidates.find(item => state.identities.indexOf(identity(item)) > originalIndex) || candidates[0]; + if (!next) return finish(); + state.current = identity(next); + if (openNext) return openCurrent(); + persist(); + options.onProgress({ index:state.identities.indexOf(state.current) + 1, total:state.identities.length }); + return true; + } + + return { + active: () => running, + resumable: () => Boolean(read()), + start() { + const identities = (options.getItems() || []).map(identity).filter(Boolean); + if (!identities.length) return finish(); + state = { version:1, login:login(), identities, current:identities[0], completed:[] }; + if (!state.login) return false; + running = true; + return openCurrent(); + }, + resume() { + state = read(); + if (!state) return false; + running = true; + return openCurrent(state.identities.indexOf(state.current)); + }, + reconcile() { + if (!running || !state) return false; + return openCurrent(state.identities.indexOf(state.current)); + }, + completeAndNext: advance, + acceptCompleted: () => advance(false), + keepUnreadAndNext: advance, + items: () => state ? available().slice() : [], + end: finish, + }; +}); diff --git a/src/frontend_bundle.py b/src/frontend_bundle.py index 498e618..4857bb9 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/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/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_comment_next.py b/tests/test_comment_next.py index 842e83a..924611b 100644 --- a/tests/test_comment_next.py +++ b/tests/test_comment_next.py @@ -303,4 +303,4 @@ async def test_unread_update_offers_reply_mark_read_and_next_independent_of_toda assert '.update-reply-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr));' in html assert '.update-reply-actions button { min-height:44px;' in html worker = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text() - assert "stackchain-dashboard-shell-v99" in worker + assert "stackchain-dashboard-shell-v100" in worker diff --git a/tests/test_frontend_bundle.py b/tests/test_frontend_bundle.py index ed25483..ebf23b3 100644 --- a/tests/test_frontend_bundle.py +++ b/tests/test_frontend_bundle.py @@ -187,7 +187,7 @@ def test_legacy_cache_marker_is_normalized_out_of_build_identity(tmp_path): worker = changed_frontend / "service-worker.js" worker.write_text( worker.read_text().replace( - "const CACHE = 'stackchain-dashboard-shell-v99';", + "const CACHE = 'stackchain-dashboard-shell-v100';", "const CACHE = 'stackchain-dashboard-shell-v999';", ) ) diff --git a/tests/test_later_sync.py b/tests/test_later_sync.py index f935d93..1b7909d 100644 --- a/tests/test_later_sync.py +++ b/tests/test_later_sync.py @@ -347,5 +347,5 @@ async def test_dashboard_syncs_every_later_change_and_exposes_account_status(): def test_later_sync_ships_atomically_in_the_offline_shell(): source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text() - assert "stackchain-dashboard-shell-v99" in source + assert "stackchain-dashboard-shell-v100" in source assert "BASE + 'static/later-sync.js'" in source diff --git a/tests/test_markdown_renderer.py b/tests/test_markdown_renderer.py index 4b4fd70..8626e3b 100644 --- a/tests/test_markdown_renderer.py +++ b/tests/test_markdown_renderer.py @@ -137,4 +137,4 @@ def test_markdown_work_bodies_are_mobile_safe_block_containers(): assert ".markdown-content { min-width:0; max-width:100%; overflow-wrap:anywhere;" in css assert ".markdown-content pre { max-width:100%; overflow-x:auto;" in css assert ".markdown-content a { min-height:44px;" in css - assert "stackchain-dashboard-shell-v99" in worker + assert "stackchain-dashboard-shell-v100" in worker diff --git a/tests/test_mobile_composer_integration.py b/tests/test_mobile_composer_integration.py index b74a21d..641f80a 100644 --- a/tests/test_mobile_composer_integration.py +++ b/tests/test_mobile_composer_integration.py @@ -45,7 +45,7 @@ def test_offline_shell_contains_every_local_dashboard_runtime_asset(): shell_assets = set(re.findall(r"BASE \+ '([^']+)'", worker.split("async function sessionCsrf", 1)[0])) assert local_assets <= shell_assets, f"Offline shell is missing: {sorted(local_assets - shell_assets)}" - assert "stackchain-dashboard-shell-v99" in worker + assert "stackchain-dashboard-shell-v100" in worker def test_all_conversation_composers_offer_accessible_mobile_mentions(): diff --git a/tests/test_mobile_device_setup.py b/tests/test_mobile_device_setup.py index b00e141..b88c3a2 100644 --- a/tests/test_mobile_device_setup.py +++ b/tests/test_mobile_device_setup.py @@ -214,7 +214,7 @@ def test_mobile_dashboard_mounts_phone_safe_device_setup_flow(): assert "promptStorage:localStorage" in dashboard assert "pushControllerReady.then(ensureDeviceSetup)" in dashboard assert "BASE + 'static/mobile-device-setup.js'" in worker - assert "stackchain-dashboard-shell-v99" in worker + assert "stackchain-dashboard-shell-v100" in worker assert ".device-setup-panel" in css assert ".device-readiness-card" in css assert "overflow-x:hidden" in css diff --git a/tests/test_plan_today.py b/tests/test_plan_today.py index e5b128d..c2d3ff6 100644 --- a/tests/test_plan_today.py +++ b/tests/test_plan_today.py @@ -410,7 +410,7 @@ async def test_plan_today_wires_cancel_back_and_success_through_overlay_history( def test_plan_today_controller_is_available_in_the_offline_shell(): source = SERVICE_WORKER.read_text() - assert "stackchain-dashboard-shell-v99" in source + assert "stackchain-dashboard-shell-v100" in source assert "BASE + 'static/plan-today.js'" in source assert "BASE + 'static/plan-today-readiness.js'" in source assert "BASE + 'static/plan-today-preview.js'" in source diff --git a/tests/test_service_worker.py b/tests/test_service_worker.py index 5036a5a..53a419d 100644 --- a/tests/test_service_worker.py +++ b/tests/test_service_worker.py @@ -150,7 +150,7 @@ async function dispatchPush(payload) {{ def test_resumable_today_session_ships_in_a_new_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v99" in source + assert "stackchain-dashboard-shell-v100" in source assert "BASE + 'static/my-work.js'" in source assert "BASE + 'static/dashboard.js'" in source assert "BASE + 'static/dashboard.css'" in source @@ -159,14 +159,14 @@ def test_resumable_today_session_ships_in_a_new_offline_shell(): def test_ownership_exit_runtime_rolls_the_offline_shell_cache(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v99" in source + assert "stackchain-dashboard-shell-v100" in source assert "BASE + 'static/dashboard.js'" in source def test_offline_review_next_ships_today_completion_atomically(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v99" in source + assert "stackchain-dashboard-shell-v100" in source assert "BASE + 'static/today-completion.js'" in source assert "BASE + 'static/dashboard.js'" in source @@ -174,7 +174,7 @@ def test_offline_review_next_ships_today_completion_atomically(): def test_duplicate_aware_capture_ships_in_a_new_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v99" in source + assert "stackchain-dashboard-shell-v100" in source assert "BASE + 'static/create-issue-sheet.js'" in source assert "BASE + 'static/dashboard.js'" in source @@ -182,14 +182,14 @@ def test_duplicate_aware_capture_ships_in_a_new_offline_shell(): def test_exact_later_picker_ships_atomically_in_a_new_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v99" in source + assert "stackchain-dashboard-shell-v100" in source assert "BASE + 'static/later-picker.js'" in source def test_navigation_deadline_ships_in_a_new_shell_cache(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v99" in source + assert "stackchain-dashboard-shell-v100" in source assert "BASE + 'static/dashboard.css'" in source assert "BASE + 'static/dashboard.js'" in source assert "BASE + 'static/install-app.js'" in source @@ -198,21 +198,21 @@ def test_navigation_deadline_ships_in_a_new_shell_cache(): def test_today_convergence_ships_in_a_new_shell_cache(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v99" in source + assert "stackchain-dashboard-shell-v100" in source assert "BASE + 'static/today-sync.js'" in source def test_mobile_search_viewport_ships_in_a_new_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v99" in source + assert "stackchain-dashboard-shell-v100" in source assert "BASE + 'static/mobile-search-viewport.js'" in source def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v99" in source + assert "stackchain-dashboard-shell-v100" in source assert "BASE + 'static/update-ownership.js'" in source @@ -761,7 +761,7 @@ def test_one_session_bound_csrf_proof_is_reused_for_a_background_drain(): def test_queue_today_ships_atomically_in_a_new_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v99" in source + assert "stackchain-dashboard-shell-v100" in source assert "BASE + 'static/queue-today.js'" in source @@ -846,6 +846,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell(): "/dashboard/static/mobile-task-dock.js", "/dashboard/static/mobile-work-entry.js", "/dashboard/static/mobile-queue-launcher.js", + "/dashboard/static/update-triage-session.js", "/dashboard/static/agenda-session-launcher.js", "/dashboard/static/mobile-launch.js", "/dashboard/static/mobile-app-shortcuts.js", diff --git a/tests/test_today_readiness.py b/tests/test_today_readiness.py index fb5eb46..9ff852c 100644 --- a/tests/test_today_readiness.py +++ b/tests/test_today_readiness.py @@ -221,7 +221,7 @@ async def test_today_blocker_opens_existing_preview_and_preserves_readiness_gate def test_readiness_runtime_is_available_in_offline_shell(): service_worker = SERVICE_WORKER.read_text() - assert "const CACHE = 'stackchain-dashboard-shell-v99';" in service_worker + assert "const CACHE = 'stackchain-dashboard-shell-v100';" in service_worker assert "BASE + 'static/today-readiness.js'" in service_worker diff --git a/tests/test_today_sync.py b/tests/test_today_sync.py index 18829aa..f658699 100644 --- a/tests/test_today_sync.py +++ b/tests/test_today_sync.py @@ -127,7 +127,7 @@ sync.enqueueConfiguration(120, {{'issue:r:1:':60}}); def test_inflight_today_drain_ships_in_a_new_offline_shell(): source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text() - assert "stackchain-dashboard-shell-v99" in source + assert "stackchain-dashboard-shell-v100" in source assert "BASE + 'static/today-sync.js'" in source diff --git a/tests/test_update_triage_session.py b/tests/test_update_triage_session.py new file mode 100644 index 0000000..6ffe8a4 --- /dev/null +++ b/tests/test_update_triage_session.py @@ -0,0 +1,95 @@ +import json +import subprocess +from pathlib import Path + +import pytest + +from tests.dashboard_bundle import dashboard + + +SESSION = Path(__file__).resolve().parents[1] / "frontend" / "update-triage-session.js" + + +def run_session(script): + source = f"const createSession = require({json.dumps(str(SESSION))});\n" + script + result = subprocess.run(["node", "-e", source], capture_output=True, text=True) + assert result.returncode == 0, result.stderr + return json.loads(result.stdout) + + +def test_update_triage_persists_account_bound_pass_and_resumes_by_identity(): + result = run_session(""" +const values = new Map(); +let login = 'timmy'; +let items = [1,2,3].map(id => ({notification_id:id,title:'Update '+id})); +const opened = [], progress = []; +const options = { + storage:{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)}, + getLogin:()=>login, getItems:()=>items, + onOpen:item=>opened.push(item.notification_id), onProgress:value=>progress.push(value), onFinish:()=>opened.push('done'), +}; +let session = createSession(options); +session.start(); +session.keepUnreadAndNext(); +const saved = JSON.parse(values.get('stackchain.update-triage.v1')); +session = createSession(options); +const resumable = session.resumable(); +session.resume(); +login = 'other'; +const isolated = session.resumable(); +process.stdout.write(JSON.stringify({opened, progress, saved, resumable, isolated})); +""") + + assert result["opened"] == [1, 2, 2] + assert result["progress"] == [ + {"index": 1, "total": 3}, + {"index": 2, "total": 3}, + {"index": 2, "total": 3}, + ] + assert result["saved"] == { + "version": 1, + "login": "timmy", + "identities": ["1", "2", "3"], + "current": "2", + "completed": ["1"], + } + assert result["resumable"] is True + assert result["isolated"] is False + + +def test_update_triage_reconciles_removed_items_and_does_not_admit_new_arrivals(): + result = run_session(""" +const values = new Map(); +let items = [1,2,3].map(notification_id => ({notification_id})); +const opened = [], progress = []; +const session = createSession({ + storage:{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)}, + getLogin:()=> 'timmy', getItems:()=>items, + onOpen:item=>opened.push(item.notification_id), onProgress:value=>progress.push(value), onFinish:()=>opened.push('done'), +}); +session.start(); +items = [1,3,4].map(notification_id => ({notification_id})); +session.completeAndNext(); +session.completeAndNext(); +process.stdout.write(JSON.stringify({opened, progress, stored:values.get('stackchain.update-triage.v1') || null})); +""") + + assert result == { + "opened": [1, 3, "done"], + "progress": [{"index": 1, "total": 3}, {"index": 3, "total": 3}], + "stored": None, + } + + +@pytest.mark.anyio +async def test_dashboard_wires_resumable_updates_triage_mobile_flow(): + html = await dashboard() + + assert '' in html + assert 'id="update-triage-progress"' in html + assert 'id="keep-update-unread" type="button">Keep unread & next' in html + assert "openUpdates: openUpdateTriage" in html + assert "updateTriage.acceptCompleted()" in html + assert "updateTriage.keepUnreadAndNext()" in html + assert "updateTriage.reconcile()" in html + assert ".update-triage-progress" in html