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 @@ -
${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 `