diff --git a/frontend/dashboard.css b/frontend/dashboard.css index 279501c..cd84e98 100644 --- a/frontend/dashboard.css +++ b/frontend/dashboard.css @@ -1031,6 +1031,14 @@ textarea { resize: vertical; min-height: 120px; } .mobile-queue-panel { padding:16px; padding-bottom:calc(16px + env(safe-area-inset-bottom)); } .mobile-queue-panel header { display:flex; align-items:center; justify-content:space-between; gap:12px; } .mobile-queue-panel h2 { margin:0; } + .mobile-delivery-recovery { box-sizing:border-box; width:100%; max-width:none; max-height:100dvh; margin:auto 0 0; padding:0; border:0; border-radius:18px 18px 0 0; color:var(--text); background:#102641; } + .mobile-delivery-recovery::backdrop { background:rgba(3,9,18,.78); } + .mobile-delivery-recovery-panel { box-sizing:border-box; display:grid; gap:12px; width:100%; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); overflow-wrap:anywhere; } + .mobile-delivery-recovery-panel header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; } + .mobile-delivery-recovery-panel h2, .mobile-delivery-recovery-panel p { margin:0; } + .mobile-delivery-recovery-panel button { min-height:48px; } + .mobile-delivery-recovery-panel > #mobile-delivery-recovery-action { width:100%; font-weight:800; } + .delivery-recovery-reason { padding:12px; border:1px solid #31577f; border-radius:12px; background:#0b1c30; } .mobile-start-day { display:grid; gap:12px; max-width:100%; overflow-wrap:anywhere; margin-top:12px; padding:14px; border:1px solid #31577f; border-radius:14px; background:linear-gradient(135deg,#173b64,#102641); } .mobile-start-day h3, .mobile-start-day p { margin:0; } .mobile-start-day p + p { margin-top:4px; } diff --git a/frontend/dashboard.js b/frontend/dashboard.js index a03ed47..0ddecf8 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -91,14 +91,15 @@ qs('#my-work').focus(); } let mobileQueueCounts = {}; - function openDeliveryRecovery() { - selectMobileQueue('draft'); - const deliveryCenterTitle = qs('#delivery-center-title'); - if (!deliveryCenterTitle) return 'empty'; - deliveryCenterTitle.scrollIntoView({block:'start'}); - deliveryCenterTitle.focus({preventScroll:true}); - return 'opened'; - } + const mobileDeliveryRecovery = createMobileDeliveryRecovery({ + getItems: () => draftInbox.partition(lastDrafts).deliveries, + getIndex: item => lastDrafts.indexOf(item), + beforeOpen: () => selectMobileQueue('draft'), + onComplete: () => { + if (!mobileStartDay.completePhase('delivery')) showMobileQueueCompletion('Delivery', true); + }, + }); + mobileDeliveryRecovery.start(); function openFiledFollowUp() { selectMobileQueue('filed'); const target = filedFollowUpTarget(completedFiledReview.visible(lastMyWork)); @@ -112,7 +113,7 @@ return target.kind === 'update' ? 'opened-update' : 'opened-issue'; } const mobileQueueLauncher = createMobileQueueLauncher({ - openDelivery: openDeliveryRecovery, + openDelivery: () => mobileDeliveryRecovery.open(), openToday: () => mobileWorkEntry.open(), openAgenda: openAgendaSession, openUpdates: openUpdateTriage, @@ -3338,6 +3339,7 @@ qs('#my-work-action-status').textContent = 'Draft discarded.'; }); }); + mobileDeliveryRecovery.render(); } function updateWorkPaginationControls() { diff --git a/frontend/index.html b/frontend/index.html index 16b0332..9206ad9 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1543,6 +1543,20 @@ + + + + Recover delivery + Close + + + + + Recover delivery + Other recovery optionsClose this guide to edit, copy, or discard the saved delivery from Drafts. + + + Work Find @@ -1639,6 +1653,7 @@ + diff --git a/frontend/mobile-delivery-recovery.js b/frontend/mobile-delivery-recovery.js new file mode 100644 index 0000000..059d1e9 --- /dev/null +++ b/frontend/mobile-delivery-recovery.js @@ -0,0 +1,148 @@ +(function (root, factory) { + if (typeof module === 'object' && module.exports) module.exports = factory; + else root.createMobileDeliveryRecovery = factory; +})(typeof self !== 'undefined' ? self : this, function createMobileDeliveryRecovery(options) { + const byId = id => typeof document === 'undefined' ? null : document.getElementById(id); + const elements = options.elements || (typeof document === 'undefined' ? null : { + dialog: byId('mobile-delivery-recovery'), close: byId('close-mobile-delivery-recovery'), + title: byId('mobile-delivery-recovery-title'), destination: byId('mobile-delivery-recovery-destination'), + reason: byId('mobile-delivery-recovery-reason'), progress: byId('mobile-delivery-recovery-progress'), + action: byId('mobile-delivery-recovery-action'), status: byId('mobile-delivery-recovery-status'), + }); + const priority = {authorization: 0, attention: 1, uncertain: 2, waiting: 3}; + + function identity(item) { + return String(item?.outbox_id || item?.id || ''); + } + + function state(item) { + if (item?.status === 'authorization') return 'authorization'; + if (item?.status === 'attention' || item?.checklist_conflict || item?.quarantined) return 'attention'; + if (item?.delivery_state === 'uncertain') return 'uncertain'; + return 'waiting'; + } + + function primary(item) { + const kind = state(item); + if (kind === 'authorization') { + return item?.outbox_kind === 'issue-close' ? 'Authorize & close' : 'Authorize & send'; + } + if (item?.checklist_conflict) return 'Review changes'; + if (kind === 'attention') return item?.quarantined ? 'Copy content' : 'Open current item'; + if (kind === 'uncertain') return 'Verified not posted — retry'; + return 'Retry now'; + } + + function ordered() { + return [...(options.getItems ? options.getItems() : [])].sort((left, right) => { + const byState = priority[state(left)] - priority[state(right)]; + if (byState) return byState; + return identity(left).localeCompare(identity(right)); + }); + } + + const stateLabels = { + authorization: 'Authorization required', + attention: 'Needs attention', + uncertain: 'Delivery uncertain', + waiting: 'Ready to retry', + }; + let currentId = ''; + let active = false; + + function currentItem(items) { + return items.find(item => identity(item) === currentId) || items[0]; + } + + function snapshot() { + const items = ordered(); + const item = currentItem(items); + if (item) currentId = identity(item); + else currentId = ''; + const index = item ? items.indexOf(item) : -1; + return { + count: items.length, + current: item ? { + id: identity(item), + state: state(item), + primary: primary(item), + position: index + 1, + total: items.length, + } : null, + }; + } + + function render() { + const result = snapshot(); + if (!elements) return result; + const items = ordered(); + const item = currentItem(items); + if (!item) { + if (active) { + active = false; + if (elements.dialog?.open) elements.dialog.close(); + if (options.onComplete) options.onComplete(); + } + return result; + } + elements.title.textContent = item.title || 'Queued delivery'; + elements.destination.textContent = [item.repository, item.details].filter(Boolean).join(' · ') || 'Saved delivery'; + elements.reason.textContent = item.last_attempt_error || item.ownership || stateLabels[state(item)]; + elements.progress.textContent = 'Delivery ' + result.current.position + ' of ' + result.current.total + ' · ' + stateLabels[state(item)]; + elements.action.textContent = primary(item); + if (elements.status) elements.status.textContent = ''; + return result; + } + + function open() { + const queue = byId('mobile-queue-sheet'); + if (queue?.open) queue.close(); + if (options.beforeOpen) options.beforeOpen(); + currentId = ''; + const result = snapshot(); + if (!result.current) return 'empty'; + active = true; + render(); + if (elements?.dialog && !elements.dialog.open) elements.dialog.showModal(); + return 'opened'; + } + + async function defaultActivate(item) { + if (typeof document === 'undefined') return false; + const index = options.getIndex ? options.getIndex(item) : -1; + const selector = item.status === 'authorization' ? '.draft-authorize' : + item.checklist_conflict ? '.draft-review-checklist' : item.quarantined ? '.draft-copy' : + item.status === 'attention' ? '.draft-resume' : '.draft-send'; + const action = document.querySelector('#my-work-list ' + selector + '[data-draft-index="' + index + '"]'); + if (!action) { + if (elements.status) elements.status.textContent = 'This delivery changed. Reopen recovery to load its current action.'; + return false; + } + action.click(); + if (selector === '.draft-resume' || selector === '.draft-copy') elements.dialog.close(); + await Promise.resolve(); + return true; + } + + async function activate() { + const items = ordered(); + const item = currentItem(items); + const perform = options.activate || defaultActivate; + if (!item) return render(); + if (elements?.action) elements.action.disabled = true; + if (elements?.status) elements.status.textContent = 'Working…'; + try { + await perform(item, state(item)); + } finally { + if (elements?.action) elements.action.disabled = false; + } + return render(); + } + + function start() { + if (elements?.action?.addEventListener) elements.action.addEventListener('click', activate); + if (elements?.close?.addEventListener) elements.close.addEventListener('click', () => elements.dialog.close()); + } + + return {activate, open, render, snapshot, start}; +}); diff --git a/frontend/service-worker.js b/frontend/service-worker.js index 2134b55..68a5ecb 100644 --- a/frontend/service-worker.js +++ b/frontend/service-worker.js @@ -106,6 +106,7 @@ const SHELL = [ BASE + 'static/mobile-task-dock.js', BASE + 'static/mobile-work-entry.js', BASE + 'static/mobile-queue-launcher.js', + BASE + 'static/mobile-delivery-recovery.js', BASE + 'static/mobile-start-day.js', BASE + 'static/update-triage-session.js', BASE + 'static/update-review-handoff.js', diff --git a/src/frontend_bundle.py b/src/frontend_bundle.py index 467c74e..2a90f85 100644 --- a/src/frontend_bundle.py +++ b/src/frontend_bundle.py @@ -34,7 +34,7 @@ FEATURE_SOURCES = { "security-center": ("static/security-center.js",), "today-timer": ( "static/conversation.js", "static/voice-transcript-store.js", "static/voice-conversation-capture.js", "static/mobile-launch.js", "static/mobile-insights.js", "static/mobile-app-shortcuts.js", "static/mobile-plan-today-nav.js", "static/mobile-find-work-nav.js", "static/mobile-pull-refresh.js", "static/live-data-status.js", - "static/today-completion.js", "static/card-planning.js", "static/work-detail-position.js", "static/work-route.js", "static/commands.js", "static/saved-searches.js", "static/task-overlay-history.js", "static/search-preview.js", "static/mobile-search-preview-nav.js", "static/search-reply-draft-store.js", "static/conversation-reply-draft-store.js", "static/conversation-photo-drafts.js", "static/search-defer.js", "static/mobile-search-viewport.js", "static/my-work.js", "static/protect-today.js", "static/mobile-task-dock.js", "static/mobile-work-entry.js", "static/mobile-queue-launcher.js", "static/mobile-start-day.js", "static/update-triage-session.js", "static/update-review-handoff.js", "static/update-triage-launcher.js", "static/update-triage-gesture.js", "static/notification-undo.js", "static/today-timer.js", "static/today-recap.js", "static/today-wrap-up.js", "static/today-handoff.js", + "static/today-completion.js", "static/card-planning.js", "static/work-detail-position.js", "static/work-route.js", "static/commands.js", "static/saved-searches.js", "static/task-overlay-history.js", "static/search-preview.js", "static/mobile-search-preview-nav.js", "static/search-reply-draft-store.js", "static/conversation-reply-draft-store.js", "static/conversation-photo-drafts.js", "static/search-defer.js", "static/mobile-search-viewport.js", "static/my-work.js", "static/protect-today.js", "static/mobile-task-dock.js", "static/mobile-work-entry.js", "static/mobile-queue-launcher.js", "static/mobile-delivery-recovery.js", "static/mobile-start-day.js", "static/update-triage-session.js", "static/update-review-handoff.js", "static/update-triage-launcher.js", "static/update-triage-gesture.js", "static/notification-undo.js", "static/today-timer.js", "static/today-recap.js", "static/today-wrap-up.js", "static/today-handoff.js", "static/today-rollover.js", "static/later-work.js", "static/later-picker.js", "static/drafts.js", "static/unfiled-captures.js", "static/unfiled-draft-sync.js", "static/assign-and-start.js", "static/filed-claim.js", "static/queue-today.js", "static/create-and-start.js", "static/draft-filing-session.js", "static/draft-capacity-dialog.js", "static/work-selection.js", diff --git a/tests/test_drafts.py b/tests/test_drafts.py index a1d2271..abde0a0 100644 --- a/tests/test_drafts.py +++ b/tests/test_drafts.py @@ -379,8 +379,8 @@ async def test_mobile_dashboard_renders_delivery_center_separately_and_retries_w assert '.delivery-center { display:flex; align-items:center; justify-content:space-between; gap:12px; flex-wrap:wrap;' in html assert '.delivery-center button { min-height:44px;' in html assert "counts.delivery = draftInbox.partition(lastDrafts).actionable;" in html - assert "openDelivery: openDeliveryRecovery" in html - assert "deliveryCenterTitle.focus({preventScroll:true});" in html + assert "openDelivery: () => mobileDeliveryRecovery.open()" in html + assert "getItems: () => draftInbox.partition(lastDrafts).deliveries" in html assert "authoritativePhases:['delivery']" in html diff --git a/tests/test_mobile_delivery_recovery.py b/tests/test_mobile_delivery_recovery.py new file mode 100644 index 0000000..6f8db96 --- /dev/null +++ b/tests/test_mobile_delivery_recovery.py @@ -0,0 +1,166 @@ +import json +import subprocess +from pathlib import Path + +import pytest + +from tests.dashboard_bundle import dashboard + + +ROOT = Path(__file__).resolve().parents[1] +RECOVERY = ROOT / "frontend" / "mobile-delivery-recovery.js" + + +def run_node(script: str) -> dict: + result = subprocess.run( + ["node", "-e", script], + cwd=ROOT, + text=True, + capture_output=True, + check=False, + ) + assert result.returncode == 0, result.stderr + return json.loads(result.stdout) + + +def test_recovery_orders_deliveries_by_safest_required_intervention(): + script = f""" +const createRecovery = require({json.dumps(str(RECOVERY))}); +const recovery = createRecovery({{ + getItems: () => [ + {{outbox_id:'waiting', delivery_state:'waiting', title:'Waiting'}}, + {{outbox_id:'uncertain', delivery_state:'uncertain', title:'Uncertain'}}, + {{outbox_id:'attention', status:'attention', title:'Attention'}}, + {{outbox_id:'authorization', status:'authorization', title:'Authorization'}}, + ], +}}); +process.stdout.write(JSON.stringify(recovery.snapshot())); +""" + + assert run_node(script) == { + "count": 4, + "current": { + "id": "authorization", + "state": "authorization", + "primary": "Authorize & send", + "position": 1, + "total": 4, + }, + } + + +def test_recovery_advances_only_after_the_current_delivery_is_resolved(): + script = f""" +const createRecovery = require({json.dumps(str(RECOVERY))}); +(async () => {{ + let items = [ + {{outbox_id:'authorize', status:'authorization', title:'Approve review'}}, + {{outbox_id:'retry', delivery_state:'waiting', title:'Post comment'}}, + ]; + let resolves = false; + const recovery = createRecovery({{ + getItems: () => items, + activate: async item => {{ + if (resolves) items = items.filter(candidate => candidate.outbox_id !== item.outbox_id); + return resolves; + }}, + }}); + recovery.open(); + const retained = await recovery.activate(); + resolves = true; + const advanced = await recovery.activate(); + process.stdout.write(JSON.stringify({{retained, advanced}})); +}})(); +""" + + assert run_node(script) == { + "retained": { + "count": 2, + "current": { + "id": "authorize", + "state": "authorization", + "primary": "Authorize & send", + "position": 1, + "total": 2, + }, + }, + "advanced": { + "count": 1, + "current": { + "id": "retry", + "state": "waiting", + "primary": "Retry now", + "position": 1, + "total": 1, + }, + }, + } + + +def test_recovery_renders_delivery_context_and_hands_off_when_cleared(): + script = f""" +const createRecovery = require({json.dumps(str(RECOVERY))}); +(async () => {{ + let items = [{{ + outbox_id:'close-7', status:'authorization', outbox_kind:'issue-close', + title:'Close resolved incident', repository:'stackchain/ops', + details:'#7', last_attempt_error:'Fresh authorization expired', + }}]; + let completed = 0; + const element = () => ({{textContent:'', hidden:false}}); + const elements = {{ + dialog: {{open:false, showModal(){{this.open=true}}, close(){{this.open=false}}}}, + title: element(), destination: element(), reason: element(), progress: element(), + action: element(), status: element(), + }}; + const recovery = createRecovery({{ + getItems: () => items, + activate: async () => {{items = []; return true}}, + onComplete: () => {{completed += 1}}, + elements, + }}); + const opened = recovery.open(); + const rendered = {{ + opened, dialogOpen:elements.dialog.open, title:elements.title.textContent, + destination:elements.destination.textContent, reason:elements.reason.textContent, + progress:elements.progress.textContent, action:elements.action.textContent, + }}; + const cleared = await recovery.activate(); + process.stdout.write(JSON.stringify({{rendered, cleared, completed, dialogOpen:elements.dialog.open}})); +}})(); +""" + + assert run_node(script) == { + "rendered": { + "opened": "opened", + "dialogOpen": True, + "title": "Close resolved incident", + "destination": "stackchain/ops · #7", + "reason": "Fresh authorization expired", + "progress": "Delivery 1 of 1 · Authorization required", + "action": "Authorize & close", + }, + "cleared": {"count": 0, "current": None}, + "completed": 1, + "dialogOpen": False, + } + + +@pytest.mark.anyio +async def test_dashboard_packages_phone_safe_guided_delivery_recovery(): + html = await dashboard() + service_worker = (ROOT / "frontend" / "service-worker.js").read_text() + bundle = (ROOT / "src" / "frontend_bundle.py").read_text() + + assert '' in html + assert "const mobileDeliveryRecovery = createMobileDeliveryRecovery({" in html + assert "openDelivery: () => mobileDeliveryRecovery.open()" in html + assert "mobileStartDay.completePhase('delivery')" in html + assert "BASE + 'static/mobile-delivery-recovery.js'" in service_worker + assert '"static/mobile-delivery-recovery.js"' in bundle + assert ".mobile-delivery-recovery-panel" in html + assert "min-height:48px" in html + assert "env(safe-area-inset-bottom)" in html diff --git a/tests/test_service_worker.py b/tests/test_service_worker.py index 405cf2e..1207b18 100644 --- a/tests/test_service_worker.py +++ b/tests/test_service_worker.py @@ -1043,6 +1043,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/mobile-delivery-recovery.js", "/dashboard/static/mobile-start-day.js", "/dashboard/static/update-triage-session.js", "/dashboard/static/update-review-handoff.js",
Close this guide to edit, copy, or discard the saved delivery from Drafts.