From 05f9e06a10a7d43b3b456a1fa68b80c2fada490a Mon Sep 17 00:00:00 2001 From: timmy Date: Mon, 10 Aug 2026 00:00:51 +0000 Subject: [PATCH] feat: surface Today issue blockers (#435) --- README.md | 5 +- frontend/dashboard.css | 5 ++ frontend/dashboard.js | 59 +++++++++++++++++++- frontend/index.html | 5 ++ frontend/plan-today-preview.js | 24 +++++++-- frontend/service-worker.js | 2 +- src/gitea_proxy.py | 46 +++++++++++++++- tests/test_issue_api.py | 55 +++++++++++++++++++ tests/test_later_sync.py | 2 +- tests/test_markdown_renderer.py | 2 +- tests/test_mobile_composer_integration.py | 2 +- tests/test_plan_today.py | 66 ++++++++++++++++++++++- tests/test_service_worker.py | 18 +++---- tests/test_today_sync.py | 2 +- 14 files changed, 270 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 7dda6ec..c9c22fb 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,10 @@ the private content. Issue capture and authored mobile actions (issue comments, pull-request comments, notification replies, and reviews) persist per-draft idempotency keys, so retrying after a timeout, reload, process restart, or handoff to another worker replays a confirmed result instead of posting duplicate content. The ordered, -five-item Today plan syncs across the operator's devices. Starting a Today work session also +five-item Today plan syncs across the operator's devices. Adding an issue through **Plan Today** +first previews its Gitea dependencies: unresolved blockers are listed with links and require the +explicit **Add blocked item anyway** override, while an unavailable dependency lookup is reported +as unknown rather than unblocked. Starting a Today work session also stores an account-bound checkpoint on the current device. After a reload or installed-app restart, **Resume Today** reopens the saved item (or the next surviving item if work changed); **Comment & next** on that current issue or pull request posts the handoff online or admits it diff --git a/frontend/dashboard.css b/frontend/dashboard.css index 11cc06e..8145d72 100644 --- a/frontend/dashboard.css +++ b/frontend/dashboard.css @@ -213,6 +213,11 @@ textarea { resize: vertical; min-height: 120px; } .issue-sheet-header { display:flex; align-items:center; justify-content:space-between; gap:10px; } .issue-sheet-header button { min-height:44px; } .issue-sheet-content { overflow-wrap:anywhere; white-space:pre-wrap; } +.issue-blockers { max-width:100%; overflow-x:hidden; margin:16px 0; padding:12px; border:1px solid #b45309; border-radius:12px; background:#291b0c; } +.issue-blockers h2 { margin-top:0; } +.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; } .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 0fc64f2..1a80761 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -872,7 +872,9 @@ const title = escapeHtml(item.title || 'Untitled work'); const controls = selected ? '
' : - '
'; + (item.kind === 'issue' ? + '
' : + '
'); return '
' + key + '' + title + '
' + controls + '
'; } @@ -955,6 +957,7 @@ function openPlanPreviewDetail(item, trigger) { qs('#plan-today-sheet').hidden = true; qs('#plan-preview-actions').hidden = false; + if (item.kind === 'issue') renderPlanIssueDependencies(null, true); if (offlineWorkMode) { openRoutedWork(item, trigger); } else if (item.kind === 'update' && item.has_update) { @@ -981,19 +984,68 @@ onClose: (_item, trigger) => { closeOpenWorkSheets(); qs('#plan-preview-actions').hidden = true; + qs('#issue-blockers').hidden = true; + qs('#issue-blocker-list').textContent = ''; + qs('#add-plan-preview').disabled = false; + qs('#add-plan-preview').textContent = 'Add to Today & back'; qs('#plan-today-sheet').hidden = false; renderPlanToday(); requestAnimationFrame(() => trigger?.focus()); }, }); + + function renderPlanIssueDependencies(detail, loading = false) { + const preview = planTodayPreview.snapshot(); + if (!preview.open || preview.item?.kind !== 'issue') return; + const panel = qs('#issue-blockers'); + const list = qs('#issue-blocker-list'); + const status = qs('#issue-blocker-status'); + const addButton = qs('#add-plan-preview'); + panel.hidden = false; + list.innerHTML = ''; + addButton.disabled = loading; + addButton.dataset.planOverride = ''; + if (loading) { + status.textContent = 'Checking unresolved blockers…'; + addButton.textContent = 'Checking blockers…'; + return; + } + const available = detail?.dependencies_available === true; + const dependencies = Array.isArray(detail?.dependencies) ? detail.dependencies : []; + planTodayPreview.setDependencies({ available, dependencies }); + if (!available) { + status.textContent = 'Blocker status unavailable. Adding requires an explicit override.'; + addButton.textContent = 'Add without blocker status anyway & back'; + addButton.dataset.planOverride = 'true'; + return; + } + if (dependencies.length) { + list.innerHTML = dependencies.map(blocker => + '' + + escapeHtml(blocker.repository + '#' + blocker.number) + ' · ' + escapeHtml(blocker.title || 'Untitled blocker') + + 'State: ' + escapeHtml(blocker.state || 'open') + '' + ).join(''); + status.textContent = dependencies.length + (dependencies.length === 1 ? ' unresolved blocker.' : ' unresolved blockers.'); + addButton.textContent = 'Add blocked item anyway & back'; + addButton.dataset.planOverride = 'true'; + return; + } + panel.hidden = true; + status.textContent = ''; + addButton.textContent = 'Add to Today & back'; + } + let addPlanPreviewOnReturn = false; + let addPlanPreviewOverride = false; qs('#back-to-plan').addEventListener('click', () => { addPlanPreviewOnReturn = false; + addPlanPreviewOverride = false; taskOverlayHistory.close(); }); qs('#add-plan-preview').addEventListener('click', () => { addPlanPreviewOnReturn = true; + addPlanPreviewOverride = qs('#add-plan-preview').dataset.planOverride === 'true'; taskOverlayHistory.close(); }); @@ -1783,6 +1835,7 @@ const detail = offlineDetail || await issueController.load(item); if (selectedIssue !== item) return; selectedIssueDetail = detail; + renderPlanIssueDependencies(detail); issueConversation = issueController.conversation(item, detail.conversation); qs('#issue-sheet-title').textContent = detail.title || 'Assigned issue'; qs('#issue-sheet-body').innerHTML = renderMarkdown(detail.body || 'No description provided.'); @@ -2936,9 +2989,11 @@ qs('#open-palette').focus(); } if (previous === 'plan-today-preview' && kind !== 'plan-today-preview') { - if (addPlanPreviewOnReturn) planTodayPreview.close({ add:true }); + if (addPlanPreviewOnReturn && addPlanPreviewOverride) planTodayPreview.close({ add:true, override:true }); + else if (addPlanPreviewOnReturn) planTodayPreview.close({ add:true }); else planTodayPreview.close(); addPlanPreviewOnReturn = false; + addPlanPreviewOverride = false; } if (previous === 'plan-today' && kind !== 'plan-today' && kind !== 'plan-today-preview') closePlanToday(false); if (kind === 'new' && previous !== 'new') openCreateIssueSheet(false); diff --git a/frontend/index.html b/frontend/index.html index 055163d..09d0535 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -262,6 +262,11 @@
+

Full conversation

diff --git a/frontend/plan-today-preview.js b/frontend/plan-today-preview.js index aa3452d..c43bcf2 100644 --- a/frontend/plan-today-preview.js +++ b/frontend/plan-today-preview.js @@ -22,8 +22,19 @@ return true; } - function close({ add = false } = {}) { + function setDependencies({ available, dependencies } = {}) { + if (!current) return false; + current.dependencies_available = available === true; + current.dependencies = Array.isArray(dependencies) ? dependencies.filter(item => item?.state === 'open') : []; + current.requires_override = !current.dependencies_available || current.dependencies.length > 0; + return true; + } + + function close({ add = false, override = false } = {}) { if (!current) return 'closed'; + if (add && current.requires_override && !override) { + return current.dependencies_available ? 'blocked' : 'dependencies-unavailable'; + } const { item, trigger, scroll } = current; let result = 'returned'; if (add) { @@ -37,9 +48,16 @@ } function snapshot() { - return current ? { open:true, item:current.item, trigger:current.trigger, scroll:current.scroll } : { open:false }; + if (!current) return { open:false }; + const state = { open:true, item:current.item, trigger:current.trigger, scroll:current.scroll }; + if (Object.prototype.hasOwnProperty.call(current, 'dependencies_available')) { + state.dependencies_available = current.dependencies_available; + state.dependencies = current.dependencies; + state.requires_override = current.requires_override; + } + return state; } - return { open, close, snapshot }; + return { open, close, setDependencies, snapshot }; }; }); diff --git a/frontend/service-worker.js b/frontend/service-worker.js index 3b62a60..b338b74 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-v72'; +const CACHE = 'stackchain-dashboard-shell-v73'; 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/src/gitea_proxy.py b/src/gitea_proxy.py index 57f47b7..b035692 100644 --- a/src/gitea_proxy.py +++ b/src/gitea_proxy.py @@ -1343,9 +1343,19 @@ async def issue_conversation_page( async def issue_detail(repository: str, number: int) -> dict: base = f"repos/{repository}/issues/{number}" - issue, conversation = await asyncio.gather( - fetch(base), issue_conversation_page(repository, number) + + async def load_dependencies() -> tuple[bool, list[dict]]: + try: + return True, await issue_dependencies(repository, number) + except Exception: + return False, [] + + issue, conversation, dependency_result = await asyncio.gather( + fetch(base), + issue_conversation_page(repository, number), + load_dependencies(), ) + dependencies_available, dependencies = dependency_result if not isinstance(issue, dict): raise ValueError("Gitea issue response was not an object") labels_value = issue.get("labels") @@ -1377,11 +1387,43 @@ async def issue_detail(repository: str, number: int) -> dict: assignee["login"] for assignee in assignees if isinstance(assignee, dict) and isinstance(assignee.get("login"), str) ], + "dependencies_available": dependencies_available, + "dependencies": dependencies, "comments": normalized_comments, "conversation": conversation, } +async def issue_dependencies(repository: str, number: int, limit: int = 20) -> list[dict]: + """Return bounded open prerequisites for an issue.""" + response = await _get_client().get( + f"/api/v1/repos/{repository}/issues/{number}/dependencies", + headers=_auth(), + params={"limit": limit}, + ) + response.raise_for_status() + value = response.json() + items = value if isinstance(value, list) else [] + dependencies = [] + for item in items[:limit]: + if not isinstance(item, dict) or item.get("state") != "open": + continue + repo_value = item.get("repository") + repo = repo_value if isinstance(repo_value, dict) else {} + dependency_repository = repo.get("full_name") + dependency_number = item.get("number") + if not isinstance(dependency_repository, str) or not isinstance(dependency_number, int): + continue + dependencies.append({ + "repository": dependency_repository, + "number": dependency_number, + "title": item.get("title", "") if isinstance(item.get("title"), str) else "", + "state": "open", + "url": _safe_web_url(item.get("html_url")), + }) + return dependencies + + async def update_assigned_issue( repository: str, number: int, diff --git a/tests/test_issue_api.py b/tests/test_issue_api.py index 03a8126..8d3c3ad 100644 --- a/tests/test_issue_api.py +++ b/tests/test_issue_api.py @@ -1543,6 +1543,25 @@ async def test_gitea_issue_detail_returns_normalized_context_and_recent_comments async def handler(request): requests.append((request.method, request.url.path, request.url.query.decode())) + if request.url.path.endswith("/dependencies"): + return httpx.Response( + 200, + json=[ + { + "number": 3, + "title": "Restore signing service", + "state": "open", + "html_url": "https://forge.example/stackchain/platform/issues/3", + "repository": {"full_name": "stackchain/platform"}, + }, + { + "number": 2, + "title": "Completed prerequisite", + "state": "closed", + "repository": {"full_name": "stackchain/platform"}, + }, + ], + ) if request.url.path.endswith("/comments"): return httpx.Response( 200, @@ -1580,6 +1599,7 @@ async def test_gitea_issue_detail_returns_normalized_context_and_recent_comments assert requests == [ ("GET", "/api/v1/repos/stackchain/api/issues/7", ""), ("GET", "/api/v1/repos/stackchain/api/issues/7/comments", "limit=20&page=1"), + ("GET", "/api/v1/repos/stackchain/api/issues/7/dependencies", "limit=20"), ] assert result == { "repository": "stackchain/api", @@ -1593,6 +1613,16 @@ async def test_gitea_issue_detail_returns_normalized_context_and_recent_comments "url": "https://forge.example/stackchain/api/issues/7", "labels": ["P1"], "assignees": ["timmy"], + "dependencies_available": True, + "dependencies": [ + { + "repository": "stackchain/platform", + "number": 3, + "title": "Restore signing service", + "state": "open", + "url": "https://forge.example/stackchain/platform/issues/3", + } + ], "comments": [ { "id": 81, @@ -1619,6 +1649,31 @@ async def test_gitea_issue_detail_returns_normalized_context_and_recent_comments } +@pytest.mark.anyio +async def test_gitea_issue_detail_keeps_planning_usable_when_dependencies_are_unavailable(): + async def handler(request): + if request.url.path.endswith("/dependencies"): + return httpx.Response(503, json={"message": "upstream timeout"}) + if request.url.path.endswith("/comments"): + return httpx.Response(200, json=[]) + return httpx.Response(200, json={ + "number": 7, + "title": "Fix mobile flow", + "state": "open", + "assignees": [{"login": "timmy"}], + }) + + gitea_proxy.start_client(transport=httpx.MockTransport(handler)) + try: + result = await gitea_proxy.issue_detail("stackchain/api", 7) + finally: + await gitea_proxy.stop_client() + + assert result["title"] == "Fix mobile flow" + assert result["dependencies_available"] is False + assert result["dependencies"] == [] + + @pytest.mark.anyio async def test_gitea_conversation_page_opens_newest_page_and_reports_older_history(): requests = [] diff --git a/tests/test_later_sync.py b/tests/test_later_sync.py index eda15ed..4567bbf 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-v72" in source + assert "stackchain-dashboard-shell-v73" 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 75f3e5a..7d02150 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-v72" in worker + assert "stackchain-dashboard-shell-v73" in worker diff --git a/tests/test_mobile_composer_integration.py b/tests/test_mobile_composer_integration.py index 9bd16ca..f3e086d 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-v72" in worker + assert "stackchain-dashboard-shell-v73" in worker diff --git a/tests/test_plan_today.py b/tests/test_plan_today.py index 1463dd4..765b267 100644 --- a/tests/test_plan_today.py +++ b/tests/test_plan_today.py @@ -126,6 +126,64 @@ process.stdout.write(JSON.stringify({{first, second, scroll, events, planner:pla } +def test_plan_today_preview_requires_explicit_override_for_blocked_or_unknown_issue(): + script = f""" +const createPlanToday = require({json.dumps(str(PLAN_TODAY))}); +const createPlanTodayPreview = require({json.dumps(str(PLAN_TODAY_PREVIEW))}); +const item = number => ({{kind:'issue', repository:'stackchain/dashboard', number, title:'Issue ' + number}}); +const planner = createPlanToday({{ + identity: value => 'issue:stackchain/dashboard:' + value.number + ':', + save: () => true, +}}); +planner.open([], [item(2), item(3)]); +const preview = createPlanTodayPreview({{ + planner, + identity: value => 'issue:stackchain/dashboard:' + value.number + ':', +}}); +preview.open(item(2)); +preview.setDependencies({{available:true, dependencies:[{{repository:'stackchain/api', number:9, title:'Restore API', state:'open'}}]}}); +const blockedState = preview.snapshot(); +const blocked = preview.close({{add:true}}); +const overridden = preview.close({{add:true, override:true}}); +preview.open(item(3)); +preview.setDependencies({{available:false, dependencies:[]}}); +const unavailableState = preview.snapshot(); +const unavailable = preview.close({{add:true}}); +const unavailableOverride = preview.close({{add:true, override:true}}); +process.stdout.write(JSON.stringify({{blockedState, blocked, overridden, unavailableState, unavailable, unavailableOverride, planner:planner.snapshot()}})); +""" + assert run_node(script) == { + "blockedState": { + "open": True, + "item": {"kind": "issue", "repository": "stackchain/dashboard", "number": 2, "title": "Issue 2"}, + "trigger": None, + "scroll": 0, + "dependencies_available": True, + "dependencies": [{"repository": "stackchain/api", "number": 9, "title": "Restore API", "state": "open"}], + "requires_override": True, + }, + "blocked": "blocked", + "overridden": "added", + "unavailableState": { + "open": True, + "item": {"kind": "issue", "repository": "stackchain/dashboard", "number": 3, "title": "Issue 3"}, + "trigger": None, + "scroll": 0, + "dependencies_available": False, + "dependencies": [], + "requires_override": True, + }, + "unavailable": "dependencies-unavailable", + "unavailableOverride": "added", + "planner": { + "open": True, + "ids": ["issue:stackchain/dashboard:2:", "issue:stackchain/dashboard:3:"], + "count": 2, + "limit": 5, + }, + } + + @pytest.mark.anyio async def test_mobile_dashboard_wires_focused_plan_today_sheet(): html = await dashboard() @@ -150,6 +208,12 @@ async def test_mobile_dashboard_wires_focused_plan_today_sheet(): assert 'id="add-plan-preview"' in html assert "taskOverlayHistory.open('plan-today-preview')" in html assert "planTodayPreview.close({ add:true })" in html + assert 'id="issue-blockers"' in html + assert "planTodayPreview.setDependencies({" in html + assert "Add blocked item anyway & back" in html + assert "Blocker status unavailable" in html + assert ".issue-blockers { max-width:100%; overflow-x:hidden;" in html + assert ".issue-blocker { min-width:0; overflow-wrap:anywhere;" in html assert "Preview unavailable offline" in html @@ -168,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-v72" in source + assert "stackchain-dashboard-shell-v73" 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 df17d18..42c76b7 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-v72" in source + assert "stackchain-dashboard-shell-v73" 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-v72" in source + assert "stackchain-dashboard-shell-v73" 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-v72" in source + assert "stackchain-dashboard-shell-v73" 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-v72" in source + assert "stackchain-dashboard-shell-v73" 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-v72" in source + assert "stackchain-dashboard-shell-v73" 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-v72" in source + assert "stackchain-dashboard-shell-v73" 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-v72" in source + assert "stackchain-dashboard-shell-v73" 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-v72" in source + assert "stackchain-dashboard-shell-v73" 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-v72" in source + assert "stackchain-dashboard-shell-v73" in source assert "BASE + 'static/queue-today.js'" in source diff --git a/tests/test_today_sync.py b/tests/test_today_sync.py index 78e2039..a0fd203 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-v72" in source + assert "stackchain-dashboard-shell-v73" in source assert "BASE + 'static/today-sync.js'" in source -- 2.43.0