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 ?
'
Up Down Remove
' :
- 'Preview Add
';
+ (item.kind === 'issue' ?
+ 'Preview to add
' :
+ 'Preview Add
');
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 @@
Reload latest issue
+
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