Merge pull request 'Keep the Today queue automatically ready offline' (#350) from timmy/349-offline-today-readiness into main
All checks were successful
CI / lint (push) Successful in 36s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s

This commit is contained in:
rockachopa 2026-08-08 23:17:49 +00:00
commit a995e3b45f
11 changed files with 267 additions and 8 deletions

View File

@ -32,7 +32,13 @@ and durable delivery flow. A different or unconfirmed account can only copy or d
the private content. Issue capture and authored mobile actions (issue the private content. Issue capture and authored mobile actions (issue
comments, pull-request comments, notification replies, and reviews) persist per-draft comments, pull-request comments, notification replies, and reviews) persist per-draft
idempotency keys, so retrying after a timeout, reload, process restart, or handoff to idempotency keys, so retrying after a timeout, reload, process restart, or handoff to
another worker replays a confirmed result instead of posting duplicate content. Closed-app another worker replays a confirmed result instead of posting duplicate content. When
**Keep My Work available offline** is enabled, every issue or pull request in the bounded
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
`updated_at` revisions make no detail request, transient failures retain the prior copy,
and pull-request diffs remain online-only.
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
with the unchanged idempotency key. Device purge cancels an active drain before closing with the unchanged idempotency key. Device purge cancels an active drain before closing

View File

@ -70,6 +70,9 @@ textarea { resize: vertical; min-height: 120px; }
.offline-work-controls label { display:flex; gap:8px; align-items:center; min-height:44px; } .offline-work-controls label { display:flex; gap:8px; align-items:center; min-height:44px; }
.offline-work-controls input { width:20px; height:20px; } .offline-work-controls input { width:20px; height:20px; }
.offline-work-controls button { min-height:44px; } .offline-work-controls button { min-height:44px; }
.offline-today-readiness { display:flex; gap:8px; align-items:center; flex-wrap:wrap; }
.offline-today-readiness[hidden] { display:none; }
.offline-today-readiness button { min-height:44px; }
.install-app-card { display:flex; gap:10px; align-items:center; flex-wrap:wrap; width:100%; padding:10px; border:1px solid #31577f; border-radius:12px; background:#10233a; } .install-app-card { display:flex; gap:10px; align-items:center; flex-wrap:wrap; width:100%; padding:10px; border:1px solid #31577f; border-radius:12px; background:#10233a; }
.install-app-card[hidden] { display:none; } .install-app-card[hidden] { display:none; }
.install-app-card p { margin:0; flex:1 1 240px; } .install-app-card p { margin:0; flex:1 1 240px; }

View File

@ -226,6 +226,32 @@
const draftInbox = createDraftInbox({ storage: localStorage, getCurrentLogin: () => activeFlushLogin }); const draftInbox = createDraftInbox({ storage: localStorage, getCurrentLogin: () => activeFlushLogin });
outboxCoordinator.subscribe(() => refreshMyWorkView()); outboxCoordinator.subscribe(() => refreshMyWorkView());
const offlineWorkStore = createOfflineWorkStore({ storage: localStorage }); const offlineWorkStore = createOfflineWorkStore({ storage: localStorage });
function renderOfflineTodayStatus(status) {
const container = qs('#offline-today-readiness');
const label = qs('#offline-today-status');
const retry = qs('#retry-offline-today');
const visible = offlineWorkStore.enabled() && status.total > 0;
container.hidden = !visible;
if (!visible) return;
label.textContent = 'Today offline: ' + status.ready + ' of ' + status.total + ' ready' +
(status.pending ? ' · saving ' + status.pending : '') +
(status.failed ? ' · retry ' + status.failed : '');
retry.hidden = status.failed === 0;
}
const offlineToday = createOfflineToday({
loadDetail: item => item.kind === 'pull' ? pullController.load(item) : issueController.load(item),
loadSavedDetail: (login, item) => offlineWorkStore.loadDetail(login, item),
saveDetail: (login, item, detail) => offlineWorkStore.saveDetail(login, item, detail),
onStatus: renderOfflineTodayStatus,
});
function warmTodayOffline() {
if (!offlineWorkStore.enabled() || !confirmedOwnerLogin || offlineWorkMode) {
offlineToday.cancel();
renderOfflineTodayStatus({ total:0, ready:0, failed:0, pending:0 });
return Promise.resolve();
}
return offlineToday.warm(confirmedOwnerLogin, todayMyWork);
}
const findWorkController = createFindWork({ const findWorkController = createFindWork({
fetchJson: fetchReviewJson, fetchJson: fetchReviewJson,
onItems: renderAvailableIssues, onItems: renderAvailableIssues,
@ -390,6 +416,7 @@
addToday: item => { addToday: item => {
const result = todayWork.add(item); const result = todayWork.add(item);
refreshMyWorkView(); refreshMyWorkView();
if (result === 'added') warmTodayOffline();
return result; return result;
}, },
onClaimed: item => { onClaimed: item => {
@ -1036,6 +1063,7 @@
(result === 'added' ? 'Added to Today without changing Gitea.' : (result === 'added' ? 'Added to Today without changing Gitea.' :
(result === 'exists' ? 'This item is already in Today.' : 'Could not save Today on this device.')); (result === 'exists' ? 'This item is already in Today.' : 'Could not save Today on this device.'));
refreshMyWorkView(); refreshMyWorkView();
if (result === 'added') warmTodayOffline();
}); });
}); });
document.querySelectorAll('[data-today-remove]').forEach(button => { document.querySelectorAll('[data-today-remove]').forEach(button => {
@ -1933,6 +1961,7 @@
notification_pagination: snapshot.notification_pagination, notification_pagination: snapshot.notification_pagination,
}); });
updateOfflineWorkControls(); updateOfflineWorkControls();
warmTodayOffline();
} }
flushIssueOutbox(); flushIssueOutbox();
flushAuthoredOutbox(); flushAuthoredOutbox();
@ -3095,6 +3124,8 @@
notification_pagination:notificationPagination }); notification_pagination:notificationPagination });
} }
updateOfflineWorkControls(keepWorkOffline.checked ? 'Offline saving enabled.' : 'Offline work data cleared.'); updateOfflineWorkControls(keepWorkOffline.checked ? 'Offline saving enabled.' : 'Offline work data cleared.');
if (keepWorkOffline.checked) warmTodayOffline();
else offlineToday.cancel();
}); });
deliveryReceipts.addEventListener('change', async () => { deliveryReceipts.addEventListener('change', async () => {
let enabled = deliveryReceipts.checked; let enabled = deliveryReceipts.checked;
@ -3110,8 +3141,13 @@
}); });
qs('#clear-offline-work').addEventListener('click', () => { qs('#clear-offline-work').addEventListener('click', () => {
offlineWorkStore.clear(); offlineWorkStore.clear();
offlineToday.cancel();
renderOfflineTodayStatus({ total:0, ready:0, failed:0, pending:0 });
updateOfflineWorkControls('Offline work data cleared.'); updateOfflineWorkControls('Offline work data cleared.');
}); });
qs('#retry-offline-today').addEventListener('click', () =>
offlineToday.retry(confirmedOwnerLogin, todayMyWork)
);
updateOfflineWorkControls(); updateOfflineWorkControls();
updateDeliveryReceiptControls(); updateDeliveryReceiptControls();
if (!navigator.onLine) showOfflineStatus(); if (!navigator.onLine) showOfflineStatus();

