mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-07-23 05:11:34 +00:00
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) <noreply@anthropic.com>
This commit is contained in:
parent
9f35fedeef
commit
a0867f8bfd
41
server.py
41
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)
|
||||
|
||||
@ -129,10 +129,12 @@
|
||||
'<button type="button" data-v3-edit-profile class="text-sm text-fb-primary hover:text-fb-primaryHi">Edit name & avatar</button>' +
|
||||
'<button type="button" data-v3-open-progress class="text-sm text-fb-primary hover:text-fb-primaryHi">View challenges & quests →</button>' +
|
||||
'</div></div></div>' +
|
||||
// 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.
|
||||
'<div class="bg-fb-card/80 backdrop-blur rounded-xl p-6 border border-fb-border/50">' +
|
||||
'<h3 class="text-lg font-bold text-fb-text mb-2">Your best scores</h3>' +
|
||||
'<p id="v3-profile-bests" class="text-sm text-fb-textDim">Play a song to start tracking your accuracy and best scores.</p>' +
|
||||
'<div id="v3-profile-bests" class="text-sm text-fb-textDim">Play a song to start tracking your accuracy and best scores.</div>' +
|
||||
'</div>' +
|
||||
(_profile && _profile.player_hash
|
||||
? '<p class="text-center text-[10px] uppercase tracking-wider text-fb-textDim/60">player id ' + esc(_profile.player_hash.slice(0, 12)) + '</p>'
|
||||
@ -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 =
|
||||
'<ol class="space-y-2">' + 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 '<li data-fn="' + esc(s.filename) + '" class="flex items-center gap-3 cursor-pointer rounded-md px-2 py-1.5 hover:bg-fb-card transition">' +
|
||||
'<span class="w-5 text-center text-fb-textDim font-semibold shrink-0">' + (i + 1) + '</span>' +
|
||||
'<span class="flex-1 min-w-0">' +
|
||||
'<span class="block text-fb-text truncate">' + esc(s.title || s.filename) + '</span>' +
|
||||
(s.artist ? '<span class="block text-xs text-fb-textDim truncate">' + esc(s.artist) + '</span>' : '') +
|
||||
'</span>' +
|
||||
'<span class="shrink-0 text-right">' +
|
||||
'<span class="block font-bold ' + accColor(acc) + '">' + pct + '%</span>' +
|
||||
'<span class="block text-xs text-fb-textDim">' + score.toLocaleString() + ' pts</span>' +
|
||||
'</span></li>';
|
||||
}).join('') + '</ol>';
|
||||
// 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 ───────────────────────────────
|
||||
|
||||
@ -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):
|
||||
|
||||
Loading…
Reference in New Issue
Block a user