feat(career): hours-per-genre odometer — honest wall-clock play time

Career v2, WS2. Nothing measured play time before (the achievements
plugin's final-position shortcut double-counts loops and mis-reads
seeks). Now:

- stats-recorder.js accrues WALL-CLOCK seconds across song:play/resume ↔
  pause/stop/ended spans (single spans clamp at 2h against suspend
  inflation) and piggybacks them as `seconds` on the POSTs it already
  sends; failed POSTs restore the accumulator; a session reset flushes
  first so time can't re-attribute to the next song/arrangement.
- POST /api/stats accepts optional `seconds` (finite, 0 < s ≤ 6h) on the
  scored and position branches, plus a new seconds-only branch for
  unscored plays that ran to the natural end — banks time WITHOUT
  touching the resume position (song:ended must not overwrite Continue)
  and still counts as playing today for the streak.
- song_stats gains additive idempotent `seconds_total`; record_session/
  touch_position accrue, new add_play_seconds() for the seconds-only
  path; the legacy-encoding stats merge sums seconds across duplicates.
- Passports surface it: "14.2 h in Blues" under the badge stamp and on
  the shelf cover sub-line — a true fact that only grows, never a
  target or a meter (Stage 5 post-cap, per the career design).

Tests: seconds accrual/validation/seconds-only branch (stats API),
per-instrument-and-genre summing (career), fmtHours formatting (vm).
Full suites: pytest 2480, JS 1165.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
byrongamatos
2026-07-13 15:08:54 +02:00
co-authored by Claude Fable 5
parent d26347981c
commit 13e68c2f83
11 changed files with 289 additions and 31 deletions
+11
View File
@@ -17,6 +17,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
Click or wait ~4s to dismiss; `prefers-reduced-motion` gets the existing Click or wait ~4s to dismiss; `prefers-reduced-motion` gets the existing
chime + notification only. The stamp still slams into the passport book on chime + notification only. The stamp still slams into the passport book on
next open, unchanged. next open, unchanged.
- **Hours-per-genre odometer (career passports)** — the app now measures real
play time: the stats recorder accrues **wall-clock** seconds across
play/resume ↔ pause/stop/end spans (wall time, not song position — position
deltas double-count A-B loops and mis-read seeks; single spans clamp at 2h
against suspend/sleep inflation) and piggybacks them as `seconds` on the
`POST /api/stats` calls it already makes. New additive
`song_stats.seconds_total` column; a seconds-only POST banks time for
unscored plays that run to the song's natural end without touching the
resume position (and still counts as playing today for the streak).
Passports surface it honestly: "14.2 h in Blues" under the badge and on the
shelf cover — a true fact that only grows, never a target or a meter.
- **Career passports (backend)** — the badge-journey layer on top of career stars. - **Career passports (backend)** — the badge-journey layer on top of career stars.
New career-plugin endpoints: `GET /api/plugins/career/passports` (per-instrument New career-plugin endpoints: `GET /api/plugins/career/passports` (per-instrument
passport walls: genre badges computed on read from `song_stats` × the library's passport walls: genre badges computed on read from `song_stats` × the library's
+46 -11
View File
@@ -614,6 +614,14 @@ class MetadataDB:
) )
""") """)
self.conn.execute("CREATE INDEX IF NOT EXISTS idx_song_stats_recent ON song_stats(last_played_at DESC)") self.conn.execute("CREATE INDEX IF NOT EXISTS idx_song_stats_recent ON song_stats(last_played_at DESC)")
# Cumulative wall-clock play time (career "hours in genre" odometer).
# Fed by the same POST /api/stats the recorder already sends; additive
# + idempotent like every other song_stats change.
try:
self.conn.execute(
"ALTER TABLE song_stats ADD COLUMN seconds_total REAL NOT NULL DEFAULT 0")
except sqlite3.OperationalError:
pass
# Playlists + the reserved "Saved for Later" system playlist. Additive. # Playlists + the reserved "Saved for Later" system playlist. Additive.
self.conn.execute(""" self.conn.execute("""
CREATE TABLE IF NOT EXISTS playlists ( CREATE TABLE IF NOT EXISTS playlists (
@@ -901,6 +909,9 @@ class MetadataDB:
"best_accuracy": max(cur["best_accuracy"] or 0.0, r["best_accuracy"] or 0.0), "best_accuracy": max(cur["best_accuracy"] or 0.0, r["best_accuracy"] or 0.0),
"last_score": newer["last_score"], "last_accuracy": newer["last_accuracy"], "last_score": newer["last_score"], "last_accuracy": newer["last_accuracy"],
"last_position": newer["last_position"], "last_position": newer["last_position"],
# Play time is additive: both encodings' hours belong to
# the one canonical song.
"seconds_total": (cur.get("seconds_total") or 0.0) + (r.get("seconds_total") or 0.0),
"last_played_at": newer["last_played_at"], "updated_at": newer["updated_at"], "last_played_at": newer["last_played_at"], "updated_at": newer["updated_at"],
} }
# Atomic swap: clear and reinsert the canonicalized set in one txn. # Atomic swap: clear and reinsert the canonicalized set in one txn.
@@ -1693,7 +1704,8 @@ class MetadataDB:
# ── Per-song practice stats ───────────────────────────────────────────--- # ── Per-song practice stats ───────────────────────────────────────────---
_STATS_COLS = ( _STATS_COLS = (
"filename", "arrangement", "plays", "best_score", "best_accuracy", "filename", "arrangement", "plays", "best_score", "best_accuracy",
"last_score", "last_accuracy", "last_position", "last_played_at", "updated_at", "last_score", "last_accuracy", "last_position", "seconds_total",
"last_played_at", "updated_at",
) )
def _stats_row(self, filename: str, arrangement: int) -> dict | None: def _stats_row(self, filename: str, arrangement: int) -> dict | None:
@@ -2060,8 +2072,9 @@ class MetadataDB:
self.conn.commit() self.conn.commit()
def record_session(self, filename: str, arrangement: int, *, score: int, def record_session(self, filename: str, arrangement: int, *, score: int,
accuracy: float, last_position=None) -> dict: accuracy: float, last_position=None, seconds: float = 0) -> dict:
"""Record a scored play: plays += 1, best_* = max, last_* = new.""" """Record a scored play: plays += 1, best_* = max, last_* = new.
`seconds` (wall-clock play time from the recorder) accrues."""
from song_score import merge_stats from song_score import merge_stats
with self._lock: with self._lock:
existing = self._stats_row(filename, int(arrangement)) existing = self._stats_row(filename, int(arrangement))
@@ -2071,8 +2084,9 @@ class MetadataDB:
self.conn.execute( self.conn.execute(
"""INSERT INTO song_stats """INSERT INTO song_stats
(filename, arrangement, plays, best_score, best_accuracy, (filename, arrangement, plays, best_score, best_accuracy,
last_score, last_accuracy, last_position, last_played_at, updated_at) last_score, last_accuracy, last_position, seconds_total,
VALUES (?, ?, ?, ?, ?, ?, ?, ?, last_played_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?,
strftime('%Y-%m-%d %H:%M:%f','now'), strftime('%Y-%m-%d %H:%M:%f','now')) strftime('%Y-%m-%d %H:%M:%f','now'), strftime('%Y-%m-%d %H:%M:%f','now'))
ON CONFLICT(filename, arrangement) DO UPDATE SET ON CONFLICT(filename, arrangement) DO UPDATE SET
plays = excluded.plays, plays = excluded.plays,
@@ -2081,32 +2095,53 @@ class MetadataDB:
last_score = excluded.last_score, last_score = excluded.last_score,
last_accuracy = excluded.last_accuracy, last_accuracy = excluded.last_accuracy,
last_position = excluded.last_position, last_position = excluded.last_position,
seconds_total = song_stats.seconds_total + excluded.seconds_total,
last_played_at = excluded.last_played_at, last_played_at = excluded.last_played_at,
updated_at = excluded.updated_at""", updated_at = excluded.updated_at""",
(filename, int(arrangement), merged["plays"], merged["best_score"], (filename, int(arrangement), merged["plays"], merged["best_score"],
merged["best_accuracy"], merged["last_score"], merged["last_accuracy"], merged["best_accuracy"], merged["last_score"], merged["last_accuracy"],
merged["last_position"]), merged["last_position"], float(seconds or 0)),
) )
self.conn.commit() self.conn.commit()
return self._stats_row(filename, int(arrangement)) return self._stats_row(filename, int(arrangement))
def touch_position(self, filename: str, arrangement: int, last_position: float) -> dict: def touch_position(self, filename: str, arrangement: int, last_position: float,
seconds: float = 0) -> dict:
"""Persist just the resume position (no plays/score change), so """Persist just the resume position (no plays/score change), so
Continue-Playing works for non-scored plays. Also stamps Continue-Playing works for non-scored plays. Also stamps
last_played_at — both /api/stats/recent and /api/session/continue last_played_at — both /api/stats/recent and /api/session/continue
filter/order on it, so a position-only touch must set it or the song filter/order on it, so a position-only touch must set it or the song
never surfaces as 'recent' / 'continue playing'.""" never surfaces as 'recent' / 'continue playing'. `seconds` accrues
wall-clock play time (career hours odometer)."""
with self._lock: with self._lock:
self.conn.execute( self.conn.execute(
"""INSERT INTO song_stats (filename, arrangement, last_position, """INSERT INTO song_stats (filename, arrangement, last_position,
last_played_at, updated_at) seconds_total, last_played_at, updated_at)
VALUES (?, ?, ?, strftime('%Y-%m-%d %H:%M:%f','now'), VALUES (?, ?, ?, ?, strftime('%Y-%m-%d %H:%M:%f','now'),
strftime('%Y-%m-%d %H:%M:%f','now')) strftime('%Y-%m-%d %H:%M:%f','now'))
ON CONFLICT(filename, arrangement) DO UPDATE SET ON CONFLICT(filename, arrangement) DO UPDATE SET
last_position = excluded.last_position, last_position = excluded.last_position,
seconds_total = song_stats.seconds_total + excluded.seconds_total,
last_played_at = excluded.last_played_at, last_played_at = excluded.last_played_at,
updated_at = excluded.updated_at""", updated_at = excluded.updated_at""",
(filename, int(arrangement), float(last_position)), (filename, int(arrangement), float(last_position), float(seconds or 0)),
)
self.conn.commit()
return self._stats_row(filename, int(arrangement))
def add_play_seconds(self, filename: str, arrangement: int, seconds: float) -> dict:
"""Accrue wall-clock play time only (no plays/score/position change) —
the recorder's seconds-only flush for unscored plays that ran to the
song's natural end (no resume position to touch there: `song:ended`
must not overwrite Continue with the end-of-song offset)."""
with self._lock:
self.conn.execute(
"""INSERT INTO song_stats (filename, arrangement, seconds_total, updated_at)
VALUES (?, ?, ?, strftime('%Y-%m-%d %H:%M:%f','now'))
ON CONFLICT(filename, arrangement) DO UPDATE SET
seconds_total = song_stats.seconds_total + excluded.seconds_total,
updated_at = excluded.updated_at""",
(filename, int(arrangement), float(seconds)),
) )
self.conn.commit() self.conn.commit()
return self._stats_row(filename, int(arrangement)) return self._stats_row(filename, int(arrangement))
+34 -2
View File
@@ -76,6 +76,22 @@ def api_record_stats(data: dict):
last_pos = data.get("lastPlayPosition", data.get("last_position")) last_pos = data.get("lastPlayPosition", data.get("last_position"))
if isinstance(last_pos, bool): # float(False)=0.0 would otherwise store a bogus position if isinstance(last_pos, bool): # float(False)=0.0 would otherwise store a bogus position
return JSONResponse({"error": "lastPlayPosition must be a finite number"}, status_code=400) return JSONResponse({"error": "lastPlayPosition must be a finite number"}, status_code=400)
# Optional wall-clock play time (career hours odometer). Bounded per POST:
# the recorder flushes on pause/stop/end, so a single delta beyond 6h is a
# clock artifact (suspend/sleep), not practice.
seconds = data.get("seconds")
if seconds is not None:
if isinstance(seconds, bool):
return JSONResponse({"error": "seconds must be a positive number"}, status_code=400)
try:
seconds = float(seconds)
if not math.isfinite(seconds):
raise ValueError("non-finite")
except (TypeError, ValueError, OverflowError):
return JSONResponse({"error": "seconds must be a positive number"}, status_code=400)
if not (0 < seconds <= 6 * 3600):
return JSONResponse({"error": "seconds must be between 0 and 21600"}, status_code=400)
seconds = seconds or 0.0
# A scored session needs BOTH score and accuracy. Exactly one provided is # A scored session needs BOTH score and accuracy. Exactly one provided is
# ambiguous — don't silently fall through to the position-only branch. # ambiguous — don't silently fall through to the position-only branch.
@@ -115,7 +131,8 @@ def api_record_stats(data: dict):
except (TypeError, ValueError, OverflowError): except (TypeError, ValueError, OverflowError):
return JSONResponse({"error": "lastPlayPosition must be a finite number"}, status_code=400) return JSONResponse({"error": "lastPlayPosition must be a finite number"}, status_code=400)
row = appstate.meta_db.record_session(filename, arrangement, score=score, row = appstate.meta_db.record_session(filename, arrangement, score=score,
accuracy=accuracy, last_position=last_pos) accuracy=accuracy, last_position=last_pos,
seconds=seconds)
# Unified XP + streak side-effects — never let these drop the stat write. # Unified XP + streak side-effects — never let these drop the stat write.
progress = None progress = None
try: try:
@@ -152,6 +169,21 @@ def api_record_stats(data: dict):
log.warning("stats side-effects (progression) failed", exc_info=True) log.warning("stats side-effects (progression) failed", exc_info=True)
return {"stats": row, "progress": progress, "progression": progression_summary} return {"stats": row, "progress": progress, "progression": progression_summary}
# Seconds-only accrual: an unscored play that ran to the song's natural
# end has play time to bank but no resume position to touch (song:ended
# must not overwrite Continue with the end-of-song offset). Still counts
# as playing today for the streak below.
if last_pos is None and seconds:
row = appstate.meta_db.add_play_seconds(filename, arrangement, seconds)
progress = None
try:
from datetime import date
appstate.meta_db.record_active_day(date.today().isoformat())
progress = appstate.meta_db.get_progress()
except Exception:
log.warning("stats side-effects (streak) failed", exc_info=True)
return {"stats": row, "progress": progress}
# Position-only touch. # Position-only touch.
if last_pos is None: if last_pos is None:
return JSONResponse( return JSONResponse(
@@ -162,7 +194,7 @@ def api_record_stats(data: dict):
pos = float(last_pos) pos = float(last_pos)
if not math.isfinite(pos): if not math.isfinite(pos):
raise ValueError("non-finite") raise ValueError("non-finite")
row = appstate.meta_db.touch_position(filename, arrangement, pos) row = appstate.meta_db.touch_position(filename, arrangement, pos, seconds=seconds)
except (TypeError, ValueError, OverflowError): except (TypeError, ValueError, OverflowError):
return JSONResponse({"error": "lastPlayPosition must be a finite number"}, status_code=400) return JSONResponse({"error": "lastPlayPosition must be a finite number"}, status_code=400)
# A resume session still counts as playing today: advance the streak (no XP — # A resume session still counts as playing today: advance the streak (no XP —
+9
View File
@@ -466,3 +466,12 @@
text-shadow: 0 2px 8px rgba(0, 0, 0, 0.8); text-shadow: 0 2px 8px rgba(0, 0, 0, 0.8);
} }
.pp-ceremony-sub { font-size: 0.8rem; color: #d1d5db; text-shadow: 0 1px 4px rgba(0, 0, 0, 0.8); } .pp-ceremony-sub { font-size: 0.8rem; color: #d1d5db; text-shadow: 0 1px 4px rgba(0, 0, 0, 0.8); }
/* Hours odometer (Stage 5 post-cap — a true fact, never a meter) */
.pp-hours {
font-size: 0.72rem;
letter-spacing: 0.08em;
color: #8a7a5e;
margin-top: 0.75rem;
font-variant-numeric: tabular-nums;
}
+17 -9
View File
@@ -203,21 +203,24 @@ def _instrument_of(arrangements, arrangement):
def _played_by_instrument_genre(): def _played_by_instrument_genre():
"""(instrument, genre_key) → {filename: stub dict}. Best accuracy per """((instrument, genre_key) → {filename: stub dict},
(instrument, song); the JOIN keeps the same dead-song filter as _stars().""" (instrument, genre_key) total played seconds).
Best accuracy per (instrument, song); seconds sum across every
arrangement row; the JOIN keeps the same dead-song filter as _stars()."""
db = _state["meta_db"] db = _state["meta_db"]
if db is None: if db is None:
return {} return {}, {}
thresholds = _state["content"]["star_accuracy_thresholds"] thresholds = _state["content"]["star_accuracy_thresholds"]
rows = db.conn.execute( rows = db.conn.execute(
"SELECT s.filename, s.arrangement, s.best_accuracy, s.last_played_at, " "SELECT s.filename, s.arrangement, s.best_accuracy, s.last_played_at, "
" songs.title, songs.artist, songs.arrangements, " " s.seconds_total, songs.title, songs.artist, songs.arrangements, "
f" {_genre_expr(db)} " f" {_genre_expr(db)} "
"FROM song_stats s JOIN songs ON songs.filename = s.filename" "FROM song_stats s JOIN songs ON songs.filename = s.filename"
).fetchall() ).fetchall()
arrs_cache = {} arrs_cache = {}
out = {} out = {}
for filename, arrangement, acc, played_at, title, artist, arrs_json, genre in rows: seconds = {}
for filename, arrangement, acc, played_at, secs, title, artist, arrs_json, genre in rows:
gkey = _genre_key(genre) gkey = _genre_key(genre)
if not gkey: if not gkey:
continue continue
@@ -227,10 +230,12 @@ def _played_by_instrument_genre():
except (TypeError, ValueError): except (TypeError, ValueError):
arrs_cache[filename] = None arrs_cache[filename] = None
instrument = _instrument_of(arrs_cache[filename], arrangement) instrument = _instrument_of(arrs_cache[filename], arrangement)
key = (instrument, gkey)
seconds[key] = seconds.get(key, 0.0) + (secs or 0.0)
acc = acc or 0.0 acc = acc or 0.0
stub = out.setdefault((instrument, gkey), {}).get(filename) stub = out.setdefault(key, {}).get(filename)
if stub is None: if stub is None:
out[(instrument, gkey)][filename] = { out[key][filename] = {
"filename": filename, "filename": filename,
"title": title or filename, "title": title or filename,
"artist": artist or "", "artist": artist or "",
@@ -245,7 +250,7 @@ def _played_by_instrument_genre():
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"] = sum(1 for t in thresholds if acc >= t)
return out return out, seconds
def _library_genres(): def _library_genres():
@@ -307,7 +312,7 @@ def _passports_view():
cfg = _state["passports_content"] cfg = _state["passports_content"]
graded = set(cfg.get("graded_instruments") or []) graded = set(cfg.get("graded_instruments") or [])
st = _career_state() st = _career_state()
played = _played_by_instrument_genre() played, played_seconds = _played_by_instrument_genre()
received_at, by_node = _drill_by_node() received_at, by_node = _drill_by_node()
instruments = {} instruments = {}
for inst in cfg.get("instruments") or []: for inst in cfg.get("instruments") or []:
@@ -345,6 +350,9 @@ def _passports_view():
"graded": is_graded, "graded": is_graded,
"songs": songs, "songs": songs,
"qualifying_count": qualifying, "qualifying_count": qualifying,
# 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}, "drills": {"required": required, "cleared": cleared},
"badge": badge, "badge": badge,
}) })
+16 -2
View File
@@ -466,17 +466,27 @@
} }
} }
// Honest hours odometer (Stage 5 post-cap). Below a minute of history
// there is nothing meaningful to show.
function fmtHours(seconds) {
const s = Number(seconds) || 0;
if (s < 60) return '';
if (s < 3600) return `${Math.round(s / 60)} min`;
return `${(s / 3600).toFixed(1).replace(/\.0$/, '')} h`;
}
function ppCoverHTML(inst, p) { function ppCoverHTML(inst, p) {
const rot = ppJitter(inst + p.genre_key, 1.6).toFixed(2); const rot = ppJitter(inst + p.genre_key, 1.6).toFixed(2);
const stamp = p.badge === 'earned' const stamp = p.badge === 'earned'
? `<span class="pp-stamp pp-stamp-mini" style="--pp-rot:${ppJitter(p.genre_key, 8).toFixed(1)}deg">BRONZE</span>` ? `<span class="pp-stamp pp-stamp-mini" style="--pp-rot:${ppJitter(p.genre_key, 8).toFixed(1)}deg">BRONZE</span>`
: ''; : '';
const stubs = p.qualifying_count === 1 ? '1 stub' : `${p.qualifying_count} stubs`; const stubs = p.qualifying_count === 1 ? '1 stub' : `${p.qualifying_count} stubs`;
const hours = fmtHours(p.seconds_total);
return `<button class="pp-cover pp-leather-${esc(inst)}" data-pp-open="${esc(p.genre_key)}" style="transform:rotate(${rot}deg)"> return `<button class="pp-cover pp-leather-${esc(inst)}" data-pp-open="${esc(p.genre_key)}" style="transform:rotate(${rot}deg)">
<span class="pp-cover-title">${esc(p.genre.toUpperCase())}</span> <span class="pp-cover-title">${esc(p.genre.toUpperCase())}</span>
<span class="pp-cover-inst">${esc(ppLabel(inst))} passport</span> <span class="pp-cover-inst">${esc(ppLabel(inst))} passport</span>
${stamp} ${stamp}
<span class="pp-cover-sub">${stubs}</span> <span class="pp-cover-sub">${stubs}${hours ? ` · ${hours}` : ''}</span>
</button>`; </button>`;
} }
@@ -568,6 +578,9 @@
</div> </div>
<div class="pp-invite">${need === 1 ? `One more ${starGl} song mints this stamp.` : `${need} more ${starGl} songs mint this stamp.`}</div>`; <div class="pp-invite">${need === 1 ? `One more ${starGl} song mints this stamp.` : `${need} more ${starGl} songs mint this stamp.`}</div>`;
} }
const hours = fmtHours(p.seconds_total);
const odometer = hours
? `<div class="pp-hours">${hours} in ${esc(p.genre)}</div>` : '';
let drills = ''; let drills = '';
const reqNodes = (p.drills || {}).required || []; const reqNodes = (p.drills || {}).required || [];
if (reqNodes.length) { if (reqNodes.length) {
@@ -589,7 +602,7 @@
<div class="pp-book"> <div class="pp-book">
<div class="pp-page pp-page-left"> <div class="pp-page pp-page-left">
<div class="pp-page-head">${esc(p.genre)} ${esc(ppLabel(inst))}</div> <div class="pp-page-head">${esc(p.genre)} ${esc(ppLabel(inst))}</div>
${badgeArea}${drills} ${badgeArea}${odometer}${drills}
</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>
@@ -792,6 +805,7 @@
// the badge-diff logic; nothing here touches the DOM. // the badge-diff logic; nothing here touches the DOM.
window.__careerPassportTest = { window.__careerPassportTest = {
ppKey, ppJitter, ppLabel, detectNewBadges, seenBadges, markBadgeSeen, ppKey, ppJitter, ppLabel, detectNewBadges, seenBadges, markBadgeSeen,
fmtHours,
}; };
if (document.readyState === 'loading') { if (document.readyState === 'loading') {
+12
View File
@@ -126,3 +126,15 @@ test('seenBadges tolerates corrupt stored values', () => {
assert.equal(w.notifications.length, 1, `stored ${bad}`); assert.equal(w.notifications.length, 1, `stored ${bad}`);
} }
}); });
test('fmtHours: silent under a minute, minutes under an hour, tenths after', () => {
const { fmtHours } = load().__careerPassportTest;
assert.equal(fmtHours(0), '');
assert.equal(fmtHours(59), '');
assert.equal(fmtHours(60), '1 min');
assert.equal(fmtHours(1800), '30 min');
assert.equal(fmtHours(3600), '1 h');
assert.equal(fmtHours(51120), '14.2 h');
assert.equal(fmtHours(null), '');
assert.equal(fmtHours('junk'), '');
});
+77 -3
View File
@@ -27,7 +27,60 @@
let cur = null; // active session let cur = null; // active session
let recordedThisSession = false; let recordedThisSession = false;
// Wall-clock play time (career hours odometer). Accrued across
// play/resume ↔ pause/stop/ended spans — wall time, NOT song position:
// position deltas double-count A-B loops and mis-read seeks.
let playingSince = 0; // performance.now() at span start, 0 while not playing
let accruedSeconds = 0; // played time not yet sent
// Failed seconds keep their song identity — restoring them into the
// global accumulator would let the NEXT song claim them after a session
// switch. Bounded; oldest dropped beyond the cap (honest loss beats
// misattribution).
let pendingSeconds = []; // [{filename, arrangement, seconds}] awaiting retry
function queuePendingSeconds(filename, arrangement, seconds) {
pendingSeconds.push({ filename, arrangement, seconds });
if (pendingSeconds.length > 20) pendingSeconds.shift();
}
function retryPendingSeconds() {
if (!pendingSeconds.length) return;
const batch = pendingSeconds;
pendingSeconds = [];
for (const body of batch) {
post(body).then((r) => { if (r == null) queuePendingSeconds(body.filename, body.arrangement, body.seconds); });
}
}
function clockStart() { if (!playingSince) playingSince = performance.now(); }
function clockStop() {
if (!playingSince) return;
const delta = (performance.now() - playingSince) / 1000;
playingSince = 0;
// A single unbroken span beyond 2h of wall clock is a suspend/sleep
// artifact, not practice — clamp it.
if (Number.isFinite(delta) && delta > 0) accruedSeconds += Math.min(delta, 7200);
}
// Take whatever has accrued (closing any open span) for sending; the
// caller restores it if the POST fails so the time isn't lost.
function takeSeconds() {
clockStop();
const s = Math.round(accruedSeconds);
accruedSeconds = 0;
return s > 0 ? s : 0;
}
// Unsent seconds belong to the outgoing song/arrangement — flush before
// a session reset would re-attribute them.
function flushSeconds() {
const s = takeSeconds();
if (!s) return;
if (!cur || !cur.filename) return; // no session to attribute to — drop
const body = { filename: cur.filename, arrangement: cur.arrangement, seconds: s };
post(body).then((r) => { if (r == null) queuePendingSeconds(body.filename, body.arrangement, s); });
}
function reset(filename, arrangement) { function reset(filename, arrangement) {
flushSeconds();
cur = { cur = {
filename: filename || null, filename: filename || null,
arrangement: Number.isFinite(arrangement) ? arrangement : 0, arrangement: Number.isFinite(arrangement) ? arrangement : 0,
@@ -84,6 +137,7 @@
if (!cur || !cur.filename || recordedThisSession) return; if (!cur || !cur.filename || recordedThisSession) return;
if (!cur.scored || (cur.hits + cur.misses) <= 0) return; // no real scoring this session if (!cur.scored || (cur.hits + cur.misses) <= 0) return; // no real scoring this session
recordedThisSession = true; recordedThisSession = true;
const seconds = takeSeconds();
const body = { const body = {
filename: cur.filename, filename: cur.filename,
arrangement: cur.arrangement, arrangement: cur.arrangement,
@@ -94,7 +148,9 @@
bestStreak: cur.bestStreak, bestStreak: cur.bestStreak,
lastPlayPosition: Number.isFinite(position) ? position : cur.lastTime, lastPlayPosition: Number.isFinite(position) ? position : cur.lastTime,
}; };
if (seconds) body.seconds = seconds;
post(body).then(async (response) => { post(body).then(async (response) => {
if (response == null && seconds) queuePendingSeconds(body.filename, body.arrangement, seconds);
await notifyProgression(response, body, !!natural); await notifyProgression(response, body, !!natural);
// Refresh the profile badge AFTER the progression state moved so // Refresh the profile badge AFTER the progression state moved so
// the rank/dB it renders are post-award values. // the rank/dB it renders are post-award values.
@@ -112,7 +168,10 @@
// Allow 0: restarting a song and stopping at the very beginning must be // Allow 0: restarting a song and stopping at the very beginning must be
// able to clear a stale Continue offset. Only negatives are invalid. // able to clear a stale Continue offset. Only negatives are invalid.
if (!Number.isFinite(position) || position < 0) return; if (!Number.isFinite(position) || position < 0) return;
post({ filename: cur.filename, arrangement: cur.arrangement, lastPlayPosition: position }); const seconds = takeSeconds();
const body = { filename: cur.filename, arrangement: cur.arrangement, lastPlayPosition: position };
if (seconds) body.seconds = seconds;
post(body).then((r) => { if (r == null && seconds) queuePendingSeconds(body.filename, body.arrangement, seconds); });
} }
// ── Session lifecycle ─────────────────────────────────────────────────-- // ── Session lifecycle ─────────────────────────────────────────────────--
@@ -164,13 +223,28 @@
}); });
}); });
// ── Play-time clock ───────────────────────────────────────────────────--
sm.on('song:play', () => { clockStart(); retryPendingSeconds(); });
sm.on('song:resume', clockStart);
// ── Finalize / resume-position ────────────────────────────────────────-- // ── Finalize / resume-position ────────────────────────────────────────--
sm.on('song:ended', (e) => finalizeScored(e && e.detail && e.detail.time, true)); sm.on('song:ended', (e) => {
sm.on('song:pause', (e) => touchPosition(e && e.detail && e.detail.time)); clockStop();
finalizeScored(e && e.detail && e.detail.time, true);
// Unscored natural end: no finalize POST and no position touch
// (Continue must not point at the end of the song) — bank the play
// time on its own.
flushSeconds();
});
sm.on('song:pause', (e) => {
clockStop();
touchPosition(e && e.detail && e.detail.time);
});
sm.on('song:stop', (e) => { sm.on('song:stop', (e) => {
// Record the scored session if it wasn't already (e.g. user closed the // Record the scored session if it wasn't already (e.g. user closed the
// player before the track ended), then persist the resume position. // player before the track ended), then persist the resume position.
// Not a natural end — no calibration-retry prompt for deliberate quits. // Not a natural end — no calibration-retry prompt for deliberate quits.
clockStop();
const t = e && e.detail && e.detail.time; const t = e && e.detail && e.detail.time;
finalizeScored(t, false); finalizeScored(t, false);
touchPosition(t); touchPosition(t);
+6 -4
View File
@@ -26,7 +26,8 @@ class FakeMetaDb:
self.conn.execute( self.conn.execute(
"""CREATE TABLE song_stats ( """CREATE TABLE song_stats (
filename TEXT, arrangement TEXT, best_accuracy REAL, filename TEXT, arrangement TEXT, best_accuracy REAL,
last_played_at TEXT last_played_at TEXT,
seconds_total REAL NOT NULL DEFAULT 0
)""" )"""
) )
self.conn.execute( self.conn.execute(
@@ -37,9 +38,10 @@ class FakeMetaDb:
) )
def add(self, filename, arrangement, best_accuracy, in_library=True, def add(self, filename, arrangement, best_accuracy, in_library=True,
genre="", arrangements=None, last_played_at=None): genre="", arrangements=None, last_played_at=None, seconds_total=0):
self.conn.execute("INSERT INTO song_stats VALUES (?, ?, ?, ?)", self.conn.execute("INSERT INTO song_stats VALUES (?, ?, ?, ?, ?)",
(filename, arrangement, best_accuracy, last_played_at)) (filename, arrangement, best_accuracy, last_played_at,
seconds_total))
if in_library: if in_library:
self.conn.execute( self.conn.execute(
"INSERT INTO songs SELECT ?, ?, ?, ?, ? WHERE NOT EXISTS " "INSERT INTO songs SELECT ?, ?, ?, ?, ? WHERE NOT EXISTS "
+12
View File
@@ -143,3 +143,15 @@ def test_drill_state_validation(client):
huge = {"byNode": {"pad": "x" * (300 * 1024)}} huge = {"byNode": {"pad": "x" * (300 * 1024)}}
assert client.post("/api/plugins/career/drill-state", assert client.post("/api/plugins/career/drill-state",
json=huge).status_code == 413 json=huge).status_code == 413
def test_hours_odometer_sums_seconds_per_instrument_and_genre(client, meta_db):
both = [{"type": "lead", "name": "Lead"}, {"type": "bass", "name": "Bass"}]
# Two lead arrangements' time sums; the bass row stays on the bass passport.
meta_db.add("a.feedpak", 0, 0.8, genre="Blues", arrangements=both, seconds_total=600)
meta_db.add("b.feedpak", 0, 0.8, genre="Blues", arrangements=both, seconds_total=300)
meta_db.add("b.feedpak", 1, 0.9, genre="Blues", arrangements=both, seconds_total=1200)
_open(client, "guitar")
_open(client, "bass")
assert _passport(client, "guitar")["seconds_total"] == 900
assert _passport(client, "bass")["seconds_total"] == 1200
+49
View File
@@ -452,3 +452,52 @@ def test_award_xp_negative_reversal_clamps_at_zero(server):
db.award_xp(50, "minigames") db.award_xp(50, "minigames")
assert db.award_xp(-50, "minigames") == 0 # exact reversal assert db.award_xp(-50, "minigames") == 0 # exact reversal
assert db.award_xp(-999, "minigames") == 0 # over-reverse clamps at 0 assert db.award_xp(-999, "minigames") == 0 # over-reverse clamps at 0
# ── Wall-clock play-time accrual (career hours odometer) ─────────────────────
def test_seconds_accrue_on_scored_and_position_posts(client):
r = client.post("/api/stats", json={"filename": "s.archive", "score": 400,
"accuracy": 0.6, "seconds": 120})
assert r.status_code == 200
assert r.json()["stats"]["seconds_total"] == pytest.approx(120)
# Position-only touch accrues too.
r2 = client.post("/api/stats", json={"filename": "s.archive",
"lastPlayPosition": 12.5, "seconds": 30})
assert r2.json()["stats"]["seconds_total"] == pytest.approx(150)
# A POST without seconds leaves the total alone.
r3 = client.post("/api/stats", json={"filename": "s.archive", "lastPlayPosition": 20.0})
assert r3.json()["stats"]["seconds_total"] == pytest.approx(150)
def test_seconds_only_post_accrues_without_touching_position(client):
client.post("/api/stats", json={"filename": "s.archive", "lastPlayPosition": 42.0})
r = client.post("/api/stats", json={"filename": "s.archive", "seconds": 90})
assert r.status_code == 200
row = r.json()["stats"]
assert row["seconds_total"] == pytest.approx(90)
# No plays counted, resume position untouched (song:ended must not
# overwrite Continue with the end-of-song offset).
assert row["plays"] == 0
assert row["last_position"] == pytest.approx(42.0)
# Still counts as playing today for the streak.
assert r.json()["progress"]["current_streak"] == 1
@pytest.mark.parametrize("bad", [True, "soon", -5, 0, 6 * 3600 + 1])
def test_seconds_validation_rejects_junk(client, bad):
r = client.post("/api/stats", json={"filename": "s.archive",
"lastPlayPosition": 1.0, "seconds": bad})
assert r.status_code == 400
@pytest.mark.parametrize("token", ["NaN", "Infinity"])
def test_seconds_validation_rejects_nonfinite(client, token):
# json= cannot serialize non-finite floats; python's json.loads (and thus
# the server's body parse) accepts the bare tokens, so send raw.
r = client.post(
"/api/stats",
content=f'{{"filename": "s.archive", "lastPlayPosition": 1.0, "seconds": {token}}}',
headers={"Content-Type": "application/json"},
)
assert r.status_code == 400