feat: queue offline Today reviews and continue (#423)
All checks were successful
CI / lint (pull_request) Successful in 49s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy 2026-08-09 20:04:20 +00:00
parent 7b1c9a608c
commit ccb595a081
11 changed files with 94 additions and 19 deletions

View File

@ -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;

View File

@ -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;

View File

@ -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;
};
}

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():
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

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-v68" in worker
assert "stackchain-dashboard-shell-v69" 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]))
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

View File

@ -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

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():
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

View File

@ -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

View File

@ -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

View File

@ -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()