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

+99
View File
@@ -0,0 +1,99 @@
'use strict';
const test = require('node:test'), assert = require('node:assert/strict');
const act = require('../reading-activity');
const core = require('../public/activity-core');
const MIN = 60000, HOUR = 60 * MIN, DAY = 24 * HOUR;
const sum = a => Object.values(a.fine).concat(Object.values(a.hourly)).reduce((s, b) => s + b.a, 0);
test('active time ending at a moment is spread over the 6-minute buckets it covers', () => {
const a = act.emptyActivity(), end = 1000 * act.FINE_MS + 2 * MIN;
act.addActive(a, 'alpha', 5 * MIN, end);
assert.deepEqual(Object.keys(a.fine).map(Number), [999, 1000]);
assert.equal(a.fine[1000].a, 2 * MIN); assert.equal(a.fine[999].a, 3 * MIN);
act.addActive(a, 'beta', MIN, end);
assert.deepEqual(a.fine[1000].m, { alpha: 2 * MIN, beta: MIN });
});
test('old buckets fold into hours, very old ones are dropped, totals stay', () => {
const now = 2000 * DAY, a = act.emptyActivity();
act.addActive(a, 'alpha', 10 * MIN, now - 200 * DAY);
act.addActive(a, 'alpha', 10 * MIN, now - 2 * DAY);
act.addActive(a, 'alpha', 10 * MIN, now - 1200 * DAY);
act.fold(a, now);
assert.equal(Object.keys(a.hourly).length, 1);
assert.equal(sum(a), 20 * MIN);
});
test('backfill places remembered visits before their last save, once', () => {
const now = 100 * DAY, at = now - DAY;
const reading = { alpha: { c1: { recentSessions: [
{ id: 'v1', at, pass: { activeMs: 20 * MIN, startedAt: at - 30 * MIN } },
{ id: 'legacy', at, counted: true },
] } } };
const a = act.backfill(act.emptyActivity(), reading, now);
assert.equal(sum(a), 20 * MIN); assert.equal(a.backfilled, true);
act.backfill(a, reading, now);
assert.equal(sum(a), 20 * MIN);
});
test('forgetting a manga removes only its share', () => {
const a = act.emptyActivity();
act.addActive(a, 'alpha', MIN, 10 * act.FINE_MS);
act.addActive(a, 'beta', 2 * MIN, 10 * act.FINE_MS);
act.forgetManga(a, 'beta');
assert.equal(sum(a), MIN); assert.deepEqual(a.fine[9].m, { alpha: MIN });
});
test('series lists buckets in time order and chapter completions with labels', () => {
const a = act.emptyActivity();
act.addActive(a, 'alpha', MIN, 20 * act.FINE_MS);
a.hourly[1] = { a: MIN, m: { beta: MIN } };
const s = act.activitySeries(a, { alpha: { c1: { done: true, doneAt: 5 }, c2: { done: false } } },
{ titleOf: id => id.toUpperCase(), labelOf: (m, c) => 'Ch. ' + c });
assert.deepEqual(s.buckets.map(b => b[0]), [act.HOUR_MS, 19 * act.FINE_MS]);
assert.deepEqual(s.done, [[5, s.mangas.findIndex(m => m.id === 'alpha'), 'Ch. c1']]);
assert.equal(s.mangas.find(m => m.id === 'beta').title, 'BETA');
});
test('periods are local and Monday based; cells carry their natural segments', () => {
const tue = new Date(2026, 10, 3, 15, 30).getTime();
assert.equal(core.periodStart('week', tue), new Date(2026, 10, 2).getTime());
assert.equal(core.periodStart('month', tue), new Date(2026, 10, 1).getTime());
assert.equal(core.isoWeek(tue), 45);
assert.equal(core.segmentBounds('month', new Date(2026, 1, 1).getTime()).length, 29);
assert.equal(core.segmentBounds('week', new Date(2026, 10, 2).getTime()).length, 29);
assert.equal(core.segmentBounds('hour', new Date(2026, 10, 3, 15).getTime()).length, 11);
});
test('layouts: two years of months, six months of weeks without repeats, a month calendar, a day of hours', () => {
const t = new Date(2026, 10, 3, 15).getTime();
const months = core.activityLayout('month', t);
assert.deepEqual(months.rows.map(r => r.cells.length), [12, 12]);
const weeks = core.activityLayout('week', t).rows.flatMap(r => r.cells);
assert.equal(new Set(weeks.map(c => c.start)).size, weeks.length);
for (let i = 1; i < weeks.length; i++) assert.equal(weeks[i].start, core.periodShift('week', weeks[i - 1].start, 1));
const days = core.activityLayout('day', t);
assert.equal(days.rows.flatMap(r => r.cells).filter(c => !c.outside).length, 30);
assert.equal(core.activityLayout('hour', t).rows.flatMap(r => r.cells).length, 24);
});
test('sums split buckets by overlap and count completions in their segment', () => {
const day = new Date(2026, 10, 3).getTime();
const cells = core.activityLayout('hour', day).rows.flatMap(r => r.cells);
const nine = day + 9 * HOUR;
core.activitySums({ buckets: [[nine - 30 * MIN, HOUR, 60 * MIN, [[0, 60 * MIN]]]], done: [[nine + 7 * MIN, 0, 'Ch. 1']] }, cells);
assert.equal(Math.round(cells[8].totalTime), 30 * MIN); assert.equal(Math.round(cells[9].totalTime), 30 * MIN);
assert.deepEqual(cells[9].time.slice(0, 6).map(Math.round), [6 * MIN, 6 * MIN, 6 * MIN, 6 * MIN, 6 * MIN, 0]);
assert.equal(cells[9].chapters[1], 1); assert.equal(cells[9].done.length, 1);
});
test('drilling: month and week open days (week marked), day opens hours, hour is the end', () => {
const week = new Date(2026, 10, 30).getTime();
const next = core.activityDrill({ level: 'week', start: week });
assert.equal(next.view, 'day'); assert.equal(next.mark, week);
assert.equal(new Date(next.anchor).getMonth(), 11);
assert.equal(core.activityDrill({ level: 'day', start: week }).view, 'hour');
assert.equal(core.activityDrill({ level: 'hour', start: week }), null);
assert.equal(core.activityGlow(0, 10), 0); assert.equal(core.activityGlow(10, 10), 1);
});
+9
View File
@@ -148,6 +148,15 @@ test('three services integrate collections, auth, custom covers and private heal
assert.equal((await send(visit(first, 'beacon-first', 2, { activeMs: 500, seen: [0], final: true, release: true }))).accepted, true);
assert.equal((await send(visit(second, 'third-visit', 1))).accepted, false);
await send(visit(second, 'second-visit', 2, { release: true }));
// The activity grid gets every credited millisecond once, duplicates and stale pulses excluded.
assert.equal((await request('/api/progress', { m: first.manga.id, c: first.chapter.id, v: first.version, p: 0, n: 1, done: true })).status, 200);
const activity = await (await request('/api/reading/activity')).json();
assert.equal(activity.readingClock, true);
assert.equal(activity.buckets.reduce((sum, b) => sum + b[2], 0), 2000);
assert.deepEqual(activity.mangas.map(m => m.id), [first.manga.id]);
assert.equal(activity.done.length, 1); assert.equal(activity.done[0][2], first.chapter.label);
assert.equal((await request('/api/reading/delete', { m: first.manga.id })).status, 200);
assert.equal((await (await request('/api/reading/activity')).json()).buckets.length, 0);
assert.equal((await fetch(base + '/api/reading/visit', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(visit(first, 'unauth-visit', 1)) })).status, 401);
await request('/api/settings', { readingClock: false });
assert.equal((await request('/api/reading/visit', visit(first, 'disabled-visit', 1))).status, 403);