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
+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) {
const rot = ppJitter(inst + p.genre_key, 1.6).toFixed(2);
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>`
: '';
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)">
<span class="pp-cover-title">${esc(p.genre.toUpperCase())}</span>
<span class="pp-cover-inst">${esc(ppLabel(inst))} passport</span>
${stamp}
<span class="pp-cover-sub">${stubs}</span>
<span class="pp-cover-sub">${stubs}${hours ? ` · ${hours}` : ''}</span>
</button>`;
}
@@ -568,6 +578,9 @@
</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 = '';
const reqNodes = (p.drills || {}).required || [];
if (reqNodes.length) {
@@ -589,7 +602,7 @@
<div class="pp-book">
<div class="pp-page pp-page-left">
<div class="pp-page-head">${esc(p.genre)}${esc(ppLabel(inst))}</div>
${badgeArea}${drills}
${badgeArea}${odometer}${drills}
</div>
<div class="pp-page pp-page-right">
<div class="pp-page-head">Ticket stubs</div>
@@ -792,6 +805,7 @@
// the badge-diff logic; nothing here touches the DOM.
window.__careerPassportTest = {
ppKey, ppJitter, ppLabel, detectNewBadges, seenBadges, markBadgeSeen,
fmtHours,
};
if (document.readyState === 'loading') {