Files
MangaCircus/reading-activity.js
MaashkinoozheandClaude Opus 5.5 fe8956087e Reading activity grid in Settings: months, weeks, days, hours with drill-down
Active reader time is now kept per user in small UTC buckets (6 minutes for
120 days, then hours, dropped after three years), seeded once from the visits
the clock still remembers. Settings -> Reading activity shows them as a grid
whose cells glow with their total and carry a start-to-end strip; month/week
cells open their days, days open their hours. Finished chapters come from
progress and work without the clock.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 12:41:44 +01:00

115 lines
4.8 KiB
JavaScript

'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 };