'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 }; }