Reading activity grid in Settings: months, weeks, days, hours with drill-down

Active reader time is now kept per user in small UTC buckets (6 minutes for
120 days, then hours, dropped after three years), seeded once from the visits
the clock still remembers. Settings -> Reading activity shows them as a grid
whose cells glow with their total and carry a start-to-end strip; month/week
cells open their days, days open their hours. Finished chapters come from
progress and work without the clock.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
MaashkinoozheandClaude Opus 5.5 committed 2026-11-03 12:41:44 +01:00
1 parent c760141636
commit fe8956087e
15 files changed
+657 -4

No files matched your search

+172
View File
@@ -0,0 +1,172 @@
'use strict';
/* Settings → Reading activity: a grid of months, weeks, days or hours. Each cell glows with its total
and carries a strip from its start to its end, so you can see *when* inside the period you read.
Click drills down (month/week → days → hours); hover, focus or a first tap shows the details. */
function activityCard() {
const VIEWS = { month: 'Months', week: 'Weeks', day: 'Days', hour: 'Hours' };
const UNIT = { month: 'days', week: 'quarter days', day: 'hours', hour: '6 minutes' };
const saved = (() => { try { return JSON.parse(store.get('activity') || '{}'); } catch (e) { return {}; } })();
const state = { view: VIEWS[saved.view] ? saved.view : 'week', metric: saved.metric === 'chapters' ? 'chapters' : 'time', anchor: Date.now(), mark: null };
let data = null, armed = null, lastPointer = 'mouse';
const remember = () => store.set('activity', JSON.stringify({ view: state.view, metric: state.metric }));
const card = h('section', { class: 'card activity-card' });
const notice = h('p', { class: 'muted small', hidden: true });
const summary = h('p', { class: 'activity-summary' });
const grid = h('div', { class: 'activity-grid', role: 'grid' });
const tip = h('div', { class: 'activity-tip', role: 'tooltip', hidden: true });
const viewSeg = h('div', { class: 'seg', role: 'group', 'aria-label': 'Grid' });
const metricSeg = h('div', { class: 'seg', role: 'group', 'aria-label': 'Measure' });
const title = h('b', { class: 'activity-title' });
const legend = h('p', { class: 'muted small activity-legend' });
card.append(
h('div', { class: 'activity-toolbar' }, viewSeg, metricSeg),
h('div', { class: 'activity-nav' },
h('button', { class: 'btn small', 'aria-label': 'Earlier', onclick: () => move(-1) }, '‹'),
title,
h('button', { class: 'btn small', 'aria-label': 'Later', onclick: () => move(1) }, '›'),
h('button', { class: 'btn small', onclick: () => { state.anchor = Date.now(); state.mark = null; draw(); } }, 'Today')),
notice, summary, grid, legend, tip);
const duration = ms => readingDuration(ms, true);
const valueText = (metric, value) => metric === 'time' ? duration(value) : `${value} chapter${value === 1 ? '' : 's'}`;
const mangaTitle = index => data.mangas[index]?.title || '?';
function move(direction) {
const layout = activityLayout(state.view, state.anchor);
state.anchor = periodShift(layout.step[0], periodStart(layout.step[0], state.anchor), direction * layout.step[1]);
state.mark = null;
draw();
}
function setView(view, anchor = state.anchor, mark = null) {
state.view = view; state.anchor = anchor; state.mark = mark;
remember(); draw();
}
function drawControls() {
viewSeg.replaceChildren(...Object.entries(VIEWS).map(([key, label]) =>
h('button', { class: key === state.view ? 'on' : '', 'aria-pressed': String(key === state.view), onclick: () => setView(key) }, label)));
metricSeg.replaceChildren(...[['time', 'Reading time'], ['chapters', 'Chapters finished']].map(([key, label]) =>
h('button', { class: key === state.metric ? 'on' : '', 'aria-pressed': String(key === state.metric), onclick: () => { state.metric = key; remember(); draw(); } }, label)));
}
function details(cell) {
const time = cell.totalTime, chapters = cell.totalChapters;
const lines = [h('b', {}, cell.title)];
if (cell.start > Date.now()) { lines.push(h('div', { class: 'muted' }, 'Still ahead')); return lines; }
lines.push(h('div', {}, time >= 30000 ? duration(time) + ' reading' : 'No reading time', ' · ', valueText('chapters', chapters) + ' finished'));
const values = state.metric === 'time' ? cell.time : cell.chapters, active = values.filter(v => v > 0).length;
if (active) {
const peak = values.indexOf(Math.max(...values));
lines.push(h('div', { class: 'muted' }, `Active in ${active} of ${values.length} ${UNIT[cell.level]} · busiest ${segmentText(cell.level, cell.bounds[peak], cell.bounds[peak + 1])}`));
}
const top = [...cell.manga].filter(([, ms]) => ms >= 30000).sort((a, b) => b[1] - a[1]).slice(0, 3);
if (top.length) lines.push(h('ul', {}, top.map(([index, ms]) => h('li', {}, mangaTitle(index), h('span', { class: 'muted' }, ' · ' + duration(ms))))));
if (cell.level === 'hour' && cell.done.length) {
lines.push(h('ul', {}, cell.done.slice(0, 6).map(([t, index, label]) => h('li', {}, `✓ ${mangaTitle(index)} · ${label}`,
h('span', { class: 'muted' }, ' · ' + new Date(t).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }))))));
} else if (!top.length && cell.done.length) {
const counts = new Map();
for (const [, index] of cell.done) counts.set(index, (counts.get(index) || 0) + 1);
lines.push(h('ul', {}, [...counts].sort((a, b) => b[1] - a[1]).slice(0, 3).map(([index, n]) => h('li', {}, mangaTitle(index), h('span', { class: 'muted' }, ` · ${n} ✓`)))));
}
if (activityDrill(cell)) lines.push(h('div', { class: 'muted small' }, lastPointer === 'touch' ? 'Tap again to open' : 'Click to open'));
return lines;
}
function showTip(cell, el) {
tip.replaceChildren(...details(cell));
tip.hidden = false;
const box = el.getBoundingClientRect(), width = tip.offsetWidth, height = tip.offsetHeight;
const left = Math.max(8, Math.min(window.innerWidth - width - 8, box.left + box.width / 2 - width / 2));
const top = box.top - height - 8 > 8 ? box.top - height - 8 : box.bottom + 8;
tip.style.left = left + 'px'; tip.style.top = top + 'px';
}
const hideTip = () => { tip.hidden = true; armed = null; };
function cellNode(cell, maxCell, maxSeg) {
const values = state.metric === 'time' ? cell.time : cell.chapters, total = state.metric === 'time' ? cell.totalTime : cell.totalChapters;
const now = Date.now(), future = cell.start > now;
const glow = activityGlow(total, maxCell);
const strip = h('span', { class: 'activity-strip', 'aria-hidden': 'true' }, values.map((v, i) =>
h('i', { class: cell.bounds[i] > now ? 'ahead' : '', style: `--a:${activityGlow(v, maxSeg).toFixed(3)}` })));
const node = h('button', {
type: 'button', role: 'gridcell',
class: 'activity-cell' + (cell.outside ? ' outside' : '') + (future ? ' future' : '') + (glow >= 0.85 ? ' hot' : '')
+ (state.mark != null && cell.level === 'day' && periodStart('week', cell.start) === state.mark ? ' marked' : '')
+ (cell.start <= now && now < cell.end ? ' now' : ''),
style: `--g:${glow.toFixed(3)}`,
'aria-label': `${cell.title}: ${future ? 'still ahead' : valueText(state.metric, total)}`,
onmouseenter: e => { if (lastPointer !== 'touch') showTip(cell, e.currentTarget); },
onmouseleave: () => { if (lastPointer !== 'touch') hideTip(); },
onfocus: e => showTip(cell, e.currentTarget),
onblur: hideTip,
onpointerdown: e => { lastPointer = e.pointerType || 'mouse'; },
onclick: e => {
// On touch the first tap shows the details; the second opens the next level.
if (lastPointer === 'touch' && armed !== cell) { armed = cell; showTip(cell, e.currentTarget); return; }
const next = activityDrill(cell);
if (next && !future) { hideTip(); setView(next.view, next.anchor, next.mark ?? null); }
},
},
h('span', { class: 'activity-label' }, cell.label, cell.sub ? h('small', {}, cell.sub) : null),
!future && total && (state.metric === 'chapters' || total >= 30000)
? h('span', { class: 'activity-value' }, state.metric === 'chapters' ? String(total)
// Month cells are narrow; whole hours are enough there, the tooltip has the minutes.
: cell.level === 'month' && total >= 36e5 ? Math.round(total / 36e5) + ' h' : duration(total)) : null,
strip);
return node;
}
function draw() {
drawControls();
hideTip();
const layout = activityLayout(state.view, state.anchor);
title.textContent = layout.title;
if (!data) { grid.replaceChildren(h('p', { class: 'muted' }, 'Loading your activity…')); return; }
const cells = layout.rows.flatMap(row => row.cells);
activitySums(data, cells);
const counted = cells.filter(cell => !cell.outside);
const key = state.metric === 'time' ? 'totalTime' : 'totalChapters', seg = state.metric === 'time' ? 'time' : 'chapters';
const maxCell = Math.max(0, ...counted.map(cell => cell[key]));
const maxSeg = Math.max(0, ...counted.flatMap(cell => cell[seg]));
grid.style.setProperty('--cols', layout.columns);
grid.className = 'activity-grid view-' + state.view;
grid.replaceChildren(
...(layout.header ? [h('div', { class: 'activity-row head', role: 'row' }, h('span', { class: 'activity-row-label' }),
h('div', { class: 'activity-cells' }, layout.header.map(name => h('span', { class: 'activity-head' }, name))))] : []),
...layout.rows.map(row => h('div', { class: 'activity-row' + (state.mark != null && row.week === state.mark ? ' marked' : ''), role: 'row' },
h('span', { class: 'activity-row-label' }, row.label),
h('div', { class: 'activity-cells' }, row.cells.map(cell => cellNode(cell, maxCell, maxSeg))))));
const time = counted.reduce((sum, cell) => sum + cell.totalTime, 0), chapters = counted.reduce((sum, cell) => sum + cell.totalChapters, 0);
const manga = new Map();
for (const cell of counted) for (const [index, ms] of cell.manga) manga.set(index, (manga.get(index) || 0) + ms);
const top = [...manga].sort((a, b) => b[1] - a[1])[0];
summary.replaceChildren(
h('b', {}, time >= 30000 ? duration(time) : 'No reading time'), ' · ', valueText('chapters', chapters), ' finished',
...(top && top[1] >= 30000 ? [' · most time with ', h('b', {}, mangaTitle(top[0]))] : []));
const coarse = layout.from < Date.now() - data.fineDays * 864e5 && state.view === 'hour';
legend.textContent = `Each cell runs from left (its start) to right (its end) in ${UNIT[layout.rows[0].cells[0].level]}; brighter means more ${state.metric === 'time' ? 'reading time' : 'finished chapters'}.`
+ (state.view === 'hour' ? '' : ' Click a cell to look closer.')
+ (coarse ? ` Time older than ${data.fineDays} days is kept per hour, so it is spread evenly inside the hour.` : '');
}
async function load() {
try {
data = await api('api/reading/activity');
notice.hidden = data.readingClock;
notice.replaceChildren('The Active Reading Clock is off, so no new reading time is recorded; finished chapters still show. Turn it on under ',
h('a', { href: 'settings.html#user/reading-time' }, 'Reading time & privacy'), '.');
if (!data.buckets.length && saved.metric == null) state.metric = 'chapters';
draw();
} catch (error) { grid.replaceChildren(h('p', { class: 'error', role: 'alert' }, `Could not load your activity: ${error.message}`)); }
}
window.addEventListener('scroll', () => { if (!tip.hidden) hideTip(); }, { passive: true });
draw();
return { element: card, activate: load, deactivate: hideTip };
}