mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-09-12 10:38:32 +00:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b6bf8a2825 | ||
|
|
f0298abda1 | ||
|
|
82edbb7266 | ||
|
|
7c897e9f2b | ||
|
|
6272af8d33 | ||
|
|
831117fb96 | ||
|
|
6cc0312661 |
@@ -7,6 +7,37 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- **Gigs (the career verb, frontend)** — book a gig from any opened passport:
|
||||
a gig poster proposes the setlist (re-roll for a different bill; save or
|
||||
copy the poster as a PNG), "Play the gig" hands the set to the play queue
|
||||
with the venue on stage, a floating strip tracks the set, and finishing it
|
||||
logs dated entries with per-song accuracies in the passport book — with an
|
||||
encore celebration (crowd eruption + confetti) when the whole set clears
|
||||
the bar, and a summary poster to share. Quitting mid-set simply abandons
|
||||
it: no log, no fail state.
|
||||
- **Career on the Profile and Home pages** — the Profile gains a passport
|
||||
wall (earned-badge covers per instrument, hours, gig count; absent until a
|
||||
passport exists), injected through the same mount-point + rendered-event
|
||||
seam the achievements plugin uses (now documented in docs/plugin-v3-ui.md).
|
||||
The home page's plugin-count stat tile becomes a career trading card
|
||||
(badges, hours, the closest stamp ask, foil shine) with the old stat as the
|
||||
built-in fallback when career has no state. Earned passports gain **Save
|
||||
card / Copy card** — a natively-drawn PNG passport card, downloadable or
|
||||
copied straight to the clipboard for pasting outside the app (shared
|
||||
`blob-io` helpers replace the download idiom previously duplicated in
|
||||
settings-io and diagnostics-export).
|
||||
- **Gigs (backend)** — career mode gains its verb: `POST
|
||||
/api/plugins/career/gigs/propose` builds a playable setlist for an
|
||||
instrument+genre (your qualifying songs plus a couple of stakes songs near
|
||||
the bar; a young passport fills from unplayed genre songs — the first gig
|
||||
is how stubs start; re-roll by calling again), naming the room your stars
|
||||
can book. `POST /gigs` logs a **completed** set — per-song accuracies read
|
||||
from the set's own freshly-recorded stats, an encore flag at the
|
||||
data-driven bar (avg ≥ 75%) — into the career state; abandoned sets never
|
||||
log (no fail state: the gig you finished is the gig you played). Passports
|
||||
carry their gig log; instruments their gig count.
|
||||
|
||||
### Changed
|
||||
- **The full mix is a stem** (#933) — core no longer depends on `original_audio:`, a
|
||||
top-level manifest key this repo invented (#583) that the feedpak spec never had.
|
||||
|
||||
@@ -189,3 +189,23 @@ out of the capability graph.
|
||||
- [ ] `#player` overlays keep `z-index` ≤ the chrome layers (transport/HUD 20,
|
||||
rail 30, popovers 40).
|
||||
- [ ] Verify at `/` — it and `/v3` serve the same (and only) v3 shell.
|
||||
|
||||
## Injecting into core shells (profile, dashboard)
|
||||
|
||||
Core screens that accept plugin sections render **mount points** — usually
|
||||
empty, sometimes holding core's own **fallback content** (the Dashboard's
|
||||
career slot ships the plugin-count stat) — and announce each (re)build with a
|
||||
DOM event, because their `innerHTML` swap wipes anything previously injected.
|
||||
A plugin listens for the event and **replaces the mount's content** (never
|
||||
append — a fallback may be present) by id — the same seam every time:
|
||||
|
||||
| Shell | Event | Mounts |
|
||||
| --- | --- | --- |
|
||||
| Profile | `v3:profile-rendered` | `#v3-profile-passports-mount` (career wall), `#v3-profile-feats-slot`, `#v3-profile-achievements-mount` |
|
||||
| Dashboard | `v3:dashboard-rendered` | `#v3-dash-career-slot` (career card; core's plugin-count stat is the fallback content a plugin may replace) |
|
||||
| Settings | `v3:settings-rendered` | per-plugin `settings.html` panels |
|
||||
|
||||
Rules: inject on every event (the mount is fresh), keep the section
|
||||
**absent-not-empty** (no state → leave the mount alone / empty), and guard
|
||||
re-wired listeners with a `dataset` flag when your own refresh path can run
|
||||
against an unwiped mount.
|
||||
|
||||
@@ -61,6 +61,8 @@ extractions and twenty-two `routers/` modules, plus lib/library_registry.py for
|
||||
and is a monolith in its own right, to be split per-table once the router train
|
||||
lands) · `static/v3/songs.js` (4,134) · `static/capabilities/audio-session.js`
|
||||
(2,974) · `plugins/highway_3d/screen.js` (15,656) · `plugins/keys_highway_3d/screen.js`
|
||||
(3,780) · `plugins/drum_highway_3d/screen.js` (3,597) — and every monolith with a PR
|
||||
(3,780) · `plugins/drum_highway_3d/screen.js` (3,597) · `plugins/career/screen.js`
|
||||
(1,516 — career v3 gigs pushed it over; split plan: carve the gig block into a
|
||||
`scriptType: module` file when career work next touches it) — and every monolith with a PR
|
||||
train in the refactor plan. Test files (e.g. `tests/test_plugins.py`) are out of scope
|
||||
by policy — the norm governs source files.
|
||||
|
||||
@@ -99,7 +99,8 @@
|
||||
.pp-inst-plus { color: #6b7280; }
|
||||
|
||||
/* Leather covers — per-instrument hue, embossed with layered shadows and a
|
||||
subtle grain gradient (no image assets). */
|
||||
subtle grain gradient (no image assets). Keep the hex pairs in sync with
|
||||
PP_LEATHER_HEX in screen.js (the canvas card draws the same leather). */
|
||||
.pp-leather-guitar { background: linear-gradient(160deg, #5c2321, #401412); }
|
||||
.pp-leather-bass { background: linear-gradient(160deg, #1f3252, #131f36); }
|
||||
.pp-leather-keys { background: linear-gradient(160deg, #1e4034, #122a21); }
|
||||
@@ -559,3 +560,221 @@
|
||||
/* 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; }
|
||||
/* ── Career surfaces outside the plugin: profile wall + home card ───────── */
|
||||
|
||||
.pp-wall { display: flex; flex-direction: column; gap: 0.6rem; }
|
||||
.pp-wall-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
font-weight: 600;
|
||||
color: #e5e7eb;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.pp-wall-meta { color: #9ca3af; font-size: 0.7rem; font-weight: 400; }
|
||||
.pp-wall-shelf {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
padding: 0.35rem 0;
|
||||
border-bottom: 1px solid rgba(75, 85, 99, 0.25);
|
||||
}
|
||||
.pp-wall-inst {
|
||||
font-size: 0.62rem;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
color: #6b7280;
|
||||
min-width: 3.6rem;
|
||||
}
|
||||
.pp-wall-cover {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.1rem;
|
||||
width: 4.2rem;
|
||||
height: 5.6rem;
|
||||
border-radius: 0.3rem 0.45rem 0.45rem 0.3rem;
|
||||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.07),
|
||||
inset 0.25rem 0 0.4rem -0.25rem rgba(0, 0, 0, 0.8),
|
||||
0 3px 8px rgba(0, 0, 0, 0.4);
|
||||
padding: 0.3rem;
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
.pp-wall-cover:hover { transform: translateY(-3px); }
|
||||
.pp-wall-cover span {
|
||||
font-size: 0.5rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
color: rgba(240, 226, 195, 0.9);
|
||||
overflow-wrap: anywhere;
|
||||
text-align: center;
|
||||
}
|
||||
.pp-wall-cover em {
|
||||
font-size: 0.42rem;
|
||||
letter-spacing: 0.22em;
|
||||
font-style: normal;
|
||||
color: #d9a253;
|
||||
}
|
||||
.pp-wall-none { font-size: 0.7rem; color: #6b7280; font-style: italic; }
|
||||
.pp-wall-link {
|
||||
align-self: flex-end;
|
||||
font-size: 0.72rem;
|
||||
color: #22d3ee;
|
||||
padding: 0.15rem 0.3rem;
|
||||
}
|
||||
.pp-wall-link:hover { text-decoration: underline; }
|
||||
|
||||
/* The home-page career card — a trading card among stat tiles. */
|
||||
.pp-dash-card {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 0.2rem;
|
||||
text-align: left;
|
||||
padding: 1rem;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid rgba(217, 162, 83, 0.35);
|
||||
background:
|
||||
linear-gradient(135deg, rgba(92, 35, 33, 0.85), rgba(30, 27, 34, 0.92)),
|
||||
linear-gradient(160deg, #2b1414, #17111c);
|
||||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.05), 0 4px 14px rgba(0, 0, 0, 0.35);
|
||||
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
.pp-dash-card:hover { transform: translateY(-2px); box-shadow: 0 8px 20px rgba(0, 0, 0, 0.5); }
|
||||
.pp-dash-shine {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(105deg, transparent 42%, rgba(255, 223, 128, 0.18) 50%, transparent 58%);
|
||||
transform: translateX(-130%);
|
||||
pointer-events: none;
|
||||
}
|
||||
.pp-dash-card:hover .pp-dash-shine { animation: pp-foil 1.4s ease-out; }
|
||||
.pp-dash-head {
|
||||
font-size: 0.58rem;
|
||||
letter-spacing: 0.3em;
|
||||
text-transform: uppercase;
|
||||
color: #d9a253;
|
||||
}
|
||||
.pp-dash-badges { color: #f3ead2; font-size: 1.05rem; }
|
||||
.pp-dash-badges b { font-weight: 700; margin: 0 0.25rem 0 0.35rem; }
|
||||
.pp-dash-meta { color: #b5a488; font-size: 0.72rem; }
|
||||
.pp-dash-ask { color: #8d9aa8; font-size: 0.66rem; }
|
||||
.pp-dash-ask em { color: #cbd5e1; }
|
||||
|
||||
.pp-card-actions { display: flex; gap: 0.5rem; margin-top: 0.9rem; }
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.pp-dash-card:hover .pp-dash-shine { animation: none; }
|
||||
.pp-wall-cover, .pp-dash-card { transition: none; }
|
||||
}
|
||||
|
||||
/* ── Gigs: poster, runner strip, summary, log ───────────────────────────── */
|
||||
|
||||
.pp-poster {
|
||||
position: relative;
|
||||
width: min(92vw, 420px);
|
||||
padding: 2rem 1.6rem 1.4rem;
|
||||
border-radius: 0.5rem;
|
||||
background: linear-gradient(180deg, #141019, #241318);
|
||||
border: 2px solid rgba(217, 162, 83, 0.45);
|
||||
box-shadow: 0 10px 32px rgba(0, 0, 0, 0.6);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
text-align: center;
|
||||
}
|
||||
.pp-poster-venue { color: rgba(240, 226, 195, 0.7); font-size: 0.95rem; letter-spacing: 0.08em; }
|
||||
.pp-poster-presents { color: rgba(240, 226, 195, 0.4); font-size: 0.58rem; letter-spacing: 0.4em; text-transform: uppercase; }
|
||||
.pp-poster-title {
|
||||
color: #d9a253;
|
||||
font-size: 1.7rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.1em;
|
||||
line-height: 1.15;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.pp-poster-inst { color: rgba(240, 226, 195, 0.5); font-size: 0.68rem; letter-spacing: 0.2em; text-transform: uppercase; }
|
||||
.pp-poster-bill { margin: 0.9rem 0 0.5rem; display: flex; flex-direction: column; gap: 0.35rem; width: 100%; }
|
||||
.pp-poster-line { color: rgba(240, 226, 195, 0.85); font-size: 0.85rem; }
|
||||
.pp-poster-line span { color: rgba(217, 162, 83, 0.7); margin-right: 0.35rem; }
|
||||
.pp-poster-line em { color: rgba(240, 226, 195, 0.5); font-style: italic; font-size: 0.72rem; }
|
||||
.pp-poster-line b { color: #f3d179; margin-left: 0.3rem; }
|
||||
.pp-poster-actions { display: flex; flex-wrap: wrap; gap: 0.5rem; justify-content: center; margin-top: 0.6rem; }
|
||||
.pp-poster-summary { cursor: default; }
|
||||
|
||||
.pp-gig-strip {
|
||||
position: fixed;
|
||||
top: 0.5rem;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 35; /* above the rail (30), under popovers (40) — the chrome invariant */
|
||||
background: rgba(10, 8, 14, 0.85);
|
||||
border: 1px solid rgba(217, 162, 83, 0.4);
|
||||
border-radius: 999px;
|
||||
color: rgba(240, 226, 195, 0.85);
|
||||
font-size: 0.72rem;
|
||||
padding: 0.3rem 0.9rem;
|
||||
pointer-events: none;
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
.pp-gig-strip b { color: #d9a253; letter-spacing: 0.2em; }
|
||||
.pp-gig-strip em { color: #f3ead2; font-style: italic; }
|
||||
|
||||
.pp-giglog { margin-top: 0.6rem; border-top: 1px dashed rgba(138, 122, 94, 0.4); padding-top: 0.5rem; }
|
||||
.pp-giglog-head {
|
||||
font-size: 0.58rem;
|
||||
letter-spacing: 0.22em;
|
||||
text-transform: uppercase;
|
||||
color: #8a7a5e;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
.pp-giglog-row { font-size: 0.7rem; color: #6d5d40; padding: 0.1rem 0; }
|
||||
.pp-giglog-row b { color: #9a5b16; letter-spacing: 0.06em; }
|
||||
|
||||
@@ -3,6 +3,12 @@
|
||||
"songs": 5,
|
||||
"min_stars": 2
|
||||
},
|
||||
"gig": {
|
||||
"min_songs": 3,
|
||||
"max_songs": 5,
|
||||
"stakes_songs": 2,
|
||||
"encore_accuracy": 0.75
|
||||
},
|
||||
"families": [
|
||||
{ "key": "metal", "match": ["metal", "djent", "grindcore", "thrash", "doom"] },
|
||||
{ "key": "blues", "match": ["blues"] },
|
||||
|
||||
+206
-4
@@ -26,11 +26,14 @@ Endpoints (all under /api/plugins/career/):
|
||||
POST /passports/commit commit to an instrument (the wax seal, Stage 0)
|
||||
POST /passports/open open a genre passport for an instrument
|
||||
POST /drill-state relayed virtuoso.progress snapshot (drill intake)
|
||||
POST /gigs/propose build a playable setlist for a genre gig
|
||||
POST /gigs log a COMPLETED gig (abandoned sets never log)
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import random
|
||||
import re
|
||||
import shutil
|
||||
import tempfile
|
||||
@@ -114,10 +117,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 +251,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"]
|
||||
@@ -376,6 +386,7 @@ def _passports_view():
|
||||
cfg = _state["passports_content"]
|
||||
graded = set(cfg.get("graded_instruments") or [])
|
||||
st = _career_state()
|
||||
all_gigs = st.get("gigs") if isinstance(st.get("gigs"), list) else []
|
||||
played, played_seconds = _played_by_instrument_genre()
|
||||
received_at, by_node = _drill_by_node()
|
||||
instruments = {}
|
||||
@@ -406,6 +417,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,13 +436,18 @@ 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),
|
||||
"drills": {"required": required, "cleared": cleared},
|
||||
"badge": badge,
|
||||
})
|
||||
instruments[inst] = {"committed_at": committed_at, "passports": passports}
|
||||
inst_gigs = [g for g in all_gigs if g.get("instrument") == inst]
|
||||
for p in passports:
|
||||
p["gigs"] = [g for g in inst_gigs if g.get("genre_key") == p["genre_key"]][-20:][::-1]
|
||||
instruments[inst] = {"committed_at": committed_at, "passports": passports,
|
||||
"gig_count": len(inst_gigs)}
|
||||
return {
|
||||
"config": {
|
||||
"badge_requirement": cfg.get("badge_requirement") or {},
|
||||
@@ -435,6 +462,60 @@ def _passports_view():
|
||||
}
|
||||
|
||||
|
||||
def _gig_config():
|
||||
cfg = _state["passports_content"].get("gig")
|
||||
cfg = cfg if isinstance(cfg, dict) else {}
|
||||
|
||||
def _num(key, default, cast):
|
||||
# Tuning data, not code: junk falls back instead of 500ing both gig
|
||||
# endpoints, and a legitimate 0 (stakes_songs: 0) is respected.
|
||||
val = cfg.get(key)
|
||||
if isinstance(val, bool) or not isinstance(val, (int, float)):
|
||||
return default
|
||||
return cast(val)
|
||||
|
||||
return {
|
||||
"min_songs": max(1, _num("min_songs", 3, int)),
|
||||
"max_songs": max(1, _num("max_songs", 5, int)),
|
||||
"stakes_songs": max(0, _num("stakes_songs", 2, int)),
|
||||
"encore_accuracy": _num("encore_accuracy", 0.75, float),
|
||||
}
|
||||
|
||||
|
||||
def _current_venue():
|
||||
"""Highest unlocked venue (the room you can book today)."""
|
||||
stars_total, _, _ = _stars()
|
||||
best = None
|
||||
for v in _state["content"]["venues"]:
|
||||
if stars_total >= v["star_threshold"]:
|
||||
if best is None or v["star_threshold"] >= best["star_threshold"]:
|
||||
best = v
|
||||
return best
|
||||
|
||||
|
||||
def _unplayed_genre_songs(gkey, exclude, limit):
|
||||
"""Library songs of a genre with no stats yet — a young passport's gig
|
||||
still gets a full set (playing them is how stubs start).
|
||||
ponytail: full stat-less scan + python-side genre match (a few ms at 7k
|
||||
songs, single-user); push the match into SQL if propose ever feels slow."""
|
||||
db = _state["meta_db"]
|
||||
if db is None:
|
||||
return []
|
||||
rows = db.conn.execute(
|
||||
f"SELECT filename, title, artist, {_genre_expr(db)} AS g FROM songs "
|
||||
"WHERE filename NOT IN (SELECT filename FROM song_stats)"
|
||||
).fetchall()
|
||||
out = []
|
||||
for filename, title, artist, genre in rows:
|
||||
if _genre_key(genre) != gkey or filename in exclude:
|
||||
continue
|
||||
out.append({"filename": filename, "title": title or filename,
|
||||
"artist": artist or ""})
|
||||
if len(out) >= limit:
|
||||
break
|
||||
return out
|
||||
|
||||
|
||||
def _validate_pack_dir(pack_dir: Path):
|
||||
"""Raise ValueError unless pack_dir holds a complete venue pack."""
|
||||
manifest_path = pack_dir / "manifest.json"
|
||||
@@ -616,6 +697,127 @@ def setup(app, context):
|
||||
"snapshot": snapshot})
|
||||
return {"ok": True}
|
||||
|
||||
@app.post(f"/api/plugins/{PLUGIN_ID}/gigs/propose")
|
||||
def propose_gig(body: dict = Body(...)):
|
||||
inst = str((body or {}).get("instrument") or "")
|
||||
genre = _genre_display((body or {}).get("genre"))
|
||||
gkey = genre.lower()
|
||||
if inst not in (_state["passports_content"].get("instruments") or []):
|
||||
raise HTTPException(400, "Unknown instrument.")
|
||||
if not gkey or len(genre) > GENRE_MAX_LEN:
|
||||
raise HTTPException(400, "Provide a genre.")
|
||||
cfg = _gig_config()
|
||||
try:
|
||||
size = int((body or {}).get("size") or 4)
|
||||
except (TypeError, ValueError):
|
||||
raise HTTPException(400, "size must be a number.")
|
||||
size = max(cfg["min_songs"], min(cfg["max_songs"], size))
|
||||
played, _seconds = _played_by_instrument_genre()
|
||||
stubs = list(played.get((inst, gkey), {}).values())
|
||||
req = _badge_requirement(gkey, inst)
|
||||
qualifying = [s for s in stubs if s["stars"] >= req["min_stars"]]
|
||||
rest = [s for s in stubs if s["stars"] < req["min_stars"]]
|
||||
# The set: mostly songs you own, plus a couple of stakes songs near
|
||||
# the bar; a young passport fills from unplayed genre songs so the
|
||||
# first gig is how stubs start. random per call = free re-roll.
|
||||
random.shuffle(qualifying)
|
||||
rest.sort(key=lambda s: -s["best_accuracy"])
|
||||
qtaken = max(1, size - cfg["stakes_songs"])
|
||||
picks = qualifying[:qtaken]
|
||||
for s in rest:
|
||||
if len(picks) >= size:
|
||||
break
|
||||
picks.append(s)
|
||||
# Surplus qualifying songs backfill a short set — a mature passport
|
||||
# with no near-bar songs left must still fill the bill. Offset by how
|
||||
# many QUALIFYING songs were taken, not len(picks): rest's stakes
|
||||
# additions would otherwise skip eligible qualifying songs entirely.
|
||||
for s in qualifying[qtaken:]:
|
||||
if len(picks) >= size:
|
||||
break
|
||||
picks.append(s)
|
||||
if len(picks) < size:
|
||||
exclude = {s["filename"] for s in picks}
|
||||
picks.extend(_unplayed_genre_songs(gkey, exclude, size - len(picks)))
|
||||
if not picks:
|
||||
raise HTTPException(404, "No songs of this genre in the library.")
|
||||
venue = _current_venue()
|
||||
return {
|
||||
"instrument": inst,
|
||||
"genre": genre,
|
||||
"genre_key": gkey,
|
||||
"venue_id": venue["id"] if venue else None,
|
||||
"venue_name": venue["name"] if venue else "",
|
||||
"songs": [{"filename": s["filename"], "title": s.get("title") or s["filename"],
|
||||
"artist": s.get("artist") or ""} for s in picks[:size]],
|
||||
}
|
||||
|
||||
@app.post(f"/api/plugins/{PLUGIN_ID}/gigs")
|
||||
def log_gig(body: dict = Body(...)):
|
||||
# Called by the runner ONLY when the set completed — an abandoned set
|
||||
# never logs (no fail state; the gig you finished is the gig you
|
||||
# played). Accuracies come from song_stats, freshly written by the
|
||||
# set's own plays.
|
||||
inst = str((body or {}).get("instrument") or "")
|
||||
genre = _genre_display((body or {}).get("genre"))
|
||||
gkey = genre.lower()
|
||||
venue_id = str((body or {}).get("venue_id") or "")
|
||||
songs = (body or {}).get("songs")
|
||||
if inst not in (_state["passports_content"].get("instruments") or []):
|
||||
raise HTTPException(400, "Unknown instrument.")
|
||||
if not gkey or len(genre) > GENRE_MAX_LEN:
|
||||
raise HTTPException(400, "Provide a genre.")
|
||||
if venue_id and (not VENUE_ID_RE.fullmatch(venue_id) or _venue(venue_id) is None):
|
||||
raise HTTPException(400, "Unknown venue.")
|
||||
if (not isinstance(songs, list) or not songs or len(songs) > 8
|
||||
or not all(isinstance(f, str) and f.strip() for f in songs)):
|
||||
raise HTTPException(400, "songs must be 1-8 filenames.")
|
||||
db = _state["meta_db"]
|
||||
entries = []
|
||||
accuracies = []
|
||||
for filename in songs:
|
||||
title = filename
|
||||
accuracy = None
|
||||
if db is not None:
|
||||
# The NEWEST row is the set's own just-recorded play — a
|
||||
# MAX(last_accuracy) across arrangements would happily log a
|
||||
# stale higher score from another instrument's old session.
|
||||
row = db.conn.execute(
|
||||
"SELECT last_accuracy FROM song_stats WHERE filename = ? "
|
||||
"ORDER BY last_played_at DESC LIMIT 1",
|
||||
(filename,)).fetchone()
|
||||
if row and row[0] is not None:
|
||||
accuracy = round(float(row[0]), 4)
|
||||
accuracies.append(accuracy)
|
||||
trow = db.conn.execute(
|
||||
"SELECT title FROM songs WHERE filename = ?", (filename,)).fetchone()
|
||||
if trow and trow[0]:
|
||||
title = trow[0]
|
||||
entries.append({"filename": filename, "title": title, "accuracy": accuracy})
|
||||
# Encore needs the WHOLE set scored at the bar — one scored song must
|
||||
# not earn an encore for a set that was 4/5 unheard.
|
||||
encore = (len(accuracies) == len(songs) and
|
||||
sum(accuracies) / len(accuracies) >= _gig_config()["encore_accuracy"])
|
||||
gig = {
|
||||
"at": _now_iso(),
|
||||
"venue_id": venue_id or None,
|
||||
"instrument": inst,
|
||||
"genre": genre,
|
||||
"genre_key": gkey,
|
||||
"songs": entries,
|
||||
"encore": encore,
|
||||
}
|
||||
with _lock:
|
||||
st = _career_state()
|
||||
if not isinstance(st.get("gigs"), list):
|
||||
st["gigs"] = []
|
||||
st["gigs"].append(gig)
|
||||
# ponytail: hard cap — nothing reads past the last 20 per
|
||||
# passport; the state file must not grow (and export) forever.
|
||||
st["gigs"] = st["gigs"][-500:]
|
||||
_save_json(_state_file(), st)
|
||||
return {"ok": True, "gig": gig}
|
||||
|
||||
@app.post(f"/api/plugins/{PLUGIN_ID}/packs/{{venue_id}}/download")
|
||||
def start_download(venue_id: str):
|
||||
venue = _venue(venue_id) if VENUE_ID_RE.fullmatch(venue_id) else None
|
||||
|
||||
@@ -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>
|
||||
|
||||
+635
-17
@@ -38,6 +38,8 @@
|
||||
let _ppCeremonyActive = false;
|
||||
let _ppBootstrapped = false;
|
||||
let _ppNotified = {}; // badges chimed this session (slam still pending)
|
||||
let _ppGigProposal = null; // the booking poster's proposal, while open
|
||||
let _ppGigRun = null; // {songs, venue_id, genre, genre_key, instrument, idx} mid-set
|
||||
|
||||
function $(id) { return document.getElementById(id); }
|
||||
|
||||
@@ -458,6 +460,8 @@
|
||||
_pp = view;
|
||||
detectNewBadges(view);
|
||||
renderPassports();
|
||||
renderProfileWall();
|
||||
renderDashCard();
|
||||
if (!_ppBootstrapped) {
|
||||
_ppBootstrapped = true;
|
||||
// Sync the local drill snapshot once per session — drill progress
|
||||
@@ -497,6 +501,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 +605,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 +631,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>`;
|
||||
@@ -601,14 +647,18 @@
|
||||
<span class="pp-stamp-tier">BRONZE</span>
|
||||
</div>
|
||||
<div class="pp-gold-foil" aria-hidden="true">GOLD</div>
|
||||
<div class="pp-gold-note">Gold rung coming — improvise it, verified.</div>`;
|
||||
<div class="pp-gold-note">Gold rung coming — improvise it, verified.</div>
|
||||
<div class="pp-card-actions">
|
||||
<button class="career-btn career-btn-ghost" data-pp-card="save">Save card</button>
|
||||
<button class="career-btn career-btn-ghost" data-pp-card="copy">Copy card</button>
|
||||
</div>`;
|
||||
} else {
|
||||
const fill = (ppFillFraction(p) * 100).toFixed(0);
|
||||
badgeArea = `<div class="pp-stamp pp-stamp-page pp-stamp-ghost" style="--pp-rot:${ppJitter(p.genre_key, 7).toFixed(1)}deg; --pp-fill:${fill}%">
|
||||
<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,15 +678,34 @@
|
||||
: `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>`;
|
||||
}
|
||||
let gigLog = '';
|
||||
if ((p.gigs || []).length) {
|
||||
gigLog = `<div class="pp-giglog">
|
||||
<div class="pp-giglog-head">Gigs played</div>
|
||||
${p.gigs.slice(0, 6).map((g) =>
|
||||
`<div class="pp-giglog-row">${esc((g.at || '').slice(0, 10))} · ${esc(_venueName(g.venue_id))}${g.encore ? ' · <b>encore</b>' : ''}</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">
|
||||
<div class="pp-page-head">${esc(p.genre)} — ${esc(ppLabel(inst))}</div>
|
||||
${badgeArea}${odometer}${drills}
|
||||
<button class="career-btn career-btn-primary pp-gig-book" data-pp-gig="${esc(p.genre_key)}">Book a gig</button>
|
||||
</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}${gigLog}</div>
|
||||
</div>
|
||||
<div class="pp-book-cover pp-leather-${esc(inst)}">
|
||||
<span class="pp-cover-title">${esc(p.genre.toUpperCase())}</span>
|
||||
@@ -685,6 +754,7 @@
|
||||
|
||||
function closeBook() {
|
||||
_ppBook = null;
|
||||
_ppGigProposal = null; // a dismissed poster is a dismissed booking
|
||||
const overlay = $('pp-overlay');
|
||||
if (overlay) { overlay.classList.add('hidden'); overlay.innerHTML = ''; }
|
||||
if (_ppReturnFocus && typeof _ppReturnFocus.focus === 'function' &&
|
||||
@@ -771,6 +841,523 @@
|
||||
if (_tiltEl) { resetTilt(_tiltEl); _tiltEl = null; }
|
||||
}
|
||||
|
||||
// ── Shareable passport card (canvas → PNG, save or clipboard) ─────────
|
||||
// Keep in sync with the .pp-leather-* gradients in assets/career.css —
|
||||
// canvas can't consume a CSS class, so the pairs live twice on purpose.
|
||||
const PP_LEATHER_HEX = {
|
||||
guitar: ['#5c2321', '#401412'],
|
||||
bass: ['#1f3252', '#131f36'],
|
||||
keys: ['#1e4034', '#122a21'],
|
||||
drums: ['#3f3f46', '#26262b'],
|
||||
};
|
||||
|
||||
function drawPassportCard(inst, p) {
|
||||
const W = 480;
|
||||
const H = 640;
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = W;
|
||||
canvas.height = H;
|
||||
const ctx = canvas.getContext('2d');
|
||||
const [c1, c2] = PP_LEATHER_HEX[inst] || PP_LEATHER_HEX.guitar;
|
||||
const bg = ctx.createLinearGradient(0, 0, W, H);
|
||||
bg.addColorStop(0, c1);
|
||||
bg.addColorStop(1, c2);
|
||||
ctx.fillStyle = bg;
|
||||
ctx.fillRect(0, 0, W, H);
|
||||
// Emboss frame
|
||||
ctx.strokeStyle = 'rgba(240,226,195,0.35)';
|
||||
ctx.lineWidth = 3;
|
||||
ctx.strokeRect(18, 18, W - 36, H - 36);
|
||||
// Genre title
|
||||
ctx.fillStyle = 'rgba(240,226,195,0.95)';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.font = '700 34px Georgia, serif';
|
||||
ctx.fillText(p.genre.toUpperCase(), W / 2, 92, W - 80);
|
||||
ctx.font = '400 15px Georgia, serif';
|
||||
ctx.fillStyle = 'rgba(240,226,195,0.55)';
|
||||
ctx.fillText(`${ppLabel(inst).toUpperCase()} PASSPORT`, W / 2, 122);
|
||||
// Stamp ring
|
||||
const gold = p.badge === 'gold';
|
||||
const ink = gold ? '#d9a253' : '#b06a2a';
|
||||
const cy = 330;
|
||||
ctx.strokeStyle = ink;
|
||||
ctx.lineWidth = 6;
|
||||
ctx.beginPath();
|
||||
ctx.arc(W / 2, cy, 118, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
ctx.arc(W / 2, cy, 106, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
ctx.fillStyle = ink;
|
||||
ctx.font = '800 26px Georgia, serif';
|
||||
ctx.fillText(p.genre.toUpperCase(), W / 2, cy - 6, 190);
|
||||
ctx.font = '600 16px Georgia, serif';
|
||||
ctx.fillText(gold ? 'G O L D' : 'B R O N Z E', W / 2, cy + 28);
|
||||
// Facts
|
||||
const stubCount = (p.songs || []).filter((sng) => sng.qualifies).length;
|
||||
const hours = fmtHours(p.seconds_total);
|
||||
ctx.fillStyle = 'rgba(240,226,195,0.75)';
|
||||
ctx.font = '400 17px Georgia, serif';
|
||||
ctx.fillText(`${stubCount} ticket stub${stubCount === 1 ? '' : 's'}${hours ? ` · ${hours} played` : ''}`, W / 2, 512);
|
||||
ctx.fillStyle = 'rgba(240,226,195,0.4)';
|
||||
ctx.font = '400 13px Georgia, serif';
|
||||
ctx.fillText('fee[dB]ack · career passport', W / 2, H - 44);
|
||||
return canvas;
|
||||
}
|
||||
|
||||
// One export path for every canvas artifact: copy (with download
|
||||
// fallback + notice) or save, failures audible.
|
||||
function exportCanvasPng(canvas, filename, mode, noun) {
|
||||
canvas.toBlob(async (blob) => {
|
||||
const fail = (why) => {
|
||||
if (window.fbNotify) window.fbNotify.show({ icon: '⚠️', title: `${noun} export failed`, message: why });
|
||||
};
|
||||
if (!blob) { fail('The canvas produced no image.'); return; }
|
||||
try {
|
||||
const io = await import('/static/js/blob-io.js');
|
||||
if (mode === 'copy') {
|
||||
const ok = await io.copyImageBlob(blob);
|
||||
if (ok) {
|
||||
if (window.fbNotify) window.fbNotify.show({ icon: '📋', title: `${noun} copied`, message: 'Paste it anywhere.' });
|
||||
return;
|
||||
}
|
||||
if (window.fbNotify) window.fbNotify.show({ icon: '💾', title: 'Clipboard unavailable', message: `Saved the ${noun.toLowerCase()} instead.` });
|
||||
}
|
||||
io.downloadBlob(blob, filename);
|
||||
} catch (e) { fail('Export helper unavailable.'); }
|
||||
}, 'image/png');
|
||||
}
|
||||
|
||||
function exportPassportCard(mode) {
|
||||
if (!_ppBook || !_pp) return;
|
||||
const { inst, gkey } = _ppBook;
|
||||
const p = (((_pp.instruments || {})[inst] || {}).passports || [])
|
||||
.find((x) => x.genre_key === gkey);
|
||||
if (!p) return;
|
||||
const canvas = drawPassportCard(inst, p);
|
||||
exportCanvasPng(canvas, `passport-${inst}-${gkey.replace(/[^a-z0-9-]+/g, '-')}.png`, mode, 'Card');
|
||||
}
|
||||
|
||||
// ── Career surfaces outside the plugin screen ─────────────────────────
|
||||
// Profile passport wall + the home-page career card. Both inject into
|
||||
// core-owned mounts announced by v3:profile-rendered /
|
||||
// v3:dashboard-rendered (the achievements seam). Absent-not-empty: with
|
||||
// no committed instrument they render nothing and the dashboard keeps
|
||||
// its built-in fallback stat.
|
||||
|
||||
function careerTotals() {
|
||||
if (!_pp) return null;
|
||||
let badges = 0;
|
||||
let seconds = 0;
|
||||
let gigs = 0;
|
||||
const walls = [];
|
||||
for (const inst of (_pp.config || {}).instruments || []) {
|
||||
const d = (_pp.instruments || {})[inst];
|
||||
// A commitment with no opened passport isn't a wall yet — the
|
||||
// external surfaces (profile, home card) stay ABSENT until a
|
||||
// passport exists (absent-not-empty).
|
||||
if (!d || !d.committed_at || !(d.passports || []).length) continue;
|
||||
const earned = (d.passports || []).filter((p) => p.badge === 'earned' || p.badge === 'gold');
|
||||
badges += earned.length;
|
||||
seconds += (d.passports || []).reduce((t, p) => t + (p.seconds_total || 0), 0);
|
||||
gigs += d.gig_count || 0;
|
||||
walls.push({ inst, earned, opened: d.passports.length });
|
||||
}
|
||||
if (!walls.length) return null;
|
||||
return { badges, seconds, gigs, walls };
|
||||
}
|
||||
|
||||
function closestAskHTML() {
|
||||
if (!_pp) return '';
|
||||
let best = null;
|
||||
for (const inst of (_pp.config || {}).instruments || []) {
|
||||
for (const p of (((_pp.instruments || {})[inst] || {}).passports || [])) {
|
||||
if (p.badge !== 'in_progress') continue;
|
||||
const need = Math.max(0, ((p.requirement || {}).songs || 0) - p.qualifying_count);
|
||||
if (!best || need < best.need) best = { p, need };
|
||||
}
|
||||
}
|
||||
if (!best) return '';
|
||||
const starGl = '★'.repeat((best.p.requirement || {}).min_stars || 0);
|
||||
if (best.need > 0) {
|
||||
return `${esc(best.p.genre)} — ${best.need === 1 ? `one more ${starGl} song` : `${best.need} more ${starGl} songs`}`;
|
||||
}
|
||||
return `${esc(best.p.genre)} — one drill away`;
|
||||
}
|
||||
|
||||
function renderProfileWall() {
|
||||
const mount = document.getElementById('v3-profile-passports-mount');
|
||||
if (!mount) return;
|
||||
const totals = careerTotals();
|
||||
if (!totals) { mount.innerHTML = ''; return; }
|
||||
const shelves = totals.walls.map(({ inst, earned, opened }) => {
|
||||
const covers = earned.map((p) =>
|
||||
`<button class="pp-wall-cover pp-leather-${esc(inst)}" data-pp-wall-inst="${esc(inst)}" data-pp-wall-gkey="${esc(p.genre_key)}" title="${esc(p.genre)}">
|
||||
<span>${esc(p.genre.toUpperCase())}</span>
|
||||
<em>${p.badge === 'gold' ? 'GOLD' : 'BRONZE'}</em>
|
||||
</button>`).join('');
|
||||
const line = earned.length
|
||||
? covers
|
||||
: `<span class="pp-wall-none">${opened} passport${opened === 1 ? '' : 's'} open — first stamp pending</span>`;
|
||||
return `<div class="pp-wall-shelf"><span class="pp-wall-inst">${esc(ppLabel(inst))}</span>${line}</div>`;
|
||||
}).join('');
|
||||
const hours = fmtHours(totals.seconds);
|
||||
mount.innerHTML = `<div class="bg-fb-card/80 backdrop-blur rounded-lg p-4 border border-fb-border/50 pp-wall">
|
||||
<div class="pp-wall-head">
|
||||
<span>Passport wall</span>
|
||||
<span class="pp-wall-meta">${totals.badges} badge${totals.badges === 1 ? '' : 's'}${hours ? ` · ${hours} played` : ''}${totals.gigs ? ` · ${totals.gigs} gig${totals.gigs === 1 ? '' : 's'}` : ''}</span>
|
||||
</div>
|
||||
${shelves}
|
||||
<button class="pp-wall-link" data-pp-wall-career="1">Open career →</button>
|
||||
</div>`;
|
||||
if (!mount.dataset.ppWired) {
|
||||
mount.dataset.ppWired = '1';
|
||||
mount.addEventListener('click', onWallClick);
|
||||
}
|
||||
}
|
||||
|
||||
function onWallClick(e) {
|
||||
const open = e.target.closest('[data-pp-wall-inst]');
|
||||
if (open) {
|
||||
// Two attributes, not a '/'-joined pair: a genre key may itself
|
||||
// contain '/' ("drum/bass") and must round-trip intact.
|
||||
const inst = open.dataset.ppWallInst;
|
||||
const gkey = open.dataset.ppWallGkey;
|
||||
lsSet(PP_INST_KEY, inst);
|
||||
if (window.showScreen) window.showScreen('plugin-career');
|
||||
showCareerTab('passports');
|
||||
renderPassports();
|
||||
openBook(inst, gkey);
|
||||
return;
|
||||
}
|
||||
if (e.target.closest('[data-pp-wall-career]')) {
|
||||
if (window.showScreen) window.showScreen('plugin-career');
|
||||
showCareerTab('passports');
|
||||
}
|
||||
}
|
||||
|
||||
function renderDashCard() {
|
||||
const slot = document.getElementById('v3-dash-career-slot');
|
||||
if (!slot) return;
|
||||
const totals = careerTotals();
|
||||
if (!totals) return; // keep core's fallback stat card
|
||||
const hours = fmtHours(totals.seconds);
|
||||
const ask = closestAskHTML();
|
||||
slot.innerHTML = `<button class="pp-dash-card" data-pp-wall-career="1">
|
||||
<span class="pp-dash-shine" aria-hidden="true"></span>
|
||||
<span class="pp-dash-head">Career</span>
|
||||
<span class="pp-dash-badges">${'⚡'.repeat(Math.min(totals.badges, 5))}<b>${totals.badges}</b> badge${totals.badges === 1 ? '' : 's'}</span>
|
||||
<span class="pp-dash-meta">${hours ? `${hours} played` : 'the stage is set'}${totals.gigs ? ` · ${totals.gigs} gig${totals.gigs === 1 ? '' : 's'}` : ''}</span>
|
||||
${ask ? `<span class="pp-dash-ask">closest: ${ask}</span>` : ''}
|
||||
</button>`;
|
||||
if (!slot.dataset.ppWired) {
|
||||
slot.dataset.ppWired = '1';
|
||||
slot.addEventListener('click', onWallClick);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Gigs: booking poster → set runner → summary ──────────────────────
|
||||
|
||||
function _venueName(venueId) {
|
||||
const v = (_state && _state.venues || []).find((x) => x.id === venueId);
|
||||
return v ? v.name : (venueId || 'the stage');
|
||||
}
|
||||
|
||||
function gigPosterHTML(prop) {
|
||||
const bill = prop.songs.map((s, i) =>
|
||||
`<div class="pp-poster-line"><span>${i + 1}.</span> ${esc(s.title)}${s.artist ? ` <em>${esc(s.artist)}</em>` : ''}</div>`).join('');
|
||||
return `<div class="pp-book-wrap" data-pp-close-bg="1" role="dialog" aria-modal="true" aria-label="Gig poster">
|
||||
<div class="pp-poster">
|
||||
<div class="pp-poster-venue">${esc(prop.venue_name || 'The stage')}</div>
|
||||
<div class="pp-poster-presents">presents</div>
|
||||
<div class="pp-poster-title">${esc(prop.genre.toUpperCase())} NIGHT</div>
|
||||
<div class="pp-poster-inst">${esc(ppLabel(prop.instrument))} · tonight</div>
|
||||
<div class="pp-poster-bill">${bill}</div>
|
||||
<div class="pp-poster-actions">
|
||||
<button class="career-btn career-btn-primary" data-pp-gig-play="1">Play the gig</button>
|
||||
<button class="career-btn career-btn-ghost" data-pp-gig-reroll="1">Re-roll</button>
|
||||
<button class="career-btn career-btn-ghost" data-pp-poster="save">Save</button>
|
||||
<button class="career-btn career-btn-ghost" data-pp-poster="copy">Copy</button>
|
||||
</div>
|
||||
<button class="pp-book-close" data-pp-close="1" aria-label="Close">✕</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
async function bookGig(gkey) {
|
||||
if (!_pp) return;
|
||||
const inst = activeInstrument();
|
||||
const p = (((_pp.instruments || {})[inst] || {}).passports || [])
|
||||
.find((x) => x.genre_key === gkey);
|
||||
if (!p) return;
|
||||
try {
|
||||
const res = await fetch(`${API}/gigs/propose`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ instrument: inst, genre: p.genre }),
|
||||
});
|
||||
if (!res.ok) return;
|
||||
_ppGigProposal = await res.json();
|
||||
} catch (_) { return; }
|
||||
const overlay = $('pp-overlay');
|
||||
if (!overlay) return;
|
||||
_ppBook = null; // the poster replaces the book in the overlay
|
||||
overlay.innerHTML = gigPosterHTML(_ppGigProposal);
|
||||
overlay.classList.remove('hidden');
|
||||
sfx('page');
|
||||
}
|
||||
|
||||
function startGig() {
|
||||
const prop = _ppGigProposal;
|
||||
const q = window.feedBack && window.feedBack.playQueue;
|
||||
if (!prop || !q || typeof q.start !== 'function' || typeof window.playSong !== 'function') return;
|
||||
// The gig BORROWS the stage: stash whatever venue/viz the user had so
|
||||
// the set ending gives it back (unlike "Play here", which is an
|
||||
// explicit persistent choice on the venue card).
|
||||
let restore = null;
|
||||
if (prop.venue_id) {
|
||||
// Capture the restore snapshot BEFORE any write: if a later write
|
||||
// (or setViz) throws, the stage must still be returnable.
|
||||
try {
|
||||
restore = {
|
||||
venue: localStorage.getItem(VENUE_OVERRIDE_KEY),
|
||||
viz: localStorage.getItem('vizSelection'),
|
||||
};
|
||||
} catch (_) { restore = null; }
|
||||
try {
|
||||
localStorage.setItem(VENUE_OVERRIDE_KEY, prop.venue_id);
|
||||
localStorage.setItem('vizSelection', 'venue');
|
||||
if (typeof window.setViz === 'function') window.setViz('venue');
|
||||
} catch (_) { /* viz optional — restore stays intact */ }
|
||||
}
|
||||
_appliedManifestVenue = null;
|
||||
_ppGigRun = {
|
||||
songs: prop.songs,
|
||||
venue_id: prop.venue_id,
|
||||
genre: prop.genre,
|
||||
genre_key: prop.genre_key,
|
||||
instrument: prop.instrument,
|
||||
idx: 0,
|
||||
restore,
|
||||
};
|
||||
closeBook();
|
||||
_ppGigProposal = null;
|
||||
// RAW filenames: the queue itself encodes for playSong — pre-encoding
|
||||
// double-encodes and breaks loading + the stats/gig filename join.
|
||||
if (!q.start(prop.songs.map((s) => s.filename), { source: 'gig' })) {
|
||||
_ppGigRun = null;
|
||||
return;
|
||||
}
|
||||
renderGigStrip();
|
||||
}
|
||||
|
||||
function restoreGigStage(run) {
|
||||
const r = run && run.restore;
|
||||
if (!r) return;
|
||||
try {
|
||||
if (r.venue == null) localStorage.removeItem(VENUE_OVERRIDE_KEY);
|
||||
else localStorage.setItem(VENUE_OVERRIDE_KEY, r.venue);
|
||||
if (r.viz && r.viz !== 'venue') {
|
||||
localStorage.setItem('vizSelection', r.viz);
|
||||
if (typeof window.setViz === 'function') window.setViz(r.viz);
|
||||
}
|
||||
} catch (_) { /* best effort */ }
|
||||
_appliedManifestVenue = null;
|
||||
}
|
||||
|
||||
function renderGigStrip() {
|
||||
if (!_ppGigRun || !document.body || typeof document.createElement !== 'function') return;
|
||||
let strip = document.getElementById('pp-gig-strip');
|
||||
if (!strip) {
|
||||
strip = document.createElement('div');
|
||||
strip.id = 'pp-gig-strip';
|
||||
strip.className = 'pp-gig-strip';
|
||||
document.body.appendChild(strip);
|
||||
}
|
||||
const run = _ppGigRun;
|
||||
const next = run.songs[run.idx + 1];
|
||||
strip.innerHTML = `<b>GIG</b> · ${esc(run.genre)} at ${esc(_venueName(run.venue_id))} · set ${Math.min(run.idx + 1, run.songs.length)}/${run.songs.length}${next ? ` — next: <em>${esc(next.title)}</em>` : ' — closer!'}`;
|
||||
}
|
||||
|
||||
function removeGigStrip() {
|
||||
const strip = document.getElementById('pp-gig-strip');
|
||||
if (strip) strip.remove();
|
||||
}
|
||||
|
||||
function abandonGig() {
|
||||
// No fail state: an abandoned set logs nothing and says nothing.
|
||||
const run = _ppGigRun;
|
||||
_ppGigRun = null;
|
||||
removeGigStrip();
|
||||
restoreGigStage(run);
|
||||
}
|
||||
|
||||
function completeGig() {
|
||||
const run = _ppGigRun;
|
||||
_ppGigRun = null;
|
||||
removeGigStrip();
|
||||
restoreGigStage(run);
|
||||
// The final song's own stats POST races this moment (both ride
|
||||
// song:ended): wait for its stats:recorded — or a short timeout, since
|
||||
// an UNSCORED play never emits one — so /gigs reads the set's real
|
||||
// accuracies, not last week's.
|
||||
const lastFile = run.songs[run.songs.length - 1].filename;
|
||||
const sm = window.feedBack;
|
||||
let done = false;
|
||||
const proceed = () => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
if (sm && typeof sm.off === 'function') { try { sm.off('stats:recorded', onRec); } catch (_) { /* ok */ } }
|
||||
postGig(run);
|
||||
};
|
||||
const onRec = (e) => {
|
||||
const d = (e && e.detail) || {};
|
||||
if (d.filename === lastFile) proceed();
|
||||
};
|
||||
if (sm && typeof sm.on === 'function') sm.on('stats:recorded', onRec);
|
||||
setTimeout(proceed, 3500);
|
||||
}
|
||||
|
||||
async function postGig(run) {
|
||||
let gig = null;
|
||||
try {
|
||||
const res = await fetch(`${API}/gigs`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
instrument: run.instrument,
|
||||
genre: run.genre,
|
||||
venue_id: run.venue_id,
|
||||
songs: run.songs.map((s) => s.filename),
|
||||
}),
|
||||
});
|
||||
if (res.ok) gig = (await res.json()).gig;
|
||||
} catch (_) { /* summary still shows, unlogged */ }
|
||||
showGigSummary(run, gig);
|
||||
refreshPassports();
|
||||
}
|
||||
|
||||
function showGigSummary(run, gig) {
|
||||
if (!document.body || typeof document.createElement !== 'function') return;
|
||||
const entries = (gig && gig.songs) || run.songs.map((s) => ({ filename: s.filename, title: s.title, accuracy: null }));
|
||||
const encore = !!(gig && gig.encore);
|
||||
if (encore && !reducedMotion()) {
|
||||
const crowd = window.v3VenueCrowd;
|
||||
if (crowd && typeof crowd.celebrate === 'function') {
|
||||
try { crowd.celebrate(); } catch (_) { /* optional */ }
|
||||
}
|
||||
}
|
||||
const el = document.createElement('div');
|
||||
el.id = 'pp-gig-summary';
|
||||
el.className = 'pp-ceremony-overlay';
|
||||
el.innerHTML = `<canvas class="pp-confetti"></canvas>
|
||||
<div class="pp-poster pp-poster-summary">
|
||||
<div class="pp-poster-venue">${esc(_venueName(run.venue_id))}</div>
|
||||
<div class="pp-poster-title">${esc(run.genre.toUpperCase())} NIGHT</div>
|
||||
<div class="pp-poster-inst">${encore ? 'ENCORE! ' : ''}the set, as played</div>
|
||||
<div class="pp-poster-bill">${entries.map((s, i) =>
|
||||
`<div class="pp-poster-line"><span>${i + 1}.</span> ${esc(s.title)}${s.accuracy != null ? ` <b>${Math.floor(s.accuracy * 100)}%</b>` : ''}</div>`).join('')}</div>
|
||||
<div class="pp-poster-actions">
|
||||
<button class="career-btn career-btn-ghost" data-pp-poster="save">Save poster</button>
|
||||
<button class="career-btn career-btn-ghost" data-pp-poster="copy">Copy poster</button>
|
||||
<button class="career-btn career-btn-primary" data-pp-gig-done="1">Done</button>
|
||||
</div>
|
||||
</div>`;
|
||||
el.addEventListener('click', (e) => {
|
||||
if (e.target === el || e.target.closest('[data-pp-gig-done]')) {
|
||||
el.remove();
|
||||
} else if (e.target.closest('[data-pp-poster]')) {
|
||||
exportGigPoster(e.target.closest('[data-pp-poster]').dataset.ppPoster,
|
||||
{ ...run, encore, entries });
|
||||
}
|
||||
});
|
||||
document.body.appendChild(el);
|
||||
if (encore && !reducedMotion()) confettiBurst(el.querySelector('.pp-confetti'));
|
||||
}
|
||||
|
||||
function drawGigPoster(data) {
|
||||
const W = 480;
|
||||
const H = 640;
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = W;
|
||||
canvas.height = H;
|
||||
const ctx = canvas.getContext('2d');
|
||||
const bg = ctx.createLinearGradient(0, 0, 0, H);
|
||||
bg.addColorStop(0, '#141019');
|
||||
bg.addColorStop(1, '#241318');
|
||||
ctx.fillStyle = bg;
|
||||
ctx.fillRect(0, 0, W, H);
|
||||
ctx.strokeStyle = 'rgba(217,162,83,0.5)';
|
||||
ctx.lineWidth = 3;
|
||||
ctx.strokeRect(16, 16, W - 32, H - 32);
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillStyle = 'rgba(240,226,195,0.65)';
|
||||
ctx.font = '400 18px Georgia, serif';
|
||||
ctx.fillText(_venueName(data.venue_id), W / 2, 76, W - 80);
|
||||
ctx.font = '400 12px Georgia, serif';
|
||||
ctx.fillText('P R E S E N T S', W / 2, 102);
|
||||
ctx.fillStyle = '#d9a253';
|
||||
ctx.font = '800 40px Georgia, serif';
|
||||
ctx.fillText(`${data.genre.toUpperCase()}`, W / 2, 160, W - 60);
|
||||
ctx.font = '800 26px Georgia, serif';
|
||||
ctx.fillText('NIGHT', W / 2, 194);
|
||||
if (data.encore) {
|
||||
ctx.fillStyle = '#f3d179';
|
||||
ctx.font = '700 16px Georgia, serif';
|
||||
ctx.fillText('— E N C O R E —', W / 2, 226);
|
||||
}
|
||||
ctx.fillStyle = 'rgba(240,226,195,0.85)';
|
||||
ctx.font = '400 18px Georgia, serif';
|
||||
const entries = data.entries || data.songs || [];
|
||||
entries.slice(0, 6).forEach((sng, i) => {
|
||||
const acc = sng.accuracy != null ? ` · ${Math.floor(sng.accuracy * 100)}%` : '';
|
||||
ctx.fillText(`${sng.title}${acc}`, W / 2, 290 + i * 44, W - 80);
|
||||
});
|
||||
ctx.fillStyle = 'rgba(240,226,195,0.4)';
|
||||
ctx.font = '400 13px Georgia, serif';
|
||||
ctx.fillText(`${ppLabel(data.instrument || 'guitar')} · fee[dB]ack career`, W / 2, H - 42);
|
||||
return canvas;
|
||||
}
|
||||
|
||||
function exportGigPoster(mode, data) {
|
||||
exportCanvasPng(drawGigPoster(data),
|
||||
`gig-${(data.genre_key || 'set').replace(/[^a-z0-9-]+/g, '-')}.png`, mode, 'Poster');
|
||||
}
|
||||
|
||||
// Queue lifecycle: advance the strip per song; complete or abandon.
|
||||
function onGigSongLoading() {
|
||||
if (!_ppGigRun) return;
|
||||
renderGigStrip();
|
||||
}
|
||||
|
||||
function onGigSongEnded() {
|
||||
if (!_ppGigRun) return;
|
||||
const q = window.feedBack && window.feedBack.playQueue;
|
||||
if (!q || typeof q.remaining !== 'function') return;
|
||||
// Only OUR live queue counts: remaining()===0 is also true for a
|
||||
// cleared/foreign queue (a manual play silently clears the gig queue,
|
||||
// and that unrelated song's end must not log a gig).
|
||||
if (q.source && q.source() !== 'gig') { abandonGig(); return; }
|
||||
if (!q.remaining()) {
|
||||
if (q.active && q.active()) completeGig();
|
||||
else abandonGig();
|
||||
return;
|
||||
}
|
||||
_ppGigRun.idx = Math.min(_ppGigRun.idx + 1, _ppGigRun.songs.length - 1);
|
||||
renderGigStrip();
|
||||
}
|
||||
|
||||
function onGigSongStop() {
|
||||
// A deliberate quit mid-set (Escape clears the queue) abandons the
|
||||
// gig — but the LAST song's teardown also fires song:stop after
|
||||
// song:ended, so only abandon while songs genuinely remain.
|
||||
if (!_ppGigRun) return;
|
||||
const q = window.feedBack && window.feedBack.playQueue;
|
||||
const active = q && typeof q.active === 'function' ? q.active() : false;
|
||||
if (!active) abandonGig();
|
||||
}
|
||||
|
||||
function openGenre(inst, genre) {
|
||||
fetch(`${API}/passports/open`, {
|
||||
method: 'POST',
|
||||
@@ -820,6 +1407,23 @@
|
||||
closeBook();
|
||||
return;
|
||||
}
|
||||
const gigBtn = e.target.closest('[data-pp-gig]');
|
||||
if (gigBtn) { bookGig(gigBtn.dataset.ppGig); return; }
|
||||
if (e.target.closest('[data-pp-gig-play]')) { startGig(); return; }
|
||||
if (e.target.closest('[data-pp-gig-reroll]')) {
|
||||
if (_ppGigProposal) bookGig(_ppGigProposal.genre_key);
|
||||
return;
|
||||
}
|
||||
const posterBtn = e.target.closest('[data-pp-poster]');
|
||||
if (posterBtn && _ppGigProposal) {
|
||||
exportGigPoster(posterBtn.dataset.ppPoster, _ppGigProposal);
|
||||
return;
|
||||
}
|
||||
const cardBtn = e.target.closest('[data-pp-card]');
|
||||
if (cardBtn) {
|
||||
exportPassportCard(cardBtn.dataset.ppCard);
|
||||
return;
|
||||
}
|
||||
const dlBtn = e.target.closest('[data-career-download]');
|
||||
const delBtn = e.target.closest('[data-career-delete]');
|
||||
const playBtn = e.target.closest('[data-career-play]');
|
||||
@@ -870,6 +1474,10 @@
|
||||
if (sm && typeof sm.on === 'function') {
|
||||
// New song stats can add stars → thresholds may cross mid-session.
|
||||
sm.on('stats:recorded', () => refresh());
|
||||
// Gig runner lifecycle (no-ops when no gig is live).
|
||||
sm.on('song:loading', onGigSongLoading);
|
||||
sm.on('song:ended', onGigSongEnded);
|
||||
sm.on('song:stop', onGigSongStop);
|
||||
// Virtuoso's progress emits are the drill-state relay trigger; the
|
||||
// payload is a thin delta, so the relay reads the full localStorage
|
||||
// snapshot instead (see relayDrillState).
|
||||
@@ -877,8 +1485,14 @@
|
||||
}
|
||||
showCareerTab(lsGet(PP_TAB_KEY) === 'passports' ? 'passports' : 'venues');
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape' && _ppBook) closeBook();
|
||||
if (e.key !== 'Escape') return;
|
||||
const overlay = $('pp-overlay');
|
||||
if (_ppBook || (overlay && !overlay.classList.contains('hidden'))) closeBook();
|
||||
});
|
||||
// Core re-renders profile/dashboard shells (innerHTML wipe) and
|
||||
// announces the fresh mount points — same seam achievements uses.
|
||||
document.addEventListener('v3:profile-rendered', renderProfileWall);
|
||||
document.addEventListener('v3:dashboard-rendered', renderDashCard);
|
||||
refresh();
|
||||
}
|
||||
|
||||
@@ -886,7 +1500,11 @@
|
||||
// the badge-diff logic; nothing here touches the DOM.
|
||||
window.__careerPassportTest = {
|
||||
ppKey, ppJitter, ppLabel, detectNewBadges, seenBadges, markBadgeSeen,
|
||||
fmtHours, ppFillFraction,
|
||||
fmtHours, ppFillFraction, careerTotals, closestAskHTML,
|
||||
onGigSongEnded, onGigSongStop,
|
||||
setGigRun(r) { _ppGigRun = r; },
|
||||
getGigRun() { return _ppGigRun; },
|
||||
setView(v) { _pp = v; },
|
||||
};
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
|
||||
@@ -150,3 +150,51 @@ test('ppFillFraction: song progress toward the bar, in-progress only', () => {
|
||||
assert.equal(ppFillFraction(p('in_progress', 3, 0)), 0); // no bar → no fill
|
||||
assert.equal(ppFillFraction(null), 0);
|
||||
});
|
||||
|
||||
test('careerTotals / wall + dash card stay absent without commitment', () => {
|
||||
const w = load();
|
||||
const t = w.__careerPassportTest;
|
||||
// No _pp at all → null; committed-less view → null (absent-not-empty).
|
||||
assert.equal(t.careerTotals(), null);
|
||||
t.setView({ config: { instruments: ['guitar'] },
|
||||
instruments: { guitar: { committed_at: null, passports: [] } } });
|
||||
assert.equal(t.careerTotals(), null);
|
||||
// Committed but zero passports opened: still absent (no zero-wall).
|
||||
t.setView({ config: { instruments: ['guitar'] },
|
||||
instruments: { guitar: { committed_at: 'x', passports: [] } } });
|
||||
assert.equal(t.careerTotals(), null);
|
||||
// Committed with an earned badge + hours → totals aggregate.
|
||||
t.setView({ config: { instruments: ['guitar', 'bass'] },
|
||||
instruments: {
|
||||
guitar: { committed_at: 'x', passports: [
|
||||
{ badge: 'earned', seconds_total: 3600, genre: 'Blues', genre_key: 'blues' },
|
||||
{ badge: 'in_progress', seconds_total: 120, genre: 'Funk', genre_key: 'funk',
|
||||
qualifying_count: 4, requirement: { songs: 5, min_stars: 2 } }] },
|
||||
bass: { committed_at: null, passports: [] },
|
||||
} });
|
||||
const totals = t.careerTotals();
|
||||
assert.equal(totals.badges, 1);
|
||||
assert.equal(totals.seconds, 3720);
|
||||
assert.equal(totals.walls.length, 1);
|
||||
});
|
||||
|
||||
test('gig runner lifecycle: advance on ended, abandon on dead-queue stop', () => {
|
||||
const w = load();
|
||||
const t = w.__careerPassportTest;
|
||||
let remaining = 1;
|
||||
w.feedBack = { playQueue: { remaining: () => remaining, active: () => remaining > 0 } };
|
||||
t.setGigRun({
|
||||
songs: [{ filename: 'a', title: 'A' }, { filename: 'b', title: 'B' }],
|
||||
venue_id: null, genre: 'Soul', genre_key: 'soul', instrument: 'guitar', idx: 0,
|
||||
});
|
||||
// First song ends, one remains → the strip advances, no completion.
|
||||
t.onGigSongEnded();
|
||||
assert.equal(t.getGigRun().idx, 1);
|
||||
// Stop while the queue is still active (end-of-song teardown) → run survives.
|
||||
t.onGigSongStop();
|
||||
assert.notEqual(t.getGigRun(), null);
|
||||
// User quits: queue cleared → stop with a dead queue abandons (no log).
|
||||
remaining = 0;
|
||||
t.onGigSongStop();
|
||||
assert.equal(t.getGigRun(), null);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
// Blob export helpers — the download idiom that used to be duplicated in
|
||||
// settings-io.js and diagnostics-export.js, plus image-to-clipboard for
|
||||
// shareable cards/posters. A LEAF module: imports nothing. Classic-script
|
||||
// plugins reach it via dynamic import('/static/js/blob-io.js').
|
||||
|
||||
export function downloadBlob(blob, filename) {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
// Copy an image blob to the system clipboard. Returns true on success, false
|
||||
// when the Clipboard API is unavailable or refuses (insecure context, no user
|
||||
// gesture, permission denied) — callers fall back to downloadBlob and say so.
|
||||
export async function copyImageBlob(blob) {
|
||||
try {
|
||||
if (!navigator.clipboard || typeof ClipboardItem === 'undefined') return false;
|
||||
await navigator.clipboard.write([new ClipboardItem({ [blob.type || 'image/png']: blob })]);
|
||||
return true;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,8 @@
|
||||
// redact toggles.
|
||||
// 3. Stream the returned zip to disk.
|
||||
|
||||
import { downloadBlob } from './blob-io.js';
|
||||
|
||||
function _diagIncludeFromUI() {
|
||||
const v = (id) => document.getElementById(id)?.checked !== false;
|
||||
return {
|
||||
@@ -265,14 +267,7 @@ export async function exportDiagnostics() {
|
||||
}
|
||||
try {
|
||||
const blob = await resp.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
downloadBlob(blob, filename);
|
||||
status.textContent = `Exported ${filename}`;
|
||||
} catch (e) {
|
||||
status.textContent = `Export failed during download: ${e.message}`;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Settings backup — the export / import bundle.
|
||||
//
|
||||
// Carved verbatim out of static/app.js (R3a). A LEAF module: imports nothing.
|
||||
// Carved verbatim out of static/app.js (R3a). Imports only the blob-io leaf.
|
||||
//
|
||||
// Two entry points, both inline handlers on the Settings screen, so app.js keeps
|
||||
// re-exposing them on window. The import is two-phase (server first, atomic; then
|
||||
@@ -29,6 +29,8 @@
|
||||
// phase 2; the localStorage side is best-effort merge after server
|
||||
// success. Failures are reported, never silenced.
|
||||
|
||||
import { downloadBlob } from './blob-io.js';
|
||||
|
||||
export async function exportSettings() {
|
||||
const status = document.getElementById('backup-status');
|
||||
status.textContent = 'Exporting...';
|
||||
@@ -66,14 +68,7 @@ export async function exportSettings() {
|
||||
if (match) filename = match[1];
|
||||
}
|
||||
const blob = new Blob([JSON.stringify(bundle, null, 2)], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
downloadBlob(blob, filename);
|
||||
status.textContent = `Exported ${filename}`;
|
||||
} catch (e) {
|
||||
status.textContent = `Export failed: ${e.message}`;
|
||||
|
||||
@@ -208,12 +208,17 @@
|
||||
'</div></div></div>' +
|
||||
continueCard +
|
||||
'</div>' +
|
||||
// Stats row
|
||||
// Stats row. The third slot belongs to the career plugin (it
|
||||
// replaces the slot's content on v3:dashboard-rendered); the
|
||||
// plugin-count stat is the built-in fallback when career is
|
||||
// absent or has no state yet.
|
||||
'<div class="grid md:grid-cols-3 gap-6 mt-6">' +
|
||||
audioRoutingCard() +
|
||||
statCard(String(songCount), 'songs', 'text-fb-gold') +
|
||||
'<div id="v3-dash-career-slot" class="grid">' +
|
||||
statCard(String(pluginCount), 'active', 'text-fb-good') +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
recentSection +
|
||||
'</div>';
|
||||
|
||||
|
||||
@@ -191,6 +191,10 @@
|
||||
'<div class="space-y-6">' +
|
||||
headerCard +
|
||||
bestsCard +
|
||||
// Passport wall — rendered by the career plugin on
|
||||
// v3:profile-rendered (absent-not-empty: nothing shows until a
|
||||
// passport exists).
|
||||
'<div id="v3-profile-passports-mount"></div>' +
|
||||
// Feats of Power trophy shelf — rendered by the achievements plugin
|
||||
// (earned Feats only; hidden-until-earned, so empty when none).
|
||||
'<div id="v3-profile-feats-slot"></div>' +
|
||||
|
||||
@@ -26,7 +26,7 @@ class FakeMetaDb:
|
||||
self.conn.execute(
|
||||
"""CREATE TABLE song_stats (
|
||||
filename TEXT, arrangement TEXT, best_accuracy REAL,
|
||||
last_played_at TEXT,
|
||||
last_accuracy REAL, last_played_at TEXT,
|
||||
seconds_total REAL NOT NULL DEFAULT 0
|
||||
)"""
|
||||
)
|
||||
@@ -38,10 +38,12 @@ class FakeMetaDb:
|
||||
)
|
||||
|
||||
def add(self, filename, arrangement, best_accuracy, in_library=True,
|
||||
genre="", arrangements=None, last_played_at=None, seconds_total=0):
|
||||
self.conn.execute("INSERT INTO song_stats VALUES (?, ?, ?, ?, ?)",
|
||||
(filename, arrangement, best_accuracy, last_played_at,
|
||||
seconds_total))
|
||||
genre="", arrangements=None, last_played_at=None, seconds_total=0,
|
||||
last_accuracy=None):
|
||||
self.conn.execute("INSERT INTO song_stats VALUES (?, ?, ?, ?, ?, ?)",
|
||||
(filename, arrangement, best_accuracy,
|
||||
last_accuracy if last_accuracy is not None else best_accuracy,
|
||||
last_played_at, seconds_total))
|
||||
if in_library:
|
||||
self.conn.execute(
|
||||
"INSERT INTO songs SELECT ?, ?, ?, ?, ? WHERE NOT EXISTS "
|
||||
|
||||
@@ -245,3 +245,129 @@ 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"])
|
||||
# ── Gigs ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_gig_propose_mixes_owned_and_stakes(client, meta_db):
|
||||
for i in range(4):
|
||||
meta_db.add(f"own{i}.feedpak", 0, 0.85, genre="Soul", arrangements=LEAD)
|
||||
meta_db.add("stake.feedpak", 0, 0.70, genre="Soul", arrangements=LEAD)
|
||||
meta_db.add_song_only("fresh.feedpak", genre="Soul")
|
||||
res = client.post("/api/plugins/career/gigs/propose",
|
||||
json={"instrument": "guitar", "genre": "Soul", "size": 4})
|
||||
assert res.status_code == 200
|
||||
gig = res.json()
|
||||
files = [s["filename"] for s in gig["songs"]]
|
||||
assert len(files) == 4
|
||||
assert "stake.feedpak" in files # a near-bar song gives the set stakes
|
||||
assert gig["venue_id"] == "bar" # 9 stars < 50: the dive bar
|
||||
|
||||
# A young passport (nothing played) still gets a playable set from the
|
||||
# library's unplayed genre songs.
|
||||
res2 = client.post("/api/plugins/career/gigs/propose",
|
||||
json={"instrument": "guitar", "genre": "Ska"})
|
||||
assert res2.status_code == 404 # no ska in the library at all
|
||||
meta_db.add_song_only("ska1.feedpak", genre="Ska")
|
||||
res3 = client.post("/api/plugins/career/gigs/propose",
|
||||
json={"instrument": "guitar", "genre": "Ska"})
|
||||
assert [s["filename"] for s in res3.json()["songs"]] == ["ska1.feedpak"]
|
||||
|
||||
|
||||
def test_gig_log_computes_encore_and_surfaces_in_passports(client, meta_db):
|
||||
for i in range(2):
|
||||
meta_db.add(f"s{i}.feedpak", 0, 0.9, genre="Soul", arrangements=LEAD,
|
||||
last_accuracy=0.9)
|
||||
_open(client, "guitar", "Soul")
|
||||
res = client.post("/api/plugins/career/gigs", json={
|
||||
"instrument": "guitar", "genre": "Soul", "venue_id": "bar",
|
||||
"songs": ["s0.feedpak", "s1.feedpak"]})
|
||||
assert res.status_code == 200
|
||||
gig = res.json()["gig"]
|
||||
assert gig["encore"] is True # avg 0.9 ≥ 0.75
|
||||
assert gig["songs"][0]["accuracy"] == 0.9
|
||||
view = client.get("/api/plugins/career/passports").json()
|
||||
assert view["instruments"]["guitar"]["gig_count"] == 1
|
||||
p = _passport(client, "guitar", "soul")
|
||||
assert len(p["gigs"]) == 1 and p["gigs"][0]["encore"] is True
|
||||
|
||||
|
||||
def test_gig_log_validation_and_no_fail_state(client):
|
||||
# Unknown venue / bad songs shapes are rejected; nothing is ever logged
|
||||
# as a failed gig — the endpoint only appends completed sets.
|
||||
assert client.post("/api/plugins/career/gigs", json={
|
||||
"instrument": "guitar", "genre": "Soul", "venue_id": "nope",
|
||||
"songs": ["x"]}).status_code == 400
|
||||
assert client.post("/api/plugins/career/gigs", json={
|
||||
"instrument": "guitar", "genre": "Soul", "songs": []}).status_code == 400
|
||||
assert client.post("/api/plugins/career/gigs", json={
|
||||
"instrument": "guitar", "genre": "Soul",
|
||||
"songs": ["f"] * 9}).status_code == 400
|
||||
|
||||
|
||||
def test_gig_accuracy_reads_newest_row_and_encore_needs_full_set(client, meta_db):
|
||||
# Newest row wins: a stale higher accuracy on another arrangement must
|
||||
# not inflate the gig log.
|
||||
meta_db.add("dual.feedpak", 1, 0.95, genre="Soul", arrangements=BASS,
|
||||
last_accuracy=0.95, last_played_at="2026-06-01T00:00:00")
|
||||
meta_db.add("dual.feedpak", 0, 0.60, genre="Soul", arrangements=LEAD,
|
||||
last_accuracy=0.60, last_played_at="2026-07-14T00:00:00")
|
||||
res = client.post("/api/plugins/career/gigs", json={
|
||||
"instrument": "guitar", "genre": "Soul", "songs": ["dual.feedpak"]})
|
||||
assert res.json()["gig"]["songs"][0]["accuracy"] == 0.6
|
||||
|
||||
# A set with an unscored song never earns the encore off one good song.
|
||||
meta_db.add("scored.feedpak", 0, 0.9, genre="Soul", arrangements=LEAD,
|
||||
last_accuracy=0.9, last_played_at="2026-07-14T00:01:00")
|
||||
res2 = client.post("/api/plugins/career/gigs", json={
|
||||
"instrument": "guitar", "genre": "Soul",
|
||||
"songs": ["scored.feedpak", "ghost.feedpak"]})
|
||||
assert res2.json()["gig"]["encore"] is False
|
||||
|
||||
|
||||
def test_gig_propose_backfills_from_surplus_qualifying(client, meta_db):
|
||||
# Mature passport: plenty of qualifying songs, nothing near the bar,
|
||||
# nothing unplayed — the set still fills to size.
|
||||
for i in range(8):
|
||||
meta_db.add(f"own{i}.feedpak", 0, 0.9, genre="Ska", arrangements=LEAD)
|
||||
res = client.post("/api/plugins/career/gigs/propose",
|
||||
json={"instrument": "guitar", "genre": "Ska", "size": 5})
|
||||
assert len(res.json()["songs"]) == 5
|
||||
|
||||
|
||||
def test_gig_propose_backfill_offset_survives_stakes(client, meta_db):
|
||||
# 4 qualifying + 1 near-bar stake, size 5: the stake must not shift the
|
||||
# qualifying backfill window past eligible songs.
|
||||
for i in range(4):
|
||||
meta_db.add(f"q{i}.feedpak", 0, 0.9, genre="Reggae", arrangements=LEAD)
|
||||
meta_db.add("near.feedpak", 0, 0.7, genre="Reggae", arrangements=LEAD)
|
||||
res = client.post("/api/plugins/career/gigs/propose",
|
||||
json={"instrument": "guitar", "genre": "Reggae", "size": 5})
|
||||
files = [s["filename"] for s in res.json()["songs"]]
|
||||
assert len(files) == 5 and len(set(files)) == 5
|
||||
assert "near.feedpak" in files
|
||||
|
||||
Reference in New Issue
Block a user