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