feat: finish unread update triage offline (#351)
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 committed 2026-08-08 23:38:52 +00:00
1 parent a995e3b45f
commit 990f0f04d6
13 files changed
+367 -21

No files matched your search

+1 -1
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 pre { max-width:100%; overflow-x:auto;" 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
+1 -1
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]))
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
+45
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():
script = f"""
const build = require({json.dumps(str(MY_WORK))});
+135
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
+4 -3
View File
@@ -205,7 +205,7 @@ async def test_saved_today_details_open_offline_without_enabling_server_state_ac
@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()
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 "Offline update · saved " 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('#load-older-update-comments').disabled = offline;" 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
+4 -3
View File
@@ -97,7 +97,7 @@ async function dispatchNotificationClick(route) {{
def test_share_target_sign_in_fix_ships_in_a_new_shell_cache():
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.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():
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
def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell():
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
@@ -303,6 +303,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
"/dashboard/static/outbox-coordinator.js",
"/dashboard/static/issue-outbox.js",
"/dashboard/static/authored-outbox.js",
"/dashboard/static/notification-read-outbox.js",
"/dashboard/static/offline-work.js",
"/dashboard/static/offline-today.js",
"/dashboard/static/my-work.js",