stackchain-dashboard/frontend/today-session-sync.js
timmy 548b95ddb6
All checks were successful
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
feat: preserve Today timing ledger across handoff (Closes #1276)
2026-08-22 19:38:30 +00:00

235 lines
8.2 KiB
JavaScript

function createTodaySessionSync({
fetchJson, getDeviceId, timer, onRemote = () => {}, onTransferred = () => {}, onStatus = () => {},
onOwnedRestore = () => {},
setInterval = globalThis.setInterval, clearInterval = globalThis.clearInterval,
}) {
let current = null;
let ownedRevision = 0;
let pollTimer = null;
let requestTail = Promise.resolve();
let latestSnapshot = null;
let publishRequested = 0;
let publishSent = 0;
const endpoint = 'api/v1/today/session';
const deviceId = () => String(getDeviceId?.() || '').trim();
function serialize(operation) {
const result = requestTail.then(operation, operation);
requestTail = result.catch(() => null);
return result;
}
function adopt(session) {
if (!session || !Number.isInteger(session.revision)) return null;
const previousOwned = current?.device_id === deviceId() &&
(current?.running || Number.isFinite(current?.break_deadline_at));
current = session;
if (session.device_id === deviceId()) {
ownedRevision = session.revision;
if (Number.isFinite(session.break_deadline_at) &&
timer?.restoreBreak?.(session.identity, session.break_deadline_at)) onOwnedRestore(session);
onRemote(null);
} else if ((session.running || Number.isFinite(session.break_deadline_at)) && session.identity) {
if (previousOwned) {
timer?.pause?.();
onTransferred(session);
}
onRemote(session);
} else {
onRemote(null);
}
return session;
}
async function refreshNow() {
try {
onStatus('syncing');
const session = await fetchJson(endpoint);
onStatus('online');
return adopt(session);
} catch (error) {
onStatus('offline', error);
return null;
}
}
const refresh = () => serialize(refreshNow);
function claim() {
return serialize(async () => {
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, entries:current.entries, 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(body),
});
adopt(session);
timer?.adopt?.(session.identity, session.elapsed_ms, session.running, session.entries);
return session;
} catch (error) {
if (error?.status === 409 || error?.code === 'session_changed') {
onStatus('conflict', error);
await refreshNow();
} else {
onStatus('offline', error);
}
return null;
}
});
}
async function publishNow(snapshot) {
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)),
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')) {
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(body),
});
adopt(session);
return session;
} catch (error) {
if (error?.status === 409 || error?.code === 'session_changed') {
onStatus('conflict', error);
await refreshNow();
} else {
onStatus('offline', error);
}
return null;
}
}
function publish(snapshot = timer?.sessionSnapshot?.() || timer?.snapshot?.()) {
snapshot = snapshot?.identity ? snapshot : latestSnapshot;
if (!snapshot?.identity || !deviceId()) return Promise.resolve(null);
latestSnapshot = snapshot;
publishRequested += 1;
return serialize(async () => {
if (publishSent === publishRequested) return current;
if (current?.device_id && current.device_id !== deviceId()) {
publishSent = publishRequested;
return current;
}
const version = publishRequested;
const result = await publishNow(latestSnapshot);
publishSent = current?.device_id && current.device_id !== deviceId()
? publishRequested
: version;
return result;
});
}
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 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));
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`;
}
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 sessionStatus = qs('#today-session-sync-status');
const showSessionStatus = message => {
sessionStatus.textContent = message;
sessionStatus.hidden = !message;
};
const sync = createTodaySessionSync({
fetchJson, getDeviceId, timer,
onOwnedRestore:renderTimer,
onStatus:state => showSessionStatus(
state === 'syncing' ? 'Session syncing…' :
(state === 'offline' ? 'Session offline · will retry.' : '')
),
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);
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.');
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, todaySessionHandoffSummary,
};