diff --git a/plugins/career/assets/career.css b/plugins/career/assets/career.css
index 76c2af0..0fbd46c 100644
--- a/plugins/career/assets/career.css
+++ b/plugins/career/assets/career.css
@@ -559,3 +559,44 @@
/* The hover glint is motion theatrics too — not just the JS tilt. */
.pp-tilt::after { display: none; }
}
+
+/* Practice invitations — closest stamps + bring-these-up */
+.pp-closest {
+ border: 1px solid rgba(75, 85, 99, 0.45);
+ border-radius: 0.6rem;
+ background: linear-gradient(165deg, rgba(45, 55, 72, 0.4), rgba(31, 41, 55, 0.4));
+ padding: 0.6rem 0.75rem;
+ display: flex;
+ flex-direction: column;
+ gap: 0.3rem;
+}
+.pp-closest-head {
+ font-size: 0.62rem;
+ letter-spacing: 0.22em;
+ text-transform: uppercase;
+ color: #9ca3af;
+}
+.pp-closest-row {
+ display: flex;
+ align-items: baseline;
+ gap: 0.75rem;
+ text-align: left;
+ font-size: 0.8rem;
+ padding: 0.15rem 0.25rem;
+ border-radius: 0.35rem;
+}
+.pp-closest-row:hover { background: rgba(55, 65, 81, 0.5); }
+.pp-closest-genre { color: #e5e7eb; font-weight: 600; white-space: nowrap; }
+.pp-closest-ask { color: #9ca3af; font-size: 0.72rem; }
+.pp-closest-ask em { color: #cbd5e1; font-style: italic; }
+
+.pp-nearest { margin-top: 0.6rem; border-top: 1px dashed rgba(138, 122, 94, 0.4); padding-top: 0.5rem; }
+.pp-nearest-head {
+ font-size: 0.58rem;
+ letter-spacing: 0.22em;
+ text-transform: uppercase;
+ color: #8a7a5e;
+ margin-bottom: 0.25rem;
+}
+.pp-nearest-row { font-size: 0.7rem; color: #6d5d40; padding: 0.1rem 0; }
+.pp-nearest-row em { color: #3f3428; }
diff --git a/plugins/career/routes.py b/plugins/career/routes.py
index 3eed518..2a2ffc7 100644
--- a/plugins/career/routes.py
+++ b/plugins/career/routes.py
@@ -114,10 +114,9 @@ def _stars():
detail = []
for filename, acc, title, artist in rows:
acc = acc or 0.0
- stars = sum(1 for t in thresholds if acc >= t)
+ stars, next_at = _star_progress(acc, thresholds)
if stars:
per_song[filename] = stars
- next_at = next((t for t in thresholds if acc < t), None)
detail.append({
"filename": filename,
"title": title or filename,
@@ -249,10 +248,18 @@ def _played_by_instrument_genre():
for stub in stubs.values():
acc = stub["best_accuracy"]
stub["best_accuracy"] = round(acc, 4)
- stub["stars"] = sum(1 for t in thresholds if acc >= t)
+ stub["stars"], stub["next_star_at"] = _star_progress(acc, thresholds)
return out, seconds
+def _star_progress(acc, thresholds):
+ """(stars, next_star_at) — the one place the ascending-thresholds
+ assumption lives; _stars() and the passport stubs both use it."""
+ stars = sum(1 for t in thresholds if acc >= t)
+ next_at = next((t for t in thresholds if acc < t), None)
+ return stars, next_at
+
+
def _library_genres():
"""Distinct effective genres across the live library (the brochure rack)."""
db = _state["meta_db"]
@@ -406,6 +413,17 @@ def _passports_view():
badge = "earned"
else:
badge = "in_progress"
+ # Practice invitation: the non-qualifying songs closest to the
+ # QUALIFYING bar (the badge ask), nearest first — invitation
+ # data, the UI voices it without meters.
+ thresholds = _state["content"]["star_accuracy_thresholds"]
+ bar = (thresholds[req["min_stars"] - 1]
+ if 0 < req["min_stars"] <= len(thresholds) else None)
+ nearest = [] if bar is None else sorted(
+ (s for s in songs if not s["qualifies"]),
+ key=lambda s: bar - s["best_accuracy"])[:3]
+ for s in nearest:
+ s["bar_at"] = bar
passports.append({
"genre_key": gkey,
"genre": meta.get("genre") or gkey,
@@ -414,6 +432,7 @@ def _passports_view():
"graded": is_graded,
"songs": songs,
"qualifying_count": qualifying,
+ "nearest": nearest,
# Honest hours odometer (Stage 5 post-cap): a true fact that
# only grows — never a target, never a meter.
"seconds_total": round(played_seconds.get((inst, gkey), 0.0), 1),
diff --git a/plugins/career/screen.html b/plugins/career/screen.html
index 3fa5047..bb4a510 100644
--- a/plugins/career/screen.html
+++ b/plugins/career/screen.html
@@ -29,6 +29,7 @@
Commit to an instrument, pick a genre, and stamp your way to its badge — five ★★ songs mint a Bronze. Your passport wall is who you are as a musician.
+
diff --git a/plugins/career/screen.js b/plugins/career/screen.js
index 4cadbae..9ad0bc1 100644
--- a/plugins/career/screen.js
+++ b/plugins/career/screen.js
@@ -497,6 +497,56 @@
`;
}
+ // Practice invitations: which stamps are closest, and what would bring
+ // them home. Invitations only — no meters, no obligations.
+
+ // Floor, never round: 74.9% must not display as the already-met "75%".
+ function pct(frac) { return Math.floor((Number(frac) || 0) * 100); }
+
+ function ppNeed(p) {
+ return Math.max(0, ((p.requirement || {}).songs || 0) - (p.qualifying_count || 0));
+ }
+
+ // The one blocker phrase — shared by the Closest-stamps strip and the
+ // passport book's invite line so they can never contradict each other.
+ function ppAskHTML(p, withHint) {
+ const req = p.requirement || {};
+ const need = ppNeed(p);
+ const starGl = '★'.repeat(req.min_stars || 0);
+ if (need > 0) {
+ const near = withHint ? (p.nearest || [])[0] : null;
+ const hint = near
+ ? ` · nearest:
${esc(near.title)} at ${pct(near.best_accuracy)}%`
+ : '';
+ return `${need === 1 ? `one more ${starGl} song` : `${need} more ${starGl} songs`}${hint}`;
+ }
+ const labels = ((_pp && _pp.config) || {}).drill_labels || {};
+ const drills = p.drills || {};
+ const pending = (drills.required || []).filter((n) => !(drills.cleared || []).includes(n));
+ return `clear ${pending.map((n) => esc(labels[n] || n)).join(', ') || 'the genre drill'} in Virtuoso`;
+ }
+
+ function closestLineHTML(p) {
+ return `
+ ${esc(p.genre)}
+ ${ppAskHTML(p, true)}
+ `;
+ }
+
+ function renderClosest(inst, data) {
+ const host = $('pp-closest');
+ if (!host) return;
+ const candidates = (data.passports || [])
+ .filter((p) => p.badge === 'in_progress')
+ .sort((a, b) => ppNeed(a) - ppNeed(b))
+ .slice(0, 3);
+ if (!candidates.length) { host.innerHTML = ''; return; }
+ host.innerHTML = `
+
Closest stamps
+ ${candidates.map(closestLineHTML).join('')}
+
`;
+ }
+
function renderShelf(inst, data) {
const shelf = $('pp-shelf');
if (!shelf) return;
@@ -551,6 +601,7 @@
${esc(ppLabel(i))}${earned ? `
⚡${earned} ` : ''}${committed ? '' : '
+ '}
`;
}).join('');
+ renderClosest(inst, data);
renderShelf(inst, data);
renderRack(inst, data);
}
@@ -576,22 +627,13 @@
function ppBookHTML(inst, p, pendingSlam) {
const req = p.requirement || {};
- const need = Math.max(0, (req.songs || 0) - p.qualifying_count);
const starGl = '★'.repeat(req.min_stars || 0);
const reqNodes = (p.drills || {}).required || [];
const clearedNodes = new Set((p.drills || {}).cleared || []);
const labels = ((_pp && _pp.config) || {}).drill_labels || {};
- const pendingDrills = reqNodes.filter((n) => !clearedNodes.has(n));
- // The invite names what actually blocks the stamp: songs first, then
- // the genre drill once the song bar is met.
- let invite;
- if (need > 0) {
- invite = need === 1 ? `One more ${starGl} song mints this stamp.`
- : `${need} more ${starGl} songs mint this stamp.`;
- } else {
- const names = pendingDrills.map((n) => labels[n] || n).join(', ');
- invite = `Clear ${names || 'the genre drill'} in Virtuoso to mint this stamp.`;
- }
+ // The invite names what actually blocks the stamp — same shared
+ // phrase as the Closest-stamps strip, so they can't contradict.
+ const invite = `${ppAskHTML(p, false)} mints this stamp.`;
let badgeArea = '';
if (p.badge === 'shown_not_judged') {
badgeArea = `
Shown, not judged — your ${esc(ppLabel(inst).toLowerCase())} repertoire speaks for itself.
`;
@@ -608,7 +650,7 @@
${esc(p.genre.toUpperCase())}
BRONZE
- ${esc(invite)}
`;
+ ${invite.charAt(0).toUpperCase()}${invite.slice(1)}
`;
}
const hours = fmtHours(p.seconds_total);
const odometer = hours
@@ -628,6 +670,16 @@
: `Play ${esc(p.genre)} songs at ${starGl} to collect ticket stubs.`;
const stubsHTML = stubs.length ? stubs.map(ppStubHTML).join('')
: `${emptyLine}
`;
+ // Bring-these-up: nearest-to-the-bar songs (graded, unearned only —
+ // an earned page is memorabilia, not homework).
+ let nearest = '';
+ if (p.badge === 'in_progress' && (p.nearest || []).length) {
+ nearest = `
+
Bring these up
+ ${p.nearest.map((s) =>
+ `
${esc(s.title)} — best ${pct(s.best_accuracy)}%, ${starGl} at ${pct(s.bar_at)}%
`).join('')}
+
`;
+ }
return `
@@ -636,7 +688,7 @@
Ticket stubs
-
${stubsHTML}
+
${stubsHTML}${nearest}
${esc(p.genre.toUpperCase())}
diff --git a/tests/plugins/career/test_passports.py b/tests/plugins/career/test_passports.py
index 27decf0..13ac92f 100644
--- a/tests/plugins/career/test_passports.py
+++ b/tests/plugins/career/test_passports.py
@@ -245,3 +245,30 @@ def test_family_drills_stay_per_instrument(client, meta_db):
p = _passport(client, "keys", "death metal")
assert p["drills"]["required"] == []
assert p["badge"] == "earned"
+
+
+def test_nearest_invitations_order_and_exclusions(client, meta_db):
+ # Non-qualifying songs sorted by distance to the QUALIFYING bar;
+ # qualifying songs never appear; capped at 3.
+ meta_db.add("q.feedpak", 0, 0.80, genre="Soul", arrangements=LEAD) # qualifies
+ meta_db.add("close.feedpak", 0, 0.74, genre="Soul", arrangements=LEAD) # 1% to 2★
+ meta_db.add("mid.feedpak", 0, 0.70, genre="Soul", arrangements=LEAD) # 5% to 2★
+ meta_db.add("far.feedpak", 0, 0.30, genre="Soul", arrangements=LEAD) # 30% to 1★
+ meta_db.add("far2.feedpak", 0, 0.25, genre="Soul", arrangements=LEAD)
+ _open(client, "guitar", "Soul")
+ p = _passport(client, "guitar", "soul")
+ names = [s["filename"] for s in p["nearest"]]
+ assert names == ["close.feedpak", "mid.feedpak", "far.feedpak"]
+ assert all(s["next_star_at"] is not None for s in p["nearest"])
+ assert "q.feedpak" not in names
+
+
+def test_nearest_targets_the_qualifying_bar_not_next_star(client, meta_db):
+ # A 0★ song 1% from its NEXT star is farther from the ★★ badge bar than
+ # a 1★ song 5% from it — nearest must rank by the badge bar.
+ meta_db.add("one_star.feedpak", 0, 0.70, genre="Soul", arrangements=LEAD) # 5% to bar
+ meta_db.add("zero_star.feedpak", 0, 0.59, genre="Soul", arrangements=LEAD) # 1% to next ★, 16% to bar
+ _open(client, "guitar", "Soul")
+ p = _passport(client, "guitar", "soul")
+ assert [s["filename"] for s in p["nearest"]] == ["one_star.feedpak", "zero_star.feedpak"]
+ assert all(s["bar_at"] == 0.75 for s in p["nearest"])