diff --git a/.dockerignore b/.dockerignore index 56be159..2dae4dc 100644 --- a/.dockerignore +++ b/.dockerignore @@ -12,6 +12,7 @@ !reading-access.js !moments.js !reader-sessions.js +!reading-activity.js !public/ !public/** !greatest/ diff --git a/Dockerfile b/Dockerfile index 83870c8..fa51dc7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/docs/architecture.md b/docs/architecture.md index fbe5cb6..a71b9b6 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 | diff --git a/docs/reading-insights-concept.md b/docs/reading-insights-concept.md index 13bfe11..84b3089 100644 --- a/docs/reading-insights-concept.md +++ b/docs/reading-insights-concept.md @@ -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: diff --git a/docs/settings-navigation.md b/docs/settings-navigation.md index f55972e..dbfc04f 100644 --- a/docs/settings-navigation.md +++ b/docs/settings-navigation.md @@ -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, diff --git a/public/activity-core.js b/public/activity-core.js new file mode 100644 index 0000000..4ab1394 --- /dev/null +++ b/public/activity-core.js @@ -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, +}; diff --git a/public/activity.js b/public/activity.js new file mode 100644 index 0000000..e177c67 --- /dev/null +++ b/public/activity.js @@ -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 }; +} diff --git a/public/app.css b/public/app.css index 228dfab..a289f51 100644 --- a/public/app.css +++ b/public/app.css @@ -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; } +} \ No newline at end of file diff --git a/public/settings-nav.js b/public/settings-nav.js index dbf4d9f..a9a7317 100644 --- a/public/settings-nav.js +++ b/public/settings-nav.js @@ -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.' }, diff --git a/public/settings.html b/public/settings.html index 8e98b49..dc2c60c 100644 --- a/public/settings.html +++ b/public/settings.html @@ -11,6 +11,8 @@ + + diff --git a/public/settings.js b/public/settings.js index 263d057..3b878fe 100644 --- a/public/settings.js +++ b/public/settings.js @@ -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' }, diff --git a/reading-activity.js b/reading-activity.js new file mode 100644 index 0000000..56a71bc --- /dev/null +++ b/reading-activity.js @@ -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 }; diff --git a/server.js b/server.js index 8da4738..ada45bc 100644 --- a/server.js +++ b/server.js @@ -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]; diff --git a/test/reading-activity.test.js b/test/reading-activity.test.js new file mode 100644 index 0000000..dfed9d0 --- /dev/null +++ b/test/reading-activity.test.js @@ -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); +}); diff --git a/test/services.test.js b/test/services.test.js index 71cc5a9..e506530 100644 --- a/test/services.test.js +++ b/test/services.test.js @@ -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);