178 lines
11 KiB
JavaScript
178 lines
11 KiB
JavaScript
'use strict';
|
|
/* Opt-in, personal reading estimates. The server counts visits with 60% coverage. */
|
|
(function () {
|
|
const { IDLE_MS, exposureTracker, stateFor } = window.ReadingClockPolicy;
|
|
const INPUTS = ['mouse', 'touch', 'keyboard'], PULSE_MS = 10000;
|
|
const labels = {
|
|
reading: ['⏱', 'Reading'], loading: ['⌛', 'Loading page'], idle: ['⏸', 'Idle'],
|
|
hidden: ['⏸', 'Tab hidden'], unfocused: ['⏸', 'Window inactive'],
|
|
'other-tab': ['⏸', 'Another reader is active'], 'page-error': ['⏸', 'Page unavailable'],
|
|
finished: ['⏸', 'Chapter finished'],
|
|
};
|
|
const blank = () => ({ activeMs: 0, loadingMs: 0, inputMs: { mouse: 0, touch: 0, keyboard: 0 } });
|
|
const fmt = ms => {
|
|
const seconds = Math.max(0, Math.floor(ms / 1000)), hours = Math.floor(seconds / 3600);
|
|
return (hours ? hours + ':' : '') + String(Math.floor(seconds / 60) % 60).padStart(2, '0') + ':' + String(seconds % 60).padStart(2, '0');
|
|
};
|
|
window.createReadingClock = function (options) {
|
|
const button = options.button, panel = options.panel;
|
|
const session = (crypto.randomUUID ? crypto.randomUUID() : Math.random().toString(36).slice(2) + Date.now().toString(36)).replace(/[^a-zA-Z0-9_-]/g, '');
|
|
const started = performance.now(), eligible = new Set(options.eligiblePages);
|
|
const required = Math.ceil(eligible.size * 0.6);
|
|
let visit = blank(), acknowledged = blank(), tracker = exposureTracker(), acknowledgedSeen = [];
|
|
let reading = options.initial || {}, state = 'idle', lastSample = started, lastInputAt = started, lastType = 'mouse';
|
|
let focused = document.hasFocus(), needsInput = false, owned = true, claimAt = 0, channel;
|
|
let sequence = 0, lastSent = started, sending = false, urgent = false, releasePending = false;
|
|
let retryTimer = 0, saveError = false, ended = false, destroyed = false, notified = false;
|
|
const listeners = [];
|
|
function listen(target, name, fn, opts) {
|
|
target.addEventListener(name, fn, opts); listeners.push(() => target.removeEventListener(name, fn, opts));
|
|
}
|
|
function accrue(now) {
|
|
const delta = Math.max(0, Math.min(now, lastInputAt + IDLE_MS) - lastSample);
|
|
if (state === 'reading') { visit.activeMs += delta; visit.inputMs[lastType] += delta; }
|
|
else if (state === 'loading') visit.loadingMs += delta;
|
|
lastSample = now;
|
|
}
|
|
function covered() { return [...tracker.seen].filter(page => eligible.has(page)); }
|
|
function sample() {
|
|
if (destroyed || ended) return;
|
|
const now = performance.now(); accrue(now);
|
|
const view = options.view();
|
|
const previous = state;
|
|
state = stateFor({ visible: !document.hidden, focused, now, lastInputAt: needsInput ? -Infinity : lastInputAt, owned, view });
|
|
tracker.update(view.pages, now, state === 'reading');
|
|
if (previous !== state && ['idle', 'finished'].includes(state) && ['reading', 'loading'].includes(previous)) requestSave(true);
|
|
if (now - lastSent >= PULSE_MS && owned && ['reading', 'loading'].includes(state)) requestSave();
|
|
render();
|
|
}
|
|
function snapshot(final = false, release = false) {
|
|
lastSent = performance.now();
|
|
return { ...options.identity, session, seq: ++sequence, elapsedMs: Math.round(lastSent - started),
|
|
activeMs: Math.round(visit.activeMs), loadingMs: Math.round(visit.loadingMs),
|
|
inputMs: Object.fromEntries(INPUTS.map(key => [key, Math.round(visit.inputMs[key])])),
|
|
seen: covered(), final, release: final || release };
|
|
}
|
|
function requestSave(release = false) {
|
|
urgent = true; releasePending ||= release;
|
|
pump();
|
|
}
|
|
async function pump() {
|
|
if (sending || destroyed || ended || !urgent) return;
|
|
sending = true; urgent = false;
|
|
const body = snapshot(false, releasePending); releasePending = false;
|
|
clearTimeout(retryTimer);
|
|
try {
|
|
const response = await fetch('api/reading/visit', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
|
const data = await response.json();
|
|
if (!response.ok) throw new Error(data.error || response.statusText);
|
|
saveError = false;
|
|
if (data.accepted) {
|
|
acknowledged = body; acknowledgedSeen = body.seen; reading = data.reading || reading;
|
|
if (reading.currentPass?.complete && !notified) {
|
|
notified = true;
|
|
options.onComplete?.({ activeMs: visit.activeMs, totalActiveMs: reading.totalActiveMs, completedPasses: reading.completedPasses });
|
|
}
|
|
} else {
|
|
accrue(performance.now()); owned = false; state = 'other-tab';
|
|
visit = { ...acknowledged, inputMs: { ...acknowledged.inputMs } };
|
|
tracker = exposureTracker(); acknowledgedSeen.forEach(page => tracker.seen.add(page));
|
|
retryTimer = setTimeout(() => { if (!needsInput && !ended) { owned = true; requestSave(); sample(); } }, Math.max(500, data.retryAfterMs || 2500));
|
|
}
|
|
} catch {
|
|
// Network delays aren't fake reading. Retain cumulative time and retry.
|
|
saveError = true;
|
|
retryTimer = setTimeout(() => requestSave(), 2500);
|
|
} finally {
|
|
sending = false; sample();
|
|
// New milliseconds accrued during a response must NOT trigger another
|
|
// request. Only an explicitly requested save can run the pump again.
|
|
if (urgent) pump();
|
|
}
|
|
}
|
|
function flush() {
|
|
if (destroyed || ended) return;
|
|
sample(); ended = true;
|
|
const body = JSON.stringify(snapshot(true, true));
|
|
// Send even with a pulse in flight. Cumulative sequence handling makes
|
|
// either arrival order safe, including the final beacon arriving first.
|
|
let queued = false;
|
|
try { queued = navigator.sendBeacon?.('api/reading/visit', new Blob([body], { type: 'application/json' })); } catch {}
|
|
if (!queued) fetch('api/reading/visit', { method: 'POST', keepalive: true, headers: { 'Content-Type': 'application/json' }, body }).catch(() => {});
|
|
}
|
|
function input(type) {
|
|
if (ended || destroyed || document.hidden) return;
|
|
const now = performance.now(); accrue(now);
|
|
lastType = type; lastInputAt = now; needsInput = false; focused = document.hasFocus();
|
|
if (!owned) { owned = true; requestSave(); }
|
|
const at = Date.now();
|
|
if (at - claimAt >= 1000) { claimAt = at; channel?.postMessage({ session, at }); }
|
|
sample();
|
|
}
|
|
function render() {
|
|
const [icon, label] = labels[state] || labels.idle, coverage = covered().length;
|
|
const qualified = coverage >= required && visit.activeMs > 0;
|
|
const total = (+reading.totalActiveMs || 0) + (qualified ? Math.max(0, visit.activeMs - (reading.currentPass?.complete ? acknowledged.activeMs : 0)) : 0);
|
|
button.querySelector('.clock-icon').textContent = icon;
|
|
button.querySelector('.clock-time').textContent = fmt(visit.activeMs);
|
|
button.querySelector('.clock-label').textContent = ` · ${label}${saveError ? ' · Save pending' : ''}`;
|
|
button.classList.toggle('clock-running', state === 'reading');
|
|
button.classList.remove('clock-warning');
|
|
button.title = `Active Reading Clock · ${label} · ${coverage}/${eligible.size} pages`;
|
|
if (!panel.hidden) {
|
|
panel.textContent = '';
|
|
panel.append(h('div', { class: 'clock-head' }, h('b', {}, 'Active Reading Clock'), h('span', { class: 'clock-state' }, `${icon} ${label}`)),
|
|
h('dl', { class: 'clock-details' },
|
|
h('dt', {}, 'This visit'), h('dd', {}, fmt(visit.activeMs)),
|
|
h('dt', {}, 'Pages viewed'), h('dd', {}, `${coverage} / ${eligible.size}`),
|
|
h('dt', {}, 'Counted as a read'), h('dd', {}, qualified ? 'Yes · time continues counting' : `Not yet · ${required} pages needed (60%)`),
|
|
h('dt', {}, 'Saved chapter total'), h('dd', {}, fmt(total)),
|
|
h('dt', {}, 'Loading wait'), h('dd', {}, fmt(visit.loadingMs))),
|
|
h('p', { class: 'muted small' }, saveError ? 'Save pending. Your time is retained in this tab and will be retried.' :
|
|
'No scrolling-speed limit. Loaded pages count after 100 ms of visible exposure. At 60% coverage, all active time from this visit counts and is saved when you leave. Short picture lookups stay out of your totals. Pauses in the background and after 30 seconds without reader activity.'));
|
|
}
|
|
}
|
|
button.hidden = false;
|
|
listen(button, 'click', e => { e.stopPropagation(); panel.hidden = !panel.hidden; render(); });
|
|
listen(panel, 'click', e => e.stopPropagation());
|
|
listen(document, 'click', () => { panel.hidden = true; });
|
|
try {
|
|
channel = new BroadcastChannel('mangacircus-reading-clock');
|
|
channel.onmessage = event => {
|
|
const msg = event.data || {};
|
|
if (msg.session === session || !msg.at || msg.at < claimAt || (msg.at === claimAt && msg.session < session)) return;
|
|
accrue(performance.now()); owned = false; state = 'other-tab'; requestSave(true); render();
|
|
};
|
|
} catch { channel = null; }
|
|
let pointer = null;
|
|
listen(document, 'pointerdown', e => { if (e.isTrusted) input(e.pointerType === 'touch' ? 'touch' : 'mouse'); }, { passive: true });
|
|
listen(document, 'pointermove', e => {
|
|
if (!e.isTrusted) return;
|
|
const now = performance.now();
|
|
if (!pointer || (now - pointer.at >= 150 && Math.hypot(e.clientX - pointer.x, e.clientY - pointer.y) >= 3)) {
|
|
pointer = { x: e.clientX, y: e.clientY, at: now }; input(e.pointerType === 'touch' ? 'touch' : 'mouse');
|
|
}
|
|
}, { passive: true });
|
|
listen(document, 'wheel', e => { if (e.isTrusted) input('mouse'); }, { passive: true });
|
|
listen(document, 'keydown', e => {
|
|
if (e.isTrusted && ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', ' ', 'PageDown', 'PageUp', 'Home', 'End', '+', '=', '-', '0', 'm', 'Escape', 'f'].includes(e.key)) input('keyboard');
|
|
});
|
|
// Momentum scrolling remains activity after the finger lifts.
|
|
if (options.surface) listen(options.surface, 'scroll', e => { if (e.isTrusted && !needsInput) input(lastType); }, { passive: true });
|
|
listen(window, 'blur', () => { accrue(performance.now()); focused = false; needsInput = true; sample(); requestSave(true); });
|
|
listen(window, 'focus', () => { focused = true; sample(); });
|
|
listen(document, 'visibilitychange', () => {
|
|
if (document.hidden) { accrue(performance.now()); needsInput = true; sample(); requestSave(true); } else sample();
|
|
});
|
|
listen(window, 'pagehide', flush);
|
|
listen(document, 'freeze', () => { accrue(performance.now()); needsInput = true; sample(); requestSave(true); });
|
|
listen(window, 'pageshow', e => { if (e.persisted) { ended = false; lastSample = performance.now(); input(lastType); requestSave(); } });
|
|
const timer = setInterval(sample, 50);
|
|
sample(); requestSave();
|
|
return { viewChanged: sample, flush, destroy() {
|
|
flush(); destroyed = true; clearInterval(timer); clearTimeout(retryTimer);
|
|
listeners.forEach(remove => remove()); channel?.close();
|
|
} };
|
|
};
|
|
})();
|