function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onRemotePlan, onStatus, createOperationId, createChannel, coordinator, setTimer = globalThis.setTimeout, clearTimer = globalThis.clearTimeout, retryBaseMs = 1000, retryMaxMs = 30000, now = Date.now, maxOfflineMs = 30 * 24 * 60 * 60 * 1000 }) { const prefix = 'stackchain.today-sync.v1.'; const migrationPrefix = 'stackchain.today-sync-migrated.v1.'; const snapshotPrefix = 'stackchain.today-sync-snapshot.v1.'; let flushing = null; let channel = null; let channelKey = ''; let retryTimer = null; let retryAttempt = 0; let expiredCount = 0; let discardedCount = 0; let recoveryNotice = { discarded: 0, until: 0 }; const knownOperationKeys = new Set(); function cancelRetry() { if (retryTimer !== null) clearTimer?.(retryTimer); retryTimer = null; } function scheduleRetry(error, ownerKey) { if (retryTimer !== null || !pending().length || !ownerKey) return; const advised = Number(error?.retryAfter); const delayMs = Number.isFinite(advised) && advised >= 0 ? advised * 1000 : Math.min(retryMaxMs, retryBaseMs * (2 ** retryAttempt)); retryAttempt += 1; onStatus?.('retrying', { delayMs }); retryTimer = setTimer?.(async () => { retryTimer = null; if (key() !== ownerKey) return false; return flush(); }, delayMs); retryTimer?.unref?.(); } 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 adopt(plan, broadcast = true) { if (!Number.isInteger(plan?.revision) || !Array.isArray(plan?.ids)) return false; if (plan.revision < savedRevision()) return false; const snapshot = { revision: plan.revision, ids: plan.ids, capacity_minutes: plan.capacity_minutes ?? null, estimates: plan.estimates || {}, }; if (['coaching', 'complete'].includes(plan.first_task_state)) { snapshot.first_task_state = plan.first_task_state; } if (plan.plan_date) { snapshot.plan_date = plan.plan_date; snapshot.timezone = plan.timezone || null; } try { storage?.setItem(snapshotKey(), JSON.stringify(snapshot)); const login = String(getLogin?.() || '').trim().toLowerCase(); const activationKey = login && 'stackchain.first-task.v1:' + login; const current = activationKey && storage?.getItem(activationKey); const rank = {'': 0, coaching: 1, complete: 2}; if (activationKey && rank[snapshot.first_task_state] > (rank[current] || 0)) { storage?.setItem(activationKey, snapshot.first_task_state); } } catch (_error) { // A storage quota failure must not prevent the current tab from using server truth. } onRemoteIds?.(plan.ids); onRemotePlan?.(plan); 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-today-' + 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 []; const recordPrefix = storageKey + '.operation.'; try { const legacy = JSON.parse(storage.getItem(storageKey) || '[]'); if (Array.isArray(legacy)) { legacy.forEach((operation, index) => { if (!operation?.operation_id) return; const recordKey = recordPrefix + encodeURIComponent(operation.operation_id); storage.setItem(recordKey, JSON.stringify({ operation, queued_at: index })); knownOperationKeys.add(recordKey); }); if (legacy.length) storage.removeItem(storageKey); } const keys = new Set([...knownOperationKeys].filter(candidate => candidate.startsWith(recordPrefix))); for (let index = 0; index < Number(storage.length || 0); index += 1) { const candidate = storage.key?.(index); if (candidate?.startsWith(recordPrefix)) keys.add(candidate); } const records = []; for (const recordKey of keys) { let record; try { record = JSON.parse(storage.getItem(recordKey) || 'null'); } catch (_error) { storage.removeItem(recordKey); knownOperationKeys.delete(recordKey); discardedCount += 1; continue; } const operation = record?.operation; const valid = operation && typeof operation.operation_id === 'string' && ['add', 'remove', 'move', 'configure', 'rollover', 'activate'].includes(operation.action) && typeof operation.item_id === 'string' && Number.isFinite(Number(record.queued_at)); if (valid) records.push({ ...record, recordKey }); else { storage.removeItem(recordKey); knownOperationKeys.delete(recordKey); discardedCount += 1; } } const expired = records.filter(record => Number(record.queued_at) >= 1_000_000_000_000 && now() - Number(record.queued_at) > maxOfflineMs); expired.forEach(record => { storage.removeItem(record.recordKey); knownOperationKeys.delete(record.recordKey); }); if (expired.length) { expiredCount += expired.length; onStatus?.('expired', { count: expiredCount }); } return records.filter(record => !expired.includes(record)) .sort((left, right) => Number(left.queued_at || 0) - Number(right.queued_at || 0) || left.operation.operation_id.localeCompare(right.operation.operation_id)) .map(record => ({ ...record.operation, base_revision: Number.isInteger(record.operation.base_revision) ? record.operation.base_revision : Math.max(0, savedRevision()), })) .filter(operation => operation && typeof operation.operation_id === 'string' && ['add', 'remove', 'move', 'configure', 'rollover', 'activate'].includes(operation.action) && typeof operation.item_id === 'string'); } catch (_error) { return []; } } function removeOperation(operationId) { const storageKey = key(); if (!storageKey || !storage) return false; const recordKey = storageKey + '.operation.' + encodeURIComponent(operationId); try { storage.removeItem(recordKey); knownOperationKeys.delete(recordKey); 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, fields = {}) { const operations = pending(); if (action === 'remove' && operations.some(operation => operation.action === 'remove' && operation.item_id === itemId )) { onStatus?.('pending'); return true; } const operation = { operation_id: operationId(), action, item_id: itemId, direction, base_revision: Math.max(0, savedRevision()), ...fields, }; const storageKey = key(); if (!storageKey || !storage) return false; const recordKey = storageKey + '.operation.' + encodeURIComponent(operation.operation_id); let saved = false; try { storage.setItem(recordKey, JSON.stringify({ operation, queued_at: now() + operations.length })); knownOperationKeys.add(recordKey); saved = true; coordinator?.notify('today'); } catch (_error) { /* Report the persistence failure below. */ } onStatus?.(saved ? 'pending' : 'error'); return saved; } function enqueueConfiguration(capacityMinutes, estimates) { const operation = { operation_id: operationId(), action: 'configure', item_id: 'plan', direction: null, capacity_minutes: capacityMinutes, estimates: estimates || {}, base_revision: Math.max(0, savedRevision()), }; const storageKey = key(); if (!storageKey || !storage) return false; const recordKey = storageKey + '.operation.' + encodeURIComponent(operation.operation_id); try { storage.setItem(recordKey, JSON.stringify({ operation, queued_at: now() })); knownOperationKeys.add(recordKey); coordinator?.notify('today'); onStatus?.('pending'); return true; } catch (_error) { onStatus?.('error'); return false; } } function enqueueActivation(state) { if (!['coaching', 'complete'].includes(state)) return false; const rank = {coaching: 1, complete: 2}; const queued = pending().filter(operation => operation.action === 'activate'); if (queued.some(operation => rank[operation.activation_state] >= rank[state])) return true; queued.forEach(operation => removeOperation(operation.operation_id)); return enqueue('activate', 'first-task', null, {activation_state: state}); } function enqueueRollover(proposed) { if (!proposed || proposed.action !== 'rollover') return false; const operation = { ...proposed, operation_id: operationId(), base_revision: Math.max(0, savedRevision()), }; const storageKey = key(); if (!storageKey || !storage) return false; const recordKey = storageKey + '.operation.' + encodeURIComponent(operation.operation_id); try { storage.setItem(recordKey, JSON.stringify({ operation, queued_at: now() })); knownOperationKeys.add(recordKey); coordinator?.notify('today'); onStatus?.('pending'); return true; } catch (_error) { onStatus?.('error'); return false; } } 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() { const ownerKey = key(); if (!ownerKey) return false; ensureChannel(); expiredCount = 0; try { let operations = pending(); let plan; let hadConflict = false; if (!operations.length) plan = await fetchJson('api/v1/today'); while (operations.length) { if (key() !== ownerKey) return false; const batch = operations.slice(0, 50); plan = await fetchJson('api/v1/today', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ operations: batch }), }); const hasReceipts = Array.isArray(plan.accepted_operation_ids) || Array.isArray(plan.duplicate_operation_ids) || Array.isArray(plan.rejected_operations); const received = hasReceipts ? [ ...(plan.accepted_operation_ids || []), ...(plan.duplicate_operation_ids || []), ...(plan.rejected_operations || []).map(item => item.operation_id), ] : batch.map(item => item.operation_id); hadConflict = hadConflict || Boolean(plan.rejected_operations?.length); for (const operationId of received) { if (pending().some(candidate => candidate.operation_id === operationId) && !removeOperation(operationId)) { throw new Error('Could not persist Today delivery receipt'); } } operations = pending(); } adopt(plan); const stillPending = pending().length; if (discardedCount) recoveryNotice = { discarded: discardedCount, until: now() + 5000 }; const recovered = recoveryNotice.until > now() ? recoveryNotice.discarded : 0; onStatus?.(stillPending ? 'pending' : hadConflict ? 'full' : expiredCount ? 'expired' : recovered ? 'recovered' : 'saved', expiredCount ? { count: expiredCount } : recovered ? { discarded: recovered } : {}); if (!stillPending) discardedCount = 0; retryAttempt = 0; cancelRetry(); return !hadConflict; } catch (error) { const stillPending = pending().length; if (discardedCount) recoveryNotice = { discarded: discardedCount, until: now() + 5000 }; const recovered = recoveryNotice.until > now() ? recoveryNotice.discarded : 0; if (stillPending) scheduleRetry(error, ownerKey); else onStatus?.(recovered ? 'recovered' : 'error', recovered ? { discarded: recovered } : {}); discardedCount = 0; return false; } } function flush() { if (!flushing) { const delivery = coordinator ? coordinator.runExclusive('today', run) : run(); flushing = Promise.resolve(delivery).finally(() => { flushing = null; }); } return flushing; } function startLifecycle({ window: windowObject, document: documentObject }) { windowObject?.addEventListener?.('online', flush); windowObject?.addEventListener?.('stackchain:first-task-complete', () => { if (enqueueActivation('complete')) flush(); }); documentObject?.addEventListener?.('visibilitychange', () => documentObject.hidden ? false : flush() ); } coordinator?.subscribe(change => { if (change.queue === 'today' && pending().length) flush(); }); return { enqueue, enqueueConfiguration, enqueueActivation, enqueueRollover, migrate, flush, pending, startLifecycle }; } if (typeof module !== 'undefined' && module.exports) module.exports = createTodaySync;