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