From 1052ce2c848702835803edbafe75868a4ed05e73 Mon Sep 17 00:00:00 2001 From: timmy Date: Mon, 10 Aug 2026 18:02:34 +0000 Subject: [PATCH] feat: reply, mark read, and continue updates (Closes #503) --- frontend/authored-outbox.js | 22 ++- frontend/background-issue-sync.js | 27 +++- frontend/dashboard.js | 37 ++--- frontend/drafts.js | 2 +- frontend/index.html | 3 +- frontend/my-work.js | 34 +++-- frontend/service-worker.js | 3 +- frontend/update-reply-read-next.js | 50 ++++++ tests/test_comment_next.py | 16 +- tests/test_later_sync.py | 2 +- tests/test_markdown_renderer.py | 2 +- tests/test_mobile_composer_integration.py | 2 +- tests/test_plan_today.py | 2 +- tests/test_service_worker.py | 21 +-- tests/test_today_readiness.py | 2 +- tests/test_today_sync.py | 2 +- tests/test_update_reply_read_next.py | 176 ++++++++++++++++++++++ 17 files changed, 335 insertions(+), 68 deletions(-) create mode 100644 frontend/update-reply-read-next.js create mode 100644 tests/test_update_reply_read_next.py diff --git a/frontend/authored-outbox.js b/frontend/authored-outbox.js index 3fe7cb3..d022b0a 100644 --- a/frontend/authored-outbox.js +++ b/frontend/authored-outbox.js @@ -4,7 +4,7 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync, globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random().toString(16).slice(2) ); const pending = new Map(); - const supportedKinds = new Set(['issue-comment', 'pull-comment', 'update-reply', 'pull-review', 'issue-close']); + const supportedKinds = new Set(['issue-comment', 'pull-comment', 'update-reply', 'update-reply-read', 'pull-review', 'issue-close']); function reviewFingerprint(message) { return JSON.stringify({ @@ -74,6 +74,7 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync, ownerLogin, status: 'queued', queuedAt: Number(now()), + ...(message.kind === 'update-reply-read' ? { replyConfirmed: message.replyConfirmed === true } : {}), ...(message.kind === 'issue-comment' && message.attachment ? { attachment: { filename: String(message.attachment.filename || ''), @@ -183,7 +184,24 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync, } result = delivery.message; } else { - if (item.kind === 'issue-close') { + if (item.kind === 'update-reply-read') { + if (!item.replyConfirmed) { + await fetchJson('api/v1/notifications/' + encodeURIComponent(item.notificationId) + '/reply', { + method: 'POST', + headers: { + Accept: 'application/json', 'Content-Type': 'application/json', + 'Idempotency-Key': item.operationId, + }, + body: JSON.stringify({ body: item.body }), + }); + write(read().map(candidate => candidate.id === item.id ? { + ...candidate, replyConfirmed: true, status: 'sending', lastAttemptAt: attemptAt, + } : candidate), false); + } + result = await fetchJson('api/v1/notifications/' + encodeURIComponent(item.notificationId) + '/read', { + method: 'PATCH', headers: { Accept: 'application/json' }, + }); + } else if (item.kind === 'issue-close') { result = await fetchJson(endpoint(item), { method: 'PATCH', headers: { Accept: 'application/json', 'Idempotency-Key': item.operationId }, diff --git a/frontend/background-issue-sync.js b/frontend/background-issue-sync.js index a40d1f3..9ea5c13 100644 --- a/frontend/background-issue-sync.js +++ b/frontend/background-issue-sync.js @@ -311,7 +311,7 @@ function createBackgroundIssueSync({ 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' || item.kind === 'update-reply-read') { return { id: item.id, status, kind: 'message', route: '#/my-work/update/' + encodeURIComponent(item.notificationId) }; } const repository = String(item.repository || '').split('/').map(encodeURIComponent).join('/'); @@ -344,6 +344,12 @@ function createBackgroundIssueSync({ if (item.kind === 'update-reply') { return authoredRequest('api/v1/notifications/' + encodeURIComponent(item.notificationId) + '/reply', item); } + if (item.kind === 'update-reply-read') { + return { + url: base + 'api/v1/notifications/' + encodeURIComponent(item.notificationId) + '/read', + options: { method: 'PATCH', headers: { Accept: 'application/json' } }, + }; + } const repository = String(item.repository || '').split('/').map(encodeURIComponent).join('/'); if (item.kind === 'issue-close') { return { @@ -420,6 +426,22 @@ function createBackgroundIssueSync({ return String(operationId || '').slice(0, 128 - suffix.length) + suffix; } + async function deliverReplyRead(item) { + let current = item; + if (!current.replyConfirmed) { + await requestStage( + current, + base + 'api/v1/notifications/' + encodeURIComponent(current.notificationId) + '/reply', + authoredRequest('', current).options, + ); + const checkpointed = await checkpointClaim(current, stored => ({ ...stored, replyConfirmed: true })); + if (checkpointed === false) throw new Error('Background delivery claim was lost.'); + current = { ...current, replyConfirmed: true }; + } + const request = deliveryRequest(current); + return requestStage(current, request.url, request.options); + } + function attachmentMultipart(attachment) { let blob = attachment?.blob; if (!blob && attachment?.data) { @@ -520,7 +542,8 @@ function createBackgroundIssueSync({ async function deliver(item) { const request = deliveryRequest(item); try { - const delivered = item.attachment && item.kind === 'issue-comment' ? + const delivered = item.kind === 'update-reply-read' ? await deliverReplyRead(item) : + item.attachment && item.kind === 'issue-comment' ? await deliverScreenshotComment(item) : item.attachment && !item.kind ? await deliverIssueCapture(item) : await requestStage(item, request.url, request.options); if (item.kind === 'issue-close' && delivered?.state !== 'closed') { diff --git a/frontend/dashboard.js b/frontend/dashboard.js index bf48503..04f1f12 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -615,8 +615,7 @@ qs('#update-reply').value = notificationReplier.loadDraft(item); qs('#update-reply-status').textContent = ''; qs('#send-update-reply').disabled = false; - qs('#send-update-reply-next').disabled = false; - setUpdateReplyNextVisibility(); + qs('#send-update-reply-read-next').disabled = false; qs('#update-ownership-action').hidden = true; qs('#retry-update-load').hidden = true; setOfflineUpdateControls(false); @@ -1019,9 +1018,7 @@ const item = kind === 'issue' ? selectedIssue : selectedPull; qs('#send-' + kind + '-comment-next').hidden = !item || !workSession.checkpointed(item); } - function setUpdateReplyNextVisibility() { - qs('#send-update-reply-next').hidden = !selectedUpdate || !workSession.checkpointed(selectedUpdate); - } + const issueCommentNext = createCommentNext({ post: async (item, body) => { const comment = await issueController.comment(item, body); @@ -1056,25 +1053,16 @@ failureMessage: 'Comment saved, but Today still needs completion.', }), }); - const updateReplyNext = createCommentNext({ - queueKind: 'update-reply', + const updateReplyReadNext = createUpdateReplyReadNext({ post: (item, body, operationId) => postNotificationReply(item.notification_id, body, operationId), + markRead: markNotificationRead, queue: message => authoredOutbox.enqueueDurably(message), canQueue: canQueueMessage, - accept: (item, result) => { + accept: item => { notificationReplier.saveDraft(item, ''); - if (selectedUpdate === item) { - qs('#update-reply').value = ''; - if (result.comment) notificationReader.appendReply(result.comment); - qs('#update-reply-status').textContent = result.delivery === 'queued' ? - 'Reply queued for background delivery.' : result.delivery === 'saved' ? - 'Reply saved for next-launch delivery.' : 'Reply posted.'; - } + if (selectedUpdate === item) qs('#update-reply').value = ''; }, - complete: item => completeTodayItem(item, { - successMessage: 'Reply saved. Next Today item opened.', - failureMessage: 'Reply saved, but Today still needs completion.', - }), + next: item => notificationReader.acceptReadAndNext(lastMyWork, item), }); const closeOfflineIssue = createOfflineIssueClose({ enqueueDurably: message => authoredOutbox.enqueueDurably(message), @@ -4326,7 +4314,7 @@ qs('#update-reply').focus(); } }); - qs('#send-update-reply-next').addEventListener('click', async () => { + qs('#send-update-reply-read-next').addEventListener('click', async () => { if (!selectedUpdate) return; const item = selectedUpdate; const body = qs('#update-reply').value.trim(); @@ -4335,14 +4323,17 @@ qs('#update-reply').focus(); return; } - const button = qs('#send-update-reply-next'); + const button = qs('#send-update-reply-read-next'); const sendButton = qs('#send-update-reply'); button.disabled = true; sendButton.disabled = true; - qs('#update-reply-status').textContent = 'Sending reply…'; + qs('#update-reply-status').textContent = 'Replying, then marking read…'; const operationId = globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random(); try { - await updateReplyNext.submit(item, body, operationId); + const result = await updateReplyReadNext.submit(item, body, operationId); + if (result?.accepted) qs('#my-work-action-status').textContent = + result.delivery === 'posted' ? 'Reply posted and update marked read.' : + 'Reply and read acknowledgement queued for sync.'; } catch (error) { qs('#update-reply-status').textContent = error.message + ' Your draft is safe; retry.'; qs('#update-reply').focus(); diff --git a/frontend/drafts.js b/frontend/drafts.js index 643512e..75ec1f1 100644 --- a/frontend/drafts.js +++ b/frontend/drafts.js @@ -141,7 +141,7 @@ function createDraftInbox({ storage, getCurrentLogin = () => '', now = () => Dat const currentLogin = String(getCurrentLogin() || '').trim(); return record.items.filter(item => item && typeof item.id === 'string' && typeof item.body === 'string') .map(item => { - const isUpdate = item.kind === 'update-reply'; + const isUpdate = item.kind === 'update-reply' || item.kind === 'update-reply-read'; const isReview = item.kind === 'pull-review'; const isClosure = item.kind === 'issue-close'; const routeKind = isReview ? 'review' : (item.kind === 'pull-comment' ? 'pull' : 'issue'); diff --git a/frontend/index.html b/frontend/index.html index 9a186f0..9a5049f 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -518,7 +518,7 @@
- +
@@ -696,6 +696,7 @@ + diff --git a/frontend/my-work.js b/frontend/my-work.js index 0c0ed35..bed62c9 100644 --- a/frontend/my-work.js +++ b/frontend/my-work.js @@ -292,6 +292,22 @@ function createNotificationReader({ let conversationPager = null; let offlineHydrated = false; + async function advanceAfterRead(items, current, queueing = false) { + const updated = acknowledgeNotification(items, current.notification_id); + onItems(updated); + const currentIndex = items.indexOf(current); + const remaining = items.slice(currentIndex + 1).concat(items.slice(0, currentIndex)); + const next = remaining.find(item => item && item.has_update && + Number.isInteger(item.notification_id) && (!queueing || loadSaved(item))); + if (next) await open(next, queueing ? loadSaved(next) : null); + else { + selected = null; + onClose(); + onStatus('Inbox cleared.'); + } + return { items: updated, next: next || null }; + } + async function open(item, savedDetail = null) { selected = item; offlineHydrated = Boolean(savedDetail && !Array.isArray(savedDetail)); @@ -327,6 +343,10 @@ function createNotificationReader({ onConversation(conversationPager.append(comment)); return true; }, + acceptReadAndNext(items, item = selected) { + if (!item || selected !== item) return false; + return advanceAfterRead(items, item); + }, async loadOlder() { if (!conversationPager || !selected || offlineHydrated) return false; const pager = conversationPager; @@ -354,19 +374,7 @@ function createNotificationReader({ try { if (queueing) await queueRead(current.notification_id); else await markRead(current.notification_id); - const updated = acknowledgeNotification(items, current.notification_id); - onItems(updated); - const currentIndex = items.indexOf(current); - const remaining = items.slice(currentIndex + 1).concat(items.slice(0, currentIndex)); - const next = remaining.find(item => item && item.has_update && - Number.isInteger(item.notification_id) && (!queueing || loadSaved(item))); - if (next) await open(next, queueing ? loadSaved(next) : null); - else { - selected = null; - onClose(); - onStatus('Inbox cleared.'); - } - return { items: updated, next: next || null }; + return await advanceAfterRead(items, current, queueing); } catch (_error) { onStatus(queueing ? 'Could not queue update read. Retry.' : 'Could not mark update read. Retry.'); return false; diff --git a/frontend/service-worker.js b/frontend/service-worker.js index 05f3ecb..c22262d 100644 --- a/frontend/service-worker.js +++ b/frontend/service-worker.js @@ -1,6 +1,6 @@ const BASE = new URL('./', self.location.href).pathname; importScripts(BASE + 'static/background-issue-sync.js'); -const CACHE = 'stackchain-dashboard-shell-v83'; +const CACHE = 'stackchain-dashboard-shell-v84'; const OFFLINE_LEASE_URL = new URL(BASE + '__offline-session-lease', self.location.origin).href; const OUTAGE_STATUSES = new Set([500, 502, 503, 504]); const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000; @@ -31,6 +31,7 @@ const SHELL = [ BASE + 'static/today-completion.js', BASE + 'static/today-readiness.js', BASE + 'static/comment-next.js', + BASE + 'static/update-reply-read-next.js', BASE + 'static/plan-today.js', BASE + 'static/plan-today-preview.js', BASE + 'static/today-sync.js', diff --git a/frontend/update-reply-read-next.js b/frontend/update-reply-read-next.js new file mode 100644 index 0000000..8bd5f82 --- /dev/null +++ b/frontend/update-reply-read-next.js @@ -0,0 +1,50 @@ +function createUpdateReplyReadNext({ + post, markRead, queue, canQueue, accept = () => undefined, next = () => undefined, +}) { + let inFlight = null; + + async function admit(item, body, operationId, replyConfirmed) { + const admission = await queue({ + kind: 'update-reply-read', + notificationId: item.notification_id, + body, + operationId, + replyConfirmed, + }); + if (!admission || (!admission.item && admission.durable !== true)) { + throw new Error('Reply and acknowledgement were not saved for delivery.'); + } + accept(item); + return { + accepted: true, + delivery: admission.background ? 'queued' : 'saved', + next: await next(item), + }; + } + + function submit(item, body, operationId) { + if (inFlight) return inFlight; + inFlight = (async () => { + let replyConfirmed = false; + try { + try { + await post(item, body, operationId); + replyConfirmed = true; + await markRead(item.notification_id); + } catch (error) { + if (!canQueue(error)) throw error; + return await admit(item, body, operationId, replyConfirmed); + } + accept(item); + return { accepted: true, delivery: 'posted', next: await next(item) }; + } finally { + inFlight = null; + } + })(); + return inFlight; + } + + return { submit, busy: () => Boolean(inFlight) }; +} + +if (typeof module !== 'undefined' && module.exports) module.exports = createUpdateReplyReadNext; diff --git a/tests/test_comment_next.py b/tests/test_comment_next.py index 9165ac1..4c063bd 100644 --- a/tests/test_comment_next.py +++ b/tests/test_comment_next.py @@ -292,17 +292,15 @@ async def test_mobile_composers_offer_comment_and_next_only_for_today_checkpoint @pytest.mark.anyio -async def test_current_today_update_offers_reply_and_next_without_marking_read(): +async def test_unread_update_offers_reply_mark_read_and_next_independent_of_today(): html = await dashboard() - assert 'id="send-update-reply-next"' in html - assert '>Reply & next' in html - assert "setUpdateReplyNextVisibility();" in html - assert "const updateReplyNext = createCommentNext({" in html - assert "notificationReplier.saveDraft(item, '');" in html - assert "successMessage: 'Reply saved. Next Today item opened.'" in html - assert "qs('#mark-update-read-next').click()" not in html + assert 'id="send-update-reply-read-next"' in html + assert '>Reply, mark read & next' in html + assert "const updateReplyReadNext = createUpdateReplyReadNext({" in html + assert "markRead: markNotificationRead" in html + assert "next: item => notificationReader.acceptReadAndNext(lastMyWork, item)" in html assert '.update-reply-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr));' in html assert '.update-reply-actions button { min-height:44px;' in html worker = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text() - assert "stackchain-dashboard-shell-v83" in worker + assert "stackchain-dashboard-shell-v84" in worker diff --git a/tests/test_later_sync.py b/tests/test_later_sync.py index 20c51b1..34c30f2 100644 --- a/tests/test_later_sync.py +++ b/tests/test_later_sync.py @@ -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(): source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text() - assert "stackchain-dashboard-shell-v83" in source + assert "stackchain-dashboard-shell-v84" in source assert "BASE + 'static/later-sync.js'" in source diff --git a/tests/test_markdown_renderer.py b/tests/test_markdown_renderer.py index 967be78..79e19ab 100644 --- a/tests/test_markdown_renderer.py +++ b/tests/test_markdown_renderer.py @@ -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-v83" in worker + assert "stackchain-dashboard-shell-v84" in worker diff --git a/tests/test_mobile_composer_integration.py b/tests/test_mobile_composer_integration.py index 379cecd..6b0a573 100644 --- a/tests/test_mobile_composer_integration.py +++ b/tests/test_mobile_composer_integration.py @@ -41,7 +41,7 @@ def test_offline_shell_contains_every_local_dashboard_runtime_asset(): shell_assets = set(re.findall(r"BASE \+ '([^']+)'", worker.split("async function sessionCsrf", 1)[0])) assert local_assets <= shell_assets, f"Offline shell is missing: {sorted(local_assets - shell_assets)}" - assert "stackchain-dashboard-shell-v83" in worker + assert "stackchain-dashboard-shell-v84" in worker def test_all_conversation_composers_offer_accessible_mobile_mentions(): diff --git a/tests/test_plan_today.py b/tests/test_plan_today.py index 91aeff6..e96e5e9 100644 --- a/tests/test_plan_today.py +++ b/tests/test_plan_today.py @@ -292,6 +292,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(): source = SERVICE_WORKER.read_text() - assert "stackchain-dashboard-shell-v83" in source + assert "stackchain-dashboard-shell-v84" in source assert "BASE + 'static/plan-today.js'" in source assert "BASE + 'static/plan-today-preview.js'" in source diff --git a/tests/test_service_worker.py b/tests/test_service_worker.py index e41be0e..47b311a 100644 --- a/tests/test_service_worker.py +++ b/tests/test_service_worker.py @@ -122,7 +122,7 @@ async function dispatchNotificationClick(route) {{ def test_resumable_today_session_ships_in_a_new_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v83" in source + assert "stackchain-dashboard-shell-v84" in source assert "BASE + 'static/my-work.js'" in source assert "BASE + 'static/dashboard.js'" in source assert "BASE + 'static/dashboard.css'" in source @@ -131,14 +131,14 @@ def test_resumable_today_session_ships_in_a_new_offline_shell(): def test_ownership_exit_runtime_rolls_the_offline_shell_cache(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v83" in source + assert "stackchain-dashboard-shell-v84" in source assert "BASE + 'static/dashboard.js'" in source def test_offline_review_next_ships_today_completion_atomically(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v83" in source + assert "stackchain-dashboard-shell-v84" in source assert "BASE + 'static/today-completion.js'" in source assert "BASE + 'static/dashboard.js'" in source @@ -146,7 +146,7 @@ def test_offline_review_next_ships_today_completion_atomically(): def test_duplicate_aware_capture_ships_in_a_new_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v83" in source + assert "stackchain-dashboard-shell-v84" in source assert "BASE + 'static/create-issue-sheet.js'" in source assert "BASE + 'static/dashboard.js'" in source @@ -154,14 +154,14 @@ def test_duplicate_aware_capture_ships_in_a_new_offline_shell(): def test_exact_later_picker_ships_atomically_in_a_new_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v83" in source + assert "stackchain-dashboard-shell-v84" in source assert "BASE + 'static/later-picker.js'" in source def test_navigation_deadline_ships_in_a_new_shell_cache(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v83" in source + assert "stackchain-dashboard-shell-v84" in source assert "BASE + 'static/dashboard.css'" in source assert "BASE + 'static/dashboard.js'" in source assert "BASE + 'static/install-app.js'" in source @@ -170,21 +170,21 @@ def test_navigation_deadline_ships_in_a_new_shell_cache(): def test_today_convergence_ships_in_a_new_shell_cache(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v83" in source + assert "stackchain-dashboard-shell-v84" in source assert "BASE + 'static/today-sync.js'" in source def test_mobile_search_viewport_ships_in_a_new_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v83" in source + assert "stackchain-dashboard-shell-v84" 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-v83" in source + assert "stackchain-dashboard-shell-v84" in source assert "BASE + 'static/update-ownership.js'" in source @@ -385,7 +385,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(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v83" in source + assert "stackchain-dashboard-shell-v84" in source assert "BASE + 'static/queue-today.js'" in source @@ -426,6 +426,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell(): "/dashboard/static/today-completion.js", "/dashboard/static/today-readiness.js", "/dashboard/static/comment-next.js", + "/dashboard/static/update-reply-read-next.js", "/dashboard/static/plan-today.js", "/dashboard/static/plan-today-preview.js", "/dashboard/static/today-sync.js", diff --git a/tests/test_today_readiness.py b/tests/test_today_readiness.py index f503995..5f2600f 100644 --- a/tests/test_today_readiness.py +++ b/tests/test_today_readiness.py @@ -221,7 +221,7 @@ async def test_today_blocker_opens_existing_preview_and_preserves_readiness_gate def test_readiness_runtime_is_available_in_offline_shell(): service_worker = SERVICE_WORKER.read_text() - assert "const CACHE = 'stackchain-dashboard-shell-v83';" in service_worker + assert "const CACHE = 'stackchain-dashboard-shell-v84';" in service_worker assert "BASE + 'static/today-readiness.js'" in service_worker diff --git a/tests/test_today_sync.py b/tests/test_today_sync.py index c6ff213..f11283d 100644 --- a/tests/test_today_sync.py +++ b/tests/test_today_sync.py @@ -127,7 +127,7 @@ sync.enqueueConfiguration(120, {{'issue:r:1:':60}}); def test_inflight_today_drain_ships_in_a_new_offline_shell(): source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text() - assert "stackchain-dashboard-shell-v83" in source + assert "stackchain-dashboard-shell-v84" in source assert "BASE + 'static/today-sync.js'" in source diff --git a/tests/test_update_reply_read_next.py b/tests/test_update_reply_read_next.py new file mode 100644 index 0000000..15b5379 --- /dev/null +++ b/tests/test_update_reply_read_next.py @@ -0,0 +1,176 @@ +import json +import subprocess +from pathlib import Path + +import pytest + +from tests.dashboard_bundle import dashboard + + +ROOT = Path(__file__).parents[1] +CONTROLLER = ROOT / "frontend" / "update-reply-read-next.js" +SYNC = ROOT / "frontend" / "background-issue-sync.js" +DRAFTS = ROOT / "frontend" / "drafts.js" + + +def run_node(script): + return json.loads(subprocess.run( + ["node", "-e", script], check=True, capture_output=True, text=True + ).stdout) + + +@pytest.mark.anyio +async def test_every_unread_update_offers_one_reply_read_next_mobile_action(): + html = await dashboard() + + assert 'id="send-update-reply-read-next"' in html + assert '>Reply, mark read & next' in html + assert "workSession.checkpointed(selectedUpdate)" not in html + assert '' in html + assert ".update-reply-actions button { min-height:44px; width:100%; }" in html + + +def test_reply_read_next_orders_online_delivery_and_is_single_flight(): + script = f""" +const createController = require({json.dumps(str(CONTROLLER))}); +const calls=[]; let releaseReply; +const controller=createController({{ + post:(item,body,id)=>{{calls.push(['reply',item.notification_id,body,id]);return new Promise(resolve=>releaseReply=resolve);}}, + markRead:id=>{{calls.push(['read',id]);return Promise.resolve({{ok:true}});}}, + queue:()=>{{throw new Error('must not queue');}}, canQueue:()=>false, + accept:item=>calls.push(['accept',item.notification_id]), + next:item=>{{calls.push(['next',item.notification_id]);return {{next:true}};}}, +}}); +const item={{notification_id:91}}; +const first=controller.submit(item,'Done','op-91'); +const second=controller.submit(item,'Done','op-91'); +releaseReply({{id:7}}); +Promise.all([first,second]).then(results=>process.stdout.write(JSON.stringify({{same:first===second,calls,results}}))); +""" + output = run_node(script) + + assert output == { + "same": True, + "calls": [ + ["reply", 91, "Done", "op-91"], + ["read", 91], + ["accept", 91], + ["next", 91], + ], + "results": [ + {"accepted": True, "delivery": "posted", "next": {"next": True}}, + {"accepted": True, "delivery": "posted", "next": {"next": True}}, + ], + } + + +def test_reply_read_next_waits_until_the_next_update_is_open(): + script = f""" +const createController=require({json.dumps(str(CONTROLLER))}); +let openNext; +const controller=createController({{ + post:async()=>({{id:1}}),markRead:async()=>({{ok:true}}),queue:async()=>null,canQueue:()=>false, + next:()=>new Promise(resolve=>openNext=()=>resolve({{notification_id:2}})), +}}); +let settled=false; +const result=controller.submit({{notification_id:1}},'Done','op-1').then(value=>{{settled=true;return value;}}); +setTimeout(()=>{{ + const before=settled; + openNext(); + result.then(value=>process.stdout.write(JSON.stringify({{before,value}}))); +}},0); +""" + output = run_node(script) + + assert output == { + "before": False, + "value": { + "accepted": True, + "delivery": "posted", + "next": {"notification_id": 2}, + }, + } + + +def test_reply_read_next_durably_checkpoints_partial_delivery_without_reposting(): + script = f""" +const createController = require({json.dumps(str(CONTROLLER))}); +const calls=[]; +const offline=Object.assign(new Error('offline'),{{status:503}}); +const controller=createController({{ + post:async()=>{{calls.push('reply');return{{id:8}};}}, + markRead:async()=>{{calls.push('read');throw offline;}}, + canQueue:error=>error.status===503, + queue:async message=>{{calls.push(['queue',message]);return{{item:{{id:'saved'}},background:true}};}}, + accept:item=>calls.push(['accept',item.notification_id]), + next:item=>{{calls.push(['next',item.notification_id]);return true;}}, +}}); +controller.submit({{notification_id:22}},'Ship it','stable-op').then(result=> + process.stdout.write(JSON.stringify({{calls,result}}))); +""" + output = run_node(script) + + assert output["calls"] == [ + "reply", + "read", + ["queue", { + "kind": "update-reply-read", + "notificationId": 22, + "body": "Ship it", + "operationId": "stable-op", + "replyConfirmed": True, + }], + ["accept", 22], + ["next", 22], + ] + assert output["result"] == {"accepted": True, "delivery": "queued", "next": True} + + +def test_background_retry_resumes_at_read_after_reply_checkpoint(): + item = { + "id": "op-31", "operationId": "op-31", "ownerLogin": "timmy", + "status": "queued", "kind": "update-reply-read", "notificationId": 31, + "body": "Acknowledged", "replyConfirmed": True, + } + script = f""" +const createSync=require({json.dumps(str(SYNC))}); +let queued={json.dumps(item)};const calls=[]; +const store={{ + claimNext:async owner=>queued?(queued=null,{{...{json.dumps(item)}}}):null, + complete:async()=>calls.push('complete'),release:async()=>calls.push('release'), + fail:async()=>calls.push('fail'),countBlocked:async()=>0, + checkpoint:async()=>{{throw new Error('must not checkpoint again');}}, +}}; +const fetchJson=async(url,options={{}})=>{{calls.push([url,options.method]);return url==='api/v1/background-identity'?{{login:'timmy'}}:{{ok:true}};}}; +createSync({{store,fetchJson}}).flush().then(result=>process.stdout.write(JSON.stringify({{calls,result}}))); +""" + output = run_node(script) + + assert output["calls"] == [ + ["api/v1/background-identity", None], + ["api/v1/notifications/31/read", "PATCH"], + "complete", + ] + assert output["result"]["confirmed"] == [{"ok": True}] + + +def test_partial_reply_read_delivery_is_recoverable_from_update_drafts(): + record = {"version": 2, "items": [{ + "id": "op-44", "operationId": "op-44", "ownerLogin": "timmy", + "status": "attention", "kind": "update-reply-read", "notificationId": 44, + "body": "Handled", "replyConfirmed": True, "error": "Read failed", + }]} + script = f""" +const createDraftInbox=require({json.dumps(str(DRAFTS))}); +const values=new Map([['stackchain.authored-outbox.v1',JSON.stringify({json.dumps(record)})]]); +const storage={{get length(){{return values.size}},key:i=>[...values.keys()][i],getItem:k=>values.get(k)||null, + setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}}; +const item=createDraftInbox({{storage,getCurrentLogin:()=>'timmy'}}).list()[0]; +process.stdout.write(JSON.stringify(item)); +""" + output = run_node(script) + + assert output["outbox_kind"] == "update-reply-read" + assert output["title"] == "Update #44" + assert output["route"] == {"kind": "update", "notification_id": 44} + assert output["status"] == "attention"