feat: sync Later deferrals across devices (#363)
CI / lint (pull_request) Successful in 43s
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 02:37:32 +00:00
1 parent 4bc2881a4b
commit 454022ee89
14 files changed
+706 -19

No files matched your search

+24
View File
@@ -160,11 +160,33 @@
const laterWork = createLaterWork({
storage: localStorage,
getLogin: () => planningOwnerLogin,
onChange: (action, itemId, wakeAt) => {
if (laterSync.enqueue(action, itemId, wakeAt)) laterSync.flush();
},
onExpire: ids => {
const queued = ids.map(id => laterSync.enqueue('restore', id)).every(Boolean);
if (queued) laterSync.flush();
},
onWake: () => {
qs('#my-work-action-status').textContent = 'Deferred work is ready again.';
refreshMyWorkView();
},
});
const laterSync = createLaterSync({
storage: localStorage,
getLogin: () => planningOwnerLogin,
fetchJson: fetchReviewJson,
onRemoteRecords: records => {
if (!planningOwnerLogin || !laterWork.adopt(records)) return;
refreshMyWorkView();
},
onStatus: state => {
qs('#later-sync-status').textContent = state === 'saved' ? 'Later saved to account.' :
(state === 'pending' ? 'Later saved on this device · sync pending.' :
'Later sync unavailable · changes stay on this device.');
},
});
laterSync.startLifecycle({ window, document });
document.addEventListener('visibilitychange', () => {
if (!document.hidden) refreshMyWorkView();
});
@@ -2017,6 +2039,8 @@
if (planningOwnerLogin) {
todaySync.migrate(todayWork.read());
todaySync.flush();
laterSync.migrate(laterWork.read());
laterSync.flush();
}
const contextIdentityFresh = !snapshot.context.error && !contextFreshness?.stale &&
!contextFreshness?.degraded && !contextFreshness?.revalidating;
+2
View File
@@ -100,6 +100,7 @@
<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>
<div class="small" id="later-sync-status" aria-live="polite">Later 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>
@@ -542,6 +543,7 @@
<script src="static/today-sync.js"></script>
<script src="static/update-ownership.js"></script>
<script src="static/later-work.js"></script>
<script src="static/later-sync.js"></script>
<script src="static/detail-defer.js"></script>
<script src="static/pick-work.js"></script>
<script src="static/conversation.js"></script>
+164
View File
@@ -0,0 +1,164 @@
function createLaterSync({ storage, getLogin, fetchJson, onRemoteRecords, onStatus, createOperationId, createChannel }) {
const prefix = 'stackchain.later-sync.v1.';
const migrationPrefix = 'stackchain.later-sync-migrated.v1.';
const snapshotPrefix = 'stackchain.later-sync-snapshot.v1.';
let flushing = null;
let channel = null;
let channelKey = '';
function key() {
const login = String(getLogin?.() || '').trim().toLowerCase();
return login ? prefix + encodeURIComponent(login) : '';
}
function snapshotKey() {
const storageKey = key();
return storageKey ? snapshotPrefix + storageKey.slice(prefix.length) : '';
}
function savedRevision() {
try {
const snapshot = JSON.parse(storage?.getItem(snapshotKey()) || 'null');
return Number.isInteger(snapshot?.revision) ? snapshot.revision : -1;
} catch (_error) {
return -1;
}
}
function validRecords(records) {
return records && typeof records === 'object' && !Array.isArray(records);
}
function adopt(plan, broadcast = true) {
if (!Number.isInteger(plan?.revision) || !validRecords(plan?.records)) return false;
if (plan.revision < savedRevision()) return false;
const snapshot = { revision: plan.revision, records: plan.records };
try {
storage?.setItem(snapshotKey(), JSON.stringify(snapshot));
} catch (_error) {
// Server truth remains usable in this tab when storage is unavailable.
}
onRemoteRecords?.(plan.records);
if (broadcast) channel?.postMessage(snapshot);
return true;
}
function ensureChannel() {
const storageKey = key();
if (!storageKey || channelKey === storageKey) return;
channel?.close?.();
const factory = createChannel || (globalThis.window?.BroadcastChannel
? name => new globalThis.window.BroadcastChannel(name)
: null);
channelKey = storageKey;
channel = factory?.('stackchain-later-' + storageKey.slice(prefix.length)) || null;
channel?.addEventListener?.('message', event => {
if (key() === storageKey) adopt(event.data, false);
});
}
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' &&
['defer', 'restore'].includes(operation.action) &&
typeof operation.item_id === 'string' &&
(operation.action === 'restore' || typeof operation.wake_at === '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, wakeAt = null) {
if (!['defer', 'restore'].includes(action) || !itemId ||
(action === 'defer' && typeof wakeAt !== 'string')) return false;
const operations = pending().filter(operation => operation.item_id !== itemId);
operations.push({ operation_id: operationId(), action, item_id: itemId, wake_at: wakeAt });
const saved = save(operations);
onStatus?.(saved ? 'pending' : 'error');
return saved;
}
function migrate(records) {
const storageKey = key();
if (!storageKey || !storage) return false;
const marker = migrationPrefix + storageKey.slice(prefix.length);
try {
if (storage.getItem(marker)) return false;
Object.entries(records || {}).forEach(([itemId, wakeAt]) =>
enqueue('defer', itemId, wakeAt)
);
storage.setItem(marker, '1');
return true;
} catch (_error) {
return false;
}
}
async function run() {
if (!key()) return false;
ensureChannel();
try {
let plan = await fetchJson('api/v1/later');
let operations = pending();
while (operations.length) {
const operation = operations[0];
plan = await fetchJson('api/v1/later', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(operation),
});
const remaining = pending();
const delivered = remaining.findIndex(candidate => candidate.operation_id === operation.operation_id);
if (delivered >= 0 && !save(remaining.filter((_, index) => index !== delivered))) {
throw new Error('Could not persist Later delivery receipt');
}
operations = pending();
}
adopt(plan);
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;
}
function startLifecycle({ window: windowObject, document: documentObject }) {
windowObject?.addEventListener?.('online', flush);
documentObject?.addEventListener?.('visibilitychange', () =>
documentObject.hidden ? false : flush()
);
}
return { enqueue, migrate, flush, pending, startLifecycle };
}
if (typeof module !== 'undefined' && module.exports) module.exports = createLaterSync;
+27 -7
View File
@@ -1,4 +1,4 @@
function createLaterWork({ storage, getLogin, now = () => new Date(), setTimer = setTimeout, clearTimer = clearTimeout, onWake = () => {} }) {
function createLaterWork({ storage, getLogin, now = () => new Date(), setTimer = setTimeout, clearTimer = clearTimeout, onWake = () => {}, onChange = () => {}, onExpire = () => {} }) {
const prefix = 'stackchain.later-work.v1.';
let timer = null;
@@ -45,8 +45,11 @@ function createLaterWork({ storage, getLogin, now = () => new Date(), setTimer =
if (!key) return 'unavailable';
if (!id || Number.isNaN(wake.getTime()) || wake <= now()) return 'invalid';
const records = read();
records[id] = wake.toISOString();
return write(records) ? 'deferred' : 'unavailable';
const wakeAt = wake.toISOString();
records[id] = wakeAt;
if (!write(records)) return 'unavailable';
onChange('defer', id, wakeAt);
return 'deferred';
}
function presetUntil(preset) {
@@ -66,10 +69,21 @@ function createLaterWork({ storage, getLogin, now = () => new Date(), setTimer =
const records = read();
if (!id || !Object.prototype.hasOwnProperty.call(records, id)) return false;
delete records[id];
write(records);
if (!write(records)) return false;
onChange('restore', id, null);
return true;
}
function adopt(records) {
if (!records || typeof records !== 'object' || Array.isArray(records)) return false;
const normalized = {};
Object.entries(records).forEach(([id, wake]) => {
const wakeTime = new Date(wake).getTime();
if (id && Number.isFinite(wakeTime)) normalized[id] = new Date(wakeTime).toISOString();
});
return write(normalized);
}
function schedule(wakeTimes, current) {
if (timer !== null) clearTimer(timer);
timer = null;
@@ -87,16 +101,22 @@ function createLaterWork({ storage, getLogin, now = () => new Date(), setTimer =
const available = new Map((items || []).map(item => [identity(item), item]));
const retained = {};
const wakeTimes = [];
const expired = [];
Object.entries(records).forEach(([id, wake]) => {
const wakeTime = new Date(wake).getTime();
if (!Number.isFinite(wakeTime) || wakeTime <= current) return;
if (!Number.isFinite(wakeTime) || wakeTime <= current) {
if (Number.isFinite(wakeTime)) expired.push(id);
return;
}
if (pruneMissing && !available.has(id)) return;
retained[id] = new Date(wakeTime).toISOString();
wakeTimes.push(wakeTime);
});
if (JSON.stringify(retained) !== JSON.stringify(records)) write(retained);
if (JSON.stringify(retained) !== JSON.stringify(records) && write(retained) && expired.length) {
onExpire(expired);
}
schedule(wakeTimes, current);
const deferredIds = new Set(Object.keys(retained));
const active = (items || []).filter(item => !deferredIds.has(identity(item)));
@@ -107,7 +127,7 @@ function createLaterWork({ storage, getLogin, now = () => new Date(), setTimer =
return { active, later };
}
return { identity, defer, restore, presetUntil, partition };
return { identity, read, adopt, defer, restore, presetUntil, partition };
}
if (typeof module !== 'undefined' && module.exports) module.exports = createLaterWork;
+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-v47';
const CACHE = 'stackchain-dashboard-shell-v48';
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000;
const SHELL = [
@@ -28,6 +28,7 @@ const SHELL = [
BASE + 'static/today-sync.js',
BASE + 'static/update-ownership.js',
BASE + 'static/later-work.js',
BASE + 'static/later-sync.js',
BASE + 'static/detail-defer.js',
BASE + 'static/pick-work.js',
BASE + 'static/conversation.js',