diff --git a/README.md b/README.md
index 58df1b9..463f1c7 100644
--- a/README.md
+++ b/README.md
@@ -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
comments, pull-request comments, notification replies, and reviews) persist per-draft
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
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
diff --git a/frontend/dashboard.css b/frontend/dashboard.css
index 514dcb9..1d1d142 100644
--- a/frontend/dashboard.css
+++ b/frontend/dashboard.css
@@ -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 input { width:20px; height:20px; }
.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[hidden] { display:none; }
.install-app-card p { margin:0; flex:1 1 240px; }
diff --git a/frontend/dashboard.js b/frontend/dashboard.js
index 812f82c..7ef3ee5 100644
--- a/frontend/dashboard.js
+++ b/frontend/dashboard.js
@@ -226,6 +226,32 @@
const draftInbox = createDraftInbox({ storage: localStorage, getCurrentLogin: () => activeFlushLogin });
outboxCoordinator.subscribe(() => refreshMyWorkView());
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({
fetchJson: fetchReviewJson,
onItems: renderAvailableIssues,
@@ -390,6 +416,7 @@
addToday: item => {
const result = todayWork.add(item);
refreshMyWorkView();
+ if (result === 'added') warmTodayOffline();
return result;
},
onClaimed: item => {
@@ -1036,6 +1063,7 @@
(result === 'added' ? 'Added to Today without changing Gitea.' :
(result === 'exists' ? 'This item is already in Today.' : 'Could not save Today on this device.'));
refreshMyWorkView();
+ if (result === 'added') warmTodayOffline();
});
});
document.querySelectorAll('[data-today-remove]').forEach(button => {
@@ -1933,6 +1961,7 @@
notification_pagination: snapshot.notification_pagination,
});
updateOfflineWorkControls();
+ warmTodayOffline();
}
flushIssueOutbox();
flushAuthoredOutbox();
@@ -3095,6 +3124,8 @@
notification_pagination:notificationPagination });
}
updateOfflineWorkControls(keepWorkOffline.checked ? 'Offline saving enabled.' : 'Offline work data cleared.');
+ if (keepWorkOffline.checked) warmTodayOffline();
+ else offlineToday.cancel();
});
deliveryReceipts.addEventListener('change', async () => {
let enabled = deliveryReceipts.checked;
@@ -3110,8 +3141,13 @@
});
qs('#clear-offline-work').addEventListener('click', () => {
offlineWorkStore.clear();
+ offlineToday.cancel();
+ renderOfflineTodayStatus({ total:0, ready:0, failed:0, pending:0 });
updateOfflineWorkControls('Offline work data cleared.');
});
+ qs('#retry-offline-today').addEventListener('click', () =>
+ offlineToday.retry(confirmedOwnerLogin, todayMyWork)
+ );
updateOfflineWorkControls();
updateDeliveryReceiptControls();
if (!navigator.onLine) showOfflineStatus();
diff --git a/frontend/index.html b/frontend/index.html
index a08ebea..6e38b91 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -77,6 +77,10 @@
+
+
+
+
Install Stackchain
Keep mobile work one tap away and launch the saved app shell during an outage.
@@ -530,6 +534,7 @@
+
diff --git a/frontend/offline-today.js b/frontend/offline-today.js
new file mode 100644
index 0000000..56204fe
--- /dev/null
+++ b/frontend/offline-today.js
@@ -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;
+});
diff --git a/frontend/offline-work.js b/frontend/offline-work.js
index 76f2471..c200286 100644
--- a/frontend/offline-work.js
+++ b/frontend/offline-work.js
@@ -19,7 +19,7 @@
];
const DETAIL_FIELDS = [
'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'];
diff --git a/frontend/service-worker.js b/frontend/service-worker.js
index e8246de..183d208 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-v40';
+const CACHE = 'stackchain-dashboard-shell-v41';
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
const SHELL = [
BASE,
@@ -20,6 +20,7 @@ const SHELL = [
BASE + 'static/issue-outbox.js',
BASE + 'static/authored-outbox.js',
BASE + 'static/offline-work.js',
+ BASE + 'static/offline-today.js',
BASE + 'static/my-work.js',
BASE + 'static/today-work.js',
BASE + 'static/update-ownership.js',
diff --git a/tests/test_markdown_renderer.py b/tests/test_markdown_renderer.py
index 448a1b6..51f4990 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-v40" in worker
+ assert "stackchain-dashboard-shell-v41" in worker
diff --git a/tests/test_mobile_composer_integration.py b/tests/test_mobile_composer_integration.py
index bb696b8..5ce45c7 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-v40" in worker
+ assert "stackchain-dashboard-shell-v41" in worker
diff --git a/tests/test_offline_today.py b/tests/test_offline_today.py
new file mode 100644
index 0000000..c9ce67a
--- /dev/null
+++ b/tests/test_offline_today.py
@@ -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 '' 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
diff --git a/tests/test_service_worker.py b/tests/test_service_worker.py
index 76b55e0..2d71bb4 100644
--- a/tests/test_service_worker.py
+++ b/tests/test_service_worker.py
@@ -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-v40" in source
+ assert "stackchain-dashboard-shell-v41" 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-v40" in source
+ assert "stackchain-dashboard-shell-v41" 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-v40" in source
+ assert "stackchain-dashboard-shell-v41" 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/authored-outbox.js",
"/dashboard/static/offline-work.js",
+ "/dashboard/static/offline-today.js",
"/dashboard/static/my-work.js",
"/dashboard/static/today-work.js",
"/dashboard/static/update-ownership.js",