View File

@ -77,6 +77,10 @@
<label for="delivery-receipts"><input id="delivery-receipts" type="checkbox" /> Notify me when queued work finishes</label> <label for="delivery-receipts"><input id="delivery-receipts" type="checkbox" /> Notify me when queued work finishes</label>
<button id="clear-offline-work" type="button">Clear offline work data</button> <button id="clear-offline-work" type="button">Clear offline work data</button>
<span class="small" id="offline-work-status" role="status" aria-live="polite"></span> <span class="small" id="offline-work-status" role="status" aria-live="polite"></span>
<span class="offline-today-readiness" id="offline-today-readiness" hidden>
<span class="small" id="offline-today-status" role="status" aria-live="polite"></span>
<button id="retry-offline-today" type="button" hidden>Retry offline items</button>
</span>
<span class="small" id="delivery-receipt-status" role="status" aria-live="polite"></span> <span class="small" id="delivery-receipt-status" role="status" aria-live="polite"></span>
<div class="install-app-card" id="install-app-card" hidden> <div class="install-app-card" id="install-app-card" hidden>
<p><strong>Install Stackchain</strong><br><span class="small">Keep mobile work one tap away and launch the saved app shell during an outage.</span></p> <p><strong>Install Stackchain</strong><br><span class="small">Keep mobile work one tap away and launch the saved app shell during an outage.</span></p>
@ -530,6 +534,7 @@
<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/offline-work.js"></script> <script src="static/offline-work.js"></script>
<script src="static/offline-today.js"></script>
<script src="static/my-work.js"></script> <script src="static/my-work.js"></script>
<script src="static/today-work.js"></script> <script src="static/today-work.js"></script>
<script src="static/update-ownership.js"></script> <script src="static/update-ownership.js"></script>

83
frontend/offline-today.js Normal file
View File

