'use strict'; // Reading activity: active reader time in small UTC time buckets, for the activity grid in Settings. // Buckets hold time and which manga it went to, never pages, input events or a raw event log. // Recent time keeps 6-minute buckets (a tenth of an hour); older time folds into hours. const FINE_MS = 6 * 60 * 1000, HOUR_MS = 60 * 60 * 1000, DAY_MS = 24 * HOUR_MS; const FINE_DAYS = 120, KEEP_DAYS = 3 * 366; const whole = value => Math.max(0, Math.round(Number(value) || 0)); function emptyActivity() { return { schema: 1, fine: {}, hourly: {}, backfilled: false }; } function normalizeActivity(activity) { const out = activity && typeof activity === 'object' ? activity : emptyActivity(); if (!out.fine || typeof out.fine !== 'object') out.fine = {}; if (!out.hourly || typeof out.hourly !== 'object') out.hourly = {}; out.schema = 1; return out; } function credit(map, key, mangaId, ms) { const bucket = (map[key] = map[key] || { a: 0, m: {} }); bucket.a += ms; bucket.m[mangaId] = (bucket.m[mangaId] || 0) + ms; } // Active time `ms` that ended at `end`, spread over the buckets it covers. function addActive(activity, mangaId, ms, end) { const act = normalizeActivity(activity); let left = whole(ms), at = whole(end); if (!left || !mangaId) return act; while (left > 0) { const key = Math.floor((at - 1) / FINE_MS), share = Math.min(left, at - key * FINE_MS); credit(act.fine, key, mangaId, share); left -= share; at -= share; } return act; } // Fine buckets older than FINE_DAYS become hourly; anything older than KEEP_DAYS is dropped. function fold(activity, now = Date.now()) { const act = normalizeActivity(activity); const fineLimit = Math.floor((now - FINE_DAYS * DAY_MS) / FINE_MS), keepLimit = Math.floor((now - KEEP_DAYS * DAY_MS) / HOUR_MS); for (const [key, bucket] of Object.entries(act.fine)) { if (+key >= fineLimit) continue; const hour = Math.floor(+key * FINE_MS / HOUR_MS); for (const [mangaId, ms] of Object.entries(bucket.m)) credit(act.hourly, hour, mangaId, ms); delete act.fine[key]; } for (const key of Object.keys(act.hourly)) if (+key < keepLimit) delete act.hourly[key]; return act; } // One-time seed from the visits the Reading Clock still remembers (the last eight per chapter). // A visit's active time is placed just before its last save; older visits are gone and stay gone. function backfill(activity, readingByManga, now = Date.now()) { const act = normalizeActivity(activity); if (act.backfilled) return act; for (const [mangaId, chapters] of Object.entries(readingByManga || {})) { for (const record of Object.values(chapters || {})) { for (const receipt of record?.recentSessions || []) { const pass = receipt?.pass, ms = whole(pass?.activeMs), end = whole(receipt?.at); if (!ms || !end || end > now) continue; const start = Math.max(whole(pass.startedAt) || end - ms, end - ms); addActive(act, mangaId, Math.min(ms, end - start), end); } } } act.backfilled = true; return fold(act, now); } function forgetManga(activity, mangaId) { const act = normalizeActivity(activity); for (const map of [act.fine, act.hourly]) { for (const [key, bucket] of Object.entries(map)) { const ms = bucket.m[mangaId]; if (!ms) continue; delete bucket.m[mangaId]; bucket.a = Math.max(0, bucket.a - ms); if (!bucket.a) delete map[key]; } } return act; } // Compact payload for the grid: [startMs, sizeMs, activeMs, [[mangaIndex, ms], …]] per bucket, // chapter completions as [doneAt, mangaIndex, chapterLabel]. The browser groups them in local time. function activitySeries(activity, progressByManga, { titleOf = id => id, labelOf = (m, c) => c } = {}) { const act = normalizeActivity(activity); const mangas = [], index = new Map(); const ref = id => { if (!index.has(id)) { index.set(id, mangas.length); mangas.push({ id, title: titleOf(id) || id }); } return index.get(id); }; const buckets = []; for (const [map, size] of [[act.hourly, HOUR_MS], [act.fine, FINE_MS]]) { for (const [key, bucket] of Object.entries(map)) { if (!bucket.a) continue; buckets.push([+key * size, size, whole(bucket.a), Object.entries(bucket.m).map(([id, ms]) => [ref(id), whole(ms)])]); } } buckets.sort((a, b) => a[0] - b[0]); const done = []; for (const [mangaId, chapters] of Object.entries(progressByManga || {})) { for (const [chapterId, p] of Object.entries(chapters || {})) { if (p && p.done && whole(p.doneAt)) done.push([whole(p.doneAt), ref(mangaId), labelOf(mangaId, chapterId)]); } } done.sort((a, b) => a[0] - b[0]); return { schema: 1, fineMs: FINE_MS, fineDays: FINE_DAYS, mangas, buckets, done }; } module.exports = { FINE_MS, HOUR_MS, FINE_DAYS, KEEP_DAYS, emptyActivity, addActive, fold, backfill, forgetManga, activitySeries };