Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions plugins/career/assets/career.css
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
25 changes: 22 additions & 3 deletions plugins/career/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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"]
Expand Down Expand Up @@ -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,
Expand All @@ -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),
Expand Down
1 change: 1 addition & 0 deletions plugins/career/screen.html
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ <h2 class="text-lg font-semibold text-white">Your star collection</h2>
<div id="career-tab-passports" class="hidden" role="tabpanel" aria-labelledby="career-tab-btn-passports">
<p class="text-sm text-gray-400 mb-4">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.</p>
<div id="pp-instruments" class="pp-instruments"></div>
<div id="pp-closest" class="mt-4"></div>
<div id="pp-shelf-wrap" class="mt-5">
<div id="pp-shelf" class="pp-shelf"></div>
</div>
Expand Down
80 changes: 66 additions & 14 deletions plugins/career/screen.js
Original file line number Diff line number Diff line change
Expand Up @@ -497,6 +497,56 @@
</button>`;
}

// 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: <em>${esc(near.title)}</em> 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 `<button class="pp-closest-row" data-pp-open="${esc(p.genre_key)}">
<span class="pp-closest-genre">${esc(p.genre)}</span>
<span class="pp-closest-ask">${ppAskHTML(p, true)}</span>
</button>`;
}

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 = `<div class="pp-closest">
<div class="pp-closest-head">Closest stamps</div>
${candidates.map(closestLineHTML).join('')}
</div>`;
}

function renderShelf(inst, data) {
const shelf = $('pp-shelf');
if (!shelf) return;
Expand Down Expand Up @@ -551,6 +601,7 @@
${esc(ppLabel(i))}${earned ? ` <span class="pp-inst-badges">⚡${earned}</span>` : ''}${committed ? '' : ' <span class="pp-inst-plus">+</span>'}
</button>`;
}).join('');
renderClosest(inst, data);
renderShelf(inst, data);
renderRack(inst, data);
}
Expand All @@ -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 = `<div class="pp-snj">Shown, not judged — your ${esc(ppLabel(inst).toLowerCase())} repertoire speaks for itself.</div>`;
Expand All @@ -608,7 +650,7 @@
<span class="pp-stamp-genre">${esc(p.genre.toUpperCase())}</span>
<span class="pp-stamp-tier">BRONZE</span>
</div>
<div class="pp-invite">${esc(invite)}</div>`;
<div class="pp-invite">${invite.charAt(0).toUpperCase()}${invite.slice(1)}</div>`;
}
const hours = fmtHours(p.seconds_total);
const odometer = hours
Expand All @@ -628,6 +670,16 @@
: `Play ${esc(p.genre)} songs at ${starGl} to collect ticket stubs.`;
const stubsHTML = stubs.length ? stubs.map(ppStubHTML).join('')
: `<div class="pp-stub-empty">${emptyLine}</div>`;
// 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 = `<div class="pp-nearest">
<div class="pp-nearest-head">Bring these up</div>
${p.nearest.map((s) =>
`<div class="pp-nearest-row"><em>${esc(s.title)}</em> — best ${pct(s.best_accuracy)}%, ${starGl} at ${pct(s.bar_at)}%</div>`).join('')}
</div>`;
}
return `<div class="pp-book-wrap" data-pp-close-bg="1" role="dialog" aria-modal="true" aria-label="${esc(p.genre)} ${esc(ppLabel(inst))} passport">
<div class="pp-book">
<div class="pp-page pp-page-left">
Expand All @@ -636,7 +688,7 @@
</div>
<div class="pp-page pp-page-right">
<div class="pp-page-head">Ticket stubs</div>
<div class="pp-stubs">${stubsHTML}</div>
<div class="pp-stubs">${stubsHTML}${nearest}</div>
</div>
<div class="pp-book-cover pp-leather-${esc(inst)}">
<span class="pp-cover-title">${esc(p.genre.toUpperCase())}</span>
Expand Down
27 changes: 27 additions & 0 deletions tests/plugins/career/test_passports.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"])
Loading