235 lines
8.2 KiB
JavaScript
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,
|
|
};
|