feat: sync Today plan across devices (#357)
CI / lint (pull_request) Successful in 38s
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-09 00:39:08 +00:00
1 parent 3384e56cb8
commit 020106f001
13 files changed
+640 -13

No files matched your search

+42 -4
View File
@@ -139,6 +139,23 @@
storage: localStorage,
getLogin: () => planningOwnerLogin,
});
const todaySync = createTodaySync({
storage: localStorage,
getLogin: () => planningOwnerLogin,
fetchJson: fetchReviewJson,
onRemoteIds: ids => {
if (!planningOwnerLogin || !todayWork.replace(ids)) return;
refreshMyWorkView();
warmTodayOffline();
},
onStatus: state => {
const status = qs('#today-sync-status');
status.textContent = state === 'saved' ? 'Today saved to account.' :
(state === 'pending' ? 'Today saved on this device · sync pending.' :
(state === 'full' ? 'Another device filled Today · showing its saved plan.' :
'Today sync unavailable · changes stay on this device.'));
},
});
const laterWork = createLaterWork({
storage: localStorage,
getLogin: () => planningOwnerLogin,
@@ -424,7 +441,11 @@
addToday: item => {
const result = todayWork.add(item);
refreshMyWorkView();
if (result === 'added') warmTodayOffline();
if (result === 'added') {
todaySync.enqueue('add', todayWork.identity(item));
todaySync.flush();
warmTodayOffline();
}
return result;
},
onClaimed: item => {
@@ -1077,19 +1098,32 @@
(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();
if (result === 'added') {
const item = lastMyWork[Number(button.dataset.workIndex)];
todaySync.enqueue('add', todayWork.identity(item));
todaySync.flush();
warmTodayOffline();
}
});
});
document.querySelectorAll('[data-today-remove]').forEach(button => {
button.addEventListener('click', () => {
todayWork.remove(lastMyWork[Number(button.dataset.workIndex)]);
const item = lastMyWork[Number(button.dataset.workIndex)];
if (todayWork.remove(item)) {
todaySync.enqueue('remove', todayWork.identity(item));
todaySync.flush();
}
qs('#my-work-action-status').textContent = 'Removed from Today without changing Gitea.';
refreshMyWorkView();
});
});
document.querySelectorAll('[data-today-move]').forEach(button => {
button.addEventListener('click', () => {
todayWork.move(lastMyWork[Number(button.dataset.workIndex)], button.dataset.todayMove);
const item = lastMyWork[Number(button.dataset.workIndex)];
if (todayWork.move(item, button.dataset.todayMove)) {
todaySync.enqueue('move', todayWork.identity(item), button.dataset.todayMove);
todaySync.flush();
}
refreshMyWorkView();
document.querySelector('[data-today-move="' + button.dataset.todayMove + '"][data-work-index="' + button.dataset.workIndex + '"]')?.focus();
});
@@ -1971,6 +2005,10 @@
String(snapshot.context.user?.login || '').trim() : '';
planningOwnerLogin = retainedPlanningLogin;
updatePlanningAvailability();
if (planningOwnerLogin) {
todaySync.migrate(todayWork.read());
todaySync.flush();
}
const contextIdentityFresh = !snapshot.context.error && !contextFreshness?.stale &&
!contextFreshness?.degraded && !contextFreshness?.revalidating;
activeFlushLogin = contextIdentityFresh ? String(snapshot.context.user?.login || '').trim() : '';
+2
View File
@@ -99,6 +99,7 @@
<div class="small" id="notification-page-status" aria-live="polite"></div>
<button class="load-more-notifications" id="load-more-notifications" type="button" hidden>Load older updates</button>
<div class="small" id="my-work-action-status" aria-live="assertive"></div>
<div class="small" id="today-sync-status" aria-live="polite">Today is saved on this device.</div>
<button class="retry-work-route" id="retry-work-route" type="button" hidden>Retry shared work item</button>
<div class="small" id="work-route-share-status" aria-live="polite"></div>
<div class="my-work-bulk" id="bulk-mark-read-bar" hidden>
@@ -538,6 +539,7 @@
<script src="static/offline-today.js"></script>
<script src="static/my-work.js"></script>
<script src="static/today-work.js"></script>
<script src="static/today-sync.js"></script>
<script src="static/update-ownership.js"></script>
<script src="static/later-work.js"></script>
<script src="static/detail-defer.js"></script>
+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-v44';
const CACHE = 'stackchain-dashboard-shell-v45';
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000;
const SHELL = [
@@ -25,6 +25,7 @@ const SHELL = [
BASE + 'static/offline-today.js',
BASE + 'static/my-work.js',
BASE + 'static/today-work.js',
BASE + 'static/today-sync.js',
BASE + 'static/update-ownership.js',
BASE + 'static/later-work.js',
BASE + 'static/detail-defer.js',
+106
View File
@@ -0,0 +1,106 @@
function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onStatus, createOperationId }) {
const prefix = 'stackchain.today-sync.v1.';
const migrationPrefix = 'stackchain.today-sync-migrated.v1.';
let flushing = null;
function key() {
const login = String(getLogin?.() || '').trim().toLowerCase();
return login ? prefix + encodeURIComponent(login) : '';
}
function pending() {
const storageKey = key();
if (!storageKey || !storage) return [];
try {
const value = JSON.parse(storage.getItem(storageKey) || '[]');
return Array.isArray(value) ? value.filter(operation =>
operation && typeof operation.operation_id === 'string' &&
['add', 'remove', 'move'].includes(operation.action) &&
typeof operation.item_id === 'string'
) : [];
} catch (_error) {
return [];
}
}
function save(operations) {
const storageKey = key();
if (!storageKey || !storage) return false;
try {
if (operations.length) storage.setItem(storageKey, JSON.stringify(operations));
else storage.removeItem(storageKey);
return true;
} catch (_error) {
return false;
}
}
function operationId() {
if (createOperationId) return createOperationId();
if (globalThis.crypto?.randomUUID) return globalThis.crypto.randomUUID();
return Date.now().toString(36) + '-' + Math.random().toString(36).slice(2);
}
function enqueue(action, itemId, direction = null) {
const operations = pending();
operations.push({ operation_id: operationId(), action, item_id: itemId, direction });
const saved = save(operations);
onStatus?.(saved ? 'pending' : 'error');
return saved;
}
function migrate(ids) {
const storageKey = key();
if (!storageKey || !storage) return false;
const marker = migrationPrefix + storageKey.slice(prefix.length);
try {
if (storage.getItem(marker)) return false;
for (const id of ids || []) enqueue('add', id);
storage.setItem(marker, '1');
return true;
} catch (_error) {
return false;
}
}
async function run() {
if (!key()) return false;
try {
let plan = await fetchJson('api/v1/today');
const operations = pending();
for (const operation of operations) {
try {
plan = await fetchJson('api/v1/today', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(operation),
});
} catch (error) {
if (error?.status !== 409) throw error;
const rejected = pending();
save(rejected.filter(candidate => candidate.operation_id !== operation.operation_id));
onRemoteIds?.(Array.isArray(plan.ids) ? plan.ids : []);
onStatus?.('full');
return false;
}
const remaining = pending();
if (remaining[0]?.operation_id === operation.operation_id) save(remaining.slice(1));
}
onRemoteIds?.(Array.isArray(plan.ids) ? plan.ids : []);
onStatus?.(pending().length ? 'pending' : 'saved');
return true;
} catch (_error) {
onStatus?.(pending().length ? 'pending' : 'error');
return false;
}
}
function flush() {
if (!flushing) flushing = run().finally(() => { flushing = null; });
return flushing;
}
return { enqueue, migrate, flush, pending };
}
if (typeof module !== 'undefined' && module.exports) module.exports = createTodaySync;
+11 -1
View File
@@ -48,6 +48,16 @@ function createTodayWork({ storage, getLogin, limit = 5 }) {
return write(ids) ? 'added' : 'unavailable';
}
function replace(ids) {
const unique = [];
for (const id of ids || []) {
if (typeof id === 'string' && id && !unique.includes(id) && unique.length < limit) {
unique.push(id);
}
}
return write(unique);
}
function remove(item) {
const id = identity(item);
const ids = read();
@@ -85,7 +95,7 @@ function createTodayWork({ storage, getLogin, limit = 5 }) {
};
}
return { identity, add, remove, move, reconcile, contains, position, limit };
return { identity, read, replace, add, remove, move, reconcile, contains, position, limit };
}
if (typeof module !== 'undefined' && module.exports) module.exports = createTodayWork;