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 <noreply@anthropic.com>
This commit is contained in:
1 parent
e782b75bd9
commit
26880016e9
5 files changed
+207
-4
No files matched your search
@@ -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.
|
||||
|
||||
|
||||
@@ -712,6 +712,7 @@
|
||||
<button style="flex:1;padding:12px;border-radius:9px;font-family:var(--font);font-size:14px;font-weight:600;cursor:pointer;border:none;background:var(--surface2);color:var(--text2)" onclick="closeSettings()">Abbrechen</button>
|
||||
<button style="flex:1;padding:12px;border-radius:9px;font-family:var(--font);font-size:14px;font-weight:600;cursor:pointer;border:none;background:linear-gradient(135deg,var(--accent),var(--accent-dark));color:#000" onclick="saveSettings()">Speichern</button>
|
||||
</div>
|
||||
<button style="width:100%;padding:10px;border-radius:9px;font-family:var(--font);font-size:13px;font-weight:600;cursor:pointer;background:none;border:1px solid var(--border);color:var(--text2);transition:background .15s" onmouseover="this.style.background='var(--surface2)'" onmouseout="this.style.background='none'" onclick="closeSettings(); openStats()">📊 Meine Statistiken</button>
|
||||
<button style="width:100%;padding:10px;border-radius:9px;font-family:var(--font);font-size:13px;font-weight:600;cursor:pointer;background:none;border:1px solid rgba(224,85,85,0.3);color:var(--danger);transition:background .15s" onmouseover="this.style.background='rgba(224,85,85,0.1)'" onmouseout="this.style.background='none'" onclick="confirmDisconnect()">Abmelden</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+1
-1
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
+198
@@ -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 = `
|
||||
<div class="modal-card stats-card" style="max-width:720px;width:calc(100vw - 32px);max-height:88vh;display:flex;flex-direction:column;background:var(--surface);border:1px solid var(--border2);border-radius:18px;box-shadow:0 40px 80px #0009;overflow:hidden">
|
||||
<div class="modal-head" style="display:flex;align-items:center;gap:10px;padding:16px 18px;border-bottom:1px solid var(--border)">
|
||||
<span style="font-size:18px">📊</span>
|
||||
<div style="font-size:17px;font-weight:800;letter-spacing:-.3px;flex:1">Meine Statistiken</div>
|
||||
<button class="hbtn" onclick="closeStats()" title="Schließen"
|
||||
style="padding:6px 10px;color:var(--text2);font-size:18px;background:none;border:none;cursor:pointer">✕</button>
|
||||
</div>
|
||||
<div class="stats-tabs" id="stats-tabs" style="display:flex;gap:6px;padding:12px 18px 0"></div>
|
||||
<div class="stats-body" id="stats-body" style="padding:16px 18px;overflow-y:auto"></div>
|
||||
</div>`;
|
||||
document.body.appendChild(_overlay);
|
||||
_renderTabs();
|
||||
}
|
||||
|
||||
function _renderTabs() {
|
||||
const tabs = _overlay.querySelector('#stats-tabs');
|
||||
tabs.innerHTML = PERIODS.map(p => `
|
||||
<button class="stats-tab${p.key === _period ? ' active' : ''}" data-period="${p.key}"
|
||||
style="padding:7px 14px;border-radius:999px;font-family:var(--font);font-size:13px;font-weight:600;cursor:pointer;
|
||||
border:1px solid var(--border);transition:all .15s;
|
||||
background:${p.key === _period ? 'var(--accent)' : 'transparent'};
|
||||
color:${p.key === _period ? '#fff' : 'var(--text2)'}">${p.label}</button>`).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 = `<div class="stats-empty" style="text-align:center;color:var(--text2);padding:40px 0;line-height:1.6">
|
||||
Kein Scrobble-Dienst konfiguriert.<br>Trage die <b>Scrobble-URL</b> in den Einstellungen ein,
|
||||
damit deine Wiedergaben aufgezeichnet werden.</div>`;
|
||||
return;
|
||||
}
|
||||
const auth = subsonicAuthParams();
|
||||
if (!auth) { body.innerHTML = _errBox('Nicht angemeldet.'); return; }
|
||||
|
||||
body.innerHTML = `<div style="text-align:center;color:var(--text2);padding:40px 0">Lade…</div>`;
|
||||
|
||||
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 `<div style="text-align:center;color:var(--danger);padding:40px 0">${esc(msg)}</div>`;
|
||||
}
|
||||
|
||||
function _render(body, d) {
|
||||
if (!d.totalPlays) {
|
||||
body.innerHTML = `<div style="text-align:center;color:var(--text2);padding:40px 0;line-height:1.6">
|
||||
Noch keine Wiedergaben in diesem Zeitraum.<br>Spiel ein paar Songs — sie erscheinen hier automatisch.</div>`;
|
||||
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) => `
|
||||
<div style="flex:1;background:var(--surface2);border:1px solid var(--border);border-radius:12px;padding:14px 16px">
|
||||
<div style="font-size:24px;font-weight:800;letter-spacing:-.5px">${val}</div>
|
||||
<div style="font-size:12px;color:var(--text2);margin-top:2px">${label}</div>
|
||||
</div>`;
|
||||
return `<div style="display:flex;gap:10px;margin-bottom:18px">
|
||||
${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')}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// 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 => `
|
||||
<div title="${esc(s.bucket)}: ${s.plays}" style="flex:1;display:flex;flex-direction:column;align-items:center;gap:4px;min-width:0">
|
||||
<div style="width:100%;display:flex;align-items:flex-end;height:90px">
|
||||
<div style="width:100%;background:var(--accent);border-radius:4px 4px 0 0;height:${Math.round((s.plays / max) * 100)}%;min-height:2px"></div>
|
||||
</div>
|
||||
<div style="font-size:10px;color:var(--text2);white-space:nowrap;transform:rotate(-45deg);transform-origin:center;height:16px">${esc(fmtLabel(s.bucket))}</div>
|
||||
</div>`).join('');
|
||||
return `${_h('Verlauf')}
|
||||
<div style="display:flex;gap:3px;align-items:flex-end;margin:6px 0 24px;overflow-x:auto;padding-bottom:8px">${bars}</div>`;
|
||||
}
|
||||
|
||||
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 `<div style="display:flex;align-items:center;gap:10px;padding:6px 0">
|
||||
<div style="width:20px;text-align:right;color:var(--text2);font-size:13px;font-weight:700">${i + 1}</div>
|
||||
<div style="flex:1;min-width:0">
|
||||
<div style="font-size:14px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${esc(labelFn(it))}</div>
|
||||
<div style="height:5px;background:var(--surface2);border-radius:3px;margin-top:4px;overflow:hidden">
|
||||
<div style="height:100%;background:var(--accent);width:${Math.round((val / max) * 100)}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="font-size:13px;color:var(--text2);font-weight:600;min-width:34px;text-align:right">${val}</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
return `${_h(title)}<div style="margin-bottom:22px">${rows}</div>`;
|
||||
}
|
||||
|
||||
function _hourClock(byHour) {
|
||||
if (!byHour?.length) return '';
|
||||
const max = Math.max(...byHour.map(h => h.plays), 1);
|
||||
const bars = byHour.map(h => `
|
||||
<div title="${h.hour}:00 — ${h.plays}" style="flex:1;display:flex;flex-direction:column;align-items:center;gap:3px;min-width:0">
|
||||
<div style="width:100%;display:flex;align-items:flex-end;height:56px">
|
||||
<div style="width:100%;background:var(--accent);opacity:${h.plays ? 1 : 0.15};border-radius:3px 3px 0 0;height:${Math.round((h.plays / max) * 100)}%;min-height:2px"></div>
|
||||
</div>
|
||||
${h.hour % 6 === 0 ? `<div style="font-size:9px;color:var(--text2)">${h.hour}</div>` : '<div style="height:11px"></div>'}
|
||||
</div>`).join('');
|
||||
return `${_h('Hör-Uhr (Stunde)')}
|
||||
<div style="display:flex;gap:2px;align-items:flex-end;margin:6px 0 8px">${bars}</div>`;
|
||||
}
|
||||
|
||||
function _h(t) {
|
||||
return `<div style="font-size:13px;font-weight:800;text-transform:uppercase;letter-spacing:.4px;color:var(--text2);margin:0 0 8px">${esc(t)}</div>`;
|
||||
}
|
||||
Reference in new issue
Block a user