150 lines
4.6 KiB
JavaScript
150 lines
4.6 KiB
JavaScript
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,
|
|
};
|