Review unseen Following changes during Prepare Today #1302

Merged
rockachopa merged 1 commits from timmy/1301-prepare-today-following into main 2026-08-23 10:19:29 +00:00
19 changed files with 189 additions and 42 deletions

View File

@ -117,10 +117,12 @@ unchanged. Following counts never influence the recommended Work queue. Set
`STACKCHAIN_FOLLOWING_DB` to override `.stackchain-state/following.sqlite3`. `STACKCHAIN_FOLLOWING_DB` to override `.stackchain-state/following.sqlite3`.
Completed delegated issues remain in the mobile **Filed** queue until their latest outcome is acknowledged. Completed delegated issues remain in the mobile **Filed** queue until their latest outcome is acknowledged.
The mobile queue sheet begins with **Prepare Today**, a live briefing that totals Agenda, Attention, Updates, and The mobile queue sheet begins with **Prepare Today**, a live briefing that totals Agenda, Attention, Updates, Filed,
Filed work and opens the highest-priority non-empty review queue. Starting it saves a confirmed-account, local-day and unseen Following activity, then opens the highest-priority non-empty review queue. Following refreshes only when
checkpoint: finishing Agenda, Updates, or the final Filed review returns to a focused handoff using fresh queue Prepare Today is opened; selecting that phase starts its changed-first sequential review directly. Starting the pass
counts, while reopening the queue sheet resumes the next live phase. **Finish for now** removes only that local saves a confirmed-account, local-day checkpoint: finishing Agenda, Updates, the final Filed review, or the final
Following revision returns to a focused handoff using fresh queue counts, while reopening the queue sheet resumes the
next live phase. **Finish for now** removes only that local
checkpoint. Once urgent review is clear the pass continues the existing Today plan, or opens Find Work when Today checkpoint. Once urgent review is clear the pass continues the existing Today plan, or opens Find Work when Today
is empty; the briefing and checkpoint never change Gitea state. is empty; the briefing and checkpoint never change Gitea state.
Filed separates actionable **Needs review** from a browsable **Reviewed** history, so acknowledgement clears the Filed separates actionable **Needs review** from a browsable **Reviewed** history, so acknowledgement clears the

View File

@ -108,6 +108,19 @@
const followingQueue = attachFollowing(item => { const followingQueue = attachFollowing(item => {
searchPreviewReturnKind = 'following'; searchPreviewReturnKind = 'following';
return searchPreview.open(item); return searchPreview.open(item);
}, {
onCount:(count, items) => {
mobileQueueCounts.following = count;
mobileQueueCounts.followingUnavailable = false;
mobilePreparationItems.following = items.filter(item => item.has_unseen_change === true);
mobileStartDay.render();
},
onStatus:status => {
if (status === 'loading') return;
mobileQueueCounts.followingUnavailable = status === 'error';
mobileStartDay.render();
},
onReviewComplete:() => mobileStartDay.completePhase('following'),
}); });
const mobileDeliveryRecovery = createMobileDeliveryRecovery({ const mobileDeliveryRecovery = createMobileDeliveryRecovery({
getItems: () => draftInbox.partition(lastDrafts).deliveries, getItems: () => draftInbox.partition(lastDrafts).deliveries,
@ -145,6 +158,7 @@
openFindWork: () => qs('#find-work').click(), openFindWork: () => qs('#find-work').click(),
}); });
function openMobileStartDay() { function openMobileStartDay() {
followingQueue.load().catch(() => {});
const state = mobileStartDay.state(); const state = mobileStartDay.state();
mobileStartDay.render(); mobileStartDay.render();
qs('#mobile-queue-heading').textContent = state.active ? 'Resume Prepare Today' : 'Prepare Today'; qs('#mobile-queue-heading').textContent = state.active ? 'Resume Prepare Today' : 'Prepare Today';
@ -181,7 +195,9 @@
openQueue: name => { openQueue: name => {
const sheet = qs('#mobile-queue-sheet'); const sheet = qs('#mobile-queue-sheet');
if (sheet.open) sheet.close(); if (sheet.open) sheet.close();
return name === 'find' ? qs('#find-work').click() : mobileQueueLauncher.open(name); return name === 'find' ? qs('#find-work').click() :
name === 'following' ? (mobileQueueCounts.followingUnavailable ? followingQueue.open() :
followingQueue.review()) : mobileQueueLauncher.open(name);
}, },
onHandoff: current => { onHandoff: current => {
qs('#mobile-queue-heading').textContent = 'Prepare Today · ' + current.label; qs('#mobile-queue-heading').textContent = 'Prepare Today · ' + current.label;

View File

@ -80,18 +80,26 @@
} }
function finishReview() { function finishReview() {
const completed = Boolean(review?.active &&
!snapshot.items.some(item => item.has_unseen_change === true));
if (review) review.active = false; if (review) review.active = false;
if (completed && !review.completed) {
review.completed = true;
options.onReviewComplete?.();
}
publish('ready'); publish('ready');
return completed;
} }
return { return {
load, open, startReview, previewLoaded:acknowledge, finishReview, load, open, startReview, previewLoaded:acknowledge, finishReview,
session:() => review?.active ? {items:[...review.items], more:false} : null, session:() => review?.active ? {items:[...review.items], more:false} : null,
items:() => snapshot.items.map(item => ({...item})),
count:() => snapshot.items.length, count:() => snapshot.items.length,
}; };
} }
function attachFollowing(onOpen) { function attachFollowing(onOpen, hooks = {}) {
const document = globalThis.document; const document = globalThis.document;
const query = selector => document.querySelector(selector); const query = selector => document.querySelector(selector);
const escapeHtml = value => String(value ?? '').replace(/[&<>"']/g, character => const escapeHtml = value => String(value ?? '').replace(/[&<>"']/g, character =>
@ -105,6 +113,7 @@
}; };
let feature; let feature;
function render(state) { function render(state) {
hooks.onStatus?.(state.status);
const list = query('#following-list'); const list = query('#following-list');
const status = query('#following-status'); const status = query('#following-status');
const reviewButton = query('#review-following'); const reviewButton = query('#review-following');
@ -138,8 +147,10 @@
value.textContent = count; value.textContent = count;
value.closest('button').setAttribute('aria-label', 'Following, ' + count + value.closest('button').setAttribute('aria-label', 'Following, ' + count +
(count === 1 ? ' unseen change' : ' unseen changes')); (count === 1 ? ' unseen change' : ' unseen changes'));
hooks.onCount?.(count, feature.items());
}, },
onOpen, onOpen,
onReviewComplete:hooks.onReviewComplete,
onAcknowledge:item => { onAcknowledge:item => {
const [owner, repo] = item.repository.split('/'); const [owner, repo] = item.repository.split('/');
return fetchJson('api/v1/following/' + encodeURIComponent(owner) + '/' + return fetchJson('api/v1/following/' + encodeURIComponent(owner) + '/' +
@ -159,6 +170,7 @@
query('#retry-following').addEventListener('click', () => feature.load().catch(() => {})); query('#retry-following').addEventListener('click', () => feature.load().catch(() => {}));
return { return {
load:feature.load, load:feature.load,
review:feature.startReview,
open() { open() {
const sheet = query('#following-sheet'); const sheet = query('#following-sheet');
if (!sheet.open) sheet.showModal(); if (!sheet.open) sheet.showModal();
@ -168,7 +180,8 @@
session:feature.session, session:feature.session,
previewLoaded:feature.previewLoaded, previewLoaded:feature.previewLoaded,
returnToFollowing() { returnToFollowing() {
feature.finishReview(); const completed = feature.finishReview();
if (completed) return 'completed-following';
const sheet = query('#following-sheet'); const sheet = query('#following-sheet');
if (!sheet.open) sheet.showModal(); if (!sheet.open) sheet.showModal();
globalThis.requestAnimationFrame?.(() => { globalThis.requestAnimationFrame?.(() => {

View File

@ -2003,7 +2003,7 @@
<div> <div>
<h3 id="mobile-start-day-heading">Prepare Today</h3> <h3 id="mobile-start-day-heading">Prepare Today</h3>
<p id="mobile-start-day-summary" class="small" aria-live="polite">Reviewing urgent queues…</p> <p id="mobile-start-day-summary" class="small" aria-live="polite">Reviewing urgent queues…</p>
<p id="mobile-start-day-phases" class="small muted">Checking Agenda, Attention, Updates, and Filed</p> <p id="mobile-start-day-phases" class="small muted">Checking Agenda, Attention, Updates, Filed, and Following</p>
</div> </div>
<button class="mobile-start-day-action" id="mobile-start-day-action" type="button">Prepare Today</button> <button class="mobile-start-day-action" id="mobile-start-day-action" type="button">Prepare Today</button>
<button class="mobile-start-day-finish" id="finish-mobile-start-day" type="button" hidden>Finish for now</button> <button class="mobile-start-day-finish" id="finish-mobile-start-day" type="button" hidden>Finish for now</button>

View File

@ -10,6 +10,7 @@
['attention', 'Attention'], ['attention', 'Attention'],
['update', 'Updates'], ['update', 'Updates'],
['filed', 'Filed'], ['filed', 'Filed'],
['following', 'Following'],
]; ];
const anonymousItems = new WeakMap(); const anonymousItems = new WeakMap();
let anonymousItemSequence = 0; let anonymousItemSequence = 0;
@ -102,19 +103,27 @@
function briefing() { function briefing() {
const counts = options.getCounts ? options.getCounts() : {}; const counts = options.getCounts ? options.getCounts() : {};
const phases = reviewPhases(counts); let phases = reviewPhases(counts);
const followingUnavailable = counts.followingUnavailable === true;
if (followingUnavailable) {
phases = phases.filter(phase => phase.name !== 'following');
phases.push({name:'following', label:'Following unavailable', count:count(counts.following)});
}
const total = phases.reduce((sum, phase) => sum + phase.count, 0); const total = phases.reduce((sum, phase) => sum + phase.count, 0);
const today = count(counts.today); const today = count(counts.today);
const delivery = phases.find(phase => phase.name === 'delivery')?.count || 0; const delivery = phases.find(phase => phase.name === 'delivery')?.count || 0;
const other = total - delivery; const other = total - delivery;
const next = phases.length ? phases[0].name : (today ? 'today' : 'find'); const next = phases.length ? phases[0].name : (today ? 'today' : 'find');
const nextLabel = next === 'delivery' ? 'Review Delivery' : const nextLabel = next === 'delivery' ? 'Review Delivery' :
(phases.length ? 'Review ' + phases[0].label : (today ? 'Continue Today' : 'Find Work')); (next === 'following' && followingUnavailable ? 'Retry Following' :
(phases.length ? 'Review ' + phases[0].label : (today ? 'Continue Today' : 'Find Work')));
const followingRetry = next === 'following' && followingUnavailable;
return { return {
total, total,
next, next,
label: nextLabel, label: nextLabel,
summary: delivery ? delivery + (delivery === 1 ? ' delivery needs' : ' deliveries need') + summary: followingRetry ? 'Following needs retry before Today · ' + today + ' planned' :
delivery ? delivery + (delivery === 1 ? ' delivery needs' : ' deliveries need') +
' action before Today' + (other ? ' · ' + other + ' other ' + (other === 1 ? 'item' : 'items') : '') + ' action before Today' + (other ? ' · ' + other + ' other ' + (other === 1 ? 'item' : 'items') : '') +
' · ' + today + ' planned' : ' · ' + today + ' planned' :
total ? total + ' items before Today · ' + today + ' planned' : total ? total + ' items before Today · ' + today + ' planned' :

View File

@ -1,7 +1,7 @@
const BASE = new URL('./', self.location.href).pathname; const BASE = new URL('./', self.location.href).pathname;
importScripts(BASE + 'static/private-data-registry.js'); importScripts(BASE + 'static/private-data-registry.js');
importScripts(BASE + 'static/background-issue-sync.js'); importScripts(BASE + 'static/background-issue-sync.js');
const CACHE = 'stackchain-dashboard-shell-v131'; const CACHE = 'stackchain-dashboard-shell-v132';
const OFFLINE_LEASE_URL = new URL(BASE + '__offline-session-lease', self.location.origin).href; const OFFLINE_LEASE_URL = new URL(BASE + '__offline-session-lease', self.location.origin).href;
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]); const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000; const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000;

View File

@ -303,4 +303,4 @@ async def test_unread_update_offers_reply_mark_read_and_next_independent_of_toda
assert '.update-reply-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr));' in html assert '.update-reply-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr));' in html
assert '.update-reply-actions button { min-height:44px;' in html assert '.update-reply-actions button { min-height:44px;' in html
worker = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text() worker = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
assert "stackchain-dashboard-shell-v131" in worker assert "stackchain-dashboard-shell-v132" in worker

View File

@ -166,6 +166,36 @@ const feature = createFollowing({{
assert result["renders"][-1]["reviewSummary"] == {"reviewed": 2, "remaining": 0} assert result["renders"][-1]["reviewSummary"] == {"reviewed": 2, "remaining": 0}
def test_following_review_notifies_prepare_today_when_final_revision_is_loaded():
script = f"""
const createFollowing = require({json.dumps(str(MODULE))});
const completed = [];
const items=[
{{repository:'stackchain/api',number:42,title:'First',updated_at:'2026-08-23T06:00:00Z',has_unseen_change:true}},
{{repository:'stackchain/web',number:9,title:'Second',updated_at:'2026-08-23T05:00:00Z',has_unseen_change:true}}
];
const feature = createFollowing({{
fetchJson:async () => ({{revision:7,items}}),
onOpen:async () => {{}},
onAcknowledge:async () => {{}},
onReviewComplete:() => completed.push('following'),
}});
(async () => {{
await feature.load();
await feature.startReview();
await feature.previewLoaded(feature.session().items[1]);
const beforeFinish = completed.slice();
feature.finishReview();
feature.finishReview();
process.stdout.write(JSON.stringify({{beforeFinish, completed}}));
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
assert json.loads(subprocess.run(
["node", "-e", script], text=True, capture_output=True, check=True
).stdout) == {"beforeFinish": [], "completed": ["following"]}
def test_following_review_never_clears_activity_newer_than_the_loaded_preview(): def test_following_review_never_clears_activity_newer_than_the_loaded_preview():
script = f""" script = f"""
const createFollowing = require({json.dumps(str(MODULE))}); const createFollowing = require({json.dumps(str(MODULE))});
@ -224,4 +254,23 @@ def test_following_review_controls_are_wired_into_the_phone_preview_flow():
assert "'Back to Following'" in dashboard assert "'Back to Following'" in dashboard
assert "if (searchPreviewReturnKind === 'following')" in dashboard assert "if (searchPreviewReturnKind === 'following')" in dashboard
assert "e.key === 'Escape' && searchPreviewReturnKind === 'following'" in dashboard assert "e.key === 'Escape' && searchPreviewReturnKind === 'following'" in dashboard
assert "stackchain-dashboard-shell-v131" in service_worker assert "stackchain-dashboard-shell-v132" in service_worker
def test_prepare_today_lazily_refreshes_and_directly_reviews_following():
following = MODULE.read_text()
dashboard = (ROOT / "frontend" / "dashboard.js").read_text()
assert "function attachFollowing(onOpen, hooks = {})" in following
assert "hooks.onCount?.(count, feature.items())" in following
assert "hooks.onStatus?.(state.status)" in following
assert "onReviewComplete:hooks.onReviewComplete" in following
assert "review:feature.startReview" in following
assert "const completed = feature.finishReview()" in following
assert "if (completed) return 'completed-following'" in following
assert "followingQueue.load().catch(() => {})" in dashboard
assert "mobileQueueCounts.following = count" in dashboard
assert "mobileQueueCounts.followingUnavailable = status === 'error'" in dashboard
assert "mobilePreparationItems.following = items.filter" in dashboard
assert "mobileQueueCounts.followingUnavailable ? followingQueue.open()" in dashboard
assert "mobileStartDay.completePhase('following')" in dashboard

View File

@ -435,5 +435,5 @@ async def test_dashboard_syncs_every_later_change_and_exposes_account_status():
def test_later_sync_ships_atomically_in_the_offline_shell(): def test_later_sync_ships_atomically_in_the_offline_shell():
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text() source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
assert "stackchain-dashboard-shell-v131" in source assert "stackchain-dashboard-shell-v132" in source
assert "BASE + 'static/later-sync.js'" in source assert "BASE + 'static/later-sync.js'" in source

View File

@ -256,4 +256,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 { 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 pre { max-width:100%; overflow-x:auto;" in css
assert ".markdown-content a { min-height:44px;" in css assert ".markdown-content a { min-height:44px;" in css
assert "stackchain-dashboard-shell-v131" in worker assert "stackchain-dashboard-shell-v132" in worker

View File

@ -45,7 +45,7 @@ def test_offline_shell_contains_every_local_dashboard_runtime_asset():
shell_assets = set(re.findall(r"BASE \+ '([^']+)'", worker.split("async function sessionCsrf", 1)[0])) 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 local_assets <= shell_assets, f"Offline shell is missing: {sorted(local_assets - shell_assets)}"
assert "stackchain-dashboard-shell-v131" in worker assert "stackchain-dashboard-shell-v132" in worker
def test_all_conversation_composers_offer_accessible_mobile_mentions(): def test_all_conversation_composers_offer_accessible_mobile_mentions():

View File

@ -383,7 +383,7 @@ def test_mobile_dashboard_mounts_phone_safe_device_setup_flow():
assert "controller.recoverPermission('deadline')" in dashboard assert "controller.recoverPermission('deadline')" in dashboard
assert "pushControllerReady.then(ensureDeviceSetup)" in dashboard assert "pushControllerReady.then(ensureDeviceSetup)" in dashboard
assert "BASE + 'static/mobile-device-setup.js'" in worker assert "BASE + 'static/mobile-device-setup.js'" in worker
assert "stackchain-dashboard-shell-v131" in worker assert "stackchain-dashboard-shell-v132" in worker
assert ".device-setup-panel" in css assert ".device-setup-panel" in css
assert ".device-readiness-card" in css assert ".device-readiness-card" in css
assert "overflow-x:hidden" in css assert "overflow-x:hidden" in css

View File

@ -243,5 +243,5 @@ async def test_mobile_home_progressively_discloses_secondary_panels_as_insights(
def test_mobile_insights_rolls_into_the_offline_shell(): def test_mobile_insights_rolls_into_the_offline_shell():
worker = (CONTROLLER.parent / "service-worker.js").read_text() worker = (CONTROLLER.parent / "service-worker.js").read_text()
assert "stackchain-dashboard-shell-v131" in worker assert "stackchain-dashboard-shell-v132" in worker
assert "BASE + 'static/mobile-insights.js'" in worker assert "BASE + 'static/mobile-insights.js'" in worker

View File

@ -146,6 +146,61 @@ process.stdout.write(JSON.stringify(controller.briefing()));
} }
def test_prepare_today_reviews_unseen_following_after_filed_before_today():
script = f"""
const createStartDay = require({json.dumps(str(START_DAY))});
const opened = [];
const controller = createStartDay({{
getCounts: () => ({{filed:1, following:2, today:3}}),
openQueue: name => opened.push(name),
}});
const briefing = controller.briefing();
controller.startNext();
process.stdout.write(JSON.stringify({{briefing, opened}}));
"""
output = run_node(script)
assert output == {
"briefing": {
"total": 3,
"next": "filed",
"label": "Review Filed",
"summary": "3 items before Today · 3 planned",
"phases": [
{"name": "filed", "label": "Filed", "count": 1},
{"name": "following", "label": "Following", "count": 2},
],
},
"opened": ["filed"],
}
def test_prepare_today_keeps_failed_following_refresh_retryable_before_today():
script = f"""
const createStartDay = require({json.dumps(str(START_DAY))});
const opened = [];
const controller = createStartDay({{
getCounts: () => ({{followingUnavailable:true, today:2}}),
openQueue: name => opened.push(name),
}});
const briefing = controller.briefing();
controller.startNext();
process.stdout.write(JSON.stringify({{briefing, opened}}));
"""
output = run_node(script)
assert output == {
"briefing": {
"total": 0,
"next": "following",
"label": "Retry Following",
"summary": "Following needs retry before Today · 2 planned",
"phases": [{"name": "following", "label": "Following unavailable", "count": 0}],
},
"opened": ["following"],
}
def test_start_day_view_renders_refreshed_phases_and_launches_primary_action(): def test_start_day_view_renders_refreshed_phases_and_launches_primary_action():
script = f""" script = f"""
const createStartDay = require({json.dumps(str(START_DAY))}); const createStartDay = require({json.dumps(str(START_DAY))});
@ -352,13 +407,14 @@ async def test_dashboard_wires_thumb_safe_start_day_briefing_into_offline_mobile
assert "mobileStartDay.finish()" in html assert "mobileStartDay.finish()" in html
assert "openQueue: name =>" in html assert "openQueue: name =>" in html
assert "if (sheet.open) sheet.close();" in html assert "if (sheet.open) sheet.close();" in html
assert "name === 'find' ? qs('#find-work').click() : mobileQueueLauncher.open(name)" in html assert "mobileQueueCounts.followingUnavailable ? followingQueue.open()" in html
assert "Checking Agenda, Attention, Updates, Filed, and Following" in html
assert "mobileStartDay.render();" in html assert "mobileStartDay.render();" in html
assert ".mobile-start-day-action { width:100%; min-height:48px;" in html assert ".mobile-start-day-action { width:100%; min-height:48px;" in html
assert ".mobile-start-day-finish { min-height:44px;" in html assert ".mobile-start-day-finish { min-height:44px;" in html
assert "max-width:100%; overflow-wrap:anywhere;" in html assert "max-width:100%; overflow-wrap:anywhere;" in html
assert "BASE + 'static/mobile-start-day.js'" in service_worker assert "BASE + 'static/mobile-start-day.js'" in service_worker
assert "stackchain-dashboard-shell-v131" in service_worker assert "stackchain-dashboard-shell-v132" in service_worker
@pytest.mark.anyio @pytest.mark.anyio

View File

@ -834,7 +834,8 @@ async def test_dashboard_renders_and_wires_mobile_queue_switcher():
assert '<script src="static/mobile-queue-launcher.js"></script>' in html assert '<script src="static/mobile-queue-launcher.js"></script>' in html
assert "const mobileQueueLauncher = createMobileQueueLauncher({" in html assert "const mobileQueueLauncher = createMobileQueueLauncher({" in html
assert "firstAction: name => qs('#my-work-list .my-work-card-main, #my-work-list .draft-resume, #my-work-list .draft-continue, #my-work-list .draft-edit')" in html assert "firstAction: name => qs('#my-work-list .my-work-card-main, #my-work-list .draft-resume, #my-work-list .draft-continue, #my-work-list .draft-edit')" in html
assert "name === 'find' ? qs('#find-work').click() : mobileQueueLauncher.open(name)" in html assert "name === 'find' ? qs('#find-work').click() :" in html
assert "mobileQueueCounts.followingUnavailable ? followingQueue.open()" in html
assert '.mobile-queue-sheet' in html assert '.mobile-queue-sheet' in html
assert '.my-work-actions { display:none;' in html assert '.my-work-actions { display:none;' in html
assert 'padding-bottom:calc(16px + env(safe-area-inset-bottom))' in html assert 'padding-bottom:calc(16px + env(safe-area-inset-bottom))' in html
@ -893,7 +894,8 @@ async def test_dashboard_renders_and_wires_phone_safe_task_dock():
assert '<script src="static/mobile-task-dock.js"></script>' in html assert '<script src="static/mobile-task-dock.js"></script>' in html
assert "createMobileTaskDock({" in html assert "createMobileTaskDock({" in html
assert "work: () => mobileWorkEntry.open()" in html assert "work: () => mobileWorkEntry.open()" in html
assert "name === 'find' ? qs('#find-work').click() : mobileQueueLauncher.open(name)" in html assert "name === 'find' ? qs('#find-work').click() :" in html
assert "mobileQueueCounts.followingUnavailable ? followingQueue.open()" in html
assert "qs('[data-work-filter=\"' + name + '\"]').click()" in html assert "qs('[data-work-filter=\"' + name + '\"]').click()" in html
assert "find: () => qs('#find-work').click()" in html assert "find: () => qs('#find-work').click()" in html
assert "new: () => qs('#new-issue').click()" in html assert "new: () => qs('#new-issue').click()" in html

View File

@ -418,7 +418,7 @@ async def test_starting_saved_today_work_closes_a_concurrent_rollover_planner():
def test_plan_today_controller_is_available_in_the_offline_shell(): def test_plan_today_controller_is_available_in_the_offline_shell():
source = SERVICE_WORKER.read_text() source = SERVICE_WORKER.read_text()
assert "stackchain-dashboard-shell-v131" in source assert "stackchain-dashboard-shell-v132" in source
assert "BASE + 'static/plan-today.js'" in source assert "BASE + 'static/plan-today.js'" in source
assert "BASE + 'static/plan-today-readiness.js'" in source assert "BASE + 'static/plan-today-readiness.js'" in source
assert "BASE + 'static/plan-today-preview.js'" in source assert "BASE + 'static/plan-today-preview.js'" in source

View File

@ -186,7 +186,7 @@ async function dispatchPush(payload) {{
def test_week_unplan_undo_rolls_the_offline_shell(): def test_week_unplan_undo_rolls_the_offline_shell():
source = WORKER.read_text() source = WORKER.read_text()
assert "stackchain-dashboard-shell-v131" in source assert "stackchain-dashboard-shell-v132" in source
assert "BASE + 'static/week-plan.js'" in source assert "BASE + 'static/week-plan.js'" in source
assert "BASE + 'static/dashboard.css'" in source assert "BASE + 'static/dashboard.css'" in source
@ -194,20 +194,20 @@ def test_week_unplan_undo_rolls_the_offline_shell():
def test_private_today_action_mailbox_rolls_the_offline_shell(): def test_private_today_action_mailbox_rolls_the_offline_shell():
source = WORKER.read_text() source = WORKER.read_text()
assert "stackchain-dashboard-shell-v131" in source assert "stackchain-dashboard-shell-v132" in source
def test_per_day_week_conflict_ui_rolls_the_offline_shell(): def test_per_day_week_conflict_ui_rolls_the_offline_shell():
source = WORKER.read_text() source = WORKER.read_text()
assert "stackchain-dashboard-shell-v131" in source assert "stackchain-dashboard-shell-v132" in source
assert "BASE + 'static/week-plan.js'" in source assert "BASE + 'static/week-plan.js'" in source
def test_resumable_today_session_ships_in_a_new_offline_shell(): def test_resumable_today_session_ships_in_a_new_offline_shell():
source = WORKER.read_text() source = WORKER.read_text()
assert "stackchain-dashboard-shell-v131" in source assert "stackchain-dashboard-shell-v132" in source
assert "BASE + 'static/my-work.js'" in source assert "BASE + 'static/my-work.js'" in source
assert "BASE + 'static/dashboard.js'" in source assert "BASE + 'static/dashboard.js'" in source
assert "BASE + 'static/dashboard.css'" in source assert "BASE + 'static/dashboard.css'" in source
@ -216,7 +216,7 @@ def test_resumable_today_session_ships_in_a_new_offline_shell():
def test_mobile_conversation_photo_bundles_roll_the_offline_shell(): def test_mobile_conversation_photo_bundles_roll_the_offline_shell():
source = WORKER.read_text() source = WORKER.read_text()
assert "stackchain-dashboard-shell-v131" in source assert "stackchain-dashboard-shell-v132" in source
assert "BASE + 'static/dashboard.js'" in source assert "BASE + 'static/dashboard.js'" in source
assert "BASE + 'static/authored-outbox.js'" in source assert "BASE + 'static/authored-outbox.js'" in source
assert "BASE + 'static/background-issue-sync.js'" in source assert "BASE + 'static/background-issue-sync.js'" in source
@ -225,7 +225,7 @@ def test_mobile_conversation_photo_bundles_roll_the_offline_shell():
def test_photo_metadata_sanitizer_rolls_the_cached_optimizer_atomically(): def test_photo_metadata_sanitizer_rolls_the_cached_optimizer_atomically():
source = WORKER.read_text() source = WORKER.read_text()
assert "stackchain-dashboard-shell-v131" in source assert "stackchain-dashboard-shell-v132" in source
assert "BASE + 'static/issue-evidence-review.js'" in source assert "BASE + 'static/issue-evidence-review.js'" in source
assert "BASE + 'static/issue-attachment.js'" in source assert "BASE + 'static/issue-attachment.js'" in source
@ -233,14 +233,14 @@ def test_photo_metadata_sanitizer_rolls_the_cached_optimizer_atomically():
def test_ownership_exit_runtime_rolls_the_offline_shell_cache(): def test_ownership_exit_runtime_rolls_the_offline_shell_cache():
source = WORKER.read_text() source = WORKER.read_text()
assert "stackchain-dashboard-shell-v131" in source assert "stackchain-dashboard-shell-v132" in source
assert "BASE + 'static/dashboard.js'" in source assert "BASE + 'static/dashboard.js'" in source
def test_offline_review_next_ships_today_completion_atomically(): def test_offline_review_next_ships_today_completion_atomically():
source = WORKER.read_text() source = WORKER.read_text()
assert "stackchain-dashboard-shell-v131" in source assert "stackchain-dashboard-shell-v132" in source
assert "BASE + 'static/today-completion.js'" in source assert "BASE + 'static/today-completion.js'" in source
assert "BASE + 'static/dashboard.js'" in source assert "BASE + 'static/dashboard.js'" in source
@ -248,7 +248,7 @@ def test_offline_review_next_ships_today_completion_atomically():
def test_duplicate_aware_capture_ships_in_a_new_offline_shell(): def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
source = WORKER.read_text() source = WORKER.read_text()
assert "stackchain-dashboard-shell-v131" in source assert "stackchain-dashboard-shell-v132" in source
assert "BASE + 'static/create-issue-sheet.js'" in source assert "BASE + 'static/create-issue-sheet.js'" in source
assert "BASE + 'static/dashboard.js'" in source assert "BASE + 'static/dashboard.js'" in source
@ -256,7 +256,7 @@ def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
def test_inline_checklist_step_flow_rolls_the_offline_shell_atomically(): def test_inline_checklist_step_flow_rolls_the_offline_shell_atomically():
source = WORKER.read_text() source = WORKER.read_text()
assert "stackchain-dashboard-shell-v131" in source assert "stackchain-dashboard-shell-v132" in source
assert "BASE + 'static/issue-sheet.js'" in source assert "BASE + 'static/issue-sheet.js'" in source
assert "BASE + 'static/checklist-conflict.js'" in source assert "BASE + 'static/checklist-conflict.js'" in source
assert "BASE + 'static/dashboard.js'" in source assert "BASE + 'static/dashboard.js'" in source
@ -266,14 +266,14 @@ def test_inline_checklist_step_flow_rolls_the_offline_shell_atomically():
def test_exact_later_picker_ships_atomically_in_a_new_offline_shell(): def test_exact_later_picker_ships_atomically_in_a_new_offline_shell():
source = WORKER.read_text() source = WORKER.read_text()
assert "stackchain-dashboard-shell-v131" in source assert "stackchain-dashboard-shell-v132" in source
assert "BASE + 'static/later-picker.js'" in source assert "BASE + 'static/later-picker.js'" in source
def test_navigation_deadline_ships_in_a_new_shell_cache(): def test_navigation_deadline_ships_in_a_new_shell_cache():
source = WORKER.read_text() source = WORKER.read_text()
assert "stackchain-dashboard-shell-v131" in source assert "stackchain-dashboard-shell-v132" in source
assert "BASE + 'static/dashboard.css'" in source assert "BASE + 'static/dashboard.css'" in source
assert "BASE + 'static/dashboard.js'" in source assert "BASE + 'static/dashboard.js'" in source
assert "BASE + 'static/install-app.js'" in source assert "BASE + 'static/install-app.js'" in source
@ -282,21 +282,21 @@ def test_navigation_deadline_ships_in_a_new_shell_cache():
def test_today_convergence_ships_in_a_new_shell_cache(): def test_today_convergence_ships_in_a_new_shell_cache():
source = WORKER.read_text() source = WORKER.read_text()
assert "stackchain-dashboard-shell-v131" in source assert "stackchain-dashboard-shell-v132" in source
assert "BASE + 'static/today-sync.js'" in source assert "BASE + 'static/today-sync.js'" in source
def test_mobile_search_viewport_ships_in_a_new_offline_shell(): def test_mobile_search_viewport_ships_in_a_new_offline_shell():
source = WORKER.read_text() source = WORKER.read_text()
assert "stackchain-dashboard-shell-v131" in source assert "stackchain-dashboard-shell-v132" in source
assert "BASE + 'static/mobile-search-viewport.js'" in source assert "BASE + 'static/mobile-search-viewport.js'" in source
def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell(): def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell():
source = WORKER.read_text() source = WORKER.read_text()
assert "stackchain-dashboard-shell-v131" in source assert "stackchain-dashboard-shell-v132" in source
assert "BASE + 'static/update-ownership.js'" in source assert "BASE + 'static/update-ownership.js'" in source
@ -1302,7 +1302,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(): def test_queue_today_ships_atomically_in_a_new_offline_shell():
source = WORKER.read_text() source = WORKER.read_text()
assert "stackchain-dashboard-shell-v131" in source assert "stackchain-dashboard-shell-v132" in source
assert "BASE + 'static/queue-today.js'" in source assert "BASE + 'static/queue-today.js'" in source

View File

@ -221,7 +221,7 @@ async def test_today_blocker_opens_existing_preview_and_preserves_readiness_gate
def test_readiness_runtime_is_available_in_offline_shell(): def test_readiness_runtime_is_available_in_offline_shell():
service_worker = SERVICE_WORKER.read_text() service_worker = SERVICE_WORKER.read_text()
assert "const CACHE = 'stackchain-dashboard-shell-v131';" in service_worker assert "const CACHE = 'stackchain-dashboard-shell-v132';" in service_worker
assert "BASE + 'static/today-readiness.js'" in service_worker assert "BASE + 'static/today-readiness.js'" in service_worker

View File

@ -343,7 +343,7 @@ listeners['stackchain:first-task-complete']();
def test_inflight_today_drain_ships_in_a_new_offline_shell(): def test_inflight_today_drain_ships_in_a_new_offline_shell():
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text() source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
assert "stackchain-dashboard-shell-v131" in source assert "stackchain-dashboard-shell-v132" in source
assert "BASE + 'static/today-sync.js'" in source assert "BASE + 'static/today-sync.js'" in source