feat: continue timed breaks across devices (Closes #1038)
CI / lint (pull_request) Successful in 2m53s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Failing after 2m8s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy committed 2026-08-17 20:08:05 +00:00
1 parent 1d242bab75
commit c5b466f281
7 files changed
+172 -33

No files matched your search

+36 -16
View File
@@ -20,12 +20,13 @@ function createTodaySessionSync({
function adopt(session) {
if (!session || !Number.isInteger(session.revision)) return null;
const previousOwned = current?.device_id === deviceId() && current?.running;
const previousOwned = current?.device_id === deviceId() &&
(current?.running || Number.isFinite(current?.break_deadline_at));
current = session;
if (session.device_id === deviceId()) {
ownedRevision = session.revision;
onRemote(null);
} else if (session.running && session.identity) {
} else if ((session.running || Number.isFinite(session.break_deadline_at)) && session.identity) {
if (previousOwned) {
timer?.pause?.();
onTransferred(session);
@@ -53,15 +54,18 @@ function createTodaySessionSync({
function claim() {
return serialize(async () => {
if (!current?.running || !current.identity || current.device_id === deviceId() || !deviceId()) return null;
if ((!current?.running && !Number.isFinite(current?.break_deadline_at)) ||
!current.identity || current.device_id === deviceId() || !deviceId()) return null;
try {
onStatus('syncing');
const body = {
base_revision:current.revision, device_id:deviceId(), identity:current.identity,
elapsed_ms:current.elapsed_ms, running:true,
};
if (Object.hasOwn(current, 'break_deadline_at')) body.break_deadline_at = null;
const session = await fetchJson(endpoint, {
method:'PATCH', headers:{'Content-Type':'application/json'},
body:JSON.stringify({
base_revision:current.revision, device_id:deviceId(), identity:current.identity,
elapsed_ms:current.elapsed_ms, running:true,
}),
body:JSON.stringify(body),
});
adopt(session);
timer?.adopt?.(session.identity, session.elapsed_ms, session.running);
@@ -82,13 +86,18 @@ function createTodaySessionSync({
if (!snapshot?.identity || !deviceId()) return null;
try {
onStatus('syncing');
const body = {
base_revision:ownedRevision, device_id:deviceId(), identity:snapshot.identity,
elapsed_ms:Math.max(0, Math.floor(Number(snapshot.elapsed_ms) || 0)),
running:Boolean(snapshot.running),
};
if (Object.hasOwn(snapshot, 'break_deadline_at') || Object.hasOwn(current || {}, 'break_deadline_at')) {
body.break_deadline_at = Number.isFinite(snapshot.break_deadline_at) ?
Math.floor(snapshot.break_deadline_at) : null;
}
const session = await fetchJson(endpoint, {
method:'PATCH', headers:{'Content-Type':'application/json'},
body:JSON.stringify({
base_revision:ownedRevision, device_id:deviceId(), identity:snapshot.identity,
elapsed_ms:Math.max(0, Math.floor(Number(snapshot.elapsed_ms) || 0)),
running:Boolean(snapshot.running),
}),
body:JSON.stringify(body),
});
adopt(session);
return session;
@@ -146,6 +155,17 @@ function createTodaySessionSync({
};
}
function todaySessionHandoffSummary(session, item) {
if (Number.isFinite(session?.break_deadline_at)) {
const end = new Date(session.break_deadline_at).toLocaleTimeString([], {
hour:'numeric', minute:'2-digit',
});
return `On break until ${end} · ready to resume here`;
}
const minutes = Math.max(0, Math.floor(Number(session?.elapsed_ms || 0) / 60000));
return `${item?.title || 'Current Today item'} · ${minutes} min elapsed`;
}
function attachTodaySessionHandoff({
fetchJson, storage, timer, qs, items, identity, selectToday, startItem, announce, renderTimer,
}) {
@@ -176,9 +196,9 @@ function attachTodaySessionHandoff({
handoff.hidden = !session;
if (!session) return;
const item = items().find(entry => identity(entry) === session.identity);
const minutes = Math.max(0, Math.floor(Number(session.elapsed_ms || 0) / 60000));
qs('#today-session-handoff-summary').textContent =
`${item?.title || 'Current Today item'} · ${minutes} min elapsed`;
qs('#today-session-handoff-summary').textContent = todaySessionHandoffSummary(session, item);
qs('#continue-today-session').textContent = Number.isFinite(session.break_deadline_at) ?
'Resume Today here' : 'Continue here';
},
onTransferred:() => {
showSessionStatus('Session continued on another device.');
@@ -201,5 +221,5 @@ function attachTodaySessionHandoff({
}
if (typeof module !== 'undefined' && module.exports) module.exports = {
createTodaySessionSync, attachTodaySessionHandoff,
createTodaySessionSync, attachTodaySessionHandoff, todaySessionHandoffSummary,
};
+11 -4
View File
@@ -61,10 +61,17 @@ function createTodayTimer({ storage, getLogin, now = () => Date.now(), onChange
const state = read();
const selected = identity || state.active_identity;
const entry = state.entries[selected];
if (!selected || !entry) return { identity:selected, elapsed_ms:0, running:false };
const elapsed = Math.max(0, Number(entry.elapsed_ms) || 0) + (entry.running ?
Math.max(0, now() - Number(entry.started_at ?? now())) : 0);
return { identity:selected, elapsed_ms:elapsed, running:Boolean(entry.running) };
const result = !selected || !entry ?
{ identity:selected, elapsed_ms:0, running:false } :
{
identity:selected,
elapsed_ms:Math.max(0, Number(entry.elapsed_ms) || 0) + (entry.running ?
Math.max(0, now() - Number(entry.started_at ?? now())) : 0),
running:Boolean(entry.running),
};
const timedBreak = validBreak(state);
if (timedBreak?.identity === selected) result.break_deadline_at = timedBreak.deadline_at;
return result;
};
return {
adopt(identity, elapsedMs, running) {