From fd2f801f3ec984e598dad6f8eac0aefb96eeff49 Mon Sep 17 00:00:00 2001 From: timmy Date: Thu, 13 Aug 2026 19:21:36 +0000 Subject: [PATCH] feat: review mobile update triage outcomes (Closes #765) --- frontend/dashboard.css | 3 ++ frontend/dashboard.js | 25 +++++++++++--- frontend/index.html | 4 +++ frontend/update-triage-session.js | 30 +++++++++++++--- tests/test_update_triage_session.py | 53 ++++++++++++++++++++++++++++- 5 files changed, 105 insertions(+), 10 deletions(-) diff --git a/frontend/dashboard.css b/frontend/dashboard.css index 2d194b7..cfb05ea 100644 --- a/frontend/dashboard.css +++ b/frontend/dashboard.css @@ -708,6 +708,9 @@ textarea { resize: vertical; min-height: 120px; } .mobile-queue-list [data-mobile-queue-count] { min-width:28px; padding:3px 8px; border-radius:999px; text-align:center; background:#1d426d; } .mobile-queue-list [data-mobile-queue="agenda"][data-deadlines="true"] { border-color:#f59e0b; background:#30240f; box-shadow:inset 3px 0 #f59e0b; } .mobile-queue-list [data-recommended="true"] { border-color:#60a5fa; box-shadow:0 0 0 2px #60a5fa; } + .mobile-update-outcome { margin-top:12px; padding:12px; border:1px solid #31577f; border-radius:12px; background:#0b1b30; } + .mobile-update-outcome p { margin:0 0 10px; } + .mobile-update-outcome button { width:100%; min-height:44px; } .mobile-today-hud { position:fixed; left:8px; right:8px; bottom:calc(56px + env(safe-area-inset-bottom)); z-index:44; display:grid; grid-template-columns:minmax(0,1fr) minmax(112px,auto); grid-template-areas:"summary complete" "progress toggle"; gap:4px 8px; max-width:100%; padding:8px; border:1px solid #31577f; border-radius:12px 12px 0 0; background:rgba(16,38,65,.98); box-shadow:0 -8px 24px rgba(0,0,0,.28); } .mobile-today-summary { grid-area:summary; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; text-align:left; font-weight:700; } .mobile-today-hud [data-work-session-progress] { grid-area:progress; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } diff --git a/frontend/dashboard.js b/frontend/dashboard.js index cb70bde..e45c130 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -82,15 +82,28 @@ findWork: () => qs('#find-work').click(), }); - function showMobileQueueCompletion(completedName) { + function showMobileQueueCompletion(completedName, cleared = true) { const next = mobileQueueLauncher.recommend(); - qs('#mobile-queue-heading').textContent = completedName + ' cleared'; + qs('#mobile-queue-heading').textContent = completedName + (cleared ? ' cleared' : ''); document.querySelectorAll('[data-mobile-queue]').forEach(row => row.removeAttribute('data-recommended')); const row = qs('[data-mobile-queue="' + next.name + '"]'); row.setAttribute('data-recommended', 'true'); qs('#mobile-queue-sheet').showModal(); row.focus(); } + let keptUpdateIdentities = []; + function showUpdateTriageOutcome(outcome) { + const box = qs('#mobile-update-outcome'); + const review = qs('#review-kept-updates'); + keptUpdateIdentities = outcome.keptIdentities; + box.hidden = false; + qs('#mobile-update-outcome-summary').textContent = outcome.reviewed + ' reviewed · ' + outcome.resolved + + ' marked read · ' + outcome.kept + ' kept unread'; + review.hidden = !outcome.kept; + review.textContent = 'Review ' + outcome.kept + ' kept unread'; + showMobileQueueCompletion(outcome.kept ? 'Updates reviewed' : 'Updates', !outcome.kept); + if (outcome.kept) review.focus(); + } const mobileTaskDock = createMobileTaskDock({ nav: qs('#mobile-task-dock'), sessionHud: qs('[data-mobile-today-hud]'), @@ -1029,12 +1042,16 @@ progress.hidden = false; progress.textContent = 'Update ' + state.index + ' of ' + state.total; }, - onFinish: () => { + onFinish: outcome => { notificationReader.prefetch(); qs('#update-triage-progress').hidden = true; - showMobileQueueCompletion('Updates'); + showUpdateTriageOutcome(outcome); }, }); + qs('#review-kept-updates').addEventListener('click', () => { + qs('#mobile-queue-sheet').close(); + updateTriage.reviewKept(keptUpdateIdentities); + }); const updateTriageLauncher = createUpdateTriageLauncher({ selectUpdates: () => selectMobileQueue('update'), diff --git a/frontend/index.html b/frontend/index.html index 7d52d6c..746aab3 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -993,6 +993,10 @@

Work queues

+

Choose what to work through next.

diff --git a/frontend/update-triage-session.js b/frontend/update-triage-session.js index 3824cf2..fba4b65 100644 --- a/frontend/update-triage-session.js +++ b/frontend/update-triage-session.js @@ -7,18 +7,20 @@ const login = () => String(options.getLogin() || '').trim(); let state = null; let running = false; + let lastOutcome = null; 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) || + if (![1, 2].includes(value?.version) || 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)); + const kept = Array.isArray(value.kept) ? value.kept.filter(value => identities.includes(value)) : []; if (!identities.length || !identities.includes(value.current)) return null; - return { version:1, login:owner, identities, current:value.current, completed }; + return { version:2, login:owner, identities, current:value.current, completed, kept }; } catch (_) { return null; } @@ -42,10 +44,15 @@ } function finish() { + const unread = new Set((options.getItems() || []).map(identity).filter(Boolean)); + const keptIdentities = state ? state.kept.filter(id => unread.has(id)) : []; + const reviewed = state ? state.completed.length : 0; + const outcome = { reviewed, resolved:reviewed - keptIdentities.length, kept:keptIdentities.length, keptIdentities }; + lastOutcome = outcome; running = false; state = null; options.storage.removeItem(key); - options.onFinish(); + options.onFinish(outcome); return false; } @@ -82,7 +89,7 @@ start() { const identities = (options.getItems() || []).map(identity).filter(Boolean); if (!identities.length) return finish(); - state = { version:1, login:login(), identities, current:identities[0], completed:[] }; + state = { version:2, login:login(), identities, current:identities[0], completed:[], kept:[] }; if (!state.login) return false; running = true; return openCurrent(); @@ -99,7 +106,20 @@ }, completeAndNext: advance, acceptCompleted: () => advance(false), - keepUnreadAndNext: advance, + keepUnreadAndNext() { + if (!running || !state) return false; + if (!state.kept.includes(state.current)) state.kept.push(state.current); + return advance(); + }, + reviewKept(identities = lastOutcome?.keptIdentities || []) { + const unread = new Set((options.getItems() || []).map(identity).filter(Boolean)); + const kept = (identities || []).filter(id => unread.has(id)); + if (!kept.length) return false; + state = { version:2, login:login(), identities:kept, current:kept[0], completed:[], kept:[] }; + if (!state.login) return false; + running = true; + return openCurrent(); + }, next: nextAvailable, items: () => state ? available().slice() : [], end: finish, diff --git a/tests/test_update_triage_session.py b/tests/test_update_triage_session.py index 81f0642..46513a4 100644 --- a/tests/test_update_triage_session.py +++ b/tests/test_update_triage_session.py @@ -47,11 +47,12 @@ process.stdout.write(JSON.stringify({opened, progress, saved, resumable, isolate {"index": 2, "total": 3}, ] assert result["saved"] == { - "version": 1, + "version": 2, "login": "timmy", "identities": ["1", "2", "3"], "current": "2", "completed": ["1"], + "kept": ["1"], } assert result["resumable"] is True assert result["isolated"] is False @@ -102,6 +103,52 @@ process.stdout.write(JSON.stringify({first, afterRemoval, afterAdvance:afterAdva assert result == {"first": 2, "afterRemoval": 3, "afterAdvance": None} +def test_update_triage_reports_truthful_outcomes_and_reopens_only_surviving_kept_items(): + result = run_session(""" +const values = new Map(); +let items = [1,2,3].map(notification_id => ({notification_id})); +const opened = [], outcomes = []; +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:()=>{}, onFinish:value=>outcomes.push(value), +}); +session.start(); +session.keepUnreadAndNext(); +session.completeAndNext(); +items = [1,3].map(notification_id => ({notification_id})); +session.keepUnreadAndNext(); +const reviewed = session.reviewKept(); +process.stdout.write(JSON.stringify({opened, outcomes, reviewed})); +""") + + assert result == { + "opened": [1, 2, 3, 1], + "outcomes": [{"reviewed": 3, "resolved": 1, "kept": 2, "keptIdentities": ["1", "3"]}], + "reviewed": True, + } + + +def test_update_triage_reconciles_kept_outcomes_before_reporting_cleared(): + result = run_session(""" +const values = new Map(); +let items = [1,2].map(notification_id => ({notification_id})); +const outcomes = []; +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:()=>{}, onProgress:()=>{}, onFinish:value=>outcomes.push(value), +}); +session.start(); +session.keepUnreadAndNext(); +items = [2].map(notification_id => ({notification_id})); +session.completeAndNext(); +process.stdout.write(JSON.stringify(outcomes[0])); +""") + + assert result == {"reviewed": 2, "resolved": 2, "kept": 0, "keptIdentities": []} + + @pytest.mark.anyio async def test_dashboard_wires_resumable_updates_triage_mobile_flow(): html = await dashboard() @@ -113,4 +160,8 @@ async def test_dashboard_wires_resumable_updates_triage_mobile_flow(): assert "updateTriage.acceptCompleted()" in html assert "updateTriage.keepUnreadAndNext()" in html assert "updateTriage.reconcile()" in html + assert 'id="mobile-update-outcome"' in html + assert 'id="review-kept-updates"' in html + assert "updateTriage.reviewKept(keptUpdateIdentities)" in html + assert "showUpdateTriageOutcome(outcome)" in html assert ".update-triage-progress" in html