feat: finish unread update triage offline (#351)
All checks were successful
CI / lint (pull_request) Successful in 35s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy 2026-08-08 23:38:52 +00:00
parent a995e3b45f
commit 990f0f04d6
13 changed files with 367 additions and 21 deletions

View File

@ -37,7 +37,12 @@ another worker replays a confirmed result instead of posting duplicate content.
Today queue is warmed automatically after a healthy authenticated refresh and as soon as Today queue is warmed automatically after a healthy authenticated refresh and as soon as
it is added. The readiness indicator reports saved, pending, and retryable items; unchanged it is added. The readiness indicator reports saved, pending, and retryable items; unchanged
`updated_at` revisions make no detail request, transient failures retain the prior copy, `updated_at` revisions make no detail request, transient failures retain the prior copy,
and pull-request diffs remain online-only. and pull-request diffs remain online-only. Saved unread-update conversations remain
triageable offline: **Queue read & next** writes an account-bound, notification-ID-
deduplicated acknowledgement to the durable background delivery system, removes the
update from the local queue immediately, and opens the next saved conversation. A cold
offline reload suppresses acknowledgements still waiting to sync; reconnect uses the
authenticated notification-read endpoint and keeps transient failures queued.
Closed-app Closed-app
delivery requests are deadline-bounded: a stalled identity, CSRF, or mutation request is delivery requests are deadline-bounded: a stalled identity, CSRF, or mutation request is
aborted after 15 seconds, its durable claim returns to the queue, and the next sync retries aborted after 15 seconds, its durable claim returns to the queue, and the next sync retries

View File

@ -232,6 +232,9 @@ function createBackgroundIssueSync({
if (status === 'attention') { if (status === 'attention') {
return { id: item.id, status, kind: item.kind ? 'message' : 'issue', route: '#/my-work/drafts' }; return { id: item.id, status, kind: item.kind ? 'message' : 'issue', route: '#/my-work/drafts' };
} }
if (item.kind === 'notification-read') {
return { id: item.id, status, kind: 'notification-read', route: '#/my-work/updates' };
}
if (item.kind === 'update-reply') { if (item.kind === 'update-reply') {
return { id: item.id, status, kind: 'message', route: '#/my-work/update/' + encodeURIComponent(item.notificationId) }; return { id: item.id, status, kind: 'message', route: '#/my-work/update/' + encodeURIComponent(item.notificationId) };
} }
@ -244,6 +247,12 @@ function createBackgroundIssueSync({
} }
function deliveryRequest(item) { function deliveryRequest(item) {
if (item.kind === 'notification-read') {
return {
url: base + 'api/v1/notifications/' + encodeURIComponent(item.notificationId) + '/read',
options: { method: 'PATCH', headers: { Accept: 'application/json' } },
};
}
if (item.kind === 'update-reply') { if (item.kind === 'update-reply') {
return authoredRequest('api/v1/notifications/' + encodeURIComponent(item.notificationId) + '/reply', item); return authoredRequest('api/v1/notifications/' + encodeURIComponent(item.notificationId) + '/reply', item);
} }

View File

@ -208,10 +208,16 @@
backgroundSync: backgroundIssueSync, backgroundSync: backgroundIssueSync,
getOwnerLogin: () => confirmedOwnerLogin, getOwnerLogin: () => confirmedOwnerLogin,
}); });
const notificationReadOutbox = createNotificationReadOutbox({
storage: localStorage, fetchJson: fetchReviewJson, coordinator: outboxCoordinator,
backgroundSync: backgroundIssueSync,
getOwnerLogin: () => confirmedOwnerLogin,
});
if (backgroundIssueSync) { if (backgroundIssueSync) {
backgroundIssueSync.snapshot().then(records => { backgroundIssueSync.snapshot().then(records => {
issueOutbox.reconcileBackground(records); issueOutbox.reconcileBackground(records);
authoredOutbox.reconcileBackground(records); authoredOutbox.reconcileBackground(records);
notificationReadOutbox.reconcileBackground(records);
}).catch(() => { /* The foreground localStorage outboxes remain available. */ }); }).catch(() => { /* The foreground localStorage outboxes remain available. */ });
} }
const shareParams = new URLSearchParams(location.search); const shareParams = new URLSearchParams(location.search);
@ -439,6 +445,8 @@
load: fetchNotificationDetail, load: fetchNotificationDetail,
loadConversation: fetchNotificationConversation, loadConversation: fetchNotificationConversation,
markRead: markNotificationRead, markRead: markNotificationRead,
queueRead: notificationId => notificationReadOutbox.enqueueDurably(notificationId),
loadSaved: item => offlineWorkStore.loadDetail(confirmedOwnerLogin, item),
onOpen: item => { onOpen: item => {
selectedUpdate = item; selectedUpdate = item;
qs('#update-sheet').classList.add('open'); qs('#update-sheet').classList.add('open');
@ -521,7 +529,7 @@
notificationReader.open(item, savedDetail).then(opened => { notificationReader.open(item, savedDetail).then(opened => {
if (opened && selectedUpdate === item) { if (opened && selectedUpdate === item) {
qs('#update-sheet-status').textContent = 'Offline update · saved ' + fmt(savedDetail.saved_at) + qs('#update-sheet-status').textContent = 'Offline update · saved ' + fmt(savedDetail.saved_at) +
' · replies queue for sync. Reconnect to mark read, take ownership, defer, or load older messages.'; ' · replies and read acknowledgements queue for sync. Reconnect to take ownership, defer, or load older messages.';
} }
}); });
} else { } else {
@ -679,7 +687,8 @@
} }
function setOfflineUpdateControls(offline) { function setOfflineUpdateControls(offline) {
qs('#mark-update-read-next').disabled = offline; qs('#mark-update-read-next').disabled = false;
qs('#mark-update-read-next').textContent = offline ? 'Queue read & next' : 'Mark read & next';
qs('#update-ownership-action').disabled = offline; qs('#update-ownership-action').disabled = offline;
qs('#load-older-update-comments').disabled = offline; qs('#load-older-update-comments').disabled = offline;
qs('#update-sheet .detail-defer').inert = offline; qs('#update-sheet .detail-defer').inert = offline;
@ -1714,6 +1723,15 @@
applyAuthoredOutboxResult(await authoredOutbox.flush(activeFlushLogin)); applyAuthoredOutboxResult(await authoredOutbox.flush(activeFlushLogin));
} }
async function flushNotificationReadOutbox() {
if (!navigator.onLine || !activeFlushLogin || !notificationReadOutbox.list().length) return;
const result = await notificationReadOutbox.flush(activeFlushLogin);
if (result.confirmed?.length) {
qs('#my-work-action-status').textContent = result.confirmed.length +
' queued update' + (result.confirmed.length === 1 ? '' : 's') + ' marked read.';
}
}
function canQueueMessage(error) { function canQueueMessage(error) {
const status = Number(error?.status || 0); const status = Number(error?.status || 0);
return !status || status >= 500; return !status || status >= 500;
@ -1965,6 +1983,7 @@
} }
flushIssueOutbox(); flushIssueOutbox();
flushAuthoredOutbox(); flushAuthoredOutbox();
flushNotificationReadOutbox();
} else if (!snapshot.context) handleContextError(new Error('Context section unavailable')); } else if (!snapshot.context) handleContextError(new Error('Context section unavailable'));
if (eventsChanged && Array.isArray(snapshot.events)) paintEventStream(snapshot.events); if (eventsChanged && Array.isArray(snapshot.events)) paintEventStream(snapshot.events);
if (eventsFreshness?.revalidating) { if (eventsFreshness?.revalidating) {
@ -3083,7 +3102,8 @@
confirmedOwnerLogin = String(saved.user?.login || '').trim(); confirmedOwnerLogin = String(saved.user?.login || '').trim();
planningOwnerLogin = confirmedOwnerLogin; planningOwnerLogin = confirmedOwnerLogin;
updatePlanningAvailability(); updatePlanningAvailability();
lastNotifications = saved.notifications || []; saved.notifications = notificationReadOutbox.suppress(saved.notifications || []);
lastNotifications = saved.notifications;
notificationPagination = saved.notification_pagination || { page:1, total:lastNotifications.length, has_more:false }; notificationPagination = saved.notification_pagination || { page:1, total:lastNotifications.length, has_more:false };
workPagination = saved.work_pagination || {}; workPagination = saved.work_pagination || {};
lastContextSnapshot = saved; lastContextSnapshot = saved;

View File

@ -533,6 +533,7 @@
<script src="static/background-issue-sync.js"></script> <script src="static/background-issue-sync.js"></script>
<script src="static/issue-outbox.js"></script> <script src="static/issue-outbox.js"></script>
<script src="static/authored-outbox.js"></script> <script src="static/authored-outbox.js"></script>
<script src="static/notification-read-outbox.js"></script>
<script src="static/offline-work.js"></script> <script src="static/offline-work.js"></script>
<script src="static/offline-today.js"></script> <script src="static/offline-today.js"></script>
<script src="static/my-work.js"></script> <script src="static/my-work.js"></script>

View File

@ -280,6 +280,8 @@ function createWorkPager({ load, onItems, onPagination, onStatus }) {
function createNotificationReader({ function createNotificationReader({
load, markRead, onOpen, onDetail, onItems, onStatus, onClose, load, markRead, onOpen, onDetail, onItems, onStatus, onClose,
queueRead = null,
loadSaved = () => null,
loadConversation = null, loadConversation = null,
onConversation = () => {}, onConversation = () => {},
createPager = typeof createConversationPager === 'function' ? createConversationPager : null, createPager = typeof createConversationPager === 'function' ? createConversationPager : null,
@ -344,20 +346,21 @@ function createNotificationReader({
} }
}, },
async markReadAndNext(items) { async markReadAndNext(items) {
if (!selected || marking || offlineHydrated) return false; if (!selected || marking || (offlineHydrated && !queueRead)) return false;
const current = selected; const current = selected;
const queueing = offlineHydrated;
marking = true; marking = true;
onStatus('Marking update read…'); onStatus(queueing ? 'Queueing update read…' : 'Marking update read…');
try { try {
await markRead(current.notification_id); if (queueing) await queueRead(current.notification_id);
else await markRead(current.notification_id);
const updated = acknowledgeNotification(items, current.notification_id); const updated = acknowledgeNotification(items, current.notification_id);
onItems(updated); onItems(updated);
const currentIndex = items.indexOf(current); const currentIndex = items.indexOf(current);
const remaining = items.slice(currentIndex + 1).concat(items.slice(0, currentIndex)); const remaining = items.slice(currentIndex + 1).concat(items.slice(0, currentIndex));
const next = remaining.find(item => const next = remaining.find(item => item && item.has_update &&
item && item.has_update && Number.isInteger(item.notification_id) Number.isInteger(item.notification_id) && (!queueing || loadSaved(item)));
); if (next) await open(next, queueing ? loadSaved(next) : null);
if (next) await open(next);
else { else {
selected = null; selected = null;
onClose(); onClose();
@ -365,7 +368,7 @@ function createNotificationReader({
} }
return { items: updated, next: next || null }; return { items: updated, next: next || null };
} catch (_error) { } catch (_error) {
onStatus('Could not mark update read. Retry.'); onStatus(queueing ? 'Could not queue update read. Retry.' : 'Could not mark update read. Retry.');
return false; return false;
} finally { } finally {
marking = false; marking = false;

View File

@ -0,0 +1,125 @@
function createNotificationReadOutbox({
storage, fetchJson, backgroundSync, coordinator, getOwnerLogin = () => '', now = () => Date.now(), maxItems = 100,
}) {
const storageKey = 'stackchain.notification-read-outbox.v1';
function read() {
try {
const record = JSON.parse(storage?.getItem(storageKey) || 'null');
if (record?.version !== 1 || !Array.isArray(record.items)) return [];
return record.items.filter(item => item?.kind === 'notification-read' &&
Number.isInteger(item.notificationId) && item.notificationId > 0 && item.ownerLogin);
} catch (_error) { return []; }
}
function write(items, mirror = true) {
storage?.setItem(storageKey, JSON.stringify({ version: 1, items }));
coordinator?.notify('notification-read');
if (mirror && backgroundSync?.reconcile) {
Promise.resolve(backgroundSync.reconcile(items, 'notification-read'))
.then(() => items.length ? backgroundSync.requestSync?.() : undefined)
.catch(() => { /* Foreground reconnect remains available. */ });
}
}
function itemId(ownerLogin, notificationId) {
return 'notification-read:' + ownerLogin + ':' + notificationId;
}
async function enqueueDurably(notificationId) {
notificationId = Number(notificationId);
const ownerLogin = String(getOwnerLogin() || '').trim();
if (!ownerLogin) throw new Error('Confirm your Gitea account before queueing this update.');
if (!Number.isInteger(notificationId) || notificationId <= 0) throw new Error('Choose a valid update.');
const items = read();
const id = itemId(ownerLogin, notificationId);
let item = items.find(candidate => candidate.id === id);
if (!item) {
if (items.length >= maxItems) throw new Error('Update acknowledgement queue is full. Reconnect before clearing more updates.');
item = {
id, kind: 'notification-read', notificationId, ownerLogin,
status: 'queued', queuedAt: Number(now()),
};
items.push(item);
write(items, false);
}
if (!backgroundSync?.reconcile || !backgroundSync?.requestSync) {
return { item: { ...item }, background: false, durability: 'foreground-only' };
}
try {
await backgroundSync.reconcile(read(), 'notification-read');
await backgroundSync.requestSync();
return { item: { ...item }, background: true, durability: 'background' };
} catch (error) {
return { item: { ...item }, background: false, durability: 'foreground-only', error };
}
}
function suppress(items, login = getOwnerLogin()) {
const pending = new Set(read().filter(item => item.ownerLogin === String(login || '').trim())
.map(item => item.notificationId));
return (items || []).filter(item => !pending.has(Number(item?.notification_id ?? item?.id)));
}
async function send(item, currentLogin) {
if (!currentLogin || item.ownerLogin !== currentLogin) return { blocked: true };
if (backgroundSync?.send) return backgroundSync.send(item, currentLogin);
await fetchJson('api/v1/notifications/' + encodeURIComponent(item.notificationId) + '/read', {
method: 'PATCH', headers: { Accept: 'application/json' },
});
return { confirmed: true };
}
async function flush(currentLogin) {
currentLogin = String(currentLogin || '').trim();
const confirmed = [];
let items = read();
for (const item of items) {
if (item.ownerLogin !== currentLogin || item.status === 'attention') continue;
try {
const result = await send(item, currentLogin);
if (result?.blocked || result?.busy) continue;
if (result?.attention) {
items = items.map(candidate => candidate.id === item.id ? {
...candidate, status: 'attention', error: String(result.error?.message || 'Update needs attention').slice(0, 240),
} : candidate);
write(items);
continue;
}
confirmed.push(item.notificationId);
items = items.filter(candidate => candidate.id !== item.id);
write(items);
} catch (error) {
const status = Number(error?.status || 0);
if (status >= 400 && status < 500) {
items = items.map(candidate => candidate.id === item.id ? {
...candidate, status: 'attention', error: String(error?.message || 'Update needs attention').slice(0, 240),
} : candidate);
write(items);
continue;
}
break;
}
}
return { confirmed, remaining: read() };
}
function reconcileBackground(records) {
const states = new Map((records || []).filter(item => item?.kind === 'notification-read')
.map(item => [item.id, item]));
const items = read().flatMap(item => {
const state = states.get(item.id);
if (state?.status === 'sent') return [];
if (state?.status === 'attention') return [{
...item, status: 'attention', error: String(state.error || 'Update needs attention').slice(0, 240),
}];
return [item];
});
write(items);
return items;
}
return { enqueueDurably, flush, suppress, reconcileBackground, list: () => read().map(item => ({ ...item })) };
}
if (typeof module !== 'undefined' && module.exports) module.exports = createNotificationReadOutbox;

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-v41'; const CACHE = 'stackchain-dashboard-shell-v42';
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]); const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
const SHELL = [ const SHELL = [
BASE, BASE,
@ -19,6 +19,7 @@ const SHELL = [
BASE + 'static/outbox-coordinator.js', BASE + 'static/outbox-coordinator.js',
BASE + 'static/issue-outbox.js', BASE + 'static/issue-outbox.js',
BASE + 'static/authored-outbox.js', BASE + 'static/authored-outbox.js',
BASE + 'static/notification-read-outbox.js',
BASE + 'static/offline-work.js', BASE + 'static/offline-work.js',
BASE + 'static/offline-today.js', BASE + 'static/offline-today.js',
BASE + 'static/my-work.js', BASE + 'static/my-work.js',

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-v41" in worker assert "stackchain-dashboard-shell-v42" 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-v41" in worker assert "stackchain-dashboard-shell-v42" in worker

View File

@ -2406,6 +2406,51 @@ const reader = build.createNotificationReader({{
} }
def test_notification_reader_queues_saved_update_read_offline_and_opens_next_saved_update():
script = f"""
const build = require({json.dumps(str(MY_WORK))});
const items = [
{{kind:'update', notification_id:42, has_update:true, title:'First'}},
{{kind:'update', notification_id:43, has_update:true, title:'Second'}},
];
const saved = new Map([
[42, {{id:42, title:'Saved first', conversation:{{comments:[],page:1,total:0}}}}],
[43, {{id:43, title:'Saved second', conversation:{{comments:[],page:1,total:0}}}}],
]);
const events = [];
const reader = build.createNotificationReader({{
load: async () => {{ throw new Error('network must not run'); }},
markRead: async () => {{ throw new Error('network must not run'); }},
queueRead: async id => events.push(['queued', id]),
loadSaved: item => saved.get(item.notification_id),
onOpen: item => events.push(['open', item.notification_id]),
onDetail: detail => events.push(['detail', detail.id]),
onItems: next => events.push(['items', next.map(item => item.notification_id)]),
onStatus: status => events.push(['status', status]), onClose: () => events.push(['close']),
}});
(async () => {{
await reader.open(items[0], saved.get(42));
events.length = 0;
const result = await reader.markReadAndNext(items);
process.stdout.write(JSON.stringify({{result, events}}));
}})();
"""
output = json.loads(subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
).stdout)
assert output["result"]["next"]["notification_id"] == 43
assert output["events"] == [
["status", "Queueing update read…"],
["queued", 42],
["items", [43]],
["open", 43],
["status", "Loading update…"],
["detail", 43],
["status", "Update ready."],
]
def test_notification_reader_appends_a_confirmed_reply_exactly_once(): def test_notification_reader_appends_a_confirmed_reply_exactly_once():
script = f""" script = f"""
const build = require({json.dumps(str(MY_WORK))}); const build = require({json.dumps(str(MY_WORK))});

View File

@ -0,0 +1,135 @@
import json
import subprocess
from pathlib import Path
import pytest
from tests.dashboard_bundle import dashboard
ROOT = Path(__file__).resolve().parents[1]
OUTBOX = ROOT / "frontend" / "notification-read-outbox.js"
BACKGROUND_SYNC = ROOT / "frontend" / "background-issue-sync.js"
WORKER = ROOT / "frontend" / "service-worker.js"
def run_node(script):
result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
return json.loads(result.stdout)
def test_offline_read_admission_is_account_bound_deduplicated_and_suppresses_saved_updates():
script = f"""
const createOutbox = require({json.dumps(str(OUTBOX))});
const values = new Map();
const storage = {{
getItem:key => values.has(key) ? values.get(key) : null,
setItem:(key,value) => values.set(key,value),
}};
const reconciled = [];
const backgroundSync = {{
reconcile: async (items, lane) => reconciled.push([lane, items]),
requestSync: async () => true,
}};
const outbox = createOutbox({{
storage, backgroundSync, getOwnerLogin:() => 'timmy', now:() => 1234,
}});
(async () => {{
const first = await outbox.enqueueDurably(42);
const duplicate = await outbox.enqueueDurably(42);
const visible = outbox.suppress([
{{notification_id:42, title:'Queued'}}, {{notification_id:43, title:'Visible'}},
]);
process.stdout.write(JSON.stringify({{
first, duplicate, items:outbox.list(), visible, reconciled,
}}));
}})();
"""
output = run_node(script)
assert output["first"]["item"]["notificationId"] == 42
assert output["duplicate"]["item"]["id"] == output["first"]["item"]["id"]
assert len(output["items"]) == 1
assert output["items"][0]["ownerLogin"] == "timmy"
assert [item["notification_id"] for item in output["visible"]] == [43]
assert output["reconciled"][-1][0] == "notification-read"
assert len(output["reconciled"][-1][1]) == 1
def test_foreground_flush_patches_each_unique_read_and_keeps_transient_failures_queued():
script = f"""
const createOutbox = require({json.dumps(str(OUTBOX))});
const values = new Map();
const storage = {{getItem:key => values.get(key) || null,setItem:(key,value) => values.set(key,value)}};
const calls = [];
const outbox = createOutbox({{
storage, getOwnerLogin:() => 'timmy',
fetchJson: async (url, options) => {{
calls.push([url, options.method]);
if (url.includes('/43/')) {{ const error = new Error('offline'); error.status = 503; throw error; }}
return {{ok:true}};
}},
}});
(async () => {{
await outbox.enqueueDurably(42);
await outbox.enqueueDurably(43);
const result = await outbox.flush('timmy');
process.stdout.write(JSON.stringify({{calls, result, items:outbox.list()}}));
}})();
"""
output = run_node(script)
assert output["calls"] == [
["api/v1/notifications/42/read", "PATCH"],
["api/v1/notifications/43/read", "PATCH"],
]
assert output["result"]["confirmed"] == [42]
assert [item["notificationId"] for item in output["items"]] == [43]
assert output["items"][0]["status"] == "queued"
def test_background_delivery_uses_patch_and_reports_read_receipt():
script = f"""
const createSync = require({json.dumps(str(BACKGROUND_SYNC))});
const requests = [];
const store = {{
upsert:async () => {{}}, claim:async () => ({{
id:'notification-read:timmy:42', kind:'notification-read', notificationId:42,
ownerLogin:'timmy', status:'sending',
}}), complete:async () => {{}}, release:async () => {{}}, fail:async () => {{}},
}};
const sync = createSync({{
store,
fetchJson:async (url, options) => {{ requests.push([url, options.method, options.body || null]); return {{ok:true}}; }},
}});
(async () => {{
const result = await sync.send({{
id:'notification-read:timmy:42', kind:'notification-read', notificationId:42,
ownerLogin:'timmy', status:'queued',
}}, 'timmy');
process.stdout.write(JSON.stringify({{requests, result}}));
}})();
"""
output = run_node(script)
assert output["requests"] == [["api/v1/notifications/42/read", "PATCH", None]]
assert output["result"]["receipt"] == {
"id": "notification-read:timmy:42",
"status": "confirmed",
"kind": "notification-read",
"route": "#/my-work/updates",
}
@pytest.mark.anyio
async def test_dashboard_wires_offline_queue_read_next_through_precached_background_outbox():
html = await dashboard()
worker = WORKER.read_text()
assert '<script src="static/notification-read-outbox.js"></script>' in html
assert "const notificationReadOutbox = createNotificationReadOutbox({" in html
assert "queueRead: notificationId => notificationReadOutbox.enqueueDurably(notificationId)" in html
assert "notificationReadOutbox.suppress(saved.notifications || [])" in html
assert "notificationReadOutbox.flush(activeFlushLogin)" in html
assert "Queue read & next" in html
assert "BASE + 'static/notification-read-outbox.js'" in worker

View File

@ -205,7 +205,7 @@ async def test_saved_today_details_open_offline_without_enabling_server_state_ac
@pytest.mark.anyio @pytest.mark.anyio
async def test_saved_unread_update_opens_offline_with_reply_only_controls(): async def test_saved_unread_update_opens_offline_with_queued_reply_and_read_controls():
html = await dashboard() html = await dashboard()
assert "notificationReader.open(item, savedDetail)" in html assert "notificationReader.open(item, savedDetail)" in html
@ -213,8 +213,9 @@ async def test_saved_unread_update_opens_offline_with_reply_only_controls():
assert "offlineWorkStore.saveDetail(confirmedOwnerLogin, selectedUpdate, detail)" in html assert "offlineWorkStore.saveDetail(confirmedOwnerLogin, selectedUpdate, detail)" in html
assert "Offline update · saved " in html assert "Offline update · saved " in html
assert "setOfflineUpdateControls(true)" in html assert "setOfflineUpdateControls(true)" in html
assert "qs('#mark-update-read-next').disabled = offline;" in html assert "qs('#mark-update-read-next').disabled = false;" in html
assert "offline ? 'Queue read & next' : 'Mark read & next'" in html
assert "qs('#update-ownership-action').disabled = offline;" in html assert "qs('#update-ownership-action').disabled = offline;" in html
assert "qs('#load-older-update-comments').disabled = offline;" in html assert "qs('#load-older-update-comments').disabled = offline;" in html
assert "document.querySelectorAll('[data-notification-id], [data-later-preset], [data-today-add]')" in html assert "document.querySelectorAll('[data-notification-id], [data-later-preset], [data-today-add]')" in html
assert "Reconnect to mark read, take ownership, defer, or load older messages." in html assert "replies and read acknowledgements queue for sync" in html

View File

@ -97,7 +97,7 @@ async function dispatchNotificationClick(route) {{
def test_share_target_sign_in_fix_ships_in_a_new_shell_cache(): def test_share_target_sign_in_fix_ships_in_a_new_shell_cache():
source = WORKER.read_text() source = WORKER.read_text()
assert "stackchain-dashboard-shell-v41" in source assert "stackchain-dashboard-shell-v42" 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
@ -106,14 +106,14 @@ def test_share_target_sign_in_fix_ships_in_a_new_shell_cache():
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-v41" in source assert "stackchain-dashboard-shell-v42" 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-v41" in source assert "stackchain-dashboard-shell-v42" in source
assert "BASE + 'static/update-ownership.js'" in source assert "BASE + 'static/update-ownership.js'" in source
@ -303,6 +303,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
"/dashboard/static/outbox-coordinator.js", "/dashboard/static/outbox-coordinator.js",
"/dashboard/static/issue-outbox.js", "/dashboard/static/issue-outbox.js",
"/dashboard/static/authored-outbox.js", "/dashboard/static/authored-outbox.js",
"/dashboard/static/notification-read-outbox.js",
"/dashboard/static/offline-work.js", "/dashboard/static/offline-work.js",
"/dashboard/static/offline-today.js", "/dashboard/static/offline-today.js",
"/dashboard/static/my-work.js", "/dashboard/static/my-work.js",