diff --git a/server.py b/server.py index d387986..b93aacd 100644 --- a/server.py +++ b/server.py @@ -1180,6 +1180,25 @@ class MetadataDB: ).fetchall() return {r[0]: r[1] for r in rows if r[2] and r[2] > 0} + def top_stats(self, limit: int = 5) -> list[dict]: + """Top scored songs (best score first) for the profile 'Your best + scores' panel. Aggregated per-song across arrangements (best score, + best accuracy, total plays), only SCORED songs (plays > 0), dead songs + skipped. Mirrors best_accuracy_map's grouping; enriched with metadata + by the /api/stats/top route.""" + limit = max(1, min(50, int(limit))) + rows = self.conn.execute( + "SELECT filename, MAX(best_score), MAX(best_accuracy), SUM(plays) " + "FROM song_stats WHERE 1=1 " + self._existing_song_filter() + # skip dead songs + "GROUP BY filename HAVING SUM(plays) > 0 " + "ORDER BY MAX(best_score) DESC, MAX(best_accuracy) DESC LIMIT ?", + (limit,), + ).fetchall() + return [ + {"filename": r[0], "best_score": r[1], "best_accuracy": r[2], "plays": r[3]} + for r in rows + ] + # ── Playlists ─────────────────────────────────────────────────────────-- SAVED_KEY = "saved_for_later" @@ -5011,6 +5030,28 @@ def api_stats_best(): return meta_db.best_accuracy_map() +@app.get("/api/stats/top") +def api_top_stats(limit: int = 5): + """Top scored songs (best first), joined to song metadata, for the profile + 'Your best scores' panel (defined before the {filename} catch-all).""" + from urllib.parse import quote + out = [] + for r in meta_db.top_stats(limit): + meta = meta_db.conn.execute( + "SELECT title, artist, tuning_name FROM songs WHERE filename = ?", + (r["filename"],), + ).fetchone() + title, artist, tuning_name = meta if meta else (None, None, None) + out.append({ + **r, + "title": title or r["filename"], + "artist": artist or "", + "tuning_name": tuning_name or "", + "art_url": f"/api/song/{quote(r['filename'])}/art", + }) + return out + + @app.get("/api/stats/{filename:path}") def api_song_stats(filename: str): return meta_db.get_song_stats(filename) diff --git a/static/v3/profile.js b/static/v3/profile.js index 58bf2e7..935e0e2 100644 --- a/static/v3/profile.js +++ b/static/v3/profile.js @@ -129,10 +129,12 @@ '' + '' + '' + - // Per-song bests (filled by prompt 14's song_stats; placeholder here) + // Per-song bests — top scored songs from /api/stats/top, filled by + // renderBests() after innerHTML is set. The placeholder text shows + // during load and when nothing's been scored yet. '
Play a song to start tracking your accuracy and best scores.
' + + 'player id ' + esc(_profile.player_hash.slice(0, 12)) + '
' @@ -145,6 +147,43 @@ if (window.v3Theme && typeof window.v3Theme.applyFrame === 'function') { window.v3Theme.applyFrame(root.querySelector('[data-v3-avatar-frame]')); } + renderBests(); + } + + // Fill the "Your best scores" panel from /api/stats/top (top scored songs, + // best first). Leaves the placeholder text in place on error / no scores so + // a fresh profile still reads sensibly. Accuracy is 0–1 (matches the library + // grid + dashboard badges). + async function renderBests() { + const host = document.getElementById('v3-profile-bests'); + if (!host) return; + let rows = []; + try { const r = await fetch('/api/stats/top?limit=5'); if (r.ok) rows = await r.json(); } catch (e) { /* P15 — keep placeholder */ } + if (!Array.isArray(rows) || !rows.length) return; // keep the placeholder text + const accColor = (a) => (a >= 0.9 ? 'text-fb-good' : a >= 0.5 ? 'text-fb-mid' : 'text-fb-low'); + host.innerHTML = + '