mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-09-11 01:14:30 +00:00
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:
co-authored by
Claude Fable 5
parent
d26347981c
commit
13e68c2f83
@@ -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
|
||||
chime + notification only. The stamp still slams into the passport book on
|
||||
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.
|
||||
New career-plugin endpoints: `GET /api/plugins/career/passports` (per-instrument
|
||||
passport walls: genre badges computed on read from `song_stats` × the library's
|
||||
|
||||
+46
-11
@@ -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)")
|
||||
# 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.
|
||||
self.conn.execute("""
|
||||
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),
|
||||
"last_score": newer["last_score"], "last_accuracy": newer["last_accuracy"],
|
||||
"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"],
|
||||
}
|
||||
# Atomic swap: clear and reinsert the canonicalized set in one txn.
|
||||
@@ -1693,7 +1704,8 @@ class MetadataDB:
|
||||
# ── Per-song practice stats ───────────────────────────────────────────---
|
||||
_STATS_COLS = (
|
||||
"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:
|
||||
@@ -2060,8 +2072,9 @@ class MetadataDB:
|
||||
self.conn.commit()
|
||||
|
||||
def record_session(self, filename: str, arrangement: int, *, score: int,
|
||||
accuracy: float, last_position=None) -> dict:
|
||||
"""Record a scored play: plays += 1, best_* = max, last_* = new."""
|
||||
accuracy: float, last_position=None, seconds: float = 0) -> dict:
|
||||
"""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
|
||||
with self._lock:
|
||||
existing = self._stats_row(filename, int(arrangement))
|
||||
@@ -2071,8 +2084,9 @@ class MetadataDB:
|
||||
self.conn.execute(
|
||||
"""INSERT INTO song_stats
|
||||
(filename, arrangement, plays, best_score, best_accuracy,
|
||||
last_score, last_accuracy, last_position, last_played_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?,
|
||||
last_score, last_accuracy, last_position, seconds_total,
|
||||
last_played_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?,
|
||||
strftime('%Y-%m-%d %H:%M:%f','now'), strftime('%Y-%m-%d %H:%M:%f','now'))
|
||||
ON CONFLICT(filename, arrangement) DO UPDATE SET
|
||||
plays = excluded.plays,
|
||||
@@ -2081,32 +2095,53 @@ class MetadataDB:
|
||||
last_score = excluded.last_score,
|
||||
last_accuracy = excluded.last_accuracy,
|
||||
last_position = excluded.last_position,
|
||||
seconds_total = song_stats.seconds_total + excluded.seconds_total,
|
||||
last_played_at = excluded.last_played_at,
|
||||
updated_at = excluded.updated_at""",
|
||||
(filename, int(arrangement), merged["plays"], merged["best_score"],
|
||||
merged["best_accuracy"], merged["last_score"], merged["last_accuracy"],
|
||||
merged["last_position"]),
|
||||
merged["last_position"], float(seconds or 0)),
|
||||
)
|
||||
self.conn.commit()
|
||||
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
|
||||
Continue-Playing works for non-scored plays. Also stamps
|
||||
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
|
||||
never surfaces as 'recent' / 'continue playing'."""
|
||||
never surfaces as 'recent' / 'continue playing'. `seconds` accrues
|
||||
wall-clock play time (career hours odometer)."""
|
||||
with self._lock:
|
||||
self.conn.execute(
|
||||
"""INSERT INTO song_stats (filename, arrangement, last_position,
|
||||
last_played_at, updated_at)
|
||||
VALUES (?, ?, ?, strftime('%Y-%m-%d %H:%M:%f','now'),
|
||||
seconds_total, last_played_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, strftime('%Y-%m-%d %H:%M:%f','now'),
|
||||
strftime('%Y-%m-%d %H:%M:%f','now'))
|
||||
ON CONFLICT(filename, arrangement) DO UPDATE SET
|
||||
last_position = excluded.last_position,
|
||||
seconds_total = song_stats.seconds_total + excluded.seconds_total,
|
||||
last_played_at = excluded.last_played_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()
|
||||
return self._stats_row(filename, int(arrangement))
|
||||
|
||||
+34
-2
@@ -76,6 +76,22 @@ def api_record_stats(data: dict):
|
||||
last_pos = data.get("lastPlayPosition", data.get("last_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)
|
||||
# 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
|
||||
# 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):
|
||||
return JSONResponse({"error": "lastPlayPosition must be a finite number"}, status_code=400)
|
||||
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.
|
||||
progress = None
|
||||
try:
|
||||
@@ -152,6 +169,21 @@ def api_record_stats(data: dict):
|
||||
log.warning("stats side-effects (progression) failed", exc_info=True)
|
||||
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.
|
||||
if last_pos is None:
|
||||
return JSONResponse(
|
||||
@@ -162,7 +194,7 @@ def api_record_stats(data: dict):
|
||||
pos = float(last_pos)
|
||||
if not math.isfinite(pos):
|
||||
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):
|
||||
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 —
|
||||
|
||||
@@ -466,3 +466,12 @@
|
||||
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); }
|
||||
|
||||
/* 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;
|
||||
}
|
||||
|
||||
@@ -203,21 +203,24 @@ def _instrument_of(arrangements, arrangement):
|
||||
|
||||
|
||||
def _played_by_instrument_genre():
|
||||
"""(instrument, genre_key) → {filename: stub dict}. Best accuracy per
|
||||
(instrument, song); the JOIN keeps the same dead-song filter as _stars()."""
|
||||
"""((instrument, genre_key) → {filename: stub dict},
|
||||
(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"]
|
||||
if db is None:
|
||||
return {}
|
||||
return {}, {}
|
||||
thresholds = _state["content"]["star_accuracy_thresholds"]
|
||||
rows = db.conn.execute(
|
||||
"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)} "
|
||||
"FROM song_stats s JOIN songs ON songs.filename = s.filename"
|
||||
).fetchall()
|
||||
arrs_cache = {}
|
||||
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)
|
||||
if not gkey:
|
||||
continue
|
||||
@@ -227,10 +230,12 @@ def _played_by_instrument_genre():
|
||||
except (TypeError, ValueError):
|
||||
arrs_cache[filename] = None
|
||||
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
|
||||
stub = out.setdefault((instrument, gkey), {}).get(filename)
|
||||
stub = out.setdefault(key, {}).get(filename)
|
||||
if stub is None:
|
||||
out[(instrument, gkey)][filename] = {
|
||||
out[key][filename] = {
|
||||
"filename": filename,
|
||||
"title": title or filename,
|
||||
"artist": artist or "",
|
||||
@@ -245,7 +250,7 @@ def _played_by_instrument_genre():
|
||||
acc = stub["best_accuracy"]
|
||||
stub["best_accuracy"] = round(acc, 4)
|
||||
stub["stars"] = sum(1 for t in thresholds if acc >= t)
|
||||
return out
|
||||
return out, seconds
|
||||
|
||||
|
||||
def _library_genres():
|
||||
@@ -307,7 +312,7 @@ def _passports_view():
|
||||
cfg = _state["passports_content"]
|
||||
graded = set(cfg.get("graded_instruments") or [])
|
||||
st = _career_state()
|
||||
played = _played_by_instrument_genre()
|
||||
played, played_seconds = _played_by_instrument_genre()
|
||||
received_at, by_node = _drill_by_node()
|
||||
instruments = {}
|
||||
for inst in cfg.get("instruments") or []:
|
||||
@@ -345,6 +350,9 @@ def _passports_view():
|
||||
"graded": is_graded,
|
||||
"songs": songs,
|
||||
"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},
|
||||
"badge": badge,
|
||||
})
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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'), '');
|
||||
});
|
||||
|
||||
@@ -27,7 +27,60 @@
|
||||
let cur = null; // active session
|
||||
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) {
|
||||
flushSeconds();
|
||||
cur = {
|
||||
filename: filename || null,
|
||||
arrangement: Number.isFinite(arrangement) ? arrangement : 0,
|
||||
@@ -84,6 +137,7 @@
|
||||
if (!cur || !cur.filename || recordedThisSession) return;
|
||||
if (!cur.scored || (cur.hits + cur.misses) <= 0) return; // no real scoring this session
|
||||
recordedThisSession = true;
|
||||
const seconds = takeSeconds();
|
||||
const body = {
|
||||
filename: cur.filename,
|
||||
arrangement: cur.arrangement,
|
||||
@@ -94,7 +148,9 @@
|
||||
bestStreak: cur.bestStreak,
|
||||
lastPlayPosition: Number.isFinite(position) ? position : cur.lastTime,
|
||||
};
|
||||
if (seconds) body.seconds = seconds;
|
||||
post(body).then(async (response) => {
|
||||
if (response == null && seconds) queuePendingSeconds(body.filename, body.arrangement, seconds);
|
||||
await notifyProgression(response, body, !!natural);
|
||||
// Refresh the profile badge AFTER the progression state moved so
|
||||
// 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
|
||||
// able to clear a stale Continue offset. Only negatives are invalid.
|
||||
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 ─────────────────────────────────────────────────--
|
||||
@@ -164,13 +223,28 @@
|
||||
});
|
||||
});
|
||||
|
||||
// ── Play-time clock ───────────────────────────────────────────────────--
|
||||
sm.on('song:play', () => { clockStart(); retryPendingSeconds(); });
|
||||
sm.on('song:resume', clockStart);
|
||||
|
||||
// ── Finalize / resume-position ────────────────────────────────────────--
|
||||
sm.on('song:ended', (e) => finalizeScored(e && e.detail && e.detail.time, true));
|
||||
sm.on('song:pause', (e) => touchPosition(e && e.detail && e.detail.time));
|
||||
sm.on('song:ended', (e) => {
|
||||
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) => {
|
||||
// Record the scored session if it wasn't already (e.g. user closed the
|
||||
// player before the track ended), then persist the resume position.
|
||||
// Not a natural end — no calibration-retry prompt for deliberate quits.
|
||||
clockStop();
|
||||
const t = e && e.detail && e.detail.time;
|
||||
finalizeScored(t, false);
|
||||
touchPosition(t);
|
||||
|
||||
@@ -26,7 +26,8 @@ class FakeMetaDb:
|
||||
self.conn.execute(
|
||||
"""CREATE TABLE song_stats (
|
||||
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(
|
||||
@@ -37,9 +38,10 @@ class FakeMetaDb:
|
||||
)
|
||||
|
||||
def add(self, filename, arrangement, best_accuracy, in_library=True,
|
||||
genre="", arrangements=None, last_played_at=None):
|
||||
self.conn.execute("INSERT INTO song_stats VALUES (?, ?, ?, ?)",
|
||||
(filename, arrangement, best_accuracy, last_played_at))
|
||||
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))
|
||||
if in_library:
|
||||
self.conn.execute(
|
||||
"INSERT INTO songs SELECT ?, ?, ?, ?, ? WHERE NOT EXISTS "
|
||||
|
||||
@@ -143,3 +143,15 @@ def test_drill_state_validation(client):
|
||||
huge = {"byNode": {"pad": "x" * (300 * 1024)}}
|
||||
assert client.post("/api/plugins/career/drill-state",
|
||||
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
|
||||
|
||||
@@ -452,3 +452,52 @@ def test_award_xp_negative_reversal_clamps_at_zero(server):
|
||||
db.award_xp(50, "minigames")
|
||||
assert db.award_xp(-50, "minigames") == 0 # exact reversal
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user