Compare commits

..
Author SHA1 Message Date
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
15 changed files with 657 additions and 4 deletions

No files matched your search

+1
View File
@@ -12,6 +12,7 @@
!reading-access.js
!moments.js
!reader-sessions.js
!reading-activity.js
!public/
!public/**
!greatest/
+1 -1
View File
@@ -3,7 +3,7 @@ RUN apk add --no-cache tzdata openssl
WORKDIR /app
ARG VCS_REF=development
LABEL org.opencontainers.image.title="MangaCircus" org.opencontainers.image.revision=$VCS_REF
COPY package.json server.js library.js reading-direction.js chapter-identity.js chapter-dates.js reading-analytics.js reading-access.js catalog.js backup.js moments.js reader-sessions.js ./
COPY package.json server.js library.js reading-direction.js chapter-identity.js chapter-dates.js reading-analytics.js reading-access.js catalog.js backup.js moments.js reader-sessions.js reading-activity.js ./
COPY public ./public
COPY greatest ./greatest
COPY discovery ./discovery
+1
View File
@@ -210,6 +210,7 @@ All requests and responses are JSON. POSTs require `Content-Type: application/js
| `GET /api/progress/latest?m=` | user | The manga's newest saved position (`latest`: `t, w, c, label, v, p, n, done, continue`) or `null` |
| `POST /api/reading/pulse` `{m, c, v, session, seq, …deltas}` | user | When opted in, idempotently merge a clamped active-time pulse, verified page coverage and input totals under a single-reader lease |
| `GET /api/reading/export` | user | Export the user's private derived reading-time summary without raw input or page-exposure diagnostics |
| `GET /api/reading/activity` | user | The user's reading activity for the Settings grid: active reader time in UTC buckets `[start, size, ms, [[mangaIndex, ms]...]]` (6 minutes for the last 120 days, hours up to three years), chapter completions `[doneAt, mangaIndex, label]`, manga titles and whether the clock is on. The browser groups them into local months, weeks, days and hours (`public/activity-core.js`) |
| `POST /api/reading/delete` `{m}` or `{all:true,password}` | user | Delete one manga's reading history, or all history after password confirmation, without changing normal reading progress |
| `POST /api/unread` `{m, c}` | user | Forget progress for a chapter |
| `POST /api/vote` `{m, c, v, value: 1\|-1\|0}` | user | Vote a version; returns the re-ranked versions |
+10
View File
@@ -144,6 +144,16 @@ history from which those values can be reconstructed.
## 5. Later: Reading Journal
Update 2026-11-03: the activity calendar exists as **Settings → Reading activity**
(`reading-activity.js`, `db.activity`). It stores active reader time per user in UTC
buckets: 6 minutes for the last 120 days, then folded into hours, dropped after three
years. Each bucket keeps only its total and the time per manga. All active reader time
counts there, including visits below the 60% threshold, because it answers "when did I
read", not "how long did a chapter take". The first use seeds the buckets from the last
eight visits per chapter the clock still remembers; older history cannot be recovered.
Finished chapters come from the existing progress `doneAt` and need no clock.
Deleting reading-time history (all, or one manga) deletes the matching activity.
If readers enjoy Reading Story, add bounded daily aggregates rather than raw activity
events:
+3 -1
View File
@@ -27,7 +27,7 @@ heading when switching within Settings.
## Routes and ownership
Personal routes: `settings.html#user/reading`, `#user/reading-time`,
Personal routes: `settings.html#user/reading`, `#user/reading-time`, `#user/activity`,
`#user/notifications`, `#user/account`.
Admin routes: `settings.html#admin/scraper`, `#admin/library`, `#admin/users`.
@@ -63,6 +63,8 @@ Service health also holds the **Backups** card: built-in backup status (last run
size, duration, error), **Back up now**, and the daily schedule and retention form,
whose draft survives the page's polling. See [backup.md](backup.md).
Reading activity is a grid of months, weeks, days or hours (`public/activity.js`). Each cell glows with its total and holds a strip from the period's start (left) to its end (right): days in a month, quarter days in a week, hours in a day, 6 minutes in an hour. Clicking a month or week opens its days (the week is marked), a day opens its hours; hover, keyboard focus or a first tap shows details. It measures reading time (needs the Active Reading Clock) or finished chapters (from progress, always available).
Reading-time collection controls, export and deletion live under Reading time &
privacy; reader layout controls stay under Reading. Rescan controls and user
administration are separate. Server & library describes existing maintenance,
+167
View File
@@ -0,0 +1,167 @@
'use strict';
/* Reading activity grid: periods, layouts and sums in the browser's local time.
Pure functions, so the node tests can run them. The server only sends UTC buckets. */
const ACTIVITY_VIEWS = ['month', 'week', 'day', 'hour'];
const ACTIVITY_DAY_NAMES = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
const ACTIVITY_MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
const ACTIVITY_QUARTERS = ['night', 'morning', 'afternoon', 'evening'];
const ACTIVITY_HOUR_MS = 3600000, ACTIVITY_SLICES = 10;
// Start of the local month, Monday-based week, day or hour containing t.
function periodStart(level, t) {
const d = new Date(t);
if (level === 'hour') { d.setMinutes(0, 0, 0); return d.getTime(); }
d.setHours(0, 0, 0, 0);
if (level === 'week') d.setDate(d.getDate() - (d.getDay() + 6) % 7);
if (level === 'month') d.setDate(1);
return d.getTime();
}
// n periods after a period start; calendar arithmetic keeps midnight across DST changes.
function periodShift(level, start, n) {
if (level === 'hour') return start + n * ACTIVITY_HOUR_MS;
const d = new Date(start);
if (level === 'month') d.setMonth(d.getMonth() + n);
else d.setDate(d.getDate() + n * (level === 'week' ? 7 : 1));
return d.getTime();
}
function isoWeek(t) {
const d = new Date(t); d.setHours(0, 0, 0, 0);
d.setDate(d.getDate() + 3 - (d.getDay() + 6) % 7); // Thursday of this week decides the year
const jan4 = new Date(d.getFullYear(), 0, 4);
return 1 + Math.round(((d - jan4) / 864e5 - 3 + (jan4.getDay() + 6) % 7) / 7);
}
const dayText = t => { const d = new Date(t); return `${d.getDate()} ${ACTIVITY_MONTHS[d.getMonth()]}`; };
const clockText = t => { const d = new Date(t); return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`; };
// Inside a cell: month → its days, week → quarter days, day → hours, hour → ten 6-minute slices.
function segmentBounds(level, start) {
const end = periodShift(level, start, 1), bounds = [start];
if (level === 'month') for (let t = periodShift('day', start, 1); t < end; t = periodShift('day', t, 1)) bounds.push(t);
else if (level === 'week') {
for (let day = start; day < end; day = periodShift('day', day, 1)) {
for (const hour of [6, 12, 18]) { const d = new Date(day); d.setHours(hour, 0, 0, 0); bounds.push(d.getTime()); }
if (periodShift('day', day, 1) < end) bounds.push(periodShift('day', day, 1));
}
} else if (level === 'day') {
for (let hour = 1; hour < 24; hour++) { const d = new Date(start); d.setHours(hour, 0, 0, 0); if (d.getTime() > bounds[bounds.length - 1] && d.getTime() < end) bounds.push(d.getTime()); }
} else for (let i = 1; i < ACTIVITY_SLICES; i++) bounds.push(start + i * ACTIVITY_HOUR_MS / ACTIVITY_SLICES);
bounds.push(end);
return bounds;
}
function segmentText(level, from, to) {
if (level === 'month') return dayText(from);
if (level === 'week') return `${ACTIVITY_DAY_NAMES[(new Date(from).getDay() + 6) % 7]} ${ACTIVITY_QUARTERS[Math.floor(new Date(from).getHours() / 6)]}`;
return `${clockText(from)}–${clockText(to)}`;
}
function cellFor(level, start, extra = {}) {
const d = new Date(start), end = periodShift(level, start, 1);
const label = level === 'month' ? ACTIVITY_MONTHS[d.getMonth()] : level === 'week' ? `W${isoWeek(start)}`
: level === 'day' ? String(d.getDate()) : String(d.getHours()).padStart(2, '0');
const title = level === 'month' ? `${ACTIVITY_MONTHS[d.getMonth()]} ${d.getFullYear()}`
: level === 'week' ? `Week ${isoWeek(start)} · ${dayText(start)} – ${dayText(periodShift('day', end, -1))} ${new Date(end - 1).getFullYear()}`
: level === 'day' ? `${ACTIVITY_DAY_NAMES[(d.getDay() + 6) % 7]} ${dayText(start)} ${d.getFullYear()}`
: `${dayText(start)} · ${clockText(start)}–${clockText(end)}`;
return { level, start, end, label, title, sub: level === 'week' ? dayText(start) : '', ...extra };
}
// The grid for one view around `anchor`: rows of cells, plus where the arrows lead.
function activityLayout(view, anchor) {
const rows = [];
if (view === 'month') {
const year = new Date(anchor).getFullYear();
for (const y of [year - 1, year]) {
rows.push({ label: String(y), cells: ACTIVITY_MONTHS.map((_, m) => cellFor('month', new Date(y, m, 1).getTime())) });
}
return { view, rows, columns: 12, title: `${year - 1} – ${year}`, step: ['month', 12], from: rows[0].cells[0].start, to: rows[1].cells[11].end };
}
if (view === 'week') {
const last = periodStart('month', anchor);
for (let i = 5; i >= 0; i--) {
const month = periodShift('month', last, -i), next = periodShift('month', month, 1), cells = [];
// A week belongs to the month of its Thursday, so every week shows exactly once.
for (let week = periodStart('week', month); week < next; week = periodShift('week', week, 1)) {
const thursday = periodShift('day', week, 3);
if (thursday >= month && thursday < next) cells.push(cellFor('week', week));
}
const d = new Date(month);
rows.push({ label: `${ACTIVITY_MONTHS[d.getMonth()]} ${d.getFullYear()}`, cells });
}
const first = new Date(periodShift('month', last, -5)), end = new Date(last);
return { view, rows, columns: 5, title: `${ACTIVITY_MONTHS[first.getMonth()]} ${first.getFullYear()} – ${ACTIVITY_MONTHS[end.getMonth()]} ${end.getFullYear()}`,
step: ['month', 6], from: rows[0].cells[0].start, to: rows[5].cells[rows[5].cells.length - 1].end };
}
if (view === 'day') {
const month = periodStart('month', anchor), next = periodShift('month', month, 1), d = new Date(month);
for (let week = periodStart('week', month); week < next; week = periodShift('week', week, 1)) {
const cells = [];
for (let i = 0; i < 7; i++) { const day = periodShift('day', week, i); cells.push(cellFor('day', day, { outside: day < month || day >= next })); }
rows.push({ label: `W${isoWeek(week)}`, week, cells });
}
return { view, rows, columns: 7, header: ACTIVITY_DAY_NAMES, title: `${['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'][d.getMonth()]} ${d.getFullYear()}`,
step: ['month', 1], from: rows[0].cells[0].start, to: rows[rows.length - 1].cells[6].end };
}
const day = periodStart('day', anchor), d = new Date(day);
for (let q = 0; q < 4; q++) {
const cells = [];
for (let i = 0; i < 6; i++) { const t = new Date(day); t.setHours(q * 6 + i, 0, 0, 0); cells.push(cellFor('hour', t.getTime())); }
rows.push({ label: ACTIVITY_QUARTERS[q][0].toUpperCase() + ACTIVITY_QUARTERS[q].slice(1), cells });
}
return { view, rows, columns: 6, title: `${ACTIVITY_DAY_NAMES[(d.getDay() + 6) % 7]} ${dayText(day)} ${d.getFullYear()}`,
step: ['day', 1], from: day, to: periodShift('day', day, 1) };
}
// Sums the server's buckets ([start, size, ms, [[manga, ms]…]]) and completions ([t, manga, label])
// into each cell's segments. A bucket split across segments is shared by overlap.
function activitySums(data, cells) {
const segs = [];
for (const cell of cells) {
cell.bounds = segmentBounds(cell.level, cell.start);
const n = cell.bounds.length - 1;
cell.time = new Array(n).fill(0); cell.chapters = new Array(n).fill(0);
cell.manga = new Map(); cell.done = [];
for (let i = 0; i < n; i++) segs.push({ s: cell.bounds[i], e: cell.bounds[i + 1], cell, i });
}
segs.sort((a, b) => a.s - b.s);
const firstEndingAfter = t => { let lo = 0, hi = segs.length; while (lo < hi) { const mid = (lo + hi) >> 1; if (segs[mid].e > t) hi = mid; else lo = mid + 1; } return lo; };
for (const [start, size, ms, mangas] of (data && data.buckets) || []) {
const end = start + size;
for (let k = firstEndingAfter(start); k < segs.length && segs[k].s < end; k++) {
const seg = segs[k], share = (Math.min(seg.e, end) - Math.max(seg.s, start)) / size;
if (share <= 0) continue;
seg.cell.time[seg.i] += ms * share;
for (const [manga, mangaMs] of mangas || []) seg.cell.manga.set(manga, (seg.cell.manga.get(manga) || 0) + mangaMs * share);
}
}
for (const item of (data && data.done) || []) {
const k = firstEndingAfter(item[0]), seg = segs[k];
if (!seg || seg.s > item[0]) continue;
seg.cell.chapters[seg.i]++;
seg.cell.done.push(item);
}
for (const cell of cells) {
cell.totalTime = cell.time.reduce((a, b) => a + b, 0);
cell.totalChapters = cell.chapters.reduce((a, b) => a + b, 0);
}
return cells;
}
// 0…1 glow, square-root scaled so one long evening does not flatten everything else.
const activityGlow = (value, max) => (value > 0 && max > 0 ? Math.min(1, Math.sqrt(value / max)) : 0);
// Where a click on a cell leads: month → its days, week → the days of its month (week marked), day → hours.
function activityDrill(cell) {
if (cell.level === 'month') return { view: 'day', anchor: cell.start };
if (cell.level === 'week') return { view: 'day', anchor: periodShift('day', cell.start, 3), mark: cell.start };
if (cell.level === 'day') return { view: 'hour', anchor: cell.start };
return null;
}
if (typeof module !== 'undefined') module.exports = {
ACTIVITY_VIEWS, periodStart, periodShift, isoWeek, segmentBounds, segmentText, activityLayout, activitySums, activityGlow, activityDrill,
};
+172
View File
@@ -0,0 +1,172 @@
'use strict';
/* Settings → Reading activity: a grid of months, weeks, days or hours. Each cell glows with its total
and carries a strip from its start to its end, so you can see *when* inside the period you read.
Click drills down (month/week → days → hours); hover, focus or a first tap shows the details. */
function activityCard() {
const VIEWS = { month: 'Months', week: 'Weeks', day: 'Days', hour: 'Hours' };
const UNIT = { month: 'days', week: 'quarter days', day: 'hours', hour: '6 minutes' };
const saved = (() => { try { return JSON.parse(store.get('activity') || '{}'); } catch (e) { return {}; } })();
const state = { view: VIEWS[saved.view] ? saved.view : 'week', metric: saved.metric === 'chapters' ? 'chapters' : 'time', anchor: Date.now(), mark: null };
let data = null, armed = null, lastPointer = 'mouse';
const remember = () => store.set('activity', JSON.stringify({ view: state.view, metric: state.metric }));
const card = h('section', { class: 'card activity-card' });
const notice = h('p', { class: 'muted small', hidden: true });
const summary = h('p', { class: 'activity-summary' });
const grid = h('div', { class: 'activity-grid', role: 'grid' });
const tip = h('div', { class: 'activity-tip', role: 'tooltip', hidden: true });
const viewSeg = h('div', { class: 'seg', role: 'group', 'aria-label': 'Grid' });
const metricSeg = h('div', { class: 'seg', role: 'group', 'aria-label': 'Measure' });
const title = h('b', { class: 'activity-title' });
const legend = h('p', { class: 'muted small activity-legend' });
card.append(
h('div', { class: 'activity-toolbar' }, viewSeg, metricSeg),
h('div', { class: 'activity-nav' },
h('button', { class: 'btn small', 'aria-label': 'Earlier', onclick: () => move(-1) }, '‹'),
title,
h('button', { class: 'btn small', 'aria-label': 'Later', onclick: () => move(1) }, '›'),
h('button', { class: 'btn small', onclick: () => { state.anchor = Date.now(); state.mark = null; draw(); } }, 'Today')),
notice, summary, grid, legend, tip);
const duration = ms => readingDuration(ms, true);
const valueText = (metric, value) => metric === 'time' ? duration(value) : `${value} chapter${value === 1 ? '' : 's'}`;
const mangaTitle = index => data.mangas[index]?.title || '?';
function move(direction) {
const layout = activityLayout(state.view, state.anchor);
state.anchor = periodShift(layout.step[0], periodStart(layout.step[0], state.anchor), direction * layout.step[1]);
state.mark = null;
draw();
}
function setView(view, anchor = state.anchor, mark = null) {
state.view = view; state.anchor = anchor; state.mark = mark;
remember(); draw();
}
function drawControls() {
viewSeg.replaceChildren(...Object.entries(VIEWS).map(([key, label]) =>
h('button', { class: key === state.view ? 'on' : '', 'aria-pressed': String(key === state.view), onclick: () => setView(key) }, label)));
metricSeg.replaceChildren(...[['time', 'Reading time'], ['chapters', 'Chapters finished']].map(([key, label]) =>
h('button', { class: key === state.metric ? 'on' : '', 'aria-pressed': String(key === state.metric), onclick: () => { state.metric = key; remember(); draw(); } }, label)));
}
function details(cell) {
const time = cell.totalTime, chapters = cell.totalChapters;
const lines = [h('b', {}, cell.title)];
if (cell.start > Date.now()) { lines.push(h('div', { class: 'muted' }, 'Still ahead')); return lines; }
lines.push(h('div', {}, time >= 30000 ? duration(time) + ' reading' : 'No reading time', ' · ', valueText('chapters', chapters) + ' finished'));
const values = state.metric === 'time' ? cell.time : cell.chapters, active = values.filter(v => v > 0).length;
if (active) {
const peak = values.indexOf(Math.max(...values));
lines.push(h('div', { class: 'muted' }, `Active in ${active} of ${values.length} ${UNIT[cell.level]} · busiest ${segmentText(cell.level, cell.bounds[peak], cell.bounds[peak + 1])}`));
}
const top = [...cell.manga].filter(([, ms]) => ms >= 30000).sort((a, b) => b[1] - a[1]).slice(0, 3);
if (top.length) lines.push(h('ul', {}, top.map(([index, ms]) => h('li', {}, mangaTitle(index), h('span', { class: 'muted' }, ' · ' + duration(ms))))));
if (cell.level === 'hour' && cell.done.length) {
lines.push(h('ul', {}, cell.done.slice(0, 6).map(([t, index, label]) => h('li', {}, `✓ ${mangaTitle(index)} · ${label}`,
h('span', { class: 'muted' }, ' · ' + new Date(t).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }))))));
} else if (!top.length && cell.done.length) {
const counts = new Map();
for (const [, index] of cell.done) counts.set(index, (counts.get(index) || 0) + 1);
lines.push(h('ul', {}, [...counts].sort((a, b) => b[1] - a[1]).slice(0, 3).map(([index, n]) => h('li', {}, mangaTitle(index), h('span', { class: 'muted' }, ` · ${n} ✓`)))));
}
if (activityDrill(cell)) lines.push(h('div', { class: 'muted small' }, lastPointer === 'touch' ? 'Tap again to open' : 'Click to open'));
return lines;
}
function showTip(cell, el) {
tip.replaceChildren(...details(cell));
tip.hidden = false;
const box = el.getBoundingClientRect(), width = tip.offsetWidth, height = tip.offsetHeight;
const left = Math.max(8, Math.min(window.innerWidth - width - 8, box.left + box.width / 2 - width / 2));
const top = box.top - height - 8 > 8 ? box.top - height - 8 : box.bottom + 8;
tip.style.left = left + 'px'; tip.style.top = top + 'px';
}
const hideTip = () => { tip.hidden = true; armed = null; };
function cellNode(cell, maxCell, maxSeg) {
const values = state.metric === 'time' ? cell.time : cell.chapters, total = state.metric === 'time' ? cell.totalTime : cell.totalChapters;
const now = Date.now(), future = cell.start > now;
const glow = activityGlow(total, maxCell);
const strip = h('span', { class: 'activity-strip', 'aria-hidden': 'true' }, values.map((v, i) =>
h('i', { class: cell.bounds[i] > now ? 'ahead' : '', style: `--a:${activityGlow(v, maxSeg).toFixed(3)}` })));
const node = h('button', {
type: 'button', role: 'gridcell',
class: 'activity-cell' + (cell.outside ? ' outside' : '') + (future ? ' future' : '') + (glow >= 0.85 ? ' hot' : '')
+ (state.mark != null && cell.level === 'day' && periodStart('week', cell.start) === state.mark ? ' marked' : '')
+ (cell.start <= now && now < cell.end ? ' now' : ''),
style: `--g:${glow.toFixed(3)}`,
'aria-label': `${cell.title}: ${future ? 'still ahead' : valueText(state.metric, total)}`,
onmouseenter: e => { if (lastPointer !== 'touch') showTip(cell, e.currentTarget); },
onmouseleave: () => { if (lastPointer !== 'touch') hideTip(); },
onfocus: e => showTip(cell, e.currentTarget),
onblur: hideTip,
onpointerdown: e => { lastPointer = e.pointerType || 'mouse'; },
onclick: e => {
// On touch the first tap shows the details; the second opens the next level.
if (lastPointer === 'touch' && armed !== cell) { armed = cell; showTip(cell, e.currentTarget); return; }
const next = activityDrill(cell);
if (next && !future) { hideTip(); setView(next.view, next.anchor, next.mark ?? null); }
},
},
h('span', { class: 'activity-label' }, cell.label, cell.sub ? h('small', {}, cell.sub) : null),
!future && total && (state.metric === 'chapters' || total >= 30000)
? h('span', { class: 'activity-value' }, state.metric === 'chapters' ? String(total)
// Month cells are narrow; whole hours are enough there, the tooltip has the minutes.
: cell.level === 'month' && total >= 36e5 ? Math.round(total / 36e5) + ' h' : duration(total)) : null,
strip);
return node;
}
function draw() {
drawControls();
hideTip();
const layout = activityLayout(state.view, state.anchor);
title.textContent = layout.title;
if (!data) { grid.replaceChildren(h('p', { class: 'muted' }, 'Loading your activity…')); return; }
const cells = layout.rows.flatMap(row => row.cells);
activitySums(data, cells);
const counted = cells.filter(cell => !cell.outside);
const key = state.metric === 'time' ? 'totalTime' : 'totalChapters', seg = state.metric === 'time' ? 'time' : 'chapters';
const maxCell = Math.max(0, ...counted.map(cell => cell[key]));
const maxSeg = Math.max(0, ...counted.flatMap(cell => cell[seg]));
grid.style.setProperty('--cols', layout.columns);
grid.className = 'activity-grid view-' + state.view;
grid.replaceChildren(
...(layout.header ? [h('div', { class: 'activity-row head', role: 'row' }, h('span', { class: 'activity-row-label' }),
h('div', { class: 'activity-cells' }, layout.header.map(name => h('span', { class: 'activity-head' }, name))))] : []),
...layout.rows.map(row => h('div', { class: 'activity-row' + (state.mark != null && row.week === state.mark ? ' marked' : ''), role: 'row' },
h('span', { class: 'activity-row-label' }, row.label),
h('div', { class: 'activity-cells' }, row.cells.map(cell => cellNode(cell, maxCell, maxSeg))))));
const time = counted.reduce((sum, cell) => sum + cell.totalTime, 0), chapters = counted.reduce((sum, cell) => sum + cell.totalChapters, 0);
const manga = new Map();
for (const cell of counted) for (const [index, ms] of cell.manga) manga.set(index, (manga.get(index) || 0) + ms);
const top = [...manga].sort((a, b) => b[1] - a[1])[0];
summary.replaceChildren(
h('b', {}, time >= 30000 ? duration(time) : 'No reading time'), ' · ', valueText('chapters', chapters), ' finished',
...(top && top[1] >= 30000 ? [' · most time with ', h('b', {}, mangaTitle(top[0]))] : []));
const coarse = layout.from < Date.now() - data.fineDays * 864e5 && state.view === 'hour';
legend.textContent = `Each cell runs from left (its start) to right (its end) in ${UNIT[layout.rows[0].cells[0].level]}; brighter means more ${state.metric === 'time' ? 'reading time' : 'finished chapters'}.`
+ (state.view === 'hour' ? '' : ' Click a cell to look closer.')
+ (coarse ? ` Time older than ${data.fineDays} days is kept per hour, so it is spread evenly inside the hour.` : '');
}
async function load() {
try {
data = await api('api/reading/activity');
notice.hidden = data.readingClock;
notice.replaceChildren('The Active Reading Clock is off, so no new reading time is recorded; finished chapters still show. Turn it on under ',
h('a', { href: 'settings.html#user/reading-time' }, 'Reading time & privacy'), '.');
if (!data.buckets.length && saved.metric == null) state.metric = 'chapters';
draw();
} catch (error) { grid.replaceChildren(h('p', { class: 'error', role: 'alert' }, `Could not load your activity: ${error.message}`)); }
}
window.addEventListener('scroll', () => { if (!tip.hidden) hideTip(); }, { passive: true });
draw();
return { element: card, activate: load, deactivate: hideTip };
}
+44
View File
@@ -575,3 +575,47 @@ kbd { display: inline-block; min-width: 24px; padding: 1px 7px; border: 1px soli
background: var(--card2); font: 13px/1.5 ui-monospace, monospace; text-align: center; }
@media (max-width: 480px) { .moment-dialog { padding: 12px; } .moment-actions .moment-hint { flex-basis: 100%; } .moment-actions .moment-hint:empty { display: none; } }
.moment-extend { display: flex; flex-wrap: wrap; justify-content: center; gap: 8px; margin-bottom: 8px; }
/* Reading activity grid (Settings → Reading activity). --g: cell glow 0…1, --a: strip segment glow 0…1. */
.activity-toolbar { display: flex; flex-wrap: wrap; gap: 8px; justify-content: space-between; }
.activity-nav { display: flex; align-items: center; gap: 8px; margin: 12px 0 4px; }
.activity-title { min-width: 11em; text-align: center; }
.activity-summary { margin: 6px 0 12px; }
.activity-grid { display: flex; flex-direction: column; gap: 6px; }
.activity-row { display: grid; grid-template-columns: 5.5em 1fr; gap: 8px; align-items: stretch; border-radius: 10px; }
.activity-row.marked { background: color-mix(in srgb, var(--accent) 12%, transparent); outline: 1px solid color-mix(in srgb, var(--accent) 45%, transparent); }
.activity-row-label { color: var(--muted); font-size: 13px; align-self: center; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.activity-cells { display: grid; grid-template-columns: repeat(var(--cols), minmax(0, 1fr)); gap: 6px; }
.activity-head { color: var(--muted); font-size: 12px; text-align: center; }
.activity-cell { position: relative; display: flex; flex-direction: column; justify-content: space-between; gap: 6px; min-height: 58px; padding: 6px 7px 7px;
border: 1px solid var(--line); border-radius: 8px; color: var(--fg); font: inherit; text-align: left; cursor: pointer;
background: color-mix(in srgb, var(--accent) calc(var(--g) * 42%), var(--card2));
box-shadow: 0 0 calc(var(--g) * 12px) color-mix(in srgb, var(--accent) calc(var(--g) * 55%), transparent);
transition: transform .12s, border-color .12s; }
.activity-cell:hover, .activity-cell:focus-visible { border-color: var(--accent); transform: translateY(-1px); outline: none; }
.activity-cell.hot { border-color: color-mix(in srgb, var(--accent) 70%, #fff); }
.activity-cell.now { border-color: var(--fg); }
.activity-cell.outside { opacity: .38; }
.activity-cell.future { cursor: default; background: transparent; border-style: dashed; box-shadow: none; transform: none; }
.view-hour .activity-cell { cursor: default; }
.activity-label { font-size: 13px; font-weight: 600; line-height: 1.1; display: flex; flex-direction: column; }
.activity-label small { font-weight: 400; color: var(--muted); font-size: 11px; }
.activity-value { position: absolute; top: 6px; right: 7px; font-size: 11px; color: color-mix(in srgb, var(--fg) 80%, transparent); }
.view-day .activity-value, .view-month .activity-value { position: static; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.activity-strip { display: flex; height: 9px; border-radius: 3px; overflow: hidden; background: color-mix(in srgb, var(--bg) 70%, transparent); }
.activity-strip i { flex: 1; background: var(--accent); opacity: var(--a); }
.activity-strip i.ahead { background: transparent; }
.activity-strip i + i { box-shadow: inset 1px 0 0 color-mix(in srgb, var(--bg) 35%, transparent); }
.view-month .activity-strip i + i, .view-week .activity-strip i + i { box-shadow: none; }
.activity-tip { position: fixed; z-index: 50; max-width: 300px; padding: 10px 12px; border-radius: 10px; pointer-events: none;
background: var(--card); border: 1px solid var(--line); box-shadow: 0 8px 24px rgba(0, 0, 0, .45); font-size: 13px; line-height: 1.45; }
.activity-tip ul { margin: 4px 0 0; padding-left: 16px; }
.activity-legend { margin-top: 12px; }
@media (max-width: 760px) {
.activity-row { grid-template-columns: 1fr; gap: 3px; }
.activity-row.head .activity-row-label { display: none; }
.view-month .activity-cells { grid-template-columns: repeat(6, minmax(0, 1fr)); }
.activity-cell { min-height: 48px; padding: 5px; }
.activity-value { display: none; }
.activity-cells { gap: 4px; }
}
+1
View File
@@ -5,6 +5,7 @@ const SETTINGS_SECTIONS = [
{ id: 'user/reading', group: 'User', title: 'Reading', hint: 'Reader modes, page spacing and zoom.' },
{ id: 'user/reading-hours', group: 'User', title: 'Reading hours', hint: 'When this server allows chapter reading.' },
{ id: 'user/reading-time', group: 'User', title: 'Reading time & privacy', hint: 'Your clock, Reading Story and personal data.' },
{ id: 'user/activity', group: 'User', title: 'Reading activity', hint: 'When you read: by month, week, day or hour. Private to your account.' },
{ id: 'user/shortcuts', group: 'User', title: 'Keyboard shortcuts', hint: 'Every key the reader understands. Press ? in the reader for the same list.' },
{ id: 'user/notifications', group: 'User', title: 'Notifications', hint: 'Chapter alerts and your optional webhook.' },
{ id: 'user/account', group: 'User', title: 'Account', hint: 'Your account and password.' },
+2
View File
@@ -11,6 +11,8 @@
<script src="common.js" defer></script>
<script src="keys.js" defer></script>
<script src="settings-nav.js" defer></script>
<script src="activity-core.js" defer></script>
<script src="activity.js" defer></script>
<script src="settings.js" defer></script>
</head>
<body>
+4
View File
@@ -184,6 +184,10 @@
'Shows your private chapter times and personal summaries on manga and library pages. Hiding it does not delete existing history.'),
readingData));
const activity = activityCard();
section('user/activity').append(activity.element);
controllers.set('user/activity', activity);
const hook = h('input', { type: 'url', placeholder: 'https://ntfy.sh/my-manga-topic', value: S.webhook || '', style: 'width:100%' });
const hookMsg = h('div', { class: 'small' });
section('user/notifications').append(h('section', { class: 'card' },
+114
View File
@@ -0,0 +1,114 @@
'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 };
+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];
+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);