mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-09-11 06:54:31 +00:00
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>
This commit is contained in:
co-authored by
Claude Fable 5
parent
6cc0312661
commit
486773e566
@@ -559,3 +559,44 @@
|
|||||||
/* The hover glint is motion theatrics too — not just the JS tilt. */
|
/* The hover glint is motion theatrics too — not just the JS tilt. */
|
||||||
.pp-tilt::after { display: none; }
|
.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 = []
|
detail = []
|
||||||
for filename, acc, title, artist in rows:
|
for filename, acc, title, artist in rows:
|
||||||
acc = acc or 0.0
|
acc = acc or 0.0
|
||||||
stars = sum(1 for t in thresholds if acc >= t)
|
stars, next_at = _star_progress(acc, thresholds)
|
||||||
if stars:
|
if stars:
|
||||||
per_song[filename] = stars
|
per_song[filename] = stars
|
||||||
next_at = next((t for t in thresholds if acc < t), None)
|
|
||||||
detail.append({
|
detail.append({
|
||||||
"filename": filename,
|
"filename": filename,
|
||||||
"title": title or filename,
|
"title": title or filename,
|
||||||
@@ -249,10 +248,18 @@ def _played_by_instrument_genre():
|
|||||||
for stub in stubs.values():
|
for stub in stubs.values():
|
||||||
acc = stub["best_accuracy"]
|
acc = stub["best_accuracy"]
|
||||||
stub["best_accuracy"] = round(acc, 4)
|
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
|
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():
|
def _library_genres():
|
||||||
"""Distinct effective genres across the live library (the brochure rack)."""
|
"""Distinct effective genres across the live library (the brochure rack)."""
|
||||||
db = _state["meta_db"]
|
db = _state["meta_db"]
|
||||||
@@ -406,6 +413,17 @@ def _passports_view():
|
|||||||
badge = "earned"
|
badge = "earned"
|
||||||
else:
|
else:
|
||||||
badge = "in_progress"
|
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({
|
passports.append({
|
||||||
"genre_key": gkey,
|
"genre_key": gkey,
|
||||||
"genre": meta.get("genre") or gkey,
|
"genre": meta.get("genre") or gkey,
|
||||||
@@ -414,6 +432,7 @@ def _passports_view():
|
|||||||
"graded": is_graded,
|
"graded": is_graded,
|
||||||
"songs": songs,
|
"songs": songs,
|
||||||
"qualifying_count": qualifying,
|
"qualifying_count": qualifying,
|
||||||
|
"nearest": nearest,
|
||||||
# Honest hours odometer (Stage 5 post-cap): a true fact that
|
# Honest hours odometer (Stage 5 post-cap): a true fact that
|
||||||
# only grows — never a target, never a meter.
|
# only grows — never a target, never a meter.
|
||||||
"seconds_total": round(played_seconds.get((inst, gkey), 0.0), 1),
|
"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">
|
<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>
|
<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-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-wrap" class="mt-5">
|
||||||
<div id="pp-shelf" class="pp-shelf"></div>
|
<div id="pp-shelf" class="pp-shelf"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+66
-14
@@ -497,6 +497,56 @@
|
|||||||
</button>`;
|
</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) {
|
function renderShelf(inst, data) {
|
||||||
const shelf = $('pp-shelf');
|
const shelf = $('pp-shelf');
|
||||||
if (!shelf) return;
|
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>'}
|
${esc(ppLabel(i))}${earned ? ` <span class="pp-inst-badges">⚡${earned}</span>` : ''}${committed ? '' : ' <span class="pp-inst-plus">+</span>'}
|
||||||
</button>`;
|
</button>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
|
renderClosest(inst, data);
|
||||||
renderShelf(inst, data);
|
renderShelf(inst, data);
|
||||||
renderRack(inst, data);
|
renderRack(inst, data);
|
||||||
}
|
}
|
||||||
@@ -576,22 +627,13 @@
|
|||||||
|
|
||||||
function ppBookHTML(inst, p, pendingSlam) {
|
function ppBookHTML(inst, p, pendingSlam) {
|
||||||
const req = p.requirement || {};
|
const req = p.requirement || {};
|
||||||
const need = Math.max(0, (req.songs || 0) - p.qualifying_count);
|
|
||||||
const starGl = '★'.repeat(req.min_stars || 0);
|
const starGl = '★'.repeat(req.min_stars || 0);
|
||||||
const reqNodes = (p.drills || {}).required || [];
|
const reqNodes = (p.drills || {}).required || [];
|
||||||
const clearedNodes = new Set((p.drills || {}).cleared || []);
|
const clearedNodes = new Set((p.drills || {}).cleared || []);
|
||||||
const labels = ((_pp && _pp.config) || {}).drill_labels || {};
|
const labels = ((_pp && _pp.config) || {}).drill_labels || {};
|
||||||
const pendingDrills = reqNodes.filter((n) => !clearedNodes.has(n));
|
// The invite names what actually blocks the stamp — same shared
|
||||||
// The invite names what actually blocks the stamp: songs first, then
|
// phrase as the Closest-stamps strip, so they can't contradict.
|
||||||
// the genre drill once the song bar is met.
|
const invite = `${ppAskHTML(p, false)} mints this stamp.`;
|
||||||
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.`;
|
|
||||||
}
|
|
||||||
let badgeArea = '';
|
let badgeArea = '';
|
||||||
if (p.badge === 'shown_not_judged') {
|
if (p.badge === 'shown_not_judged') {
|
||||||
badgeArea = `<div class="pp-snj">Shown, not judged — your ${esc(ppLabel(inst).toLowerCase())} repertoire speaks for itself.</div>`;
|
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-genre">${esc(p.genre.toUpperCase())}</span>
|
||||||
<span class="pp-stamp-tier">BRONZE</span>
|
<span class="pp-stamp-tier">BRONZE</span>
|
||||||
</div>
|
</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 hours = fmtHours(p.seconds_total);
|
||||||
const odometer = hours
|
const odometer = hours
|
||||||
@@ -628,6 +670,16 @@
|
|||||||
: `Play ${esc(p.genre)} songs at ${starGl} to collect ticket stubs.`;
|
: `Play ${esc(p.genre)} songs at ${starGl} to collect ticket stubs.`;
|
||||||
const stubsHTML = stubs.length ? stubs.map(ppStubHTML).join('')
|
const stubsHTML = stubs.length ? stubs.map(ppStubHTML).join('')
|
||||||
: `<div class="pp-stub-empty">${emptyLine}</div>`;
|
: `<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">
|
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-book">
|
||||||
<div class="pp-page pp-page-left">
|
<div class="pp-page pp-page-left">
|
||||||
@@ -636,7 +688,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="pp-page pp-page-right">
|
<div class="pp-page pp-page-right">
|
||||||
<div class="pp-page-head">Ticket stubs</div>
|
<div class="pp-page-head">Ticket stubs</div>
|
||||||
<div class="pp-stubs">${stubsHTML}</div>
|
<div class="pp-stubs">${stubsHTML}${nearest}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="pp-book-cover pp-leather-${esc(inst)}">
|
<div class="pp-book-cover pp-leather-${esc(inst)}">
|
||||||
<span class="pp-cover-title">${esc(p.genre.toUpperCase())}</span>
|
<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")
|
p = _passport(client, "keys", "death metal")
|
||||||
assert p["drills"]["required"] == []
|
assert p["drills"]["required"] == []
|
||||||
assert p["badge"] == "earned"
|
assert p["badge"] == "earned"
|
||||||
|
|
||||||
|
|
||||||
|
def test_nearest_invitations_order_and_exclusions(client, meta_db):
|
||||||
|
# Non-qualifying songs sorted by distance to the NEXT star; 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"])
|
||||||
|
|||||||
Reference in New Issue
Block a user