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:
1 parent
c760141636
commit
fe8956087e
15 files changed
+657
-4
No files matched your search
@@ -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];
|
||||
|
||||
Reference in new issue
Block a user