feat(scrobble stats): navigator lower bound, clickable rows, android button to top

- Navigator can't step before logging began: the endpoint returns firstPlayedAt
  (MIN(played_at) over all the user's plays, unfiltered by window); web + android
  disable the back arrow once the shown year/month is at/before that first play.
- Top artists/tracks/albums are now clickable → open the artist / album page (web
  navigates and closes the overlay; android navigates via nav routes). Tracks open
  their album.
- Android: moved the 'Meine Statistiken' button to the top of Settings (was at the
  bottom near logout). SW cache -> ns-v70.

Android compileDebugKotlin + web build green; firstPlayedAt verified server-side.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
HoneyandClaude Opus 4.8 committed 2026-08-08 21:50:27 +02:00
1 parent 86eb893f40
commit 3129333f9a
8 files changed
+93 -26

No files matched your search

+34 -6
View File
@@ -14,6 +14,7 @@ let _overlay = null;
let _view = 'all'; // all | year | month
let _year = new Date().getFullYear();
let _month = new Date().getMonth(); // 0-11
let _firstPlayedAt = null; // epoch secs of the user's earliest play → navigator lower bound
const _MONTHS = ['Januar', 'Februar', 'März', 'April', 'Mai', 'Juni',
'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember'];
@@ -64,6 +65,15 @@ function _atCurrent() {
return true;
}
// True when the shown period is at/before the user's first logged play — caps stepping backwards.
function _atStart() {
if (!_firstPlayedAt) return false;
const f = new Date(_firstPlayedAt * 1000);
if (_view === 'year') return _year <= f.getFullYear();
if (_view === 'month') return _year < f.getFullYear() || (_year === f.getFullYear() && _month <= f.getMonth());
return false;
}
function _step(dir) {
if (_view === 'year') { _year += dir; }
else if (_view === 'month') {
@@ -101,6 +111,16 @@ function _buildOverlay() {
<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>`;
// Delegated navigation: click an artist / album / track row → open its page.
_overlay.querySelector('#stats-body').addEventListener('click', async e => {
const row = e.target.closest('[data-nav-type]');
if (!row) return;
const { navType, navId, navName, navArtist } = row.dataset;
closeStats();
const { navigate } = await import('../views/views.js');
if (navType === 'artist') navigate('artist-detail', { id: navId, name: navName });
else if (navType === 'album') navigate('album-detail', { id: navId, name: navName, artist: navArtist || '' });
});
document.body.appendChild(_overlay);
_renderTabs();
}
@@ -121,7 +141,7 @@ function _renderTabs() {
font-size:16px;line-height:1;display:flex;align-items:center;justify-content:center">${label}</button>`;
const stepper = _view === 'all' ? '' : `
<div style="display:flex;align-items:center;gap:8px;margin-left:auto">
${stepBtn(-1, '‹', false)}
${stepBtn(-1, '‹', _atStart())}
<div style="min-width:118px;text-align:center;font-weight:700;font-size:14px">${esc(_periodLabel())}</div>
${stepBtn(1, '›', _atCurrent())}
</div>`;
@@ -153,6 +173,7 @@ async function _load() {
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();
if (d.firstPlayedAt) { _firstPlayedAt = d.firstPlayedAt; _renderTabs(); } // apply back-bound
_render(body, d);
} catch {
body.innerHTML = _errBox('Scrobble-Dienst nicht erreichbar.');
@@ -172,9 +193,12 @@ function _render(body, d) {
body.innerHTML = [
_tiles(d),
_seriesChart(d.series, d.range.bucket),
_topList('Top-Künstler', d.topArtists, a => a.name, a => a.plays, a => a.artistId, true),
_topList('Top-Songs', d.topTracks, t => `${t.title}${t.artist ? ' · ' + t.artist : ''}`, t => t.plays, t => t.coverArt || t.albumId || t.songId),
_topList('Top-Alben', d.topAlbums, a => `${a.name}${a.artist ? ' · ' + a.artist : ''}`, a => a.plays, a => a.coverArt || a.albumId),
_topList('Top-Künstler', d.topArtists, a => a.name, a => a.plays, a => a.artistId, true,
a => a.artistId ? { type: 'artist', id: a.artistId, name: a.name } : null),
_topList('Top-Songs', d.topTracks, t => `${t.title}${t.artist ? ' · ' + t.artist : ''}`, t => t.plays, t => t.coverArt || t.albumId || t.songId, false,
t => t.albumId ? { type: 'album', id: t.albumId, name: t.album, artist: t.artist } : null),
_topList('Top-Alben', d.topAlbums, a => `${a.name}${a.artist ? ' · ' + a.artist : ''}`, a => a.plays, a => a.coverArt || a.albumId, false,
a => a.albumId ? { type: 'album', id: a.albumId, name: a.name, artist: a.artist } : null),
_hourClock(d.byHour),
].join('');
}
@@ -210,7 +234,7 @@ function _seriesChart(series, bucket) {
<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, idFn = null, circular = false) {
function _topList(title, items, labelFn, valFn, idFn = null, circular = false, navFn = null) {
if (!items?.length) return '';
const max = Math.max(...items.map(valFn), 1);
const radius = circular ? '50%' : '6px';
@@ -220,7 +244,11 @@ function _topList(title, items, labelFn, valFn, idFn = null, circular = false) {
const thumb = `<div style="width:40px;height:40px;border-radius:${radius};background:var(--surface2);flex-shrink:0;overflow:hidden">
${cover ? `<img src="${esc(cover)}" loading="lazy" style="width:100%;height:100%;object-fit:cover" onerror="this.style.display='none'">` : ''}
</div>`;
return `<div style="display:flex;align-items:center;gap:10px;padding:6px 0">
const nav = navFn ? navFn(it) : null;
const navAttrs = nav
? `data-nav-type="${nav.type}" data-nav-id="${esc(nav.id)}" data-nav-name="${esc(nav.name || '')}" data-nav-artist="${esc(nav.artist || '')}"`
: '';
return `<div ${navAttrs} style="display:flex;align-items:center;gap:10px;padding:6px 0${nav ? ';cursor:pointer' : ''}">
<div style="width:18px;text-align:right;color:var(--text2);font-size:13px;font-weight:700">${i + 1}</div>
${thumb}
<div style="flex:1;min-width:0">