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:
1 parent
c760141636
commit
fe8956087e
15 files changed
+657
-4
No files matched your search
@@ -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' },
|
||||
|
||||
Reference in new issue
Block a user