feat: hand off active Today sessions across devices (Closes #1022)
CI / lint (pull_request) Successful in 3m11s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 2m22s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy committed 2026-08-17 12:44:01 +00:00
1 parent 51f4223f4b
commit 8d12a0384b
14 files changed
+596 -7

No files matched your search

+4
View File
@@ -926,6 +926,10 @@ textarea { resize: vertical; min-height: 120px; }
.mobile-today-hud { display:none; }
.mobile-today-hud[hidden], .mobile-today-hud[data-overlay-hidden="true"] { display:none; }
.today-session-handoff { position:fixed; left:12px; right:12px; bottom:calc(68px + env(safe-area-inset-bottom)); z-index:45; display:grid; grid-template-columns:minmax(0,1fr) auto; align-items:center; gap:10px; padding:12px; border:1px solid #60a5fa; border-radius:12px; background:rgba(16,38,65,.98); box-shadow:0 8px 28px rgba(0,0,0,.35); }
.today-session-handoff[hidden] { display:none; }
.today-session-handoff button { min-height:44px; }
@media(max-width:420px) { .today-session-handoff { grid-template-columns:1fr; } .today-session-handoff button { width:100%; } }
.mobile-task-action { min-width:0; min-height:44px; padding:6px 2px; border:0; border-radius:8px; background:transparent; display:grid; place-items:center; gap:2px; font-size:12px; }
.mobile-task-action[hidden] { display:none; }
.mobile-task-action[aria-current="page"] { color:#bfdbfe; background:#17365a; outline:1px solid #31577f; }
+11
View File
@@ -1795,9 +1795,11 @@
'Agenda progress could not be saved on this device. You can keep working.';
},
});
let todaySessionSync = null;
const timer = createTodayTimer({
storage: localStorage,
getLogin: () => confirmedOwnerLogin,
onChange: snapshot => todaySessionSync?.publish(snapshot),
});
const timerView = createTodayTimerView({
timer,
@@ -1814,6 +1816,15 @@
return completeTodayItem(item);
},
});
todaySessionSync = attachTodaySessionHandoff({
fetchJson:fetchReviewJson, storage:localStorage, timer, qs,
items:() => [...todayMyWork, ...activeMyWork],
identity:item => todayWork.identity(item),
selectToday:() => selectTodayWork(),
startItem:item => workSession.start(item),
announce:message => { qs('#my-work-action-status').textContent = message; },
renderTimer:() => timerView.render(),
});
renderAttentionInterruption();
qs('#return-to-today').addEventListener('click', () => {
const returned = timer.returnFromAttention();
+9
View File
@@ -1515,6 +1515,14 @@
</form>
</dialog>
<section class="today-session-handoff" id="today-session-handoff" aria-live="polite" hidden>
<div>
<strong>Today is active on another device</strong>
<div class="small" id="today-session-handoff-summary"></div>
</div>
<button id="continue-today-session" type="button">Continue here</button>
</section>
<section class="mobile-today-hud" data-mobile-today-hud aria-label="Active Today session" hidden>
<button class="mobile-today-summary" data-mobile-today-open type="button" aria-label="Open current Today item"></button>
<button data-mobile-today-complete type="button" hidden>Done &amp; next</button>
@@ -1616,6 +1624,7 @@
<script src="static/work-selection.js"></script>
<script src="static/today-work.js"></script>
<script src="static/today-timer.js"></script>
<script src="static/today-session-sync.js"></script>
<script src="static/today-recap.js"></script>
<script src="static/today-wrap-up.js"></script>
<script src="static/today-handoff.js"></script>
+1
View File
@@ -51,6 +51,7 @@ const SHELL = [
BASE + 'static/work-selection.js',
BASE + 'static/today-work.js',
BASE + 'static/today-timer.js',
BASE + 'static/today-session-sync.js',
BASE + 'static/today-recap.js',
BASE + 'static/today-wrap-up.js',
BASE + 'static/today-handoff.js',
+149
View File
@@ -0,0 +1,149 @@
function createTodaySessionSync({
fetchJson, getDeviceId, timer, onRemote = () => {}, onTransferred = () => {}, onStatus = () => {},
setInterval = globalThis.setInterval, clearInterval = globalThis.clearInterval,
}) {
let current = null;
let ownedRevision = 0;
let pollTimer = null;
const endpoint = 'api/v1/today/session';
const deviceId = () => String(getDeviceId?.() || '').trim();
function adopt(session) {
if (!session || !Number.isInteger(session.revision)) return null;
const previousOwned = current?.device_id === deviceId() && current?.running;
current = session;
if (session.device_id === deviceId()) {
ownedRevision = session.revision;
onRemote(null);
} else if (session.running && session.identity) {
if (previousOwned) {
timer?.pause?.();
onTransferred(session);
}
onRemote(session);
} else {
onRemote(null);
}
return session;
}
async function refresh() {
try {
const session = await fetchJson(endpoint);
onStatus('online');
return adopt(session);
} catch (error) {
onStatus('offline', error);
return null;
}
}
async function claim() {
if (!current?.running || !current.identity || current.device_id === deviceId() || !deviceId()) return null;
try {
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,
}),
});
adopt(session);
timer?.adopt?.(session.identity, session.elapsed_ms, session.running);
return session;
} catch (error) {
onStatus('conflict', error);
await refresh();
return null;
}
}
async function publish(snapshot = timer?.snapshot?.()) {
if (!snapshot?.identity || !deviceId()) return null;
try {
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),
}),
});
adopt(session);
return session;
} catch (error) {
onStatus('offline', error);
return null;
}
}
const pulse = () => current?.device_id === deviceId() && current?.running
? publish()
: refresh();
return {
refresh, claim, publish,
session:() => current,
start(intervalMs = 15000) {
if (pollTimer !== null) return false;
pulse();
pollTimer = setInterval?.(pulse, intervalMs);
pollTimer?.unref?.();
return true;
},
stop() {
if (pollTimer === null) return false;
clearInterval?.(pollTimer);
pollTimer = null;
return true;
},
};
}
function attachTodaySessionHandoff({
fetchJson, storage, timer, qs, items, identity, selectToday, startItem, announce, renderTimer,
}) {
const deviceKey = 'stackchain.today-session-device.v1';
const getDeviceId = () => {
let value = storage.getItem(deviceKey);
if (!value) {
value = globalThis.crypto?.randomUUID?.() || Math.random().toString(36).slice(2);
storage.setItem(deviceKey, value);
}
return value;
};
let offered = null;
const sync = createTodaySessionSync({
fetchJson, getDeviceId, timer,
onRemote:session => {
offered = session;
const handoff = qs('#today-session-handoff');
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`;
},
onTransferred:() => {
announce('Today continued on another device. Timer paused here.');
renderTimer();
},
});
qs('#continue-today-session').addEventListener('click', async () => {
const target = offered;
const claimed = await sync.claim();
if (!claimed || !target) return;
selectToday();
const item = items().find(entry => identity(entry) === claimed.identity);
if (item) startItem(item);
else announce('Today session moved here; refresh work to open its item.');
renderTimer();
});
sync.start(5000);
return sync;
}
if (typeof module !== 'undefined' && module.exports) module.exports = {
createTodaySessionSync, attachTodaySessionHandoff,
};
+18 -1
View File
@@ -1,4 +1,4 @@
function createTodayTimer({ storage, getLogin, now = () => Date.now() }) {
function createTodayTimer({ storage, getLogin, now = () => Date.now(), onChange = () => {} }) {
const key = () => {
const login = String(getLogin?.() || '').trim().toLowerCase();
return login ? 'stackchain.today-timer.v1.' + encodeURIComponent(login) : '';
@@ -24,6 +24,7 @@ function createTodayTimer({ storage, getLogin, now = () => Date.now() }) {
if (!ownerKey || !storage) return false;
try {
storage.setItem(ownerKey, JSON.stringify(state));
onChange(snapshot());
return true;
} catch (_error) {
return false;
@@ -60,6 +61,22 @@ function createTodayTimer({ storage, getLogin, now = () => Date.now() }) {
return { identity:selected, elapsed_ms:elapsed, running:Boolean(entry.running) };
};
return {
adopt(identity, elapsedMs, running) {
if (!key() || typeof identity !== 'string' || !identity ||
!Number.isFinite(Number(elapsedMs)) || Number(elapsedMs) < 0) 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.away_at = null;
state.pending_interruption = null;
state.attention_interruption = null;
return write(state);
},
activate(identity) {
if (!key() || typeof identity !== 'string' || !identity) return false;
const state = read();