feat: sync first-task completion across devices (Closes #1148)
CI / lint (pull_request) Successful in 3m6s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Failing after 3m27s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy committed 2026-08-19 20:34:10 +00:00
1 parent e2be14d7c7
commit fa47ded3ab
7 files changed
+184 -12

No files matched your search

+12 -4
View File
@@ -44,9 +44,16 @@
function store(value) {
const currentKey = key();
if (!currentKey) return;
try { options.storage.setItem(currentKey, value); }
catch (_error) {}
const current = state();
if (!currentKey || current === value || current === 'complete') return false;
try {
options.storage.setItem(currentKey, value);
return true;
} catch (_error) { return false; }
}
function adoptRemote(value) {
return ['coaching', 'complete'].includes(value) && store(value);
}
function required() {
@@ -95,6 +102,7 @@
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;
@@ -120,5 +128,5 @@
options.eventTarget?.addEventListener('offline', render);
}
return {required, open, refresh, render, start, completeOutcome};
return {required, open, refresh, render, start, completeOutcome, adoptRemote};
});
+27 -5
View File
@@ -61,12 +61,22 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onRemotePl
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.
}
@@ -123,7 +133,7 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onRemotePl
}
const operation = record?.operation;
const valid = operation && typeof operation.operation_id === 'string' &&
['add', 'remove', 'move', 'configure', 'rollover'].includes(operation.action) &&
['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 {
@@ -151,7 +161,7 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onRemotePl
? record.operation.base_revision : Math.max(0, savedRevision()),
}))
.filter(operation => operation && typeof operation.operation_id === 'string' &&
['add', 'remove', 'move', 'configure', 'rollover'].includes(operation.action) && typeof operation.item_id === 'string');
['add', 'remove', 'move', 'configure', 'rollover', 'activate'].includes(operation.action) && typeof operation.item_id === 'string');
} catch (_error) {
return [];
}
@@ -176,7 +186,7 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onRemotePl
return Date.now().toString(36) + '-' + Math.random().toString(36).slice(2);
}
function enqueue(action, itemId, direction = null) {
function enqueue(action, itemId, direction = null, fields = {}) {
const operations = pending();
if (action === 'remove' && operations.some(operation =>
operation.action === 'remove' && operation.item_id === itemId
@@ -186,7 +196,7 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onRemotePl
}
const operation = {
operation_id: operationId(), action, item_id: itemId, direction,
base_revision: Math.max(0, savedRevision()),
base_revision: Math.max(0, savedRevision()), ...fields,
};
const storageKey = key();
if (!storageKey || !storage) return false;
@@ -223,6 +233,15 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onRemotePl
}
}
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 = {
@@ -323,6 +342,9 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onRemotePl
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()
);
@@ -332,7 +354,7 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onRemotePl
if (change.queue === 'today' && pending().length) flush();
});
return { enqueue, enqueueConfiguration, enqueueRollover, migrate, flush, pending, startLifecycle };
return { enqueue, enqueueConfiguration, enqueueActivation, enqueueRollover, migrate, flush, pending, startLifecycle };
}
if (typeof module !== 'undefined' && module.exports) module.exports = createTodaySync;