From 26880016e918062c444b777d7d7c85cd0fa08e5d Mon Sep 17 00:00:00 2001 From: Honey Date: Fri, 7 Aug 2026 17:17:14 +0200 Subject: [PATCH] feat(scrobble): web statistics view (Phase 3 UI) Opens from Settings ("Meine Statistiken") as a self-contained modal overlay (no router changes). Fetches GET /scrobble/stats and renders: totals tiles (plays + listening time), a per-bucket play series, top artists/tracks/albums with mini bars, and a 24h listening clock. Period tabs (all / this year / last 30 days) switch from/to + bucket. Vanilla inline SVG-free bars, no chart lib. Reuses .modal-overlay.open + design tokens; sends local tz offset for day bucketing. Graceful empty/error/unconfigured states. SW cache -> ns-v65. Co-Authored-By: Claude Opus 4.8 --- docs/scrobble-plan.md | 8 +- index.html | 1 + public/sw.js | 2 +- src/main.js | 2 + src/ui/stats.js | 198 ++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 207 insertions(+), 4 deletions(-) create mode 100644 src/ui/stats.js diff --git a/docs/scrobble-plan.md b/docs/scrobble-plan.md index e72d49b..6efa421 100644 --- a/docs/scrobble-plan.md +++ b/docs/scrobble-plan.md @@ -209,9 +209,11 @@ volumes: Offline-Queue (Web: localStorage `ns_play_queue`; Android: `filesDir/play-queue.json`), idempotenter Batch-Flush bei Start + `online`-Event. `SCROBBLE_URL`/`scrobbleUrl` in beiden Settings. Web-Build + Android-`compileDebugKotlin` grün. *Ab jetzt wird real erfasst.* -3. 🔨 **In Arbeit**: `/scrobble/stats` ✅ (Endpoint: totals, Top-Listen, day/month/year-Serie, - Hör-Uhr; on-the-fly `GROUP BY strftime`, tz-Offset, unit-getestet) — **noch offen: Web-Statistik-View** - (Kalender-Heatmap, Monats-Balken, Top-Listen). +3. ✅ **Erledigt**: `/scrobble/stats` (Endpoint: totals, Top-Listen, day/month/year-Serie, Hör-Uhr; + on-the-fly `GROUP BY strftime`, tz-Offset, unit-getestet) **+ Web-Statistik-View** + ([src/ui/stats.js](../src/ui/stats.js)) — Overlay aus den Einstellungen („📊 Meine Statistiken"), + Zeitraum-Tabs (Alles / Dieses Jahr / Letzte 30 Tage), Kacheln, Verlaufs-Balken, Top-Listen, + Hör-Uhr. Vanilla/inline, keine Chart-Lib. **Braucht Runtime-Test am Gerät.** 4. **`/scrobble/wrapped`** + Story-Seite; Android-Stats-Screen. 5. *(optional)* ListenBrainz-Relay. diff --git a/index.html b/index.html index 8604fb7..e1525f9 100644 --- a/index.html +++ b/index.html @@ -712,6 +712,7 @@ + diff --git a/public/sw.js b/public/sw.js index 9f065bc..a556346 100644 --- a/public/sw.js +++ b/public/sw.js @@ -1,4 +1,4 @@ -const CACHE = 'ns-v64'; +const CACHE = 'ns-v65'; const PRECACHE = ['/', 'index.html', 'manifest.json', 'icon.svg']; const SKIP_PATHS = ['/rest/stream', '/rest/download', '/config.js']; // Auth token params stripped from cache keys so artwork survives across sessions/token refreshes. diff --git a/src/main.js b/src/main.js index 193c44c..62c1a25 100644 --- a/src/main.js +++ b/src/main.js @@ -3,6 +3,7 @@ import { toast } from './utils.js'; import { api, fetchRomajiIndex, loadRomajiOverrides } from './api.js'; import { injectPWA } from './pwa.js'; import { initPlayLog } from './player/scrobbleLog.js'; +import { openStats, closeStats } from './ui/stats.js'; import { loadSavedTheme } from './themes.js'; import { initScrollRows } from './ui/components.js'; import { @@ -330,6 +331,7 @@ Object.assign(window, { // UI / Modals openSettings, closeSettings, saveSettings, confirmDisconnect, checkBackend, openSidebar, closeSidebar, switchNavMode, showCtxMenu, + openStats, closeStats, // Views loadAlbums, loadStarred, loadRandom, filterStarred, filterPlaylist, plReorderEnter, plReorderSave, plReorderCancel, toggleAllGenres, diff --git a/src/ui/stats.js b/src/ui/stats.js new file mode 100644 index 0000000..2ea1d95 --- /dev/null +++ b/src/ui/stats.js @@ -0,0 +1,198 @@ +// ─── Listening statistics view ────────────────────────────────────────────── +// Opened from Settings ("Meine Statistiken"). Reads the self-hosted scrobble +// service (S.cfg.scrobbleUrl) → GET /scrobble/stats and renders totals, top +// artists/albums/tracks, a per-bucket play series, and a 24h listening clock. +// Self-contained: builds its own overlay DOM, so no index.html markup is needed. + +import { S } from '../state.js'; +import { esc, formatDur } from '../utils.js'; +import { subsonicAuthParams } from '../api.js'; + +let _overlay = null; +let _period = 'all'; // all | year | 30d + +const PERIODS = [ + { key: 'all', label: 'Alles', bucket: 'month' }, + { key: 'year', label: 'Dieses Jahr', bucket: 'month' }, + { key: '30d', label: 'Letzte 30 Tage', bucket: 'day' }, +]; + +// Human listening time: "3h 12m" / "12m" / "45s" +function _fmtTime(sec) { + sec = Math.floor(sec || 0); + const h = Math.floor(sec / 3600); + const m = Math.floor((sec % 3600) / 60); + if (h) return `${h}h ${m}m`; + if (m) return `${m}m`; + return `${sec}s`; +} + +function _tzOffsetSec() { return -new Date().getTimezoneOffset() * 60; } + +function _periodRange(key) { + const now = Math.floor(Date.now() / 1000); + if (key === 'year') { + const from = Math.floor(new Date(new Date().getFullYear(), 0, 1).getTime() / 1000); + return { from, to: now + 1 }; + } + if (key === '30d') return { from: now - 30 * 86400, to: now + 1 }; + return { from: 0, to: now + 1 }; // all time +} + +export function openStats() { + if (!_overlay) _buildOverlay(); + _overlay.classList.add('open'); // .modal-overlay.open = visible (matches app's modals) + document.body.style.overflow = 'hidden'; + _load(); +} + +export function closeStats() { + if (_overlay) _overlay.classList.remove('open'); + document.body.style.overflow = ''; +} + +function _buildOverlay() { + _overlay = document.createElement('div'); + _overlay.className = 'modal-overlay'; + _overlay.id = 'stats-overlay'; + _overlay.addEventListener('click', e => { if (e.target === _overlay) closeStats(); }); + _overlay.innerHTML = ` + `; + document.body.appendChild(_overlay); + _renderTabs(); +} + +function _renderTabs() { + const tabs = _overlay.querySelector('#stats-tabs'); + tabs.innerHTML = PERIODS.map(p => ` + `).join(''); + tabs.querySelectorAll('.stats-tab').forEach(b => + b.addEventListener('click', () => { _period = b.dataset.period; _renderTabs(); _load(); })); +} + +async function _load() { + const body = _overlay.querySelector('#stats-body'); + if (!S.cfg.scrobbleUrl) { + body.innerHTML = `
+ Kein Scrobble-Dienst konfiguriert.
Trage die Scrobble-URL in den Einstellungen ein, + damit deine Wiedergaben aufgezeichnet werden.
`; + return; + } + const auth = subsonicAuthParams(); + if (!auth) { body.innerHTML = _errBox('Nicht angemeldet.'); return; } + + body.innerHTML = `
Lade…
`; + + const bucket = PERIODS.find(p => p.key === _period)?.bucket || 'month'; + const { from, to } = _periodRange(_period); + const qs = `${auth}&from=${from}&to=${to}&tz=${_tzOffsetSec()}&bucket=${bucket}&limit=10`; + try { + const res = await fetch(`${S.cfg.scrobbleUrl}/scrobble/stats?${qs}`); + if (!res.ok) { body.innerHTML = _errBox(`Fehler ${res.status}`); return; } + const d = await res.json(); + _render(body, d); + } catch { + body.innerHTML = _errBox('Scrobble-Dienst nicht erreichbar.'); + } +} + +function _errBox(msg) { + return `
${esc(msg)}
`; +} + +function _render(body, d) { + if (!d.totalPlays) { + body.innerHTML = `
+ Noch keine Wiedergaben in diesem Zeitraum.
Spiel ein paar Songs — sie erscheinen hier automatisch.
`; + return; + } + body.innerHTML = [ + _tiles(d), + _seriesChart(d.series, d.range.bucket), + _topList('Top-Künstler', d.topArtists, a => a.name, a => a.plays), + _topList('Top-Songs', d.topTracks, t => `${t.title}${t.artist ? ' · ' + t.artist : ''}`, t => t.plays), + _topList('Top-Alben', d.topAlbums, a => `${a.name}${a.artist ? ' · ' + a.artist : ''}`, a => a.plays), + _hourClock(d.byHour), + ].join(''); +} + +function _tiles(d) { + const tile = (val, label) => ` +
+
${val}
+
${label}
+
`; + return `
+ ${tile(d.totalPlays.toLocaleString('de-DE'), 'Wiedergaben')} + ${tile(_fmtTime(d.totalSeconds), 'Hörzeit')} + ${tile((d.topArtists?.length || 0) >= 10 ? '10+' : (d.topArtists?.length || 0), 'Künstler')} +
`; +} + +// Simple vertical bar chart of plays per bucket. +function _seriesChart(series, bucket) { + if (!series?.length) return ''; + const max = Math.max(...series.map(s => s.plays), 1); + const fmtLabel = b => bucket === 'month' + ? b.slice(5) + '.' + b.slice(2, 4) // "08.25" + : bucket === 'year' ? b : b.slice(8) + '.' + b.slice(5, 7); // "03.08" + const bars = series.map(s => ` +
+
+
+
+
${esc(fmtLabel(s.bucket))}
+
`).join(''); + return `${_h('Verlauf')} +
${bars}
`; +} + +function _topList(title, items, labelFn, valFn) { + if (!items?.length) return ''; + const max = Math.max(...items.map(valFn), 1); + const rows = items.map((it, i) => { + const val = valFn(it); + return `
+
${i + 1}
+
+
${esc(labelFn(it))}
+
+
+
+
+
${val}
+
`; + }).join(''); + return `${_h(title)}
${rows}
`; +} + +function _hourClock(byHour) { + if (!byHour?.length) return ''; + const max = Math.max(...byHour.map(h => h.plays), 1); + const bars = byHour.map(h => ` +
+
+
+
+ ${h.hour % 6 === 0 ? `
${h.hour}
` : '
'} +
`).join(''); + return `${_h('Hör-Uhr (Stunde)')} +
${bars}
`; +} + +function _h(t) { + return `
${esc(t)}
`; +}