feat: preserve Today timing ledger across handoff (Closes #1276)
CI / lint (pull_request) Successful in 3m48s
CI / build-release (pull_request) Successful in 8s
CI / browser-journey (pull_request) Successful in 5m42s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy committed 2026-08-22 19:38:30 +00:00
1 parent e0d8ab9f10
commit 548b95ddb6
9 files changed
+267 -20

No files matched your search

+1 -1
View File
@@ -1956,7 +1956,7 @@
storage: localStorage,
getLogin: () => confirmedOwnerLogin,
onChange: snapshot => {
todaySessionSync?.publish(snapshot);
todaySessionSync?.publish(timer.sessionSnapshot());
queueMicrotask(() => {
if (todayLockScreen) todayLockScreen.sync(snapshot, workSession.checkpointed());
});
+8 -3
View File
@@ -63,7 +63,7 @@ function createTodaySessionSync({
onStatus('syncing');
const body = {
base_revision:current.revision, device_id:deviceId(), identity:current.identity,
elapsed_ms:current.elapsed_ms, running:true,
elapsed_ms:current.elapsed_ms, entries:current.entries, running:true,
};
if (Object.hasOwn(current, 'break_deadline_at')) body.break_deadline_at = null;
const session = await fetchJson(endpoint, {
@@ -71,7 +71,7 @@ function createTodaySessionSync({
body:JSON.stringify(body),
});
adopt(session);
timer?.adopt?.(session.identity, session.elapsed_ms, session.running);
timer?.adopt?.(session.identity, session.elapsed_ms, session.running, session.entries);
return session;
} catch (error) {
if (error?.status === 409 || error?.code === 'session_changed') {
@@ -92,6 +92,7 @@ function createTodaySessionSync({
const body = {
base_revision:ownedRevision, device_id:deviceId(), identity:snapshot.identity,
elapsed_ms:Math.max(0, Math.floor(Number(snapshot.elapsed_ms) || 0)),
entries:Array.isArray(snapshot.entries) ? snapshot.entries : undefined,
running:Boolean(snapshot.running),
};
if (Object.hasOwn(snapshot, 'break_deadline_at') || Object.hasOwn(current || {}, 'break_deadline_at')) {
@@ -115,7 +116,7 @@ function createTodaySessionSync({
}
}
function publish(snapshot = timer?.snapshot?.()) {
function publish(snapshot = timer?.sessionSnapshot?.() || timer?.snapshot?.()) {
snapshot = snapshot?.identity ? snapshot : latestSnapshot;
if (!snapshot?.identity || !deviceId()) return Promise.resolve(null);
latestSnapshot = snapshot;
@@ -166,6 +167,10 @@ function todaySessionHandoffSummary(session, item) {
return `On break until ${end} · ready to resume here`;
}
const minutes = Math.max(0, Math.floor(Number(session?.elapsed_ms || 0) / 60000));
if (Array.isArray(session?.entries) && session.entries.length > 1) {
const total = session.entries.reduce((sum, entry) => sum + Math.max(0, Number(entry?.elapsed_ms) || 0), 0);
return `${session.entries.length} tracked items · ${Math.floor(total / 60000)} min total`;
}
return `${item?.title || 'Current Today item'} · ${minutes} min elapsed`;
}
+28 -9
View File
@@ -75,8 +75,9 @@ function createTodayTimer({ storage, getLogin, now = () => Date.now(), onChange
entry.running = false;
return state;
};
const snapshot = (identity = '') => {
const snapshot = (identity = '', includeEntries = false) => {
const state = read();
const sampledAt = now();
const selected = identity || state.active_identity;
const entry = state.entries[selected];
const result = !selected || !entry ?
@@ -84,25 +85,42 @@ function createTodayTimer({ storage, getLogin, now = () => Date.now(), onChange
{
identity:selected,
elapsed_ms:Math.max(0, Number(entry.elapsed_ms) || 0) + (entry.running ?
Math.max(0, now() - Number(entry.started_at ?? now())) : 0),
Math.max(0, sampledAt - Number(entry.started_at ?? sampledAt)) : 0),
running:Boolean(entry.running),
};
const timedBreak = validBreak(state);
if (timedBreak?.identity === selected) result.break_deadline_at = timedBreak.deadline_at;
if (includeEntries) {
result.entries = Object.entries(state.entries).slice(0, 20).map(([entryIdentity, value]) => ({
identity:entryIdentity,
elapsed_ms:Math.max(0, Math.floor(Number(value.elapsed_ms) || 0) + (value.running ?
Math.max(0, sampledAt - Number(value.started_at ?? sampledAt)) : 0)),
}));
}
return result;
};
return {
adopt(identity, elapsedMs, running) {
adopt(identity, elapsedMs, running, entries = null) {
if (!key() || typeof identity !== 'string' || !identity ||
!Number.isFinite(Number(elapsedMs)) || Number(elapsedMs) < 0) return false;
const incoming = entries === null ? [{identity, elapsed_ms:elapsedMs}] : entries;
if (!Array.isArray(incoming) || incoming.length > 20) return false;
const seen = new Set();
const ledger = {};
for (const entry of incoming) {
if (typeof entry?.identity !== 'string' || !entry.identity || entry.identity.length > 500 ||
seen.has(entry.identity) || !Number.isFinite(Number(entry.elapsed_ms)) || Number(entry.elapsed_ms) < 0) return false;
seen.add(entry.identity);
ledger[entry.identity] = {
elapsed_ms:Math.floor(Number(entry.elapsed_ms)), started_at:null, running:false,
};
}
if (!ledger[identity] || ledger[identity].elapsed_ms !== Math.floor(Number(elapsedMs))) return false;
const state = read();
settle(state);
state.active_identity = identity;
state.entries[identity] = {
elapsed_ms:Math.floor(Number(elapsedMs)),
started_at:running ? now() : null,
running:Boolean(running),
};
state.entries = ledger;
state.entries[identity].started_at = running ? now() : null;
state.entries[identity].running = Boolean(running);
state.away_at = null;
state.pending_interruption = null;
state.attention_interruption = null;
@@ -397,6 +415,7 @@ function createTodayTimer({ storage, getLogin, now = () => Date.now(), onChange
}, 0);
},
snapshot,
sessionSnapshot:(identity = '') => snapshot(identity, true),
};
}