Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fe8956087e |
No files matched your search
@@ -12,6 +12,7 @@
|
||||
!reading-access.js
|
||||
!moments.js
|
||||
!reader-sessions.js
|
||||
!reading-activity.js
|
||||
!public/
|
||||
!public/**
|
||||
!greatest/
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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.' },
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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 };
|
||||
@@ -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];
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user