@ -0,0 +1,83 @@
(function (root, factory) {
const api = factory();
if (typeof module !== 'undefined' && module.exports) module.exports = api;
else root.createOfflineToday = api;
})(typeof self !== 'undefined' ? self : this, function () {
'use strict';
const itemKey = item => [item?.kind, item?.repository, Number(item?.number || 0)].join(':');
function createOfflineToday({
loadDetail, loadSavedDetail, saveDetail, onStatus = () => {}, concurrency = 2, maxItems = 5,
}) {
let failed = new Set();
let generation = 0;
function bounded(items) {
return (Array.isArray(items) ? items : []).filter(item =>
['issue', 'pull'].includes(item?.kind) && item?.repository && Number(item?.number) > 0
).slice(0, Math.max(1, maxItems));
}
async function run(login, sourceItems, onlyFailed) {
login = String(login || '').trim();
const items = bounded(sourceItems);
const runGeneration = ++generation;
if (!login || !items.length) {
failed = new Set();
const empty = { total: items.length, ready: 0, failed: 0, pending: 0 };
onStatus(empty);
return empty;
}
const previousFailed = failed;
const readyKeys = new Set(items.filter(item => loadSavedDetail(login, item)).map(itemKey));
const snapshot = pending => ({
total: items.length, ready: readyKeys.size, failed: failed.size, pending,
});
const candidates = items.filter(item => {
const key = itemKey(item);
if (onlyFailed && !previousFailed.has(key)) return false;
const saved = loadSavedDetail(login, item);
return onlyFailed || !saved || saved.source_updated_at !== item.updated_at;
});
failed = new Set();
onStatus(snapshot(candidates.length));
let cursor = 0;
async function worker() {
while (cursor < candidates.length && runGeneration === generation) {
const item = candidates[cursor++];
try {
const detail = await loadDetail(item);
if (runGeneration !== generation) return;
const saved = saveDetail(login, item, { ...detail, source_updated_at: item.updated_at });
if (saved !== false) readyKeys.add(itemKey(item));
} catch (_error) {
if (runGeneration === generation) failed.add(itemKey(item));
}
if (runGeneration === generation) {
onStatus(snapshot(Math.max(0, candidates.length - cursor)));
}
}
}
await Promise.all(Array.from(
{ length: Math.min(Math.max(1, concurrency), candidates.length) }, worker
));
if (runGeneration !== generation) return snapshot(0);
const status = snapshot(0);
onStatus(status);
return status;
}
return {
warm: (login, items) => run(login, items, false),
retry: (login, items) => run(login, items, true),
cancel() { generation += 1; failed = new Set(); },
failedKeys: () => Array.from(failed),
};
}
return createOfflineToday;
});

View File

