diff --git a/plugins/career/assets/career.css b/plugins/career/assets/career.css
index 83cd1b0..5fac8c2 100644
--- a/plugins/career/assets/career.css
+++ b/plugins/career/assets/career.css
@@ -34,3 +34,33 @@
background-color: #06b6d4;
transition: width 0.3s ease;
}
+
+.career-star-list {
+ display: grid;
+ gap: 0.375rem;
+}
+.career-star-row {
+ display: flex;
+ align-items: baseline;
+ gap: 0.75rem;
+ padding: 0.375rem 0.625rem;
+ border-radius: 0.5rem;
+ background-color: rgba(31, 41, 55, 0.4);
+ font-size: 0.8rem;
+}
+.career-star-row .stars {
+ color: #facc15;
+ letter-spacing: 0.1em;
+ min-width: 3.2em;
+}
+.career-star-row .stars .off { color: rgba(250, 204, 21, 0.25); }
+.career-star-row .song {
+ color: #e5e7eb;
+ flex: 1;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.career-star-row .song .artist { color: #9ca3af; }
+.career-star-row .hint { color: #6b7280; white-space: nowrap; }
+.career-star-row .hint.close { color: #22d3ee; }
diff --git a/plugins/career/routes.py b/plugins/career/routes.py
index 3705194..b9d7310 100644
--- a/plugins/career/routes.py
+++ b/plugins/career/routes.py
@@ -65,23 +65,39 @@ def _installed(venue_id):
def _stars():
- """(total, per-song dict). Accuracy in song_stats is a 0..1 fraction."""
+ """(total, per-song dict, detail rows). Accuracy is a 0..1 fraction."""
db = _state["meta_db"]
if db is None:
- return 0, {}
+ return 0, {}, []
thresholds = _state["content"]["star_accuracy_thresholds"]
# Existing-song filter: a scan hides (not deletes) stats of songs removed
# from the library, so orphaned rows must not keep counting toward stars.
rows = db.conn.execute(
- "SELECT filename, MAX(best_accuracy) FROM song_stats "
- "WHERE filename IN (SELECT filename FROM songs) GROUP BY filename"
+ "SELECT s.filename, MAX(s.best_accuracy), "
+ " COALESCE(MAX(sg.title), ''), COALESCE(MAX(sg.artist), '') "
+ "FROM song_stats s JOIN songs sg ON sg.filename = s.filename "
+ "GROUP BY s.filename"
).fetchall()
per_song = {}
- for filename, acc in rows:
- stars = sum(1 for t in thresholds if (acc or 0.0) >= t)
+ detail = []
+ for filename, acc, title, artist in rows:
+ acc = acc or 0.0
+ stars = sum(1 for t in thresholds if acc >= t)
if stars:
per_song[filename] = stars
- return sum(per_song.values()), per_song
+ next_at = next((t for t in thresholds if acc < t), None)
+ detail.append({
+ "filename": filename,
+ "title": title or filename,
+ "artist": artist,
+ "stars": stars,
+ "best_accuracy": round(acc, 4),
+ "next_star_at": next_at,
+ })
+ # closest-to-next-star first (a practice worklist), maxed songs last
+ detail.sort(key=lambda r: (r["next_star_at"] is None,
+ (r["next_star_at"] or 1.0) - r["best_accuracy"]))
+ return sum(per_song.values()), per_song, detail
def _validate_pack_dir(pack_dir: Path):
@@ -164,7 +180,7 @@ def setup(app, context):
@app.get(f"/api/plugins/{PLUGIN_ID}/state")
def get_state():
- stars_total, per_song = _stars()
+ stars_total, per_song, star_detail = _stars()
venues = []
for v in _state["content"]["venues"]:
with _lock:
@@ -182,6 +198,7 @@ def setup(app, context):
return {
"stars_total": stars_total,
"stars_per_song": per_song,
+ "star_detail": star_detail,
"star_accuracy_thresholds": _state["content"]["star_accuracy_thresholds"],
"venues": venues,
}
@@ -194,7 +211,7 @@ def setup(app, context):
pack = venue.get("pack")
if not pack:
raise HTTPException(404, "No pack published for this venue yet.")
- stars_total, _ = _stars()
+ stars_total, _, _ = _stars()
if stars_total < venue["star_threshold"]:
raise HTTPException(403, "Venue not unlocked yet.")
with _lock:
diff --git a/plugins/career/screen.html b/plugins/career/screen.html
index 9305a8c..38c58ca 100644
--- a/plugins/career/screen.html
+++ b/plugins/career/screen.html
@@ -11,4 +11,11 @@
+
+
+
Your star collection
+
+
+
+
diff --git a/plugins/career/screen.js b/plugins/career/screen.js
index 44a67e7..58e0c03 100644
--- a/plugins/career/screen.js
+++ b/plugins/career/screen.js
@@ -108,6 +108,43 @@
`;
}
+ function starGlyphs(n) {
+ let out = '';
+ for (let i = 0; i < 3; i++) {
+ out += `★`;
+ }
+ return out;
+ }
+
+ function renderStars(state) {
+ const list = $('career-star-list');
+ const summary = $('career-star-summary');
+ if (!list || !summary) return;
+ const detail = state.star_detail || [];
+ const tiers = [0, 0, 0, 0];
+ for (const r of detail) tiers[r.stars]++;
+ summary.textContent =
+ `${tiers[3]}× 3★ · ${tiers[2]}× 2★ · ${tiers[1]}× 1★ · ${tiers[0]} unstarred`;
+ if (!detail.length) {
+ list.innerHTML = 'Play songs to start collecting stars — 60% accuracy earns the first one.
';
+ return;
+ }
+ list.innerHTML = detail.map((r) => {
+ let hint = 'maxed';
+ let close = '';
+ if (r.next_star_at != null) {
+ const gap = Math.max(0, r.next_star_at - r.best_accuracy) * 100;
+ hint = `${gap.toFixed(0)}% to next ★`;
+ if (gap <= 5) close = ' close';
+ }
+ return `
+ ${starGlyphs(r.stars)}
+ ${esc(r.title)}${r.artist ? ` — ${esc(r.artist)}` : ''}
+ best ${(r.best_accuracy * 100).toFixed(0)}% · ${hint}
+
`;
+ }).join('');
+ }
+
function render(state) {
const host = $('career-venues');
if (!host) return;
@@ -128,6 +165,7 @@
label.textContent = 'All venues unlocked — enjoy the arena.';
}
host.innerHTML = state.venues.map((v) => venueCardHTML(v, state)).join('');
+ renderStars(state);
}
function schedulePoll(state) {
diff --git a/plugins/career/venues.json b/plugins/career/venues.json
index d4684ba..c749a65 100644
--- a/plugins/career/venues.json
+++ b/plugins/career/venues.json
@@ -1,5 +1,9 @@
{
- "star_accuracy_thresholds": [0.6, 0.75, 0.85],
+ "star_accuracy_thresholds": [
+ 0.6,
+ 0.75,
+ 0.85
+ ],
"venues": [
{
"id": "bar",
@@ -12,14 +16,14 @@
"id": "club",
"name": "Velvet Room",
"description": "A proper club stage. People actually came to hear you.",
- "star_threshold": 15,
+ "star_threshold": 50,
"pack": null
},
{
"id": "arena",
"name": "Feedback Arena",
"description": "Ten thousand seats. Try not to think about it.",
- "star_threshold": 40,
+ "star_threshold": 150,
"pack": null
}
]
diff --git a/tests/plugins/career/conftest.py b/tests/plugins/career/conftest.py
index f738dd3..eb6ad63 100644
--- a/tests/plugins/career/conftest.py
+++ b/tests/plugins/career/conftest.py
@@ -23,15 +23,16 @@ class FakeMetaDb:
filename TEXT, arrangement TEXT, best_accuracy REAL
)"""
)
- self.conn.execute("CREATE TABLE songs (filename TEXT)")
+ self.conn.execute("CREATE TABLE songs (filename TEXT, title TEXT, artist TEXT)")
def add(self, filename, arrangement, best_accuracy, in_library=True):
self.conn.execute("INSERT INTO song_stats VALUES (?, ?, ?)",
(filename, arrangement, best_accuracy))
if in_library:
self.conn.execute(
- "INSERT INTO songs SELECT ? WHERE NOT EXISTS "
- "(SELECT 1 FROM songs WHERE filename = ?)", (filename, filename))
+ "INSERT INTO songs SELECT ?, ?, ? WHERE NOT EXISTS "
+ "(SELECT 1 FROM songs WHERE filename = ?)",
+ (filename, filename.replace(".feedpak", "").title(), "Test Artist", filename))
self.conn.commit()
diff --git a/tests/plugins/career/test_routes.py b/tests/plugins/career/test_routes.py
index 3ec59bb..581d553 100644
--- a/tests/plugins/career/test_routes.py
+++ b/tests/plugins/career/test_routes.py
@@ -32,7 +32,7 @@ def test_stars_from_best_accuracy_across_arrangements(client, meta_db):
def test_unlock_flags_follow_thresholds(client, meta_db):
- # 6 stars: bar (0) unlocked, club (15) and arena (40) locked.
+ # 6 stars: bar (0) unlocked, club (50) and arena (150) locked.
for i in range(2):
meta_db.add(f"s{i}.feedpak", "guitar", 0.9) # 3 stars each
state = client.get("/api/plugins/career/state").json()
@@ -52,6 +52,18 @@ def test_orphaned_stats_do_not_count(client, meta_db):
assert "gone.feedpak" not in state["stars_per_song"]
+def test_star_detail_rows_sorted_by_next_star_gap(client, meta_db):
+ meta_db.add("far.feedpak", "guitar", 0.61) # 1★, 14% from next
+ meta_db.add("close.feedpak", "guitar", 0.84) # 2★, 1% from next
+ meta_db.add("maxed.feedpak", "guitar", 0.99) # 3★, maxed
+ detail = client.get("/api/plugins/career/state").json()["star_detail"]
+ assert [r["filename"] for r in detail] == \
+ ["close.feedpak", "far.feedpak", "maxed.feedpak"]
+ close = detail[0]
+ assert close["stars"] == 2 and close["next_star_at"] == 0.85
+ assert detail[2]["next_star_at"] is None
+
+
def test_no_stats_still_serves_state(client):
state = client.get("/api/plugins/career/state").json()
assert state["stars_total"] == 0