From ccb595a0817188d174d13849eae14b79bd79839d Mon Sep 17 00:00:00 2001 From: timmy Date: Sun, 9 Aug 2026 20:04:20 +0000 Subject: [PATCH] feat: queue offline Today reviews and continue (#423) --- frontend/dashboard.js | 20 ++++++++++++-- frontend/service-worker.js | 2 +- frontend/today-completion.js | 6 ++--- tests/test_later_sync.py | 2 +- tests/test_markdown_renderer.py | 2 +- tests/test_mobile_composer_integration.py | 2 +- tests/test_offline_work.py | 18 +++++++++++++ tests/test_plan_today.py | 2 +- tests/test_service_worker.py | 24 +++++++++++------ tests/test_today_sync.py | 2 +- tests/test_today_work.py | 33 +++++++++++++++++++++++ 11 files changed, 94 insertions(+), 19 deletions(-) diff --git a/frontend/dashboard.js b/frontend/dashboard.js index a79c13c..02936c0 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -782,6 +782,10 @@ announce: message => { qs('#my-work-action-status').textContent = message; }, }); + function reviewingActiveTodayItem() { + return workSession.checkpointed(); + } + function acceptClaimedIssue(confirmed) { lastContextSnapshot = lastContextSnapshot || { user: {}, repos: [], issues: [], pull_requests: [] }; lastContextSnapshot.issues = [confirmed].concat((lastContextSnapshot.issues || []).filter(candidate => @@ -2381,7 +2385,8 @@ qs('#review-submit-status').textContent = ''; qs('#continue-review-to-merge').hidden = true; qs('#submit-review').disabled = true; - qs('#submit-review').textContent = offlineReview ? 'Queue review for reconnect' : 'Submit review'; + qs('#submit-review').textContent = offlineReview && reviewingActiveTodayItem() ? 'Queue review & next' : + (offlineReview ? 'Queue review for reconnect' : 'Submit review'); closeInlineComposer(); draft = null; reviewFiles = []; @@ -3660,6 +3665,7 @@ const button = qs('#submit-review'); button.disabled = true; if (offlineReview) { + const queuedTodayReview = reviewingActiveTodayItem(); qs('#review-submit-status').textContent = 'Queueing review safely…'; try { await authoredOutbox.enqueueDurably({ @@ -3675,7 +3681,17 @@ draftFingerprint: localStorage.getItem(draft.storageKey) || '', progressFingerprint: localStorage.getItem(progress?.storageKey) || '', }); - qs('#review-submit-status').textContent = 'Review queued · it will submit after reconnect.'; + if (queuedTodayReview) { + const advanced = completeTodayItem(selectedReview, { + successMessage: 'Review queued. Next Today item opened.', + failureMessage: 'Review queued, but Today still needs completion.', + }); + if (!advanced) { + qs('#review-submit-status').textContent = 'Review queued, but Today still needs completion.'; + } + } else { + qs('#review-submit-status').textContent = 'Review queued · it will submit after reconnect.'; + } } catch (error) { qs('#review-submit-status').textContent = error.message + ' Your draft is safe; retry when ready.'; button.disabled = false; diff --git a/frontend/service-worker.js b/frontend/service-worker.js index 0844de2..440c269 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-v68'; +const CACHE = 'stackchain-dashboard-shell-v69'; 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; diff --git a/frontend/today-completion.js b/frontend/today-completion.js index 76d21e5..2494a0c 100644 --- a/frontend/today-completion.js +++ b/frontend/today-completion.js @@ -1,7 +1,7 @@ function createTodayCompletion({ todayWork, todaySync, workSession, refresh, warm, announce }) { - return function completeTodayItem(item) { + return function completeTodayItem(item, options = {}) { if (!item || !todayWork.remove(item)) { - announce('Could not update Today on this device. Try again.'); + announce(options.failureMessage || 'Could not update Today on this device. Try again.'); return false; } todaySync.enqueue('remove', todayWork.identity(item)); @@ -9,7 +9,7 @@ function createTodayCompletion({ todayWork, todaySync, workSession, refresh, war refresh(); warm(); workSession.complete(); - announce('Done for Today. The Gitea item is unchanged.'); + announce(options.successMessage || 'Done for Today. The Gitea item is unchanged.'); return true; }; } diff --git a/tests/test_later_sync.py b/tests/test_later_sync.py index de43640..26a549c 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-v68" in source + assert "stackchain-dashboard-shell-v69" 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 7baddca..d496471 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-v68" in worker + assert "stackchain-dashboard-shell-v69" in worker diff --git a/tests/test_mobile_composer_integration.py b/tests/test_mobile_composer_integration.py index ad7e42b..5af9149 100644 --- a/tests/test_mobile_composer_integration.py +++ b/tests/test_mobile_composer_integration.py @@ -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-v68" in worker + assert "stackchain-dashboard-shell-v69" in worker diff --git a/tests/test_offline_work.py b/tests/test_offline_work.py index c912a48..e2b970b 100644 --- a/tests/test_offline_work.py +++ b/tests/test_offline_work.py @@ -283,3 +283,21 @@ async def test_saved_requested_review_queues_complete_sha_scoped_feedback_offlin assert "progressFingerprint: localStorage.getItem(progress?.storageKey) || ''" in html assert "Review queued · it will submit after reconnect." in html assert "if (selectedReview && offlineReview) openReviewSheet(selectedReview, reviewTrigger);" in html + + +@pytest.mark.anyio +async def test_offline_today_review_queues_durably_before_completing_and_advancing(): + html = await dashboard() + + assert "function reviewingActiveTodayItem()" in html + assert "offlineReview && reviewingActiveTodayItem() ? 'Queue review & next'" in html + handler = html.split("qs('#submit-review').addEventListener('click'", 1)[1].split( + "qs('#continue-review-to-merge').addEventListener", 1 + )[0] + admission = handler.index("await authoredOutbox.enqueueDurably") + completion = handler.index("completeTodayItem(selectedReview") + assert admission < completion + assert "successMessage: 'Review queued. Next Today item opened.'" in handler + assert "failureMessage: 'Review queued, but Today still needs completion.'" in handler + assert "if (!advanced)" in handler + assert "Review queued, but Today still needs completion." in handler diff --git a/tests/test_plan_today.py b/tests/test_plan_today.py index 2d7e47d..2025db4 100644 --- a/tests/test_plan_today.py +++ b/tests/test_plan_today.py @@ -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(): source = SERVICE_WORKER.read_text() - assert "stackchain-dashboard-shell-v68" in source + assert "stackchain-dashboard-shell-v69" 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 08fb91a..052c1b5 100644 --- a/tests/test_service_worker.py +++ b/tests/test_service_worker.py @@ -121,16 +121,24 @@ async function dispatchNotificationClick(route) {{ def test_resumable_today_session_ships_in_a_new_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v68" in source + assert "stackchain-dashboard-shell-v69" in source assert "BASE + 'static/my-work.js'" in source assert "BASE + 'static/dashboard.js'" in source assert "BASE + 'static/dashboard.css'" in source +def test_offline_review_next_ships_today_completion_atomically(): + source = WORKER.read_text() + + assert "stackchain-dashboard-shell-v69" in source + assert "BASE + 'static/today-completion.js'" in source + assert "BASE + 'static/dashboard.js'" in source + + def test_duplicate_aware_capture_ships_in_a_new_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v68" in source + assert "stackchain-dashboard-shell-v69" in source assert "BASE + 'static/create-issue-sheet.js'" in source assert "BASE + 'static/dashboard.js'" in source @@ -138,14 +146,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-v68" in source + assert "stackchain-dashboard-shell-v69" 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-v68" in source + assert "stackchain-dashboard-shell-v69" in source assert "BASE + 'static/dashboard.css'" in source assert "BASE + 'static/dashboard.js'" in source assert "BASE + 'static/install-app.js'" in source @@ -154,21 +162,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-v68" in source + assert "stackchain-dashboard-shell-v69" 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-v68" in source + assert "stackchain-dashboard-shell-v69" 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-v68" in source + assert "stackchain-dashboard-shell-v69" in source assert "BASE + 'static/update-ownership.js'" in source @@ -334,7 +342,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-v68" in source + assert "stackchain-dashboard-shell-v69" in source assert "BASE + 'static/queue-today.js'" in source diff --git a/tests/test_today_sync.py b/tests/test_today_sync.py index 47ceb97..37ae5a9 100644 --- a/tests/test_today_sync.py +++ b/tests/test_today_sync.py @@ -86,7 +86,7 @@ sync.enqueue('add', 'issue:r:1:'); 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-v68" in source + assert "stackchain-dashboard-shell-v69" in source assert "BASE + 'static/today-sync.js'" in source diff --git a/tests/test_today_work.py b/tests/test_today_work.py index bdcf350..2517d62 100644 --- a/tests/test_today_work.py +++ b/tests/test_today_work.py @@ -148,6 +148,39 @@ process.stdout.write(JSON.stringify({{completed, calls}})); } +def test_today_completion_reports_context_specific_success_and_partial_failure(): + script = f""" +const createTodayCompletion = require({json.dumps(str(TODAY_COMPLETION))}); +const messages = []; +let removable = false; +const complete = createTodayCompletion({{ + todayWork: {{identity: () => 'review:r:7:', remove: () => removable}}, + todaySync: {{enqueue: () => true, flush: () => undefined}}, + refresh: () => undefined, + warm: () => undefined, + workSession: {{complete: () => undefined}}, + announce: message => messages.push(message), +}}); +const options = {{ + successMessage: 'Review queued. Next Today item opened.', + failureMessage: 'Review queued, but Today still needs completion.', +}}; +const failed = complete({{kind:'review', repository:'r', number:7}}, options); +removable = true; +const completed = complete({{kind:'review', repository:'r', number:7}}, options); +process.stdout.write(JSON.stringify({{failed, completed, messages}})); +""" + + assert json.loads(run_node(script)) == { + "failed": False, + "completed": True, + "messages": [ + "Review queued, but Today still needs completion.", + "Review queued. Next Today item opened.", + ], + } + + def test_done_for_today_logic_is_available_in_the_offline_app_shell(): assert "BASE + 'static/today-completion.js'" in SERVICE_WORKER.read_text() -- 2.43.0