feat: keep Today work ready offline (#349)
CI / lint (pull_request) Successful in 37s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy committed 2026-08-08 23:16:22 +00:00
1 parent e11e55149c
commit ac3d1f32d1
11 files changed
+267 -8

No files matched your search

+3
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 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; }
+36
View File
@@ -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();
+5
View File
@@ -77,6 +77,10 @@
<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>
<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>
<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>
@@ -530,6 +534,7 @@
<script src="static/issue-outbox.js"></script>
<script src="static/authored-outbox.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/today-work.js"></script>
<script src="static/update-ownership.js"></script>
+83
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;
});
+1 -1
View File
@@ -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'];
+2 -1
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-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',