Comment and continue to the next Today item #430

Merged
timmy merged 1 commits from timmy/429-comment-next into main 2026-08-09 22:02:02 +00:00
14 changed files with 370 additions and 19 deletions

View File

@ -40,7 +40,10 @@ another worker replays a confirmed result instead of posting duplicate content.
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. Starting a Today work session also
stores an account-bound checkpoint on the current device. After a reload or installed-app 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); 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 **Comment & next** on that current issue or pull request posts the handoff online or admits it
to durable account-bound delivery, then removes the item only from Today and opens the next
one without closing or merging it. Delivery or local-admission failure preserves both the draft
and checkpoint. 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 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

48
frontend/comment-next.js Normal file
View File

@ -0,0 +1,48 @@
function createCommentNext({ post, queue, canQueue, accept = () => undefined, complete }) {
let inFlight = null;
function submit(item, body, operationId = '') {
if (inFlight) return inFlight;
inFlight = (async () => {
try {
let comment;
try {
comment = await post(item, body);
} catch (error) {
if (!canQueue(error)) throw error;
const admission = await queue({
kind: item.kind === 'pull' ? 'pull-comment' : 'issue-comment',
repository: item.repository,
number: item.number,
body,
operationId: typeof operationId === 'function' ? operationId() : operationId,
});
if (!admission || (!admission.item && admission.durable !== true)) {
throw new Error('Comment was not saved for delivery.');
}
accept(item, { delivery: admission.background ? 'queued' : 'saved' });
return {
accepted: true,
delivery: admission.background ? 'queued' : 'saved',
background: Boolean(admission.background),
completed: Boolean(complete(item)),
};
}
accept(item, { delivery: 'posted', comment });
return {
accepted: true,
delivery: 'posted',
comment,
completed: Boolean(complete(item)),
};
} finally {
inFlight = null;
}
})();
return inFlight;
}
return { submit, busy: () => Boolean(inFlight) };
}
if (typeof module !== 'undefined' && module.exports) module.exports = createCommentNext;

View File

