feat: sync Today plan across devices (#357)
This commit is contained in:
1 parent
3384e56cb8
commit
020106f001
13 files changed
+640
-13
No files matched your search
+42
-4
@@ -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() : '';
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
@@ -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;
|
||||
Reference in new issue
Block a user