/* ============================================================================ * stats-core.js — listening statistics (pure; window.StatsCore / node) * * Stored in data.stats (synced with the profile): * { v: 1, * days: { 'YYYY-MM-DD': { s: secondsListened, p: plays, songs: { id: plays } } }, * meta: { id: { t: title, c: channel } } } * Days are LOCAL calendar days. A play counts once a song has been listened * to for 30 s in one load. Days older than KEEP_DAYS are pruned. * ========================================================================== */ (function (root) { 'use strict'; const KEEP_DAYS = 400; const META_MAX = 600; const STREAK_MIN_SEC = 5 * 60; // a day counts toward the streak at 5 min const pad = (n) => String(n).padStart(2, '0'); const dayKey = (d) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; const addDays = (d, n) => { const x = new Date(d.getFullYear(), d.getMonth(), d.getDate()); x.setDate(x.getDate() + n); return x; }; function empty() { return { v: 1, days: {}, meta: {} }; } function ensure(stats) { if (!stats || typeof stats !== 'object' || !stats.days) return empty(); if (!stats.meta) stats.meta = {}; return stats; } // Add listened seconds (and optionally one play) for a song on `now`'s day. function record(stats, song, seconds, countPlay, now = new Date()) { stats = ensure(stats); const k = dayKey(now); const d = stats.days[k] || (stats.days[k] = { s: 0, p: 0, songs: {} }); if (seconds > 0) d.s = Math.round((d.s + seconds) * 10) / 10; if (countPlay && song && song.id) { d.p += 1; d.songs[song.id] = (d.songs[song.id] || 0) + 1; } if (song && song.id) { stats.meta[song.id] = { t: String(song.title || '').slice(0, 200), c: String(song.channel || '').slice(0, 120) }; const ids = Object.keys(stats.meta); if (ids.length > META_MAX) { const used = new Set(); Object.values(stats.days).forEach((x) => Object.keys(x.songs || {}).forEach((id) => used.add(id))); for (const id of ids) if (!used.has(id) && Object.keys(stats.meta).length > META_MAX) delete stats.meta[id]; } } prune(stats, now); return stats; } function prune(stats, now = new Date()) { const cutoff = dayKey(addDays(now, -KEEP_DAYS)); for (const k of Object.keys(stats.days)) if (k < cutoff) delete stats.days[k]; return stats; } // "Hillsong Worship - Topic", "BethelVEVO" → artist name for grouping. function artistOf(channel) { return String(channel || '').replace(/\s*-\s*Topic$/i, '').replace(/VEVO$/i, '').replace(/\s+Official$/i, '').trim() || 'Unknown'; } function streaks(stats, now = new Date()) { const active = (d) => ((stats.days[dayKey(d)] || {}).s || 0) >= STREAK_MIN_SEC; // Current streak: ending today, or yesterday if today hasn't counted yet. let cur = 0; let d = active(now) ? now : addDays(now, -1); while (active(d)) { cur++; d = addDays(d, -1); } let best = 0, run = 0; const keys = Object.keys(stats.days).sort(); if (keys.length) { const [y, m, dd] = keys[0].split('-').map(Number); for (let x = new Date(y, m - 1, dd); dayKey(x) <= dayKey(now); x = addDays(x, 1)) { run = active(x) ? run + 1 : 0; if (run > best) best = run; } } return { current: cur, best: Math.max(best, cur) }; } function summarize(statsIn, now = new Date()) { const stats = ensure(statsIn); const monthKey = (d) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}`; const thisMonth = monthKey(now); const lastMonth = monthKey(new Date(now.getFullYear(), now.getMonth() - 1, 1)); let thisMonthSec = 0, lastMonthSec = 0, totalSec = 0, totalPlays = 0; const songsAll = {}, artistsMonth = {}; for (const [k, d] of Object.entries(stats.days)) { totalSec += d.s || 0; totalPlays += d.p || 0; if (k.startsWith(thisMonth)) thisMonthSec += d.s || 0; if (k.startsWith(lastMonth)) lastMonthSec += d.s || 0; for (const [id, n] of Object.entries(d.songs || {})) { songsAll[id] = (songsAll[id] || 0) + n; if (k.startsWith(thisMonth)) { const a = artistOf((stats.meta[id] || {}).c); artistsMonth[a] = (artistsMonth[a] || 0) + n; } } } const topArtists = Object.entries(artistsMonth).sort((a, b) => b[1] - a[1]).slice(0, 5) .map(([name, plays]) => ({ name, plays })); // Top songs over the last 30 days, each with a 30-point daily sparkline. const last30 = Array.from({ length: 30 }, (_, i) => dayKey(addDays(now, i - 29))); const recent = {}; for (const k of last30) for (const [id, n] of Object.entries((stats.days[k] || {}).songs || {})) recent[id] = (recent[id] || 0) + n; const topSongs = Object.entries(recent).sort((a, b) => b[1] - a[1]).slice(0, 5).map(([id, plays]) => ({ id, plays, title: (stats.meta[id] || {}).t || id, channel: (stats.meta[id] || {}).c || '', spark: last30.map((k) => ((stats.days[k] || {}).songs || {})[id] || 0), })); // 16-week heatmap: columns are weeks (oldest → current), rows Mon..Sun. const WEEKS = 16; const monIdx = (d) => (d.getDay() + 6) % 7; // Mon=0 … Sun=6 const thisMonday = addDays(now, -monIdx(now)); const start = addDays(thisMonday, -(WEEKS - 1) * 7); const heat = []; for (let w = 0; w < WEEKS; w++) { const col = []; for (let r = 0; r < 7; r++) { const d = addDays(start, w * 7 + r); const k = dayKey(d); col.push({ date: k, minutes: Math.round(((stats.days[k] || {}).s || 0) / 60), future: k > dayKey(now) }); } heat.push(col); } // Peak weekday: average hours on that weekday across the heatmap window. const byDow = Array.from({ length: 7 }, () => ({ sum: 0, n: 0 })); heat.forEach((col) => col.forEach((c, r) => { if (!c.future) { byDow[r].sum += c.minutes; byDow[r].n += 1; } })); const names = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday']; let peak = null; byDow.forEach((x, i) => { const avg = x.n ? x.sum / x.n / 60 : 0; if (avg > 0 && (!peak || avg > peak.avgHours)) peak = { day: names[i], avgHours: avg }; }); return { totalSec, totalPlays, thisMonthSec, lastMonthSec, songsPlayed: Object.keys(songsAll).length, streak: streaks(stats, now), topArtists, topSongs, heat, peak, }; } function fmtHours(sec) { const m = Math.round((sec || 0) / 60); const h = Math.floor(m / 60); return h ? `${h}h ${pad(m % 60)}m` : `${m}m`; } const StatsCore = { empty, ensure, record, prune, summarize, streaks, artistOf, dayKey, fmtHours, STREAK_MIN_SEC }; if (typeof module !== 'undefined' && module.exports) module.exports = StatsCore; else root.StatsCore = StatsCore; })(typeof globalThis !== 'undefined' ? globalThis : this);