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>
This commit is contained in:
MaashkinoozheandClaude Opus 5.5 committed 2026-11-03 12:41:44 +01:00
1 parent c760141636
commit fe8956087e
15 files changed
+657 -4

No files matched your search

+29 -2
View File
@@ -34,6 +34,7 @@ const { shouldRunNightly, Harvester } = require('./discovery/harvest');
const {
applyPulse, applyVisit, chapterInsight, earliestCompletion, eligiblePages, emptyRecord, libraryInsight, mangaInsight, publicSummary,
} = require('./reading-analytics');
const readingActivity = require('./reading-activity');
const { chapterDates } = require('./chapter-dates');
const { parseSchedule, publicSchedule, loadQuestionBank, createReadingAccess } = require('./reading-access');
const { BackupService, BackupError } = require('./backup');
@@ -80,7 +81,7 @@ const greatestCovers = new GreatestCovers(path.join(DATA, 'greatest-covers'));
const greatestCoverUrl = entry => entry.cover
? `api/greatest-cover?id=${encodeURIComponent(entry.id)}&v=${greatestCovers.version(entry.cover)}` : null;
const DB_FILE = path.join(DATA, 'db.json'), CACHE_FILE = path.join(DATA, 'scan-cache.json'), INDEX_FILE = path.join(DATA, 'library-index.json');
const db = { users: {}, sessions: {}, progress: {}, reading: {}, readingAccess: {}, follows: {}, notes: {}, votes: {}, greatestRatings: {}, discoveryFeedback: {}, moments: {}, known: {} };
const db = { users: {}, sessions: {}, progress: {}, reading: {}, activity: {}, readingAccess: {}, follows: {}, notes: {}, votes: {}, greatestRatings: {}, discoveryFeedback: {}, moments: {}, known: {} };
// A corrupt db.json stops the server instead of silently starting empty and overwriting it.
if (fs.existsSync(DB_FILE)) Object.assign(db, JSON.parse(fs.readFileSync(DB_FILE, 'utf8')));
function writeAtomic(file, text) { fs.writeFileSync(file + '.tmp', text); fs.renameSync(file + '.tmp', file); }
@@ -89,6 +90,7 @@ function save() { saveTimer = saveTimer || setTimeout(flush, 500); }
function flush() { clearTimeout(saveTimer); saveTimer = null; writeAtomic(DB_FILE, JSON.stringify(db)); }
for (const sig of ['SIGINT', 'SIGTERM']) process.on(sig, () => { flush(); process.exit(0); });
db.readingAccess ||= {};
db.activity ||= {};
// The reader owns backups: it alone mounts all of DATA and holds db.json in memory.
const backup = new BackupService({ dataRoot: DATA, backupRoot: process.env.BACKUP || '', snapshotDb: () => JSON.stringify(db),
scraper: { state: () => scraperRequest('/api/state'), control: action => scraperRequest('/api/control', { action }) } }).start();
@@ -251,6 +253,18 @@ const chapterOf = (m, id) => m.chapterById.get(String(id)) || bad(404, 'Chapter
const progressOf = (u, m) => (db.progress[u.name] && db.progress[u.name][m.id]) || {};
const mangaReadingOf = (u, m) => (db.reading[u.name] && db.reading[u.name][m.id]) || {};
const readingOf = (u, m, ch) => (db.reading[u.name] && db.reading[u.name][m.id] && db.reading[u.name][m.id][ch.id]) || null;
const activityPayload = u => readingActivity.activitySeries(activityOf(u), db.progress[u.name], {
titleOf: id => lib.byId.get(id)?.title,
labelOf: (mangaId, chapterId) => lib.byId.get(mangaId)?.chapterById.get(chapterId)?.label || chapterId,
});
// The activity grid's buckets; the first use seeds them from the visits the clock still remembers.
function activityOf(u, now = Date.now()) {
if (!db.activity[u.name]) { db.activity[u.name] = readingActivity.backfill(readingActivity.emptyActivity(), db.reading[u.name], now); save(); }
const act = db.activity[u.name];
// Folding old buckets into hours is cheap, but there is no need to do it on every pulse.
if (Math.floor(now / readingActivity.HOUR_MS) !== act.foldedHour) { readingActivity.fold(act, now); act.foldedHour = Math.floor(now / readingActivity.HOUR_MS); }
return act;
}
const unreadOf = u => (db.notes[u.name] || []).filter(n => !n.read).length;
const momentsOf = u => (db.moments[u.name] ||= []);
const momentOf = (u, id) => momentsOf(u).find(x => x.id === String(id || '')) || bad(404, 'Moment not found');
@@ -801,19 +815,24 @@ const routes = {
user: u.name,
reading: libraryInsight(mangas),
mangas,
activity: activityPayload(u),
};
},
'GET /api/reading/activity': ({ u }) => ({ readingClock: !!settingsOf(u).readingClock, ...activityPayload(u) }),
'POST /api/reading/delete': ({ u, b }) => {
if (b.all) {
if (!checkPw(String(b.password || ''), u.hash)) bad(403, 'The current password is wrong.');
delete db.reading[u.name];
delete db.activity[u.name];
readingLeases.delete(u.name);
save();
return { ok: true, deleted: 'all' };
}
const m = mangaOf(b.m), all = db.reading[u.name];
if (all) delete all[m.id];
if (db.activity[u.name]) readingActivity.forgetManga(db.activity[u.name], m.id);
save();
return { ok: true, deleted: m.id };
},
@@ -906,7 +925,15 @@ const routes = {
const byUser = (db.reading[u.name] = db.reading[u.name] || {});
const byManga = (byUser[m.id] = byUser[m.id] || {});
const record = (byManga[ch.id] = readingOf(u, m, ch) || emptyRecord());
// Seed the activity buckets from remembered visits before this visit changes them.
const activity = activityOf(u, now);
const visitMs = () => record.recentSessions.find(item => item.id === session)?.pass?.activeMs || 0, before = visitMs();
const result = (cumulative ? applyVisit : applyPulse)(record, b, { version: v.id, pageCount: v.pages.length, eligible, now });
// The activity grid counts all active reader time, including visits below the 60% threshold.
if (!result.duplicate) {
const added = cumulative ? visitMs() - before : result.activeMs;
if (added > 0) readingActivity.addActive(activity, m.id, added, now);
}
if (b.release && readingLeases.get(u.name)?.session === session) readingLeases.delete(u.name);
if (!result.duplicate) save();
return {
@@ -1029,7 +1056,7 @@ const routes = {
admin(u);
const name = String(b.name || '');
if (name === u.name) bad(400, "You can't delete your own account.");
delete db.users[name]; delete db.progress[name]; delete db.reading[name]; delete db.follows[name]; delete db.notes[name]; delete db.moments[name];
delete db.users[name]; delete db.progress[name]; delete db.reading[name]; delete db.activity[name]; delete db.follows[name]; delete db.notes[name]; delete db.moments[name];
if (db.discoveryFeedback) delete db.discoveryFeedback[name];
if (db.greatestRatings) delete db.greatestRatings[name];
for (const [k, s] of Object.entries(db.sessions)) if (s.u === name) delete db.sessions[k];