From a0867f8bfd1b34d3f413a28619bb67c6c8d549d5 Mon Sep 17 00:00:00 2001 From: Byron Gamatos Date: Sun, 21 Jun 2026 20:55:02 +0200 Subject: [PATCH] fix(profile): wire "Your best scores" panel to real song stats (#549) (#550) The profile card's "Your best scores" panel was a hardcoded placeholder (`#v3-profile-bests` was never filled), so it always read "Play a song to start tracking..." regardless of how many songs had been scored. The backend already records best_score/best_accuracy per song; only this panel was left unwired. - server.py: add MetadataDB.top_stats(limit) (per-song aggregate, best score first, scored songs only, dead songs skipped) + /api/stats/top route that enriches rows with title/artist/art, mirroring /api/stats/recent. Declared before the /api/stats/{filename} catch-all. - static/v3/profile.js: renderBests() fetches /api/stats/top and fills the panel (rank, title/artist, best accuracy %, score; click to play), keeping the placeholder only when nothing's been scored. - tests: cover ordering, per-song aggregation, limit, and resume-only/dead-song exclusion. Co-authored-by: Claude Opus 4.8 (1M context) --- server.py | 41 ++++++++++++++++++++++++++++++++++ static/v3/profile.js | 43 ++++++++++++++++++++++++++++++++++-- tests/test_song_stats_api.py | 34 ++++++++++++++++++++++++++++ 3 files changed, 116 insertions(+), 2 deletions(-) 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. '
' + '

Your best scores

' + - '

Play a song to start tracking your accuracy and best scores.

' + + '
Play a song to start tracking your accuracy and best scores.
' + '
' + (_profile && _profile.player_hash ? '

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 = + '
    ' + rows.map((s, i) => { + const acc = Number(s.best_accuracy) || 0; + const pct = Math.round(acc * 100); + const score = Number(s.best_score) || 0; + return '
  1. ' + + '' + (i + 1) + '' + + '' + + '' + esc(s.title || s.filename) + '' + + (s.artist ? '' + esc(s.artist) + '' : '') + + '' + + '' + + '' + pct + '%' + + '' + score.toLocaleString() + ' pts' + + '
  2. '; + }).join('') + '
'; + // Click a row to play that song (default arrangement). + host.querySelectorAll('[data-fn]').forEach((li) => { + li.addEventListener('click', () => { + const fn = li.getAttribute('data-fn'); + if (fn && typeof window.playSong === 'function') window.playSong(encodeURIComponent(fn)); + }); + }); } // ── First-run onboarding (and edit) overlay ─────────────────────────────── diff --git a/tests/test_song_stats_api.py b/tests/test_song_stats_api.py index 8d2c94f..f9690c5 100644 --- a/tests/test_song_stats_api.py +++ b/tests/test_song_stats_api.py @@ -104,6 +104,40 @@ def test_recent_orders_by_last_played(client, server): assert "art_url" in recent[0] and "title" in recent[0] +def test_top_orders_by_best_score_and_enriches(client, server): + # The profile "Your best scores" panel: top songs by best score, descending, + # joined to metadata. A worse-scored and a resume-only song must rank below / + # be excluded respectively. + server.meta_db.put("low.archive", 0, 0, {"title": "Low", "artist": "A"}) + server.meta_db.put("high.archive", 0, 0, {"title": "High", "artist": "B"}) + server.meta_db.put("resume.archive", 0, 0, {"title": "Resume"}) + client.post("/api/stats", json={"filename": "low.archive", "score": 100, "accuracy": 0.5}) + client.post("/api/stats", json={"filename": "high.archive", "score": 900, "accuracy": 0.95}) + client.post("/api/stats", json={"filename": "resume.archive", "lastPlayPosition": 12.0}) # plays==0 + top = client.get("/api/stats/top").json() + names = [r["filename"] for r in top] + assert names[:2] == ["high.archive", "low.archive"] # best score first + assert "resume.archive" not in names # unscored excluded + assert top[0]["title"] == "High" and "art_url" in top[0] + assert top[0]["best_score"] == 900 and top[0]["best_accuracy"] == pytest.approx(0.95) + + +def test_top_aggregates_arrangements_and_respects_limit(client, server): + # Per-song aggregation (best across arrangements) + the limit param. + server.meta_db.put("multi.archive", 0, 0, {"title": "Multi", + "arrangements": [{"name": "Lead"}, {"name": "Bass"}]}) + server.meta_db.put("solo.archive", 0, 0, {"title": "Solo"}) + client.post("/api/stats", json={"filename": "multi.archive", "arrangement": 0, "score": 200, "accuracy": 0.6}) + client.post("/api/stats", json={"filename": "multi.archive", "arrangement": 1, "score": 800, "accuracy": 0.9}) + client.post("/api/stats", json={"filename": "solo.archive", "score": 500, "accuracy": 0.7}) + top = client.get("/api/stats/top").json() + multi = next(r for r in top if r["filename"] == "multi.archive") + assert multi["best_score"] == 800 # best arrangement, not summed/duplicated + assert [r["filename"] for r in top].count("multi.archive") == 1 + # limit caps the list. + assert len(client.get("/api/stats/top?limit=1").json()) == 1 + + # ── Codex-preflight regressions (bad-input hardening + resume touch) ────────── def test_stats_rejects_non_finite_score_accuracy(client):