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
+91 -24

No files matched your search

@@ -5,6 +5,7 @@ import kotlinx.serialization.Serializable
// Mirrors the JSON from the scrobble service GET /scrobble/stats. // Mirrors the JSON from the scrobble service GET /scrobble/stats.
@Serializable @Serializable
data class ScrobbleStats( data class ScrobbleStats(
val firstPlayedAt: Long? = null,
val totalPlays: Int = 0, val totalPlays: Int = 0,
val totalSeconds: Long = 0, val totalSeconds: Long = 0,
val topArtists: List<StatArtist> = emptyList(), val topArtists: List<StatArtist> = emptyList(),
@@ -394,6 +394,8 @@ fun AppNavigation() {
StatsScreen( StatsScreen(
coverUrl = { id -> coverUrl(id) }, coverUrl = { id -> coverUrl(id) },
onBack = { navController.popBackStack() }, onBack = { navController.popBackStack() },
onArtistClick = { id -> navController.navigate("artist/$id") },
onAlbumClick = { id -> navController.navigate("album/$id") },
) )
} }
@@ -80,6 +80,14 @@ fun SettingsScreen(onOpenStats: () -> Unit = {}) {
color = NsOnSurface, color = NsOnSurface,
) )
// ── 📊 Statistics (TOP) ──────────────────────────────────────────────
OutlinedButton(
onClick = onOpenStats,
modifier = Modifier.fillMaxWidth().height(48.dp),
) {
Text("📊 Meine Statistiken", color = NsOnSurface, fontFamily = Outfit, fontWeight = FontWeight.SemiBold)
}
// ── Save button (TOP) ──────────────────────────────────────────────── // ── Save button (TOP) ────────────────────────────────────────────────
SaveButton(vm, state.isSaving, state.savedOk, state.error) SaveButton(vm, state.isSaving, state.savedOk, state.error)
@@ -228,15 +236,6 @@ fun SettingsScreen(onOpenStats: () -> Unit = {}) {
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp), color = NsBorder) HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp), color = NsBorder)
OutlinedButton(
onClick = onOpenStats,
modifier = Modifier.fillMaxWidth().height(48.dp),
) {
Text("📊 Meine Statistiken", color = NsOnSurface, fontFamily = Outfit, fontWeight = FontWeight.SemiBold)
}
Spacer(Modifier.height(8.dp))
OutlinedButton( OutlinedButton(
onClick = { showLogoutDialog = true }, onClick = { showLogoutDialog = true },
modifier = Modifier.fillMaxWidth().height(48.dp), modifier = Modifier.fillMaxWidth().height(48.dp),
@@ -41,6 +41,8 @@ private val MONTHS = listOf(
fun StatsScreen( fun StatsScreen(
coverUrl: (String?) -> String?, coverUrl: (String?) -> String?,
onBack: () -> Unit, onBack: () -> Unit,
onArtistClick: (String) -> Unit = {},
onAlbumClick: (String) -> Unit = {},
vm: StatsViewModel = viewModel(), vm: StatsViewModel = viewModel(),
) { ) {
val state by vm.state.collectAsStateWithLifecycle() val state by vm.state.collectAsStateWithLifecycle()
@@ -82,7 +84,7 @@ fun StatsScreen(
} }
if (state.view != StatsView.ALL) { if (state.view != StatsView.ALL) {
Spacer(Modifier.weight(1f)) Spacer(Modifier.weight(1f))
StepArrow("‹", enabled = true) { vm.step(-1) } StepArrow("‹", enabled = !vm.atStart()) { vm.step(-1) }
Text( Text(
if (state.view == StatsView.YEAR) state.year.toString() else "${MONTHS[state.month]} ${state.year}", if (state.view == StatsView.YEAR) state.year.toString() else "${MONTHS[state.month]} ${state.year}",
fontFamily = Outfit, fontSize = 14.sp, fontWeight = FontWeight.Bold, color = NsOnSurface, fontFamily = Outfit, fontSize = 14.sp, fontWeight = FontWeight.Bold, color = NsOnSurface,
@@ -101,13 +103,18 @@ fun StatsScreen(
(state.stats?.totalPlays ?: 0) == 0 -> CenterMessage( (state.stats?.totalPlays ?: 0) == 0 -> CenterMessage(
"Noch keine Wiedergaben in diesem Zeitraum.\nSpiel ein paar Songs — sie erscheinen hier automatisch.", "Noch keine Wiedergaben in diesem Zeitraum.\nSpiel ein paar Songs — sie erscheinen hier automatisch.",
) )
else -> StatsContent(state.stats!!, coverUrl) else -> StatsContent(state.stats!!, coverUrl, onArtistClick, onAlbumClick)
} }
} }
} }
@Composable @Composable
private fun StatsContent(d: ScrobbleStats, coverUrl: (String?) -> String?) { private fun StatsContent(
d: ScrobbleStats,
coverUrl: (String?) -> String?,
onArtistClick: (String) -> Unit,
onAlbumClick: (String) -> Unit,
) {
Column( Column(
Modifier.fillMaxSize().verticalScroll(rememberScrollState()) Modifier.fillMaxSize().verticalScroll(rememberScrollState())
.padding(horizontal = 16.dp).padding(bottom = 24.dp), .padding(horizontal = 16.dp).padding(bottom = 24.dp),
@@ -128,7 +135,8 @@ private fun StatsContent(d: ScrobbleStats, coverUrl: (String?) -> String?) {
if (d.topArtists.isNotEmpty()) { if (d.topArtists.isNotEmpty()) {
SectionHeader("Top-Künstler") SectionHeader("Top-Künstler")
d.topArtists.forEachIndexed { i, a -> d.topArtists.forEachIndexed { i, a ->
TopRow(i + 1, coverUrl(a.artistId), a.name ?: "—", null, a.plays, circular = true) TopRow(i + 1, coverUrl(a.artistId), a.name ?: "—", null, a.plays, circular = true,
onClick = a.artistId?.let { id -> { onArtistClick(id) } })
} }
Spacer(Modifier.height(20.dp)) Spacer(Modifier.height(20.dp))
} }
@@ -136,7 +144,8 @@ private fun StatsContent(d: ScrobbleStats, coverUrl: (String?) -> String?) {
if (d.topTracks.isNotEmpty()) { if (d.topTracks.isNotEmpty()) {
SectionHeader("Top-Songs") SectionHeader("Top-Songs")
d.topTracks.forEachIndexed { i, t -> d.topTracks.forEachIndexed { i, t ->
TopRow(i + 1, coverUrl(t.coverArt ?: t.albumId ?: t.songId), t.title ?: "—", t.artist, t.plays) TopRow(i + 1, coverUrl(t.coverArt ?: t.albumId ?: t.songId), t.title ?: "—", t.artist, t.plays,
onClick = t.albumId?.let { id -> { onAlbumClick(id) } })
} }
Spacer(Modifier.height(20.dp)) Spacer(Modifier.height(20.dp))
} }
@@ -144,7 +153,8 @@ private fun StatsContent(d: ScrobbleStats, coverUrl: (String?) -> String?) {
if (d.topAlbums.isNotEmpty()) { if (d.topAlbums.isNotEmpty()) {
SectionHeader("Top-Alben") SectionHeader("Top-Alben")
d.topAlbums.forEachIndexed { i, a -> d.topAlbums.forEachIndexed { i, a ->
TopRow(i + 1, coverUrl(a.coverArt ?: a.albumId), a.name ?: "—", a.artist, a.plays) TopRow(i + 1, coverUrl(a.coverArt ?: a.albumId), a.name ?: "—", a.artist, a.plays,
onClick = a.albumId?.let { id -> { onAlbumClick(id) } })
} }
Spacer(Modifier.height(20.dp)) Spacer(Modifier.height(20.dp))
} }
@@ -193,10 +203,13 @@ private fun SectionHeader(t: String) {
@Composable @Composable
private fun TopRow( private fun TopRow(
rank: Int, cover: String?, title: String, subtitle: String?, plays: Int, circular: Boolean = false, rank: Int, cover: String?, title: String, subtitle: String?, plays: Int, circular: Boolean = false,
onClick: (() -> Unit)? = null,
) { ) {
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().padding(vertical = 5.dp), modifier = Modifier.fillMaxWidth()
.then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier)
.padding(vertical = 5.dp),
) { ) {
Text( Text(
rank.toString(), fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 13.sp, rank.toString(), fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 13.sp,
@@ -21,6 +21,7 @@ data class StatsUiState(
val view: StatsView = StatsView.ALL, val view: StatsView = StatsView.ALL,
val year: Int = Calendar.getInstance().get(Calendar.YEAR), val year: Int = Calendar.getInstance().get(Calendar.YEAR),
val month: Int = Calendar.getInstance().get(Calendar.MONTH), // 0-11 val month: Int = Calendar.getInstance().get(Calendar.MONTH), // 0-11
val firstPlayedAt: Long? = null, // earliest logged play → navigator lower bound
val stats: ScrobbleStats? = null, val stats: ScrobbleStats? = null,
) )
@@ -67,6 +68,20 @@ class StatsViewModel(app: Application) : AndroidViewModel(app) {
} }
} }
/** True when the shown window is at/before the first logged play — caps stepping backwards. */
fun atStart(): Boolean {
val s = _state.value
val first = s.firstPlayedAt ?: return false
val c = Calendar.getInstance().apply { timeInMillis = first * 1000 }
val fy = c.get(Calendar.YEAR)
val fm = c.get(Calendar.MONTH)
return when (s.view) {
StatsView.YEAR -> s.year <= fy
StatsView.MONTH -> s.year < fy || (s.year == fy && s.month <= fm)
else -> false
}
}
private fun startOf(year: Int, month: Int, day: Int): Long { private fun startOf(year: Int, month: Int, day: Int): Long {
val c = Calendar.getInstance().apply { val c = Calendar.getInstance().apply {
clear() clear()
@@ -99,7 +114,11 @@ class StatsViewModel(app: Application) : AndroidViewModel(app) {
_state.value = if (stats == null) _state.value = if (stats == null)
_state.value.copy(loading = false, error = "Scrobble-Dienst nicht erreichbar") _state.value.copy(loading = false, error = "Scrobble-Dienst nicht erreichbar")
else else
_state.value.copy(loading = false, configured = true, stats = stats) _state.value.copy(
loading = false, configured = true, stats = stats,
// Keep the earliest-play bound once known (a windowed query may return null for it).
firstPlayedAt = stats.firstPlayedAt ?: _state.value.firstPlayedAt,
)
} }
} }
} }
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE = 'ns-v69'; const CACHE = 'ns-v70';
const PRECACHE = ['/', 'index.html', 'manifest.json', 'icon.svg']; const PRECACHE = ['/', 'index.html', 'manifest.json', 'icon.svg'];
const SKIP_PATHS = ['/rest/stream', '/rest/download', '/config.js']; const SKIP_PATHS = ['/rest/stream', '/rest/download', '/config.js'];
// Auth token params stripped from cache keys so artwork survives across sessions/token refreshes. // Auth token params stripped from cache keys so artwork survives across sessions/token refreshes.
+5
View File
@@ -195,6 +195,10 @@ app.get('/scrobble/stats', async (req, res) => {
`SELECT COUNT(*) AS plays, COALESCE(SUM(duration), 0) AS seconds FROM plays WHERE ${WHERE}` `SELECT COUNT(*) AS plays, COALESCE(SUM(duration), 0) AS seconds FROM plays WHERE ${WHERE}`
).get(win); ).get(win);
// Earliest play across ALL of the user's history (unfiltered by window) — lets the client cap the
// navigator so you can't step before logging began.
const firstPlayedAt = db.prepare('SELECT MIN(played_at) AS t FROM plays WHERE user = :user').get({ user }).t || null;
const topArtists = db.prepare(` const topArtists = db.prepare(`
SELECT MAX(artist) AS name, MAX(artist_id) AS artistId, COUNT(*) AS plays, COALESCE(SUM(duration),0) AS seconds SELECT MAX(artist) AS name, MAX(artist_id) AS artistId, COUNT(*) AS plays, COALESCE(SUM(duration),0) AS seconds
FROM plays WHERE ${WHERE} AND artist IS NOT NULL AND artist <> '' FROM plays WHERE ${WHERE} AND artist IS NOT NULL AND artist <> ''
@@ -231,6 +235,7 @@ app.get('/scrobble/stats', async (req, res) => {
res.json({ res.json({
user, user,
range: { from, to, tz, bucket }, range: { from, to, tz, bucket },
firstPlayedAt,
totalPlays: totals.plays, totalPlays: totals.plays,
totalSeconds: totals.seconds, totalSeconds: totals.seconds,
topArtists, topAlbums, topTracks, series, byHour, topArtists, topAlbums, topTracks, series, byHour,
+34 -6
View File
@@ -14,6 +14,7 @@ let _overlay = null;
let _view = 'all'; // all | year | month let _view = 'all'; // all | year | month
let _year = new Date().getFullYear(); let _year = new Date().getFullYear();
let _month = new Date().getMonth(); // 0-11 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', const _MONTHS = ['Januar', 'Februar', 'März', 'April', 'Mai', 'Juni',
'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember']; 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember'];
@@ -64,6 +65,15 @@ function _atCurrent() {
return true; 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) { function _step(dir) {
if (_view === 'year') { _year += dir; } if (_view === 'year') { _year += dir; }
else if (_view === 'month') { 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-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 class="stats-body" id="stats-body" style="padding:16px 18px;overflow-y:auto"></div>
</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); document.body.appendChild(_overlay);
_renderTabs(); _renderTabs();
} }
@@ -121,7 +141,7 @@ function _renderTabs() {
font-size:16px;line-height:1;display:flex;align-items:center;justify-content:center">${label}</button>`; font-size:16px;line-height:1;display:flex;align-items:center;justify-content:center">${label}</button>`;
const stepper = _view === 'all' ? '' : ` const stepper = _view === 'all' ? '' : `
<div style="display:flex;align-items:center;gap:8px;margin-left:auto"> <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> <div style="min-width:118px;text-align:center;font-weight:700;font-size:14px">${esc(_periodLabel())}</div>
${stepBtn(1, '›', _atCurrent())} ${stepBtn(1, '›', _atCurrent())}
</div>`; </div>`;
@@ -153,6 +173,7 @@ async function _load() {
const res = await fetch(`${S.cfg.scrobbleUrl}/scrobble/stats?${qs}`); const res = await fetch(`${S.cfg.scrobbleUrl}/scrobble/stats?${qs}`);
if (!res.ok) { body.innerHTML = _errBox(`Fehler ${res.status}`); return; } if (!res.ok) { body.innerHTML = _errBox(`Fehler ${res.status}`); return; }
const d = await res.json(); const d = await res.json();
if (d.firstPlayedAt) { _firstPlayedAt = d.firstPlayedAt; _renderTabs(); } // apply back-bound
_render(body, d); _render(body, d);
} catch { } catch {
body.innerHTML = _errBox('Scrobble-Dienst nicht erreichbar.'); body.innerHTML = _errBox('Scrobble-Dienst nicht erreichbar.');
@@ -172,9 +193,12 @@ function _render(body, d) {
body.innerHTML = [ body.innerHTML = [
_tiles(d), _tiles(d),
_seriesChart(d.series, d.range.bucket), _seriesChart(d.series, d.range.bucket),
_topList('Top-Künstler', d.topArtists, a => a.name, a => a.plays, a => a.artistId, true), _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), a => a.artistId ? { type: 'artist', id: a.artistId, name: a.name } : null),
_topList('Top-Alben', d.topAlbums, a => `${a.name}${a.artist ? ' · ' + a.artist : ''}`, a => a.plays, a => a.coverArt || a.albumId), _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), _hourClock(d.byHour),
].join(''); ].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>`; <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 ''; if (!items?.length) return '';
const max = Math.max(...items.map(valFn), 1); const max = Math.max(...items.map(valFn), 1);
const radius = circular ? '50%' : '6px'; 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"> 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'">` : ''} ${cover ? `<img src="${esc(cover)}" loading="lazy" style="width:100%;height:100%;object-fit:cover" onerror="this.style.display='none'">` : ''}
</div>`; </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> <div style="width:18px;text-align:right;color:var(--text2);font-size:13px;font-weight:700">${i + 1}</div>
${thumb} ${thumb}
<div style="flex:1;min-width:0"> <div style="flex:1;min-width:0">