- Chapter rows show when you first finished a chapter (setting: date + hour, date, off) from a new durable progress doneAt; older reads fall back to the reading clock's earliest remembered completion. - chapter.json keeps added (arrival in this library, now kept on replace) plus x-dates.source (source upload, bulk imports flagged), x-dates.release (official, for a later metadata job) and x-dates.ecosystem (first entry into any Manga Circus library; carried over peering, earliest wins, clamped). - Rows show one release date by tier: official > source upload > ecosystem > library, with all known dates in the tooltip (setting to hide). - Fixes: WeebCentral dates came from the chapter page, which has none (every chapter got the download time); they now come from the full chapter list. MangaDex no longer writes a group's publishAt into added. Peering sends dates. - tools/backfill-chapter-dates.js migrates an existing library (dry run default). - Coverage dry run on the server: docs/chapter-dates-coverage-2026-10-24.md. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
312 lines
14 KiB
JavaScript
312 lines
14 KiB
JavaScript
'use strict';
|
|
|
|
const INPUT_TYPES = ['mouse', 'touch', 'keyboard'];
|
|
const MAX_RECENT_SESSIONS = 8;
|
|
|
|
const whole = (value, max = Number.MAX_SAFE_INTEGER) => Math.min(max, Math.max(0, Math.round(Number(value) || 0)));
|
|
|
|
function rangeSet(ranges, pageCount = Number.MAX_SAFE_INTEGER) {
|
|
const out = new Set();
|
|
for (const pair of Array.isArray(ranges) ? ranges : []) {
|
|
if (!Array.isArray(pair) || pair.length < 2) continue;
|
|
const start = whole(pair[0], pageCount - 1), end = whole(pair[1], pageCount - 1);
|
|
for (let i = Math.min(start, end); i <= Math.max(start, end); i++) out.add(i);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function compactRanges(values) {
|
|
const sorted = [...new Set(values)].filter(Number.isInteger).sort((a, b) => a - b);
|
|
const ranges = [];
|
|
for (const value of sorted) {
|
|
const last = ranges[ranges.length - 1];
|
|
if (last && value <= last[1] + 1) last[1] = Math.max(last[1], value);
|
|
else ranges.push([value, value]);
|
|
}
|
|
return ranges;
|
|
}
|
|
|
|
function eligiblePages(version, settings) {
|
|
const eligible = [];
|
|
for (let i = 0; i < version.pages.length; i++) {
|
|
const tags = version.pages[i].t || [];
|
|
if (settings.skipExtras && tags.includes('extra')) continue;
|
|
if (settings.skipCovers && tags.includes('cover')) continue;
|
|
eligible.push(i);
|
|
}
|
|
return eligible.length ? eligible : version.pages.map((_, i) => i);
|
|
}
|
|
|
|
function emptyRecord() {
|
|
return {
|
|
schema: 1,
|
|
totalActiveMs: 0,
|
|
firstCompleteMs: null,
|
|
lastCompleteMs: null,
|
|
loadingMs: 0,
|
|
sessions: 0,
|
|
completedPasses: 0,
|
|
inputMs: { mouse: 0, touch: 0, keyboard: 0 },
|
|
fastBursts: 0,
|
|
rejectedPageViews: 0,
|
|
maxObservedPps: 0,
|
|
currentPass: null,
|
|
recentSessions: [],
|
|
updatedAt: 0,
|
|
};
|
|
}
|
|
|
|
function normalizeRecord(record) {
|
|
const base = emptyRecord(), out = record && typeof record === 'object' ? record : {};
|
|
for (const [key, value] of Object.entries(base)) if (out[key] === undefined) out[key] = value;
|
|
out.inputMs = { ...base.inputMs, ...(out.inputMs || {}) };
|
|
out.recentSessions = Array.isArray(out.recentSessions) ? out.recentSessions : [];
|
|
return out;
|
|
}
|
|
|
|
function publicSummary(record, version, eligible) {
|
|
const r = normalizeRecord(record || emptyRecord());
|
|
const pass = r.currentPass && r.currentPass.version === version ? r.currentPass : null;
|
|
const allowed = new Set(eligible);
|
|
const seen = pass ? [...rangeSet(pass.seenRanges)].filter(x => allowed.has(x)) : [];
|
|
const predominantInput = INPUT_TYPES.reduce((best, key) => r.inputMs[key] > r.inputMs[best] ? key : best, 'mouse');
|
|
const hasInput = INPUT_TYPES.some(key => r.inputMs[key] > 0);
|
|
return {
|
|
schema: 1,
|
|
totalActiveMs: whole(r.totalActiveMs),
|
|
firstCompleteMs: r.firstCompleteMs == null ? null : whole(r.firstCompleteMs),
|
|
lastCompleteMs: r.lastCompleteMs == null ? null : whole(r.lastCompleteMs),
|
|
loadingMs: whole(r.loadingMs),
|
|
sessions: whole(r.sessions),
|
|
completedPasses: whole(r.completedPasses),
|
|
inputMs: Object.fromEntries(INPUT_TYPES.map(key => [key, whole(r.inputMs[key])])),
|
|
predominantInput: hasInput ? predominantInput : null,
|
|
fastBursts: whole(r.fastBursts),
|
|
rejectedPageViews: whole(r.rejectedPageViews),
|
|
maxObservedPps: whole(r.maxObservedPps),
|
|
eligiblePages: eligible.length,
|
|
currentPass: {
|
|
version,
|
|
activeMs: whole(pass && pass.activeMs),
|
|
seenRanges: compactRanges(seen),
|
|
seenPages: seen.length,
|
|
complete: !!(pass && pass.completedAt),
|
|
startedAt: pass ? whole(pass.startedAt) : null,
|
|
protocol: pass?.protocol || 1,
|
|
requiredPages: Math.ceil(eligible.length * (pass?.protocol === 2 ? 0.6 : 1)),
|
|
},
|
|
updatedAt: whole(r.updatedAt),
|
|
};
|
|
}
|
|
|
|
function chapterInsight(record) {
|
|
if (!record || typeof record !== 'object') return null;
|
|
const r = normalizeRecord(record);
|
|
const totalActiveMs = whole(r.totalActiveMs);
|
|
if (!totalActiveMs && !whole(r.completedPasses) && (!whole(r.updatedAt) || r.currentPass?.protocol === 2)) return null;
|
|
return {
|
|
totalActiveMs,
|
|
firstCompleteMs: r.firstCompleteMs == null ? null : whole(r.firstCompleteMs),
|
|
lastCompleteMs: r.lastCompleteMs == null ? null : whole(r.lastCompleteMs),
|
|
completedPasses: whole(r.completedPasses),
|
|
currentPassMs: whole(r.currentPass && r.currentPass.activeMs),
|
|
currentPassComplete: !!(r.currentPass && r.currentPass.completedAt),
|
|
updatedAt: whole(r.qualifiedUpdatedAt || r.updatedAt),
|
|
};
|
|
}
|
|
|
|
// Earliest completion time the clock still remembers (only the last few visits are kept).
|
|
// approx: older completed passes were already dropped, so the first read may be earlier.
|
|
function earliestCompletion(record) {
|
|
if (!record || typeof record !== 'object') return null;
|
|
const passes = [record.currentPass, ...(record.recentSessions || []).map(item => item && item.pass)];
|
|
const times = passes.map(pass => pass && whole(pass.completedAt)).filter(time => time > 0);
|
|
if (!times.length) return null;
|
|
const counted = new Set(times).size;
|
|
return { at: Math.min(...times), approx: whole(record.completedPasses) > counted };
|
|
}
|
|
|
|
function median(values) {
|
|
const sorted = values.filter(value => Number.isFinite(value) && value > 0).sort((a, b) => a - b);
|
|
if (!sorted.length) return null;
|
|
const middle = Math.floor(sorted.length / 2);
|
|
return sorted.length % 2 ? whole(sorted[middle]) : whole((sorted[middle - 1] + sorted[middle]) / 2);
|
|
}
|
|
|
|
function mangaInsight(records, chapterIds) {
|
|
const source = records && typeof records === 'object' ? records : {};
|
|
const ids = Array.isArray(chapterIds) ? chapterIds : Object.keys(source);
|
|
const chapters = ids.map(id => ({ id, reading: chapterInsight(source[id]) })).filter(item => item.reading);
|
|
const latest = chapters.reduce((best, item) => !best || item.reading.updatedAt > best.reading.updatedAt ? item : best, null);
|
|
const completed = chapters.filter(item => item.reading.completedPasses > 0);
|
|
return {
|
|
activeMs: chapters.reduce((sum, item) => sum + item.reading.totalActiveMs, 0),
|
|
timedChapters: chapters.filter(item => item.reading.totalActiveMs > 0).length,
|
|
completedChapters: completed.length,
|
|
completedPasses: completed.reduce((sum, item) => sum + item.reading.completedPasses, 0),
|
|
rereads: completed.reduce((sum, item) => sum + Math.max(0, item.reading.completedPasses - 1), 0),
|
|
typicalChapterMs: median(completed.map(item => item.reading.lastCompleteMs)),
|
|
lastChapterId: latest ? latest.id : null,
|
|
updatedAt: latest ? latest.reading.updatedAt : 0,
|
|
};
|
|
}
|
|
|
|
function libraryInsight(mangas) {
|
|
const tracked = (Array.isArray(mangas) ? mangas : []).filter(item => item && item.reading && item.reading.activeMs > 0);
|
|
const most = tracked.reduce((best, item) => !best || item.reading.activeMs > best.reading.activeMs ? item : best, null);
|
|
const latest = tracked.reduce((best, item) => !best || item.reading.updatedAt > best.reading.updatedAt ? item : best, null);
|
|
return {
|
|
activeMs: tracked.reduce((sum, item) => sum + item.reading.activeMs, 0),
|
|
timedChapters: tracked.reduce((sum, item) => sum + item.reading.timedChapters, 0),
|
|
completedPasses: tracked.reduce((sum, item) => sum + item.reading.completedPasses, 0),
|
|
rereads: tracked.reduce((sum, item) => sum + item.reading.rereads, 0),
|
|
timedMangas: tracked.length,
|
|
mostTimeMangaId: most ? most.id : null,
|
|
lastMangaId: latest ? latest.id : null,
|
|
lastChapterId: latest ? latest.reading.lastChapterId : null,
|
|
updatedAt: latest ? latest.reading.updatedAt : 0,
|
|
};
|
|
}
|
|
|
|
// Cumulative visits tolerate retry and an exit beacon overtaking a regular pulse.
|
|
// Coverage belongs to THIS visit, so an old read cannot qualify a picture lookup.
|
|
// Legacy totals are preserved; unqualified visit time is a draft, not a statistic.
|
|
function applyVisit(record, pulse, { version, pageCount, eligible, now = Date.now() } = {}) {
|
|
const r = normalizeRecord(record), session = String(pulse.session || ''), seq = whole(pulse.seq, 1e9);
|
|
let receipt = r.recentSessions.find(item => item.id === session);
|
|
if (receipt && seq <= whole(receipt.seq)) return { duplicate: true, record: r };
|
|
if (!receipt) {
|
|
if (!r.qualifiedUpdatedAt && r.totalActiveMs) r.qualifiedUpdatedAt = r.updatedAt;
|
|
receipt = { id: session, seq: 0, at: now, pass: {
|
|
protocol: 2, visit: session, version, pageCount, seenRanges: [], activeMs: 0,
|
|
loadingMs: 0, inputMs: { mouse: 0, touch: 0, keyboard: 0 }, creditedMs: 0,
|
|
startedAt: now, completedAt: null,
|
|
} };
|
|
r.recentSessions.push(receipt);
|
|
r.currentPass = receipt.pass;
|
|
}
|
|
const pass = receipt.pass;
|
|
if (!pass || pass.version !== version) return { duplicate: true, record: r };
|
|
// Wall-time sanity, not a speed test or the legacy twelve-second packet cap.
|
|
const budget = whole(pulse.elapsedMs, 24 * 60 * 60 * 1000) + 1500;
|
|
const requestedActive = whole(pulse.activeMs, budget), requestedLoading = whole(pulse.loadingMs, budget);
|
|
const scale = requestedActive + requestedLoading > budget ? budget / (requestedActive + requestedLoading) : 1;
|
|
const active = Math.max(pass.activeMs, Math.round(requestedActive * scale));
|
|
const loading = Math.max(pass.loadingMs, Math.round(requestedLoading * scale));
|
|
r.loadingMs += loading - pass.loadingMs;
|
|
pass.activeMs = active; pass.loadingMs = loading;
|
|
const input = Object.fromEntries(INPUT_TYPES.map(key => [key, whole(pulse.inputMs?.[key], active)]));
|
|
const inputTotal = INPUT_TYPES.reduce((sum, key) => sum + input[key], 0);
|
|
const inputScale = inputTotal > active ? active / inputTotal : 1;
|
|
for (const key of INPUT_TYPES) pass.inputMs[key] = Math.max(pass.inputMs[key], Math.round(input[key] * inputScale));
|
|
const allowed = new Set(eligible), seen = rangeSet(pass.seenRanges, pageCount);
|
|
for (const page of Array.isArray(pulse.seen) ? pulse.seen.slice(0, pageCount) : []) {
|
|
if (Number.isInteger(page) && allowed.has(page)) seen.add(page);
|
|
}
|
|
pass.seenRanges = compactRanges(seen);
|
|
const covered = eligible.filter(page => seen.has(page)).length;
|
|
if (!pass.completedAt && eligible.length && covered >= Math.ceil(eligible.length * 0.6) && active > 0) {
|
|
pass.completedAt = now;
|
|
pass.ordinal = ++r.completedPasses;
|
|
r.sessions++;
|
|
}
|
|
if (pass.completedAt) {
|
|
r.totalActiveMs += active - pass.creditedMs;
|
|
pass.creditedMs = active;
|
|
pass.creditedInput = pass.creditedInput || { mouse: 0, touch: 0, keyboard: 0 };
|
|
for (const key of INPUT_TYPES) {
|
|
r.inputMs[key] += pass.inputMs[key] - pass.creditedInput[key];
|
|
pass.creditedInput[key] = pass.inputMs[key];
|
|
}
|
|
// Duration keeps growing through the last page and departure, not just 60%.
|
|
if (pass.ordinal === 1) r.firstCompleteMs = active;
|
|
if (pass.ordinal === r.completedPasses) r.lastCompleteMs = active;
|
|
r.qualifiedUpdatedAt = now;
|
|
}
|
|
pass.closed = !!pulse.final;
|
|
receipt.seq = seq; receipt.at = now;
|
|
if (r.currentPass?.visit === session) r.currentPass = pass;
|
|
r.recentSessions.sort((a, b) => whole(b.at) - whole(a.at));
|
|
r.recentSessions.splice(MAX_RECENT_SESSIONS);
|
|
r.updatedAt = now;
|
|
return { duplicate: false, record: r };
|
|
}
|
|
|
|
function applyPulse(record, pulse, { version, pageCount, eligible, now = Date.now(), maxDeltaMs = 12000 } = {}) {
|
|
const r = normalizeRecord(record);
|
|
const session = String(pulse.session || '');
|
|
const seq = whole(pulse.seq, 1e9);
|
|
let receipt = r.recentSessions.find(item => item.id === session);
|
|
if (receipt && seq <= whole(receipt.seq, 1e9)) return { duplicate: true, record: r };
|
|
|
|
const newSession = !receipt;
|
|
if (!receipt) {
|
|
receipt = { id: session, seq: 0, at: now, counted: false };
|
|
r.recentSessions.push(receipt);
|
|
}
|
|
|
|
const elapsed = newSession ? maxDeltaMs : Math.max(0, now - whole(receipt.at));
|
|
const allowedDelta = Math.min(maxDeltaMs, elapsed + 1500);
|
|
const requestedActive = whole(pulse.activeMs, maxDeltaMs * 2);
|
|
const requestedLoading = whole(pulse.loadingMs, maxDeltaMs * 2);
|
|
const requestedTotal = requestedActive + requestedLoading;
|
|
const scale = requestedTotal > allowedDelta && requestedTotal ? allowedDelta / requestedTotal : 1;
|
|
const activeMs = Math.round(requestedActive * scale);
|
|
const loadingMs = Math.round(requestedLoading * scale);
|
|
|
|
receipt.seq = seq;
|
|
receipt.at = now;
|
|
if (!receipt.counted && activeMs > 0) { receipt.counted = true; r.sessions++; }
|
|
r.recentSessions.sort((a, b) => whole(b.at) - whole(a.at));
|
|
r.recentSessions.splice(MAX_RECENT_SESSIONS);
|
|
|
|
const prior = r.currentPass;
|
|
if (!prior || prior.version !== version || (prior.completedAt && newSession)) {
|
|
r.currentPass = {
|
|
version,
|
|
pageCount,
|
|
seenRanges: [],
|
|
activeMs: 0,
|
|
startedAt: now,
|
|
completedAt: null,
|
|
};
|
|
}
|
|
const pass = r.currentPass;
|
|
pass.pageCount = pageCount;
|
|
pass.activeMs = whole(pass.activeMs) + activeMs;
|
|
|
|
r.totalActiveMs = whole(r.totalActiveMs) + activeMs;
|
|
r.loadingMs = whole(r.loadingMs) + loadingMs;
|
|
const rawInput = pulse.inputMs && typeof pulse.inputMs === 'object' ? pulse.inputMs : {};
|
|
const requestedInput = Object.fromEntries(INPUT_TYPES.map(key => [key, whole(rawInput[key], maxDeltaMs * 2)]));
|
|
const inputTotal = INPUT_TYPES.reduce((sum, key) => sum + requestedInput[key], 0);
|
|
const inputScale = inputTotal > activeMs && inputTotal ? activeMs / inputTotal : 1;
|
|
for (const key of INPUT_TYPES) r.inputMs[key] = whole(r.inputMs[key]) + Math.round(requestedInput[key] * inputScale);
|
|
|
|
const allowedPages = new Set(eligible);
|
|
const seen = rangeSet(pass.seenRanges, pageCount);
|
|
for (const raw of Array.isArray(pulse.seen) ? pulse.seen.slice(0, pageCount) : []) {
|
|
const page = Number(raw);
|
|
if (Number.isInteger(page) && allowedPages.has(page)) seen.add(page);
|
|
}
|
|
pass.seenRanges = compactRanges([...seen]);
|
|
|
|
r.fastBursts = whole(r.fastBursts) + whole(pulse.fastBursts, 100);
|
|
r.rejectedPageViews = whole(r.rejectedPageViews) + whole(pulse.rejectedPageViews, pageCount * 2);
|
|
r.maxObservedPps = Math.max(whole(r.maxObservedPps), whole(pulse.maxPps, 1000));
|
|
|
|
if (!pass.completedAt && eligible.length && eligible.every(page => seen.has(page))) {
|
|
pass.completedAt = now;
|
|
r.completedPasses = whole(r.completedPasses) + 1;
|
|
r.lastCompleteMs = pass.activeMs;
|
|
if (r.firstCompleteMs == null) r.firstCompleteMs = pass.activeMs;
|
|
}
|
|
r.updatedAt = now;
|
|
return { duplicate: false, record: r, activeMs, loadingMs };
|
|
}
|
|
|
|
module.exports = {
|
|
INPUT_TYPES, applyPulse, applyVisit, chapterInsight, compactRanges, earliestCompletion, eligiblePages, emptyRecord,
|
|
libraryInsight, mangaInsight, publicSummary, rangeSet,
|
|
};
|