mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-07-20 20:01:21 +00:00
feat(career): practice invitations — closest stamps + bring-these-up (#953)
* feat(career): practice invitations — closest stamps + bring-these-up Career v3, WS1. The passport now points at the practice that pays: - Stubs carry next_star_at (the same primitive _stars() uses) and each passport exposes `nearest`: the top 3 non-qualifying songs by distance to their next star, in the worklist order. - "Closest stamps" strip above the shelf: the in-progress graded passports nearest to minting, each row naming the ask — N more songs (with the nearest title + best %) or the blocking Virtuoso drill. Rows open the passport. - "Bring these up" list on the stubs page of in-progress passports; earned pages stay memorabilia (no homework on a won badge). - Invitation-voiced throughout: no meters, no completion pressure. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(test): comment says qualifying-bar ranking, matching the assertion Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
6cc0312661
commit
831117fb96
@ -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; }
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -29,6 +29,7 @@
|
||||
<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>
|
||||
|
||||
@ -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;
|
||||
@ -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);
|
||||
}
|
||||
@ -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>`;
|
||||
@ -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
|
||||
@ -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">
|
||||
@ -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>
|
||||
|
||||
@ -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…
Reference in New Issue
Block a user