From a0aa3c0a2201698a334226f4db18f7e068ee2410 Mon Sep 17 00:00:00 2001 From: timmy Date: Mon, 10 Aug 2026 02:05:11 +0000 Subject: [PATCH] feat: work Today blockers in-app (#443) --- frontend/dashboard.css | 1 + frontend/dashboard.js | 45 +++++++++++++++++--- frontend/service-worker.js | 2 +- frontend/today-readiness.js | 13 +++++- tests/test_later_sync.py | 2 +- tests/test_markdown_renderer.py | 2 +- tests/test_mobile_composer_integration.py | 2 +- tests/test_plan_today.py | 2 +- tests/test_service_worker.py | 18 ++++---- tests/test_today_readiness.py | 52 ++++++++++++++++++++++- tests/test_today_sync.py | 2 +- 11 files changed, 118 insertions(+), 23 deletions(-) diff --git a/frontend/dashboard.css b/frontend/dashboard.css index 5f1f18a..a307500 100644 --- a/frontend/dashboard.css +++ b/frontend/dashboard.css @@ -226,6 +226,7 @@ textarea { resize: vertical; min-height: 120px; } .issue-blocker-list { display:grid; gap:8px; } .issue-blocker { min-width:0; overflow-wrap:anywhere; display:grid; gap:4px; padding:10px; border:1px solid #92400e; border-radius:10px; color:#fef3c7; text-decoration:none; } .issue-blocker:hover, .issue-blocker:focus-visible { border-color:#f59e0b; } +.today-readiness-blocker { width:100%; min-height:44px; text-align:left; background:#291b0c; } .issue-planning { max-width:100%; margin-top:16px; border:1px solid #2a496e; border-radius:12px; padding:0 12px 12px; overflow-x:hidden; } .issue-planning > summary { min-height:44px; display:flex; align-items:center; cursor:pointer; font-weight:700; } .issue-planning-retry { min-height:44px; width:100%; } diff --git a/frontend/dashboard.js b/frontend/dashboard.js index d844d88..d6906db 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -729,6 +729,8 @@ } let todayReadinessTrigger = null; + let todayReadinessBlockerFocus = null; + let searchPreviewReturnKind = null; function inspectTodayDependencies(item) { if (offlineWorkMode) { const login = confirmedOwnerLogin || String(offlineWorkStore.load()?.user?.login || '').trim(); @@ -746,12 +748,17 @@ })); } - function closeTodayReadiness(navigate = true) { + function suspendTodayReadiness() { qs('#today-readiness-sheet').hidden = true; document.body.classList.remove('task-overlay-open'); + } + + function closeTodayReadiness(navigate = true) { + suspendTodayReadiness(); if (navigate) taskOverlayHistory.close(); else { todayReadiness.cancel(); + todayReadinessBlockerFocus = null; requestAnimationFrame(() => todayReadinessTrigger?.focus()); } } @@ -765,18 +772,32 @@ : state.dependencies.length + (state.dependencies.length === 1 ? ' open dependency must finish first.' : ' open dependencies must finish first.'); qs('#today-readiness-item').innerHTML = '' + escapeHtml(state.target.title || 'Untitled issue') + '
' + escapeHtml(state.target.repository + '#' + state.target.number) + '
'; - qs('#today-readiness-blockers').innerHTML = state.dependencies.map(blocker => - '' + + const blockers = qs('#today-readiness-blockers'); + blockers.innerHTML = state.dependencies.map((blocker, index) => + '' ).join(''); + blockers.querySelectorAll('[data-today-blocker-index]').forEach(button => { + button.addEventListener('click', () => { + const blocker = state.dependencies[Number(button.dataset.todayBlockerIndex)]; + todayReadinessBlockerFocus = blocker?.repository + '#' + blocker?.number; + todayReadiness.previewBlocker(blocker); + }); + }); qs('#today-readiness-next').hidden = !state.nextReady; if (state.nextReady) qs('#today-readiness-next').textContent = 'Start next ready · ' + (state.nextReady.title || state.nextReady.key || 'work item'); qs('#today-readiness-retry').hidden = !unknown; qs('#today-readiness-sheet').hidden = false; document.body.classList.add('task-overlay-open'); - requestAnimationFrame(() => (state.nextReady ? qs('#today-readiness-next') : qs('#today-readiness-anyway')).focus()); + requestAnimationFrame(() => { + const blocker = state.dependencies.findIndex(candidate => + candidate?.repository + '#' + candidate?.number === todayReadinessBlockerFocus + ); + if (blocker >= 0) blockers.querySelector('[data-today-blocker-index="' + blocker + '"]')?.focus(); + else (state.nextReady ? qs('#today-readiness-next') : qs('#today-readiness-anyway')).focus(); + }); } function performTodayTransition(action, item = null) { @@ -804,6 +825,11 @@ renderTodayReadiness(state); taskOverlayHistory.open('today-readiness'); }, + onPreview:blocker => { + searchPreviewReturnKind = 'today-readiness'; + searchPreview.open({ ...blocker, kind:'issue' }).catch(() => {}); + taskOverlayHistory.open('search-preview'); + }, }); function runTodayTransition(action) { @@ -2892,6 +2918,8 @@ const status = qs('#search-preview-status'); const claimButton = qs('#claim-search-result'); const startButton = qs('#start-search-result'); + qs('#close-search-preview').textContent = searchPreviewReturnKind === 'today-readiness' + ? 'Back to blockers' : 'Back to search'; if (state.status === 'closed') { sheet.classList.remove('open'); return; @@ -3003,6 +3031,7 @@ qs('#cmd-input').value = ''; } else { mobileSearchViewport.rememberScroll(); + searchPreviewReturnKind = 'search'; searchPreview.open(item.result).catch(() => {}); taskOverlayHistory.open('search-preview'); } @@ -3062,6 +3091,7 @@ searchPreview.close(); mobileSearchViewport.close(); } + searchPreviewReturnKind = null; } if (previous === 'search' && kind !== 'search' && kind !== 'search-preview') { qs('#cmd-palette').classList.remove('open'); @@ -3077,7 +3107,10 @@ addPlanPreviewOverride = false; } if (previous === 'plan-today' && kind !== 'plan-today' && kind !== 'plan-today-preview') closePlanToday(false); - if (previous === 'today-readiness' && kind !== 'today-readiness') closeTodayReadiness(false); + if (previous === 'today-readiness' && kind !== 'today-readiness') { + if (kind === 'search-preview') suspendTodayReadiness(); + else closeTodayReadiness(false); + } if (kind === 'new' && previous !== 'new') openCreateIssueSheet(false); if (kind === 'find' && previous !== 'find') openFindWorkSheet(false); if (kind === 'search' && previous !== 'search-preview') openCommandPalette(false); diff --git a/frontend/service-worker.js b/frontend/service-worker.js index 5262706..313839f 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-v74'; +const CACHE = 'stackchain-dashboard-shell-v75'; 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; diff --git a/frontend/today-readiness.js b/frontend/today-readiness.js index 94ce965..2095767 100644 --- a/frontend/today-readiness.js +++ b/frontend/today-readiness.js @@ -5,7 +5,7 @@ })(typeof globalThis !== 'undefined' ? globalThis : this, function () { 'use strict'; - return function createTodayReadiness({ inspect, onOpen = () => {}, onGate = () => {} }) { + return function createTodayReadiness({ inspect, onOpen = () => {}, onGate = () => {}, onPreview = () => {} }) { let pending = null; let generation = 0; @@ -67,9 +67,20 @@ return true; } + function previewBlocker(blocker) { + if (!pending || !blocker) return false; + const current = pending.dependencies.find(dependency => + dependency?.repository === blocker.repository && Number(dependency?.number) === Number(blocker.number) + ); + if (!current) return false; + onPreview(current); + return true; + } + return { run, retry: () => pending ? run(pending.action, pending.items, pending.target) : Promise.resolve('closed'), + previewBlocker, startNextReady: () => open(pending?.nextReady), workAnyway: () => open(pending?.target), cancel() { diff --git a/tests/test_later_sync.py b/tests/test_later_sync.py index 7ff1f73..67793e5 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-v74" in source + assert "stackchain-dashboard-shell-v75" 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 68a30e4..4fa031b 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-v74" in worker + assert "stackchain-dashboard-shell-v75" in worker diff --git a/tests/test_mobile_composer_integration.py b/tests/test_mobile_composer_integration.py index 6b87c6e..dba568c 100644 --- a/tests/test_mobile_composer_integration.py +++ b/tests/test_mobile_composer_integration.py @@ -35,4 +35,4 @@ 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-v74" in worker + assert "stackchain-dashboard-shell-v75" in worker diff --git a/tests/test_plan_today.py b/tests/test_plan_today.py index fc1249b..574743c 100644 --- a/tests/test_plan_today.py +++ b/tests/test_plan_today.py @@ -232,6 +232,6 @@ 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-v74" in source + assert "stackchain-dashboard-shell-v75" in source assert "BASE + 'static/plan-today.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 5bd4171..f15a73b 100644 --- a/tests/test_service_worker.py +++ b/tests/test_service_worker.py @@ -122,7 +122,7 @@ async function dispatchNotificationClick(route) {{ def test_resumable_today_session_ships_in_a_new_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v74" in source + assert "stackchain-dashboard-shell-v75" in source assert "BASE + 'static/my-work.js'" in source assert "BASE + 'static/dashboard.js'" in source assert "BASE + 'static/dashboard.css'" in source @@ -131,7 +131,7 @@ def test_resumable_today_session_ships_in_a_new_offline_shell(): def test_offline_review_next_ships_today_completion_atomically(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v74" in source + assert "stackchain-dashboard-shell-v75" in source assert "BASE + 'static/today-completion.js'" in source assert "BASE + 'static/dashboard.js'" in source @@ -139,7 +139,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-v74" in source + assert "stackchain-dashboard-shell-v75" in source assert "BASE + 'static/create-issue-sheet.js'" in source assert "BASE + 'static/dashboard.js'" in source @@ -147,14 +147,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-v74" in source + assert "stackchain-dashboard-shell-v75" 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-v74" in source + assert "stackchain-dashboard-shell-v75" in source assert "BASE + 'static/dashboard.css'" in source assert "BASE + 'static/dashboard.js'" in source assert "BASE + 'static/install-app.js'" in source @@ -163,21 +163,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-v74" in source + assert "stackchain-dashboard-shell-v75" 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-v74" in source + assert "stackchain-dashboard-shell-v75" 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-v74" in source + assert "stackchain-dashboard-shell-v75" in source assert "BASE + 'static/update-ownership.js'" in source @@ -358,7 +358,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-v74" in source + assert "stackchain-dashboard-shell-v75" in source assert "BASE + 'static/queue-today.js'" in source diff --git a/tests/test_today_readiness.py b/tests/test_today_readiness.py index 282f8cd..992f236 100644 --- a/tests/test_today_readiness.py +++ b/tests/test_today_readiness.py @@ -102,6 +102,41 @@ readiness.run('resume', items, items[0]).then(async result => {{ } +def test_readiness_previews_only_a_current_blocker_without_closing_or_reordering_gate(): + script = f""" +const createTodayReadiness = require({json.dumps(str(READINESS))}); +const previews = []; +const items = [1, 2].map(number => ({{kind:'issue', repository:'r', number}})); +const blocker = {{repository:'r', number:99, title:'Prerequisite', state:'open'}}; +const readiness = createTodayReadiness({{ + inspect: async candidate => candidate.number === 1 + ? {{available:true, dependencies:[blocker]}} + : {{available:true, dependencies:[]}}, + onPreview: candidate => previews.push(candidate.number), +}}); +(async () => {{ + await readiness.run('start', items, items[0]); + const accepted = readiness.previewBlocker(blocker); + const rejected = readiness.previewBlocker({{repository:'r', number:100}}); + const snapshot = readiness.snapshot(); + process.stdout.write(JSON.stringify({{ + accepted, rejected, previews, + open:snapshot.status === 'blocked', + target:snapshot.target.number, + order:snapshot.items.map(item => item.number), + }})); +}})(); +""" + assert json.loads(run_node(script)) == { + "accepted": True, + "rejected": False, + "previews": [99], + "open": True, + "target": 1, + "order": [1, 2], + } + + def test_readiness_never_calls_unknown_ready_and_supports_retry_or_explicit_override(): script = f""" const createTodayReadiness = require({json.dumps(str(READINESS))}); @@ -170,10 +205,23 @@ async def test_dashboard_exposes_mobile_today_readiness_gate_and_runtime(): assert "runTodayTransition('complete'" in html +@pytest.mark.anyio +async def test_today_blocker_opens_existing_preview_and_preserves_readiness_gate(): + html = await dashboard() + + assert 'data-today-blocker-index="' in html + assert "todayReadiness.previewBlocker(blocker)" in html + assert "searchPreview.open({ ...blocker, kind:'issue' })" in html + assert "taskOverlayHistory.open('search-preview')" in html + assert "suspendTodayReadiness()" in html + assert "todayReadiness.cancel()" in html + assert "todayReadinessBlockerFocus" in html + + def test_readiness_runtime_is_available_in_offline_shell(): service_worker = SERVICE_WORKER.read_text() - assert "const CACHE = 'stackchain-dashboard-shell-v74';" in service_worker + assert "const CACHE = 'stackchain-dashboard-shell-v75';" in service_worker assert "BASE + 'static/today-readiness.js'" in service_worker @@ -185,3 +233,5 @@ def test_mobile_readiness_gate_has_touch_safe_wrapping_actions(): assert ".today-readiness-item" in css assert "overflow-wrap:anywhere" in css assert "env(safe-area-inset-bottom)" in css + assert ".today-readiness-blocker" in css + assert "min-height:44px" in css diff --git a/tests/test_today_sync.py b/tests/test_today_sync.py index 9a5f7bd..abfc424 100644 --- a/tests/test_today_sync.py +++ b/tests/test_today_sync.py @@ -86,7 +86,7 @@ sync.enqueue('add', 'issue:r:1:'); 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-v74" in source + assert "stackchain-dashboard-shell-v75" in source assert "BASE + 'static/today-sync.js'" in source -- 2.43.0