Resume interrupted Today sessions #396

Merged
rockachopa merged 1 commits from timmy/395-resume-today-session into main 2026-08-09 11:03:46 +00:00
13 changed files with 305 additions and 14 deletions

View File

@ -37,7 +37,11 @@ the private content. Issue capture and authored mobile actions (issue
comments, pull-request comments, notification replies, and reviews) persist per-draft comments, pull-request comments, notification replies, and reviews) persist per-draft
idempotency keys, so retrying after a timeout, reload, process restart, or handoff to 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, another worker replays a confirmed result instead of posting duplicate content. The ordered,
five-item Today plan syncs across the operator's devices. Server revisions prevent delayed five-item Today plan syncs across the operator's devices. 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);
finishing or choosing **End session** clears only the checkpoint and leaves the Today plan
unchanged. Another or unconfirmed account cannot see or resume it. Server revisions prevent delayed
responses from replacing a newer plan; same-account browser tabs exchange fresh snapshots, responses from replacing a newer plan; same-account browser tabs exchange fresh snapshots,
and reconnecting or returning to the dashboard refreshes server truth after replaying queued and reconnecting or returning to the dashboard refreshes server truth after replaying queued
offline operations. Planning edits can remain offline for up to 30 days. After that, the offline operations. Planning edits can remain offline for up to 30 days. After that, the

View File

@ -246,6 +246,7 @@ textarea { resize: vertical; min-height: 120px; }
.find-work-action { min-height:44px; } .find-work-action { min-height:44px; }
.my-work-actions { display:flex; flex-wrap:wrap; gap:8px; } .my-work-actions { display:flex; flex-wrap:wrap; gap:8px; }
.start-work-session { min-height:44px; } .start-work-session { min-height:44px; }
.resume-today-session, .end-today-session { min-height:44px; }
.work-session-nav { position:sticky; bottom:0; z-index:5; display:grid; grid-template-columns:1fr 1fr; gap:8px; margin-top:12px; padding:10px 4px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; } .work-session-nav { position:sticky; bottom:0; z-index:5; display:grid; grid-template-columns:1fr 1fr; gap:8px; margin-top:12px; padding:10px 4px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
.work-session-nav[hidden] { display:none; } .work-session-nav[hidden] { display:none; }
.work-session-nav [data-work-session-progress] { grid-column:1 / -1; text-align:center; } .work-session-nav [data-work-session-progress] { grid-column:1 / -1; text-align:center; }

View File

@ -665,12 +665,30 @@
openRoutedWork(item, null, { replace:true }); openRoutedWork(item, null, { replace:true });
} }
const sessionCheckpoint = createWorkSessionCheckpoint({
storage: localStorage,
getLogin: () => confirmedOwnerLogin,
onError: () => {
qs('#my-work-action-status').textContent =
'Session recovery could not be saved on this device. You can keep working.';
},
});
function updateWorkSessionActions() {
const active = workSession.active();
qs('#start-work-session').hidden = active;
qs('#resume-today-session').hidden = active || !todayMyWork.length || !workSession.resumable();
qs('#end-today-session').hidden = !active;
}
const workSession = createWorkSession({ const workSession = createWorkSession({
getItems: () => selectedWorkFilter === 'today' ? todayMyWork : activeMyWork, getItems: () => selectedWorkFilter === 'today' ? todayMyWork : activeMyWork,
getFilter: () => selectedWorkFilter === 'today' ? 'all' : selectedWorkFilter, getFilter: () => selectedWorkFilter === 'today' ? 'all' : selectedWorkFilter,
getMilestone: () => selectedWorkMilestone, getMilestone: () => selectedWorkMilestone,
checkpoint: sessionCheckpoint,
checkpointEnabled: () => selectedWorkFilter === 'today',
onOpen: openWorkSessionItem, onOpen: openWorkSessionItem,
onProgress: state => { onProgress: state => {
updateWorkSessionActions();
document.querySelectorAll('.work-session-nav').forEach(nav => { nav.hidden = false; }); document.querySelectorAll('.work-session-nav').forEach(nav => { nav.hidden = false; });
document.querySelectorAll('[data-work-session-progress]').forEach(element => { document.querySelectorAll('[data-work-session-progress]').forEach(element => {
element.textContent = 'Item ' + state.index + ' of ' + state.total; element.textContent = 'Item ' + state.index + ' of ' + state.total;
@ -686,6 +704,7 @@
closeOpenWorkSheets(); closeOpenWorkSheets();
document.querySelectorAll('.work-session-nav').forEach(nav => { nav.hidden = true; }); document.querySelectorAll('.work-session-nav').forEach(nav => { nav.hidden = true; });
qs('#my-work-action-status').textContent = 'Work session complete.'; qs('#my-work-action-status').textContent = 'Work session complete.';
updateWorkSessionActions();
qs('#start-work-session').focus(); qs('#start-work-session').focus();
}, },
}); });
@ -1042,6 +1061,7 @@
updateWorkPaginationControls(); updateWorkPaginationControls();
renderMyWork(); renderMyWork();
if (reconcileSession && workSession.active()) workSession.reconcile(); if (reconcileSession && workSession.active()) workSession.reconcile();
updateWorkSessionActions();
} }
function activeWorkStreams() { function activeWorkStreams() {
@ -3576,6 +3596,18 @@
} }
workSession.start(); workSession.start();
}); });
qs('#resume-today-session').addEventListener('click', () => {
qs('[data-work-filter="today"]').click();
if (!workSession.resume()) {
qs('#my-work-action-status').textContent = 'Saved Today session is no longer available.';
updateWorkSessionActions();
}
});
qs('#end-today-session').addEventListener('click', () => {
workSession.end();
qs('#my-work-action-status').textContent = 'Today session ended. Your plan is unchanged.';
qs('#start-work-session').focus();
});
document.querySelectorAll('[data-work-session-previous]').forEach(button => document.querySelectorAll('[data-work-session-previous]').forEach(button =>
button.addEventListener('click', () => workSession.previous()) button.addEventListener('click', () => workSession.previous())
); );

View File

@ -50,6 +50,8 @@
<div class="my-work-actions"> <div class="my-work-actions">
<button class="plan-today" id="plan-today" type="button">Plan Today</button> <button class="plan-today" id="plan-today" type="button">Plan Today</button>
<button class="start-work-session" id="start-work-session" type="button">Start work</button> <button class="start-work-session" id="start-work-session" type="button">Start work</button>
<button class="resume-today-session" id="resume-today-session" type="button" hidden>Resume Today</button>
<button class="end-today-session" id="end-today-session" type="button" hidden>End session</button>
<button class="find-work-action" id="find-work" type="button">Find work</button> <button class="find-work-action" id="find-work" type="button">Find work</button>
<button class="new-issue" id="new-issue" type="button">New issue</button> <button class="new-issue" id="new-issue" type="button">New issue</button>
</div> </div>

View File

@ -472,10 +472,67 @@ function workIdentity(item) {
return [kind, repository, number, notification].join(':'); return [kind, repository, number, notification].join(':');
} }
function createWorkSession({ getItems, getFilter, getMilestone = () => 'all', onOpen, onProgress, onFinish }) { function createWorkSessionCheckpoint({
storage,
getLogin,
onError = () => {},
key = 'stackchain.today-session.v1',
}) {
const login = () => String(getLogin() || '').trim();
let errorReported = false;
const reportError = error => {
if (errorReported) return;
errorReported = true;
onError(error);
};
const read = () => {
const owner = login();
if (!owner) return null;
try {
const value = JSON.parse(storage.getItem(key) || 'null');
if (value?.version !== 1 || value.login !== owner || typeof value.identity !== 'string' ||
!Number.isInteger(value.index) || value.index < 0) return null;
return value;
} catch (error) {
reportError(error);
return null;
}
};
return {
read,
save(identity, index) {
const owner = login();
if (!owner) return false;
try {
storage.setItem(key, JSON.stringify({ version:1, login:owner, identity, index }));
return true;
} catch (error) {
reportError(error);
return false;
}
},
clear() {
if (!read()) return false;
try {
storage.removeItem(key);
return true;
} catch (error) {
reportError(error);
return false;
}
},
};
}
function createWorkSession({
getItems, getFilter, getMilestone = () => 'all', checkpoint = null,
checkpointEnabled = () => true,
onOpen, onProgress, onFinish,
}) {
let currentIdentity = ''; let currentIdentity = '';
let currentIndex = -1; let currentIndex = -1;
let running = false; let running = false;
let durable = false;
const queue = () => filterMyWork(getItems() || [], getFilter(), getMilestone()); const queue = () => filterMyWork(getItems() || [], getFilter(), getMilestone());
const report = (items, index) => onProgress({ const report = (items, index) => onProgress({
@ -485,9 +542,12 @@ function createWorkSession({ getItems, getFilter, getMilestone = () => 'all', on
can_next: index < items.length - 1, can_next: index < items.length - 1,
}); });
const finish = () => { const finish = () => {
const clearCheckpoint = durable;
running = false; running = false;
durable = false;
currentIdentity = ''; currentIdentity = '';
currentIndex = -1; currentIndex = -1;
if (clearCheckpoint) checkpoint?.clear();
onFinish(); onFinish();
return false; return false;
}; };
@ -495,6 +555,7 @@ function createWorkSession({ getItems, getFilter, getMilestone = () => 'all', on
if (!items.length || index < 0 || index >= items.length) return finish(); if (!items.length || index < 0 || index >= items.length) return finish();
currentIndex = index; currentIndex = index;
currentIdentity = workIdentity(items[index]); currentIdentity = workIdentity(items[index]);
if (durable) checkpoint?.save(currentIdentity, currentIndex);
report(items, index); report(items, index);
onOpen(items[index]); onOpen(items[index]);
return true; return true;
@ -502,10 +563,23 @@ function createWorkSession({ getItems, getFilter, getMilestone = () => 'all', on
return { return {
active: () => running, active: () => running,
end: () => finish(),
resumable: () => Boolean(checkpoint?.read()),
resume() {
const saved = checkpoint?.read();
if (!saved) return false;
durable = true;
const items = queue();
if (!items.length) return finish();
running = true;
const exact = items.findIndex(item => workIdentity(item) === saved.identity);
return openAt(items, exact >= 0 ? exact : Math.min(saved.index, items.length - 1));
},
start(item = null) { start(item = null) {
const items = queue(); const items = queue();
if (!items.length) return finish(); if (!items.length) return finish();
running = true; running = true;
durable = Boolean(checkpoint && checkpointEnabled());
const requested = item ? items.findIndex(candidate => workIdentity(candidate) === workIdentity(item)) : 0; const requested = item ? items.findIndex(candidate => workIdentity(candidate) === workIdentity(item)) : 0;
return openAt(items, requested >= 0 ? requested : 0); return openAt(items, requested >= 0 ? requested : 0);
}, },
@ -612,6 +686,7 @@ if (typeof module !== 'undefined' && module.exports) {
buildMyWork.filterMyWork = filterMyWork; buildMyWork.filterMyWork = filterMyWork;
buildMyWork.milestoneLanes = milestoneLanes; buildMyWork.milestoneLanes = milestoneLanes;
buildMyWork.createWorkSession = createWorkSession; buildMyWork.createWorkSession = createWorkSession;
buildMyWork.createWorkSessionCheckpoint = createWorkSessionCheckpoint;
buildMyWork.workIdentity = workIdentity; buildMyWork.workIdentity = workIdentity;
buildMyWork.replaceIssueLabels = replaceIssueLabels; buildMyWork.replaceIssueLabels = replaceIssueLabels;
buildMyWork.replaceIssueContent = replaceIssueContent; buildMyWork.replaceIssueContent = replaceIssueContent;

View File

@ -1,6 +1,6 @@
const BASE = new URL('./', self.location.href).pathname; const BASE = new URL('./', self.location.href).pathname;
importScripts(BASE + 'static/background-issue-sync.js'); importScripts(BASE + 'static/background-issue-sync.js');
const CACHE = 'stackchain-dashboard-shell-v59'; const CACHE = 'stackchain-dashboard-shell-v60';
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;
const SHELL = [ const SHELL = [

View File

@ -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(): 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-v59" in source assert "stackchain-dashboard-shell-v60" in source
assert "BASE + 'static/later-sync.js'" in source assert "BASE + 'static/later-sync.js'" in source

View File

@ -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 { 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-v59" in worker assert "stackchain-dashboard-shell-v60" in worker

View File

@ -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])) 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-v59" in worker assert "stackchain-dashboard-shell-v60" in worker

View File

@ -1428,6 +1428,157 @@ process.stdout.write(JSON.stringify({{finished,active:session.active()}}));
assert json.loads(result.stdout) == {"finished": 1, "active": False} assert json.loads(result.stdout) == {"finished": 1, "active": False}
def test_today_session_checkpoint_resumes_current_item_after_reload():
script = f"""
const buildMyWork = require({json.dumps(str(MY_WORK))});
const values = new Map();
const storage = {{
getItem: key => values.has(key) ? values.get(key) : null,
setItem: (key, value) => values.set(key, value),
removeItem: key => values.delete(key),
}};
const items = [1, 2, 3].map(number => ({{
kind:'issue', repository:'stackchain/dashboard', number, title:'Item ' + number,
}}));
const opened = [];
const firstCheckpoint = buildMyWork.createWorkSessionCheckpoint({{
storage, getLogin: () => 'timmy',
}});
const first = buildMyWork.createWorkSession({{
getItems: () => items, getFilter: () => 'all',
checkpoint: firstCheckpoint,
onOpen: item => opened.push('first:' + item.number),
onProgress: () => {{}}, onFinish: () => {{}},
}});
first.start();
first.next();
const secondCheckpoint = buildMyWork.createWorkSessionCheckpoint({{
storage, getLogin: () => 'timmy',
}});
const resumed = buildMyWork.createWorkSession({{
getItems: () => items, getFilter: () => 'all',
checkpoint: secondCheckpoint,
onOpen: item => opened.push('resumed:' + item.number),
onProgress: () => {{}}, onFinish: () => {{}},
}});
const available = resumed.resumable();
const didResume = resumed.resume();
process.stdout.write(JSON.stringify({{
available, didResume, opened, checkpoint:JSON.parse(values.values().next().value),
}}));
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
assert json.loads(result.stdout) == {
"available": True,
"didResume": True,
"opened": ["first:1", "first:2", "resumed:2"],
"checkpoint": {
"version": 1,
"login": "timmy",
"identity": "issue:stackchain/dashboard:2:",
"index": 1,
},
}
def test_today_session_checkpoint_is_account_bound_and_end_clears_it():
script = f"""
const buildMyWork = require({json.dumps(str(MY_WORK))});
let login = 'timmy';
const values = new Map();
const storage = {{
getItem: key => values.has(key) ? values.get(key) : null,
setItem: (key, value) => values.set(key, value),
removeItem: key => values.delete(key),
}};
const checkpoint = buildMyWork.createWorkSessionCheckpoint({{storage, getLogin:() => login}});
const session = buildMyWork.createWorkSession({{
getItems: () => [{{kind:'issue',repository:'stackchain/dashboard',number:7}}],
getFilter: () => 'all', checkpoint,
onOpen: () => {{}}, onProgress: () => {{}}, onFinish: () => {{}},
}});
session.start();
login = 'alexander';
const visibleToOtherAccount = session.resumable();
const retainedForOwner = values.size;
login = 'timmy';
session.end();
process.stdout.write(JSON.stringify({{
visibleToOtherAccount, retainedForOwner, active:session.active(), stored:values.size,
}}));
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
assert json.loads(result.stdout) == {
"visibleToOtherAccount": False,
"retainedForOwner": 1,
"active": False,
"stored": 0,
}
def test_resuming_an_empty_today_plan_clears_the_stale_checkpoint():
script = f"""
const buildMyWork = require({json.dumps(str(MY_WORK))});
const values = new Map([['stackchain.today-session.v1', JSON.stringify({{
version:1, login:'timmy', identity:'issue:stackchain/dashboard:7:', index:0,
}})]]);
const storage = {{
getItem:key => values.get(key) || null,
setItem:(key,value) => values.set(key,value),
removeItem:key => values.delete(key),
}};
const checkpoint = buildMyWork.createWorkSessionCheckpoint({{storage,getLogin:() => 'timmy'}});
const session = buildMyWork.createWorkSession({{
getItems:() => [], getFilter:() => 'all', checkpoint,
onOpen:() => {{}}, onProgress:() => {{}}, onFinish:() => {{}},
}});
const resumed = session.resume();
process.stdout.write(JSON.stringify({{resumed, stored:values.size, available:session.resumable()}}));
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
assert json.loads(result.stdout) == {"resumed": False, "stored": 0, "available": False}
def test_today_session_keeps_working_when_checkpoint_storage_fails():
script = f"""
const buildMyWork = require({json.dumps(str(MY_WORK))});
let errors = 0;
const opened = [];
const checkpoint = buildMyWork.createWorkSessionCheckpoint({{
storage:{{
getItem:() => null,
setItem:() => {{ throw new Error('quota'); }},
removeItem:() => {{ throw new Error('quota'); }},
}},
getLogin:() => 'timmy',
onError:() => {{ errors += 1; }},
}});
const session = buildMyWork.createWorkSession({{
getItems:() => [1,2].map(number => ({{kind:'issue',repository:'stackchain/dashboard',number}})),
getFilter:() => 'all', checkpoint,
onOpen:item => opened.push(item.number), onProgress:() => {{}}, onFinish:() => {{}},
}});
session.start();
session.next();
process.stdout.write(JSON.stringify({{errors,opened,active:session.active()}}));
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
assert json.loads(result.stdout) == {"errors": 1, "opened": [1, 2], "active": True}
@pytest.mark.anyio @pytest.mark.anyio
async def test_mobile_work_session_renders_touch_safe_controls_for_every_work_sheet(): async def test_mobile_work_session_renders_touch_safe_controls_for_every_work_sheet():
html = await dashboard() html = await dashboard()
@ -1442,6 +1593,23 @@ async def test_mobile_work_session_renders_touch_safe_controls_for_every_work_sh
assert 'aria-live="polite" data-work-session-progress' in html assert 'aria-live="polite" data-work-session-progress' in html
@pytest.mark.anyio
async def test_dashboard_offers_account_safe_resume_and_end_today_controls():
html = await dashboard()
assert 'id="resume-today-session"' in html
assert 'id="end-today-session"' in html
assert 'const sessionCheckpoint = createWorkSessionCheckpoint({' in html
assert 'getLogin: () => confirmedOwnerLogin' in html
assert 'checkpoint: sessionCheckpoint' in html
assert 'checkpointEnabled: () => selectedWorkFilter === \'today\'' in html
assert "qs('#resume-today-session').addEventListener('click'" in html
assert "qs('#end-today-session').addEventListener('click'" in html
assert "workSession.resume()" in html
assert "workSession.end()" in html
assert '.resume-today-session, .end-today-session { min-height:44px;' in html
@pytest.mark.anyio @pytest.mark.anyio
async def test_dashboard_wires_work_session_to_existing_sheet_flows_and_completion_actions(): async def test_dashboard_wires_work_session_to_existing_sheet_flows_and_completion_actions():
html = await dashboard() html = await dashboard()

View File

@ -101,5 +101,5 @@ async def test_mobile_dashboard_wires_focused_plan_today_sheet():
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-v59" in source assert "stackchain-dashboard-shell-v60" in source
assert "BASE + 'static/plan-today.js'" in source assert "BASE + 'static/plan-today.js'" in source

View File

@ -105,10 +105,19 @@ async function dispatchNotificationClick(route) {{
return json.loads(completed.stdout) return json.loads(completed.stdout)
def test_resumable_today_session_ships_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v60" in source
assert "BASE + 'static/my-work.js'" in source
assert "BASE + 'static/dashboard.js'" in source
assert "BASE + 'static/dashboard.css'" in source
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-v59" in source assert "stackchain-dashboard-shell-v60" 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
@ -116,14 +125,14 @@ def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
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-v59" in source assert "stackchain-dashboard-shell-v60" 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-v59" in source assert "stackchain-dashboard-shell-v60" 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
@ -132,21 +141,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-v59" in source assert "stackchain-dashboard-shell-v60" 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-v59" in source assert "stackchain-dashboard-shell-v60" 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-v59" in source assert "stackchain-dashboard-shell-v60" in source
assert "BASE + 'static/update-ownership.js'" in source assert "BASE + 'static/update-ownership.js'" in source

View File

@ -86,7 +86,7 @@ sync.enqueue('add', 'issue:r:1:');
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-v59" in source assert "stackchain-dashboard-shell-v60" in source
assert "BASE + 'static/today-sync.js'" in source assert "BASE + 'static/today-sync.js'" in source