feat: keep Today work ready offline (#349)
This commit is contained in:
1 parent
e11e55149c
commit
ac3d1f32d1
11 files changed
+267
-8
No files matched your search
@@ -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; }
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
@@ -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'];
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in new issue
Block a user