@ -226,6 +226,8 @@ textarea { resize: vertical; min-height: 120px; }
.issue-comment { padding:10px 0; border-bottom:1px solid #1b2d45; } .issue-comment { padding:10px 0; border-bottom:1px solid #1b2d45; }
.issue-comment-composer { display:grid; gap:8px; margin-top:16px; } .issue-comment-composer { display:grid; gap:8px; margin-top:16px; }
.issue-comment-composer button { min-height:44px; width:100%; } .issue-comment-composer button { min-height:44px; width:100%; }
.comment-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; }
.comment-actions button { min-height:44px; width:100%; }
.issue-label-editor { max-width:100%; margin:14px 0; padding:12px; border:1px solid #2a496e; border-radius:12px; } .issue-label-editor { max-width:100%; margin:14px 0; padding:12px; border:1px solid #2a496e; border-radius:12px; }
.issue-label-list { display:grid; grid-template-columns:repeat(auto-fit,minmax(min(180px,100%),1fr)); gap:8px; max-width:100%; } .issue-label-list { display:grid; grid-template-columns:repeat(auto-fit,minmax(min(180px,100%),1fr)); gap:8px; max-width:100%; }
.issue-label-option { min-height:44px; max-width:100%; display:flex; align-items:center; gap:10px; padding:8px; border:1px solid #2a496e; border-radius:10px; overflow-wrap:anywhere; } .issue-label-option { min-height:44px; max-width:100%; display:flex; align-items:center; gap:10px; padding:8px; border:1px solid #2a496e; border-radius:10px; overflow-wrap:anywhere; }

View File

@ -782,6 +782,44 @@
warm: warmTodayOffline, warm: warmTodayOffline,
announce: message => { qs('#my-work-action-status').textContent = message; }, announce: message => { qs('#my-work-action-status').textContent = message; },
}); });
function setCommentNextVisibility(kind) {
const item = kind === 'issue' ? selectedIssue : selectedPull;
qs('#send-' + kind + '-comment-next').hidden = !item || !workSession.checkpointed(item);
}
const issueCommentNext = createCommentNext({
post: async (item, body) => {
const comment = await issueController.comment(item, body);
if (selectedIssue === item && issueConversation) renderIssueConversation(issueConversation.append(comment));
return comment;
},
queue: message => authoredOutbox.enqueueDurably(message),
canQueue: canQueueMessage,
accept: item => {
issueController.saveDraft(item, '');
if (selectedIssue === item) qs('#issue-comment').value = '';
},
complete: item => completeTodayItem(item, {
successMessage: 'Comment saved. Next Today item opened.',
failureMessage: 'Comment saved, but Today still needs completion.',
}),
});
const pullCommentNext = createCommentNext({
post: async (item, body) => {
const comment = await pullController.comment(item, body);
if (selectedPull === item && pullConversation) renderPullConversation(pullConversation.append(comment));
return comment;
},
queue: message => authoredOutbox.enqueueDurably(message),
canQueue: canQueueMessage,
accept: item => {
pullController.saveDraft(item, '');
if (selectedPull === item) qs('#pull-comment').value = '';
},
complete: item => completeTodayItem(item, {
successMessage: 'Comment saved. Next Today item opened.',
failureMessage: 'Comment saved, but Today still needs completion.',
}),
});
const closeOfflineIssue = createOfflineIssueClose({ const closeOfflineIssue = createOfflineIssueClose({
enqueueDurably: message => authoredOutbox.enqueueDurably(message), enqueueDurably: message => authoredOutbox.enqueueDurably(message),
completeToday: (item, options) => completeTodayItem(item, options), completeToday: (item, options) => completeTodayItem(item, options),
@ -1732,6 +1770,7 @@
qs('#retry-issue-load').hidden = true; qs('#retry-issue-load').hidden = true;
qs('#open-issue-gitea').href = item.url || '#'; qs('#open-issue-gitea').href = item.url || '#';
qs('#send-issue-comment').disabled = false; qs('#send-issue-comment').disabled = false;
setCommentNextVisibility('issue');
qs('#edit-issue-content').disabled = true; qs('#edit-issue-content').disabled = true;
qs('#issue-edit-form').hidden = true; qs('#issue-edit-form').hidden = true;
qs('#issue-edit-status').textContent = ''; qs('#issue-edit-status').textContent = '';
@ -1894,6 +1933,7 @@
qs('#merge-pull').textContent = workSession.active() ? 'Merge & next' : 'Merge'; qs('#merge-pull').textContent = workSession.active() ? 'Merge & next' : 'Merge';
qs('#retry-pull-load').hidden = true; qs('#retry-pull-load').hidden = true;
qs('#open-pull-gitea').href = item.url || '#'; qs('#open-pull-gitea').href = item.url || '#';
setCommentNextVisibility('pull');
qs('#close-pull-sheet').focus(); qs('#close-pull-sheet').focus();
try { try {
const detail = offlineDetail || await pullController.load(item); const detail = offlineDetail || await pullController.load(item);
@ -3351,6 +3391,43 @@
qs('#issue-milestone').disabled = false; qs('#issue-milestone').disabled = false;
} }
}); });
async function submitCommentAndNext(kind) {
const item = kind === 'issue' ? selectedIssue : selectedPull;
if (!item || !workSession.checkpointed(item)) return;
const textarea = qs('#' + kind + '-comment');
const status = qs('#' + kind + '-comment-status');
const body = textarea.value.trim();
if (!body) {
status.textContent = 'Write a comment before posting.';
textarea.focus();
return;
}
const postButton = qs('#send-' + kind + '-comment');
const nextButton = qs('#send-' + kind + '-comment-next');
const controller = kind === 'issue' ? issueCommentNext : pullCommentNext;
const operationId = () => localStorage.getItem('stackchain.' + kind + '-comment.v1:' +
item.repository + '#' + item.number + ':operation');
postButton.disabled = true;
nextButton.disabled = true;
status.textContent = 'Posting comment and opening next…';
try {
const result = await controller.submit(item, body, operationId);
const stillOpen = kind === 'issue' ? selectedIssue === item : selectedPull === item;
if (!stillOpen) return;
if (!result.completed) status.textContent = 'Comment saved, but Today still needs completion.';
else if (result.delivery === 'posted') status.textContent = 'Comment posted.';
else if (result.background) status.textContent = 'Queued for sync when the connection returns.';
else status.textContent = 'Saved for next launch; background delivery unavailable.';
} catch (error) {
status.textContent = error.message + ' Your draft and Today position are safe; retry.';
textarea.focus();
} finally {
postButton.disabled = false;
nextButton.disabled = false;
}
}
qs('#send-issue-comment-next').addEventListener('click', () => submitCommentAndNext('issue'));
qs('#send-pull-comment-next').addEventListener('click', () => submitCommentAndNext('pull'));
qs('#send-issue-comment').addEventListener('click', async () => { qs('#send-issue-comment').addEventListener('click', async () => {
if (!selectedIssue) return; if (!selectedIssue) return;
const body = qs('#issue-comment').value.trim(); const body = qs('#issue-comment').value.trim();

View File

@ -269,7 +269,10 @@
<section class="issue-comment-composer" aria-labelledby="issue-comment-title"> <section class="issue-comment-composer" aria-labelledby="issue-comment-title">
<h2 id="issue-comment-title">Add comment</h2> <h2 id="issue-comment-title">Add comment</h2>
<textarea id="issue-comment" maxlength="10000" placeholder="Write a comment"></textarea> <textarea id="issue-comment" maxlength="10000" placeholder="Write a comment"></textarea>
<button id="send-issue-comment" type="button">Post comment</button> <div class="comment-actions">
<button id="send-issue-comment" type="button">Post comment</button>
<button id="send-issue-comment-next" type="button" hidden>Comment &amp; next</button>
</div>
<div id="issue-comment-status" class="small" aria-live="assertive"></div> <div id="issue-comment-status" class="small" aria-live="assertive"></div>
</section> </section>
<details class="issue-planning" id="issue-planning"> <details class="issue-planning" id="issue-planning">
@ -470,7 +473,10 @@
<section class="pull-comment-composer" aria-labelledby="pull-comment-title"> <section class="pull-comment-composer" aria-labelledby="pull-comment-title">
<h2 id="pull-comment-title">Add comment</h2> <h2 id="pull-comment-title">Add comment</h2>
<textarea id="pull-comment" maxlength="10000" placeholder="Write a comment"></textarea> <textarea id="pull-comment" maxlength="10000" placeholder="Write a comment"></textarea>
<button id="send-pull-comment" type="button">Post comment</button> <div class="comment-actions">
<button id="send-pull-comment" type="button">Post comment</button>
<button id="send-pull-comment-next" type="button" hidden>Comment &amp; next</button>
</div>
<div id="pull-comment-status" class="small" aria-live="assertive"></div> <div id="pull-comment-status" class="small" aria-live="assertive"></div>
</section> </section>
<details class="pull-review" id="pull-review"> <details class="pull-review" id="pull-review">
@ -602,6 +608,7 @@
<script src="static/card-planning.js"></script> <script src="static/card-planning.js"></script>
<script src="static/today-work.js"></script> <script src="static/today-work.js"></script>
<script src="static/today-completion.js"></script> <script src="static/today-completion.js"></script>
<script src="static/comment-next.js"></script>
<script src="static/plan-today.js"></script> <script src="static/plan-today.js"></script>
<script src="static/plan-today-preview.js"></script> <script src="static/plan-today-preview.js"></script>
<script src="static/today-sync.js"></script> <script src="static/today-sync.js"></script>

View File

@ -563,7 +563,7 @@ function createWorkSession({
return { return {
active: () => running, active: () => running,
checkpointed: () => running && durable, checkpointed: item => running && durable && (!item || workIdentity(item) === currentIdentity),
end: () => finish(), end: () => finish(),
resumable: () => Boolean(checkpoint?.read()), resumable: () => Boolean(checkpoint?.read()),
reopen() { reopen() {

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-v71'; const CACHE = 'stackchain-dashboard-shell-v72';
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;
@ -29,6 +29,7 @@ const SHELL = [
BASE + 'static/card-planning.js', BASE + 'static/card-planning.js',
BASE + 'static/today-work.js', BASE + 'static/today-work.js',
BASE + 'static/today-completion.js', BASE + 'static/today-completion.js',
BASE + 'static/comment-next.js',
BASE + 'static/plan-today.js', BASE + 'static/plan-today.js',
BASE + 'static/plan-today-preview.js', BASE + 'static/plan-today-preview.js',
BASE + 'static/today-sync.js', BASE + 'static/today-sync.js',

212
tests/test_comment_next.py Normal file
View File

@ -0,0 +1,212 @@
import json
import subprocess
from pathlib import Path
import pytest
from tests.dashboard_bundle import dashboard
COMMENT_NEXT = Path(__file__).parents[1] / "frontend" / "comment-next.js"
MY_WORK = Path(__file__).parents[1] / "frontend" / "my-work.js"
def run_node(script):
return subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
).stdout
def test_comment_and_next_posts_then_completes_current_today_item_once():
script = f"""
const createCommentNext = require({json.dumps(str(COMMENT_NEXT))});
const calls = [];
let finishPost;
const controller = createCommentNext({{
post: (item, body) => {{ calls.push(`post:${{item.number}}:${{body}}`); return new Promise(resolve => {{ finishPost = resolve; }}); }},
queue: () => {{ throw new Error('must not queue'); }},
canQueue: () => false,
complete: item => {{ calls.push(`complete:${{item.number}}`); return true; }},
}});
const item = {{kind:'issue', repository:'stackchain/dashboard', number:429}};
const first = controller.submit(item, 'Handoff ready');
const second = controller.submit(item, 'Handoff ready');
if (first !== second) throw new Error('submission was not single-flight');
finishPost({{id:7, body:'Handoff ready'}});
(async () => {{
const result = await first;
process.stdout.write(JSON.stringify({{result, calls, busy:controller.busy()}}));
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
assert json.loads(run_node(script)) == {
"result": {
"accepted": True,
"delivery": "posted",
"comment": {"id": 7, "body": "Handoff ready"},
"completed": True,
},
"calls": ["post:429:Handoff ready", "complete:429"],
"busy": False,
}
def test_comment_and_next_advances_after_durable_offline_admission():
script = f"""
const createCommentNext = require({json.dumps(str(COMMENT_NEXT))});
const calls = [];
const retryable = new Error('offline'); retryable.status = 503;
const controller = createCommentNext({{
post: () => Promise.reject(retryable),
canQueue: error => error.status === 503,
queue: message => {{ calls.push(message); return Promise.resolve({{durable:true, background:false}}); }},
complete: item => {{ calls.push(`complete:${{item.number}}`); return true; }},
}});
(async () => {{
const result = await controller.submit({{kind:'pull', repository:'stackchain/dashboard', number:12}}, 'Please review', 'op-12');
process.stdout.write(JSON.stringify({{result, calls}}));
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
assert json.loads(run_node(script)) == {
"result": {
"accepted": True,
"delivery": "saved",
"background": False,
"completed": True,
},
"calls": [
{
"kind": "pull-comment",
"repository": "stackchain/dashboard",
"number": 12,
"body": "Please review",
"operationId": "op-12",
},
"complete:12",
],
}
def test_comment_and_next_reads_retry_identity_after_the_failed_post():
script = f"""
const createCommentNext = require({json.dumps(str(COMMENT_NEXT))});
let operationId = '';
let queued;
const controller = createCommentNext({{
post: () => {{ operationId = 'post-attempt-id'; return Promise.reject(Object.assign(new Error('timeout'), {{status:503}})); }},
canQueue: () => true,
queue: message => {{ queued = message; return Promise.resolve({{durable:true}}); }},
complete: () => true,
}});
(async () => {{
const result = await controller.submit(
{{kind:'issue', repository:'r', number:3}}, 'Status', () => operationId
);
process.stdout.write(JSON.stringify({{result, queued}}));
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
output = json.loads(run_node(script))
assert output["result"]["accepted"] is True
assert output["result"]["delivery"] == "saved"
assert output["queued"]["operationId"] == "post-attempt-id"
def test_comment_and_next_clears_the_completed_draft_before_opening_next_item():
script = f"""
const createCommentNext = require({json.dumps(str(COMMENT_NEXT))});
const calls = [];
const controller = createCommentNext({{
post: () => Promise.resolve({{id:1}}), queue: () => null, canQueue: () => false,
accept: item => calls.push(`clear:${{item.number}}`),
complete: item => {{ calls.push(`complete:${{item.number}}`); return true; }},
}});
(async () => {{
await controller.submit({{kind:'issue',repository:'r',number:8}}, 'Done');
process.stdout.write(JSON.stringify(calls));
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
assert json.loads(run_node(script)) == ["clear:8", "complete:8"]
def test_comment_and_next_keeps_today_position_when_delivery_or_completion_fails():
script = f"""
const createCommentNext = require({json.dumps(str(COMMENT_NEXT))});
const failures = [];
const permanent = createCommentNext({{
post: () => Promise.reject(Object.assign(new Error('forbidden'), {{status:403}})),
canQueue: () => false,
queue: () => Promise.resolve({{durable:true}}),
complete: () => {{ failures.push('advanced'); return true; }},
}});
const notDurable = createCommentNext({{
post: () => Promise.reject(Object.assign(new Error('offline'), {{status:503}})),
canQueue: () => true,
queue: () => Promise.resolve({{durable:false, background:false}}),
complete: () => {{ failures.push('advanced'); return true; }},
}});
const cannotComplete = createCommentNext({{
post: () => Promise.resolve({{id:9}}), canQueue: () => false, queue: () => null,
complete: () => false,
}});
(async () => {{
for (const [name, controller] of [['permanent', permanent], ['notDurable', notDurable]]) {{
try {{ await controller.submit({{kind:'issue',repository:'r',number:1}}, 'draft'); }}
catch (error) {{ failures.push(`${{name}}:${{error.message}}`); }}
}}
const partial = await cannotComplete.submit({{kind:'issue',repository:'r',number:1}}, 'draft');
process.stdout.write(JSON.stringify({{failures, partial}}));
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
assert json.loads(run_node(script)) == {
"failures": ["permanent:forbidden", "notDurable:Comment was not saved for delivery."],
"partial": {
"accepted": True,
"delivery": "posted",
"comment": {"id": 9},
"completed": False,
},
}
def test_today_checkpoint_only_matches_the_current_session_item():
script = f"""
const buildMyWork = require({json.dumps(str(MY_WORK))});
const items = [
{{kind:'issue', repository:'r', number:1}},
{{kind:'pull', repository:'r', number:2}},
];
const session = buildMyWork.createWorkSession({{
getItems: () => items, getFilter: () => 'all', checkpointEnabled: () => true,
checkpoint: {{save:()=>undefined, clear:()=>undefined, read:()=>null}},
onOpen:()=>undefined, onProgress:()=>undefined, onFinish:()=>undefined,
}});
session.start();
process.stdout.write(JSON.stringify({{
current: session.checkpointed(items[0]),
other: session.checkpointed(items[1]),
generic: session.checkpointed(),
}}));
"""
assert json.loads(run_node(script)) == {
"current": True,
"other": False,
"generic": True,
}
@pytest.mark.anyio
async def test_mobile_composers_offer_comment_and_next_only_for_today_checkpoint():
html = await dashboard()
assert 'id="send-issue-comment-next"' in html
assert 'id="send-pull-comment-next"' in html
assert html.count('>Comment &amp; next</button>') == 2
assert "setCommentNextVisibility('issue')" in html
assert "setCommentNextVisibility('pull')" in html
assert '.comment-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr));' in html
assert '.comment-actions button { min-height:44px;' in html

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-v71" in source assert "stackchain-dashboard-shell-v72" 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-v71" in worker assert "stackchain-dashboard-shell-v72" 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-v71" in worker assert "stackchain-dashboard-shell-v72" in worker

View File

@ -168,6 +168,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(): 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-v71" in source assert "stackchain-dashboard-shell-v72" in source
assert "BASE + 'static/plan-today.js'" in source assert "BASE + 'static/plan-today.js'" in source
assert "BASE + 'static/plan-today-preview.js'" in source assert "BASE + 'static/plan-today-preview.js'" in source

View File

@ -122,7 +122,7 @@ async function dispatchNotificationClick(route) {{
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-v71" in source assert "stackchain-dashboard-shell-v72" 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
@ -131,7 +131,7 @@ def test_resumable_today_session_ships_in_a_new_offline_shell():
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-v71" in source assert "stackchain-dashboard-shell-v72" 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
@ -139,7 +139,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-v71" in source assert "stackchain-dashboard-shell-v72" 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
@ -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(): def test_exact_later_picker_ships_atomically_in_a_new_offline_shell():
source = WORKER.read_text() source = WORKER.read_text()
assert "stackchain-dashboard-shell-v71" in source assert "stackchain-dashboard-shell-v72" 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-v71" in source assert "stackchain-dashboard-shell-v72" 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
@ -163,21 +163,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-v71" in source assert "stackchain-dashboard-shell-v72" 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-v71" in source assert "stackchain-dashboard-shell-v72" 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-v71" in source assert "stackchain-dashboard-shell-v72" in source
assert "BASE + 'static/update-ownership.js'" 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(): def test_queue_today_ships_atomically_in_a_new_offline_shell():
source = WORKER.read_text() source = WORKER.read_text()
assert "stackchain-dashboard-shell-v71" in source assert "stackchain-dashboard-shell-v72" in source
assert "BASE + 'static/queue-today.js'" in source assert "BASE + 'static/queue-today.js'" in source
@ -397,6 +397,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
"/dashboard/static/card-planning.js", "/dashboard/static/card-planning.js",
"/dashboard/static/today-work.js", "/dashboard/static/today-work.js",
"/dashboard/static/today-completion.js", "/dashboard/static/today-completion.js",
"/dashboard/static/comment-next.js",
"/dashboard/static/plan-today.js", "/dashboard/static/plan-today.js",
"/dashboard/static/plan-today-preview.js", "/dashboard/static/plan-today-preview.js",
"/dashboard/static/today-sync.js", "/dashboard/static/today-sync.js",

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-v71" in source assert "stackchain-dashboard-shell-v72" in source
assert "BASE + 'static/today-sync.js'" in source assert "BASE + 'static/today-sync.js'" in source