feat: make Today completion durably atomic (Closes #1150)
CI / lint (pull_request) Successful in 3m0s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Failing after 3m30s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy committed 2026-08-19 21:39:03 +00:00
1 parent f785134dfe
commit d0780a72a7
17 files changed
+214 -45

No files matched your search

+1 -1
View File
@@ -102,7 +102,6 @@
function completeOutcome() {
if (state() !== 'coaching') return false;
store('complete');
options.eventTarget?.dispatchEvent?.(new CustomEvent('stackchain:first-task-complete'));
renderCoach();
if (options.receipt) {
options.receipt.hidden = false;
@@ -128,5 +127,6 @@
options.eventTarget?.addEventListener('offline', render);
}
globalThis.stackchainFirstTaskCompleting = () => state() === 'coaching';
return {required, open, refresh, render, start, completeOutcome, adoptRemote};
});
+1 -1
View File
@@ -1,7 +1,7 @@
const BASE = new URL('./', self.location.href).pathname;
importScripts(BASE + 'static/private-data-registry.js');
importScripts(BASE + 'static/background-issue-sync.js');
const CACHE = 'stackchain-dashboard-shell-v123';
const CACHE = 'stackchain-dashboard-shell-v124';
const OFFLINE_LEASE_URL = new URL(BASE + '__offline-session-lease', self.location.origin).href;
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000;
+13 -1
View File
@@ -1,4 +1,5 @@
function createTodayCompletion({ todayWork, todaySync, workSession, refresh, warm, announce, advance = null,
completeActivation = () => globalThis.stackchainFirstTaskCompleting?.(),
now = Date.now, ttlMs = 10000, onOffer = null, onClear = null,
setTimer = globalThis.setTimeout, clearTimer = globalThis.clearTimeout,
undo = [globalThis.document?.getElementById('today-completion-undo'),
@@ -23,7 +24,18 @@ function createTodayCompletion({ todayWork, todaySync, workSession, refresh, war
announce(options.failureMessage || 'Could not update Today on this device. Try again.');
return false;
}
todaySync.enqueue('remove', todayWork.identity(item));
const operations = [
{action:'remove', item_id:identity},
...(completeActivation() ? [{action:'activate', item_id:'first-task', activation_state:'complete'}] : []),
];
const admitted = todaySync.enqueueBatch ? todaySync.enqueueBatch(operations) :
todaySync.enqueue('remove', identity);
if (!admitted) {
todayWork.restore?.(snapshot);
announce(options.admissionFailureMessage ||
'Device storage is full. Free space and try again.');
return false;
}
todaySync.flush();
refresh();
warm();
+42 -18
View File
@@ -13,6 +13,7 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onRemotePl
let discardedCount = 0;
let recoveryNotice = { discarded: 0, until: 0 };
const knownOperationKeys = new Set();
const operationRecordKeys = new Map();
function cancelRetry() {
if (retryTimer !== null) clearTimer?.(retryTimer);
@@ -131,11 +132,15 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onRemotePl
discardedCount += 1;
continue;
}
const operation = record?.operation;
const valid = operation && typeof operation.operation_id === 'string' &&
const operations = Array.isArray(record?.operations) ? record.operations : [record?.operation];
const valid = operations.length && operations.every(operation => 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 });
typeof operation.item_id === 'string') && Number.isFinite(Number(record.queued_at));
if (valid) operations.forEach((operation, index) => {
operationRecordKeys.set(operation.operation_id, recordKey);
records.push({operation, queued_at:Number(record.queued_at) + index / 1000, recordKey});
});
else {
storage.removeItem(recordKey);
knownOperationKeys.delete(recordKey);
@@ -170,10 +175,12 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onRemotePl
function removeOperation(operationId) {
const storageKey = key();
if (!storageKey || !storage) return false;
const recordKey = storageKey + '.operation.' + encodeURIComponent(operationId);
const recordKey = operationRecordKeys.get(operationId) ||
storageKey + '.operation.' + encodeURIComponent(operationId);
try {
storage.removeItem(recordKey);
knownOperationKeys.delete(recordKey);
operationRecordKeys.delete(operationId);
return true;
} catch (_error) {
return false;
@@ -186,6 +193,26 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onRemotePl
return Date.now().toString(36) + '-' + Math.random().toString(36).slice(2);
}
function saveOperations(operations, queuedAt = now()) {
const storageKey = key();
if (!storageKey || !storage) return false;
const batched = operations.length > 1;
const recordKey = storageKey + '.operation.' + (batched ? 'batch-' : '') +
encodeURIComponent(operations[0].operation_id);
try {
storage.setItem(recordKey, JSON.stringify(batched ?
{operations, queued_at:queuedAt} : {operation:operations[0], queued_at:queuedAt}));
knownOperationKeys.add(recordKey);
operations.forEach(operation => operationRecordKeys.set(operation.operation_id, recordKey));
coordinator?.notify('today');
onStatus?.('pending');
return true;
} catch (_error) {
onStatus?.('error');
return false;
}
}
function enqueue(action, itemId, direction = null, fields = {}) {
const operations = pending();
if (action === 'remove' && operations.some(operation =>
@@ -198,18 +225,15 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onRemotePl
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;
return saveOperations([operation], now() + operations.length);
}
function enqueueBatch(specifications) {
const operations = specifications.map(specification => ({
operation_id: operationId(), direction: specification.direction ?? null,
base_revision: Math.max(0, savedRevision()), ...specification,
}));
return saveOperations(operations);
}
function enqueueConfiguration(capacityMinutes, estimates) {
@@ -354,7 +378,7 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onRemotePl
if (change.queue === 'today' && pending().length) flush();
});
return { enqueue, enqueueConfiguration, enqueueActivation, enqueueRollover, migrate, flush, pending, startLifecycle };
return { enqueue, enqueueBatch, enqueueConfiguration, enqueueActivation, enqueueRollover, migrate, flush, pending, startLifecycle };
}
if (typeof module !== 'undefined' && module.exports) module.exports = createTodaySync;