@ -19,7 +19,7 @@
]; ];
const DETAIL_FIELDS = [ const DETAIL_FIELDS = [
'title', 'body', 'state', 'labels', 'assignees', 'author', 'url', 'due_date', 'milestone', 'title', 'body', 'state', 'labels', 'assignees', 'author', 'url', 'due_date', 'milestone',
'id', 'repository', 'subject_type', 'subject_body', 'id', 'repository', 'subject_type', 'subject_body', 'source_updated_at',
]; ];
const COMMENT_FIELDS = ['id', 'author', 'body', 'created_at', 'updated_at', 'url']; const COMMENT_FIELDS = ['id', 'author', 'body', 'created_at', 'updated_at', 'url'];

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-v40'; const CACHE = 'stackchain-dashboard-shell-v41';
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]); const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
const SHELL = [ const SHELL = [
BASE, BASE,
@ -20,6 +20,7 @@ const SHELL = [
BASE + 'static/issue-outbox.js', BASE + 'static/issue-outbox.js',
BASE + 'static/authored-outbox.js', BASE + 'static/authored-outbox.js',
BASE + 'static/offline-work.js', BASE + 'static/offline-work.js',
BASE + 'static/offline-today.js',
BASE + 'static/my-work.js', BASE + 'static/my-work.js',
BASE + 'static/today-work.js', BASE + 'static/today-work.js',
BASE + 'static/update-ownership.js', BASE + 'static/update-ownership.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-v40" in worker assert "stackchain-dashboard-shell-v41" 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-v40" in worker assert "stackchain-dashboard-shell-v41" in worker

124
tests/test_offline_today.py Normal file
View File

@ -0,0 +1,124 @@
import json
import subprocess
from pathlib import Path
import pytest
from tests.dashboard_bundle import dashboard
OFFLINE_TODAY = Path(__file__).parents[1] / "frontend" / "offline-today.js"
def run_scenario(scenario: str) -> dict:
script = f"""
const createOfflineToday = require({json.dumps(str(OFFLINE_TODAY))});
(async () => {{
{scenario}
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
return json.loads(result.stdout)
def test_warms_bounded_today_queue_with_two_concurrent_requests():
result = run_scenario("""
let active = 0;
let peak = 0;
const loaded = [];
const saved = [];
const items = Array.from({length:7}, (_, index) => ({
kind:index % 2 ? 'pull' : 'issue', repository:'stackchain/dashboard', number:index + 1,
updated_at:'2026-08-08T12:0' + index + ':00Z',
}));
const warmer = createOfflineToday({
loadDetail: async item => {
active += 1;
peak = Math.max(peak, active);
loaded.push(item.number);
await new Promise(resolve => setTimeout(resolve, 5));
active -= 1;
return {title:'Work ' + item.number, body:'Context ' + item.number};
},
loadSavedDetail: () => null,
saveDetail: (login, item, detail) => saved.push({login, number:item.number, detail}),
});
const status = await warmer.warm('timmy', items);
process.stdout.write(JSON.stringify({peak, loaded, saved, status}));
""")
assert result["peak"] == 2
assert result["loaded"] == [1, 2, 3, 4, 5]
assert [entry["number"] for entry in result["saved"]] == [1, 2, 3, 4, 5]
assert result["saved"][0]["detail"]["source_updated_at"] == "2026-08-08T12:00:00Z"
assert result["status"] == {"total": 5, "ready": 5, "failed": 0, "pending": 0}
def test_refreshes_only_changed_revisions_and_keeps_prior_copy_on_failure():
result = run_scenario("""
const items = [
{kind:'issue', repository:'stackchain/dashboard', number:1, updated_at:'new'},
{kind:'pull', repository:'stackchain/dashboard', number:2, updated_at:'same'},
];
const old = new Map([
['issue:stackchain/dashboard:1', {title:'Prior 1', source_updated_at:'old'}],
['pull:stackchain/dashboard:2', {title:'Prior 2', source_updated_at:'same'}],
]);
const loaded = [];
const warmer = createOfflineToday({
loadDetail: async item => { loaded.push(item.number); throw new Error('temporary outage'); },
loadSavedDetail: (_login, item) => old.get(item.kind + ':' + item.repository + ':' + item.number),
saveDetail: () => { throw new Error('must not replace a prior copy after failure'); },
});
const status = await warmer.warm('timmy', items);
process.stdout.write(JSON.stringify({loaded, status, failed:warmer.failedKeys()}));
""")
assert result == {
"loaded": [1],
"status": {"total": 2, "ready": 2, "failed": 1, "pending": 0},
"failed": ["issue:stackchain/dashboard:1"],
}
def test_retry_fetches_only_failed_items():
result = run_scenario("""
const items = [
{kind:'issue', repository:'stackchain/dashboard', number:1, updated_at:'v2'},
{kind:'issue', repository:'stackchain/dashboard', number:2, updated_at:'v1'},
];
let fail = true;
const calls = [];
const saved = new Map([['issue:stackchain/dashboard:2', {source_updated_at:'v1'}]]);
const warmer = createOfflineToday({
loadDetail: async item => {
calls.push(item.number);
if (fail) throw new Error('offline');
return {title:'Fresh'};
},
loadSavedDetail: (_login, item) => saved.get(item.kind + ':' + item.repository + ':' + item.number),
saveDetail: (_login, item, detail) => saved.set(item.kind + ':' + item.repository + ':' + item.number, detail),
});
await warmer.warm('timmy', items);
fail = false;
const status = await warmer.retry('timmy', items);
process.stdout.write(JSON.stringify({calls, status}));
""")
assert result["calls"] == [1, 1]
assert result["status"] == {"total": 2, "ready": 2, "failed": 0, "pending": 0}
@pytest.mark.anyio
async def test_dashboard_warms_today_after_fresh_snapshot_and_exposes_mobile_retry():
html = await dashboard()
assert '<script src="static/offline-today.js"></script>' in html
assert 'id="offline-today-status"' in html
assert 'id="retry-offline-today"' in html
assert "offlineToday.warm(confirmedOwnerLogin, todayMyWork)" in html
assert "offlineToday.retry(confirmedOwnerLogin, todayMyWork)" in html
assert "Today offline: " in html
assert ".offline-today-readiness button { min-height:44px;" 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-v40" in source assert "stackchain-dashboard-shell-v41" 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-v40" in source assert "stackchain-dashboard-shell-v41" 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-v40" in source assert "stackchain-dashboard-shell-v41" in source
assert "BASE + 'static/update-ownership.js'" in source assert "BASE + 'static/update-ownership.js'" in source
@ -304,6 +304,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
"/dashboard/static/issue-outbox.js", "/dashboard/static/issue-outbox.js",
"/dashboard/static/authored-outbox.js", "/dashboard/static/authored-outbox.js",
"/dashboard/static/offline-work.js", "/dashboard/static/offline-work.js",
"/dashboard/static/offline-today.js",
"/dashboard/static/my-work.js", "/dashboard/static/my-work.js",
"/dashboard/static/today-work.js", "/dashboard/static/today-work.js",
"/dashboard/static/update-ownership.js", "/dashboard/static/update-ownership.js",