diff --git a/frontend/dashboard.js b/frontend/dashboard.js index dc2103e..b17f9b4 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -940,6 +940,13 @@ event.currentTarget.setAttribute('aria-expanded', String(more.open)); if (more.open) more.scrollIntoView({ behavior:'smooth', block:'end' }); }); + const updateDecision = createUpdateDecisionTransaction({ + controls: [ + qs('#keep-update-unread'), qs('#mark-update-read-next'), + qs('#focus-update-reply'), qs('#toggle-update-more'), + ], + status: qs('#update-gesture-status'), + }); const notificationReader = createNotificationReader({ load: fetchNotificationDetail, getScope: () => confirmedOwnerLogin, @@ -5554,13 +5561,17 @@ button.focus(); } }); - qs('#keep-update-unread').addEventListener('click', () => { - if (updateTriage.active()) updateTriage.keepUnreadAndNext(); - else closeUpdateSheet(true); + const updateDecisionActions = createUpdateDecisionActions({ + transaction:updateDecision, triage:updateTriage, close:()=>closeUpdateSheet(true), + reader:notificationReader, items:()=>lastMyWork, undo:notificationUndo, + keepControl:qs('#keep-update-unread'), readControl:qs('#mark-update-read-next'), }); + const keepUpdateUnread = () => updateDecisionActions.keepUnread(); + const markUpdateRead = () => updateDecisionActions.markRead(); + qs('#keep-update-unread').addEventListener('click', keepUpdateUnread); createUpdateTriageGesture({ surface: qs('#update-sheet .update-sheet-panel'), enabled: () => selectedUpdateDetail, - keepUnread: () => qs('#keep-update-unread').click(), markRead: () => qs('#mark-update-read-next').click(), + keepUnread: keepUpdateUnread, markRead: markUpdateRead, }); qs('#update-ownership-action').addEventListener('click', () => updateOwnership.act()); qs('#update-ownership-start').addEventListener('click', () => updateOwnership.start()); @@ -5647,18 +5658,7 @@ updateReplyAttachmentController.setBusy(false); } }); - qs('#mark-update-read-next').addEventListener('click', async () => { - qs('#mark-update-read-next').disabled = true; - try { - const result = await notificationReader.markReadAndNext(lastMyWork); - if (result) { - notificationUndo.offer(result.item, result.items); - if (updateTriage.active()) updateTriage.acceptCompleted(); - } - } finally { - qs('#mark-update-read-next').disabled = false; - } - }); + qs('#mark-update-read-next').addEventListener('click', markUpdateRead); qs('#acknowledge-update-next').addEventListener('click', async () => { const button = qs('#acknowledge-update-next'); button.disabled = true; diff --git a/frontend/index.html b/frontend/index.html index d99e2a2..c9cca02 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1091,6 +1091,7 @@ + diff --git a/frontend/service-worker.js b/frontend/service-worker.js index 938be0b..c15c851 100644 --- a/frontend/service-worker.js +++ b/frontend/service-worker.js @@ -80,6 +80,7 @@ const SHELL = [ BASE + 'static/update-read-position.js', BASE + 'static/update-triage-launcher.js', BASE + 'static/update-triage-gesture.js', + BASE + 'static/update-decision-transaction.js', BASE + 'static/agenda-session-launcher.js', BASE + 'static/mobile-launch.js', BASE + 'static/mobile-app-shortcuts.js', diff --git a/frontend/update-decision-transaction.js b/frontend/update-decision-transaction.js new file mode 100644 index 0000000..1aa0a91 --- /dev/null +++ b/frontend/update-decision-transaction.js @@ -0,0 +1,38 @@ +(function(r){ + function transaction({controls=[],status}) { + let busy=false, disabled=[]; + async function run(message, action, {origin,failure}={}) { + if (busy) return false; + busy=true; + disabled=controls.map(control=>control.disabled); + controls.forEach(control=>{ control.disabled=true; }); + status.textContent=message; + let completed=false; + try { completed=Boolean(await action()); } + catch (error) { failure=failure || error.message || 'Could not complete decision. Retry.'; } + if (!completed && failure) status.textContent=failure; + busy=false; + controls.forEach((control,index)=>{ control.disabled=disabled[index]; }); + if (!completed) origin?.focus?.(); + return completed; + } + return {run,busy:()=>busy}; + } + function actions(o) { + return { + keepUnread:()=>o.transaction.run('Keeping unread…',()=>{ + if(o.triage.active()) o.triage.keepUnreadAndNext(); else o.close(); + return true; + },{origin:o.keepControl,failure:'Could not keep this update unread. Retry.'}), + markRead:()=>o.transaction.run('Marking read…',async()=>{ + const result=await o.reader.markReadAndNext(o.items()); + if(!result) return false; + o.undo.offer(result.item,result.items); + if(o.triage.active()) o.triage.acceptCompleted(); + return true; + },{origin:o.readControl,failure:'Could not mark update read. Retry.'}), + }; + } + if(typeof module==='object'&&module.exports){module.exports=transaction;module.exports.createActions=actions;} + else {r.createUpdateDecisionTransaction=transaction;r.createUpdateDecisionActions=actions;} +})(typeof self!=='undefined'?self:this); diff --git a/tests/test_frontend_bundle.py b/tests/test_frontend_bundle.py index ab4e1cd..8c66c1f 100644 --- a/tests/test_frontend_bundle.py +++ b/tests/test_frontend_bundle.py @@ -68,8 +68,8 @@ def test_product_workflows_are_stable_lazy_feature_chunks(tmp_path): assert b"function attachSecurityCenter" in security_center.runtime_bytes assert b"gitea_time_logged" not in first.runtime_bytes assert b"gitea_time_logged" in security_center.runtime_bytes - # One-ahead Updates prefetch stays in the core reader so transitions can reuse its in-flight request. - assert len(first.runtime_gzip_bytes) <= 97 * 1024 + # Core mobile workflows stay below 98 KiB gzip, including transaction-safe Update decisions. + assert len(first.runtime_gzip_bytes) <= 98 * 1024 assert f'name="stackchain-feature-issue-capture" content="{capture.runtime_name}"' in first.dashboard_html assert f'name="stackchain-feature-pull-workflow" content="{pull_workflow.runtime_name}"' in first.dashboard_html assert f"BASE + '{capture.runtime_name}'" in first.service_worker_source diff --git a/tests/test_my_work.py b/tests/test_my_work.py index f824f3e..3fdfc0b 100644 --- a/tests/test_my_work.py +++ b/tests/test_my_work.py @@ -6339,7 +6339,7 @@ async def test_mobile_update_reader_is_in_app_safe_area_aware_and_actionable(): assert '.update-sheet-actions button, .update-sheet-actions a { min-height:44px;' in html assert "createNotificationReader" in html assert "api/v1/notifications/" in html - assert "notificationReader.markReadAndNext(lastMyWork)" in html + assert "reader:notificationReader, items:()=>lastMyWork" in html def test_notification_replier_preserves_failed_draft_and_clears_only_after_success(): diff --git a/tests/test_service_worker.py b/tests/test_service_worker.py index a048794..39aa748 100644 --- a/tests/test_service_worker.py +++ b/tests/test_service_worker.py @@ -850,6 +850,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell(): "/dashboard/static/update-read-position.js", "/dashboard/static/update-triage-launcher.js", "/dashboard/static/update-triage-gesture.js", + "/dashboard/static/update-decision-transaction.js", "/dashboard/static/agenda-session-launcher.js", "/dashboard/static/mobile-launch.js", "/dashboard/static/mobile-app-shortcuts.js", diff --git a/tests/test_update_triage_gesture.py b/tests/test_update_triage_gesture.py index 4a84083..63b7589 100644 --- a/tests/test_update_triage_gesture.py +++ b/tests/test_update_triage_gesture.py @@ -8,6 +8,7 @@ from tests.dashboard_bundle import dashboard GESTURE = Path(__file__).resolve().parents[1] / "frontend" / "update-triage-gesture.js" +TRANSACTION = Path(__file__).resolve().parents[1] / "frontend" / "update-decision-transaction.js" def run_gesture(script): @@ -17,6 +18,71 @@ def run_gesture(script): return json.loads(result.stdout) +def run_transaction(script): + source = f"const createTransaction = require({json.dumps(str(TRANSACTION))});\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_decision_transaction_locks_every_action_until_success(): + result = run_transaction(r""" +const controls = ['keep', 'read', 'reply', 'more'].map(name => ({name, disabled:false, focus(){}})); +const status = {textContent:'Ready'}; +let resolveAction; +let calls = 0; +const transaction = createTransaction({controls, status}); +const pending = transaction.run('Marking read…', () => new Promise(resolve => { + calls += 1; + resolveAction = resolve; +})); +const locked = controls.every(control => control.disabled) && transaction.busy(); +const rejected = transaction.run('Keeping unread…', () => { calls += 1; }); +resolveAction(true); +Promise.all([pending, rejected]).then(([completed, second]) => process.stdout.write(JSON.stringify({ + locked, pendingStatus: 'Marking read…', calls, completed, second, + unlocked: controls.every(control => !control.disabled) && !transaction.busy(), + finalStatus: status.textContent, +}))); +""") + + assert result == { + "locked": True, + "pendingStatus": "Marking read…", + "calls": 1, + "completed": True, + "second": False, + "unlocked": True, + "finalStatus": "Marking read…", + } + + +def test_update_decision_transaction_restores_focus_and_announces_failure(): + result = run_transaction(r""" +let focused = 0; +let focusedWhileEnabled = false; +const origin = {disabled:false, focus(){ focused += 1; focusedWhileEnabled = !this.disabled; }}; +const other = {disabled:false, focus(){}}; +const status = {textContent:''}; +const transaction = createTransaction({controls:[origin, other], status}); +transaction.run('Marking read…', async () => false, { + origin, + failure:'Could not mark update read. Retry.', +}).then(completed => process.stdout.write(JSON.stringify({ + completed, focused, focusedWhileEnabled, status:status.textContent, + unlocked:!origin.disabled && !other.disabled && !transaction.busy(), +}))); +""") + + assert result == { + "completed": False, + "focused": 1, + "focusedWhileEnabled": True, + "status": "Could not mark update read. Retry.", + "unlocked": True, + } + + def test_committed_horizontal_swipes_decide_once_and_lock_while_pending(): result = run_gesture(r""" class Surface { @@ -102,9 +168,11 @@ async def test_dashboard_wires_accessible_phone_only_update_swipe_triage(): html = await dashboard() assert '' in html + assert '' in html assert 'id="update-gesture-status"' in html assert "createUpdateTriageGesture({" in html - assert "qs('#keep-update-unread').click()" in html - assert "qs('#mark-update-read-next').click()" in html + assert "createUpdateDecisionActions({" in html + assert "keepUnread: keepUpdateUnread" in html + assert "markRead: markUpdateRead" in html assert "data-triage-gesture" in html assert "prefers-reduced-motion: reduce" in html diff --git a/tests/test_update_triage_session.py b/tests/test_update_triage_session.py index 46513a4..128b370 100644 --- a/tests/test_update_triage_session.py +++ b/tests/test_update_triage_session.py @@ -158,7 +158,7 @@ async def test_dashboard_wires_resumable_updates_triage_mobile_flow(): assert 'id="keep-update-unread" type="button" aria-label="Keep unread and open next update">Keep unread' in html assert "openUpdates: openUpdateTriage" in html assert "updateTriage.acceptCompleted()" in html - assert "updateTriage.keepUnreadAndNext()" in html + assert "triage:updateTriage" in html assert "updateTriage.reconcile()" in html assert 'id="mobile-update-outcome"' in html assert 'id="review-kept-updates"' in html