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

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:
Byron Gamatos
2026-07-13 15:12:23 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent d26347981c
commit 0fc6a4beed
11 changed files with 289 additions and 31 deletions
+12
View File
@@ -126,3 +126,15 @@ test('seenBadges tolerates corrupt stored values', () => {
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'), '');
});