fix(v3): floor accuracy percentages so 100% means all notes hit

Math.round let 431/433 (99.54%) display as 100%. Floor at every
accuracy display site (HUD, library badges, dashboard, lessons,
profile, playlists, calibration overlay); stored fractions and
mastery thresholds unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
OmikronApex
2026-07-13 00:35:09 +02:00
co-authored by Claude Fable 5
parent e2215df753
commit 81ef11d855
8 changed files with 13 additions and 9 deletions
+2 -1
View File
@@ -33,7 +33,8 @@
// Accuracy badge ramp (design/04-badges.md §C): ≥90% good, 5089% mid, <50% low. // Accuracy badge ramp (design/04-badges.md §C): ≥90% good, 5089% mid, <50% low.
function accuracyBadge(acc) { function accuracyBadge(acc) {
if (acc == null) return ''; if (acc == null) return '';
const pct = Math.round(acc * 100); // Floor, never round: 100% must mean every note hit.
const pct = Math.floor(acc * 100);
const color = acc >= 0.9 ? 'bg-fb-good' : (acc >= 0.5 ? 'bg-fb-mid' : 'bg-fb-low'); const color = acc >= 0.9 ? 'bg-fb-good' : (acc >= 0.5 ? 'bg-fb-mid' : 'bg-fb-low');
const text = acc >= 0.5 && acc < 0.9 ? 'text-black' : 'text-white'; const text = acc >= 0.5 && acc < 0.9 ? 'text-black' : 'text-white';
return '<span class="absolute bottom-0 right-0 ' + color + '/90 ' + text + return '<span class="absolute bottom-0 right-0 ' + color + '/90 ' + text +
+1 -1
View File
@@ -74,7 +74,7 @@
if (st && st.passed) return '<span class="text-fb-good text-xs font-bold flex items-center gap-1">✓ Passed</span>'; if (st && st.passed) return '<span class="text-fb-good text-xs font-bold flex items-center gap-1">✓ Passed</span>';
if (st && st.best_accuracy != null && st.best_accuracy > 0) { if (st && st.best_accuracy != null && st.best_accuracy > 0) {
const acc = st.best_accuracy; const acc = st.best_accuracy;
const pct = Math.round(acc * 100); const pct = Math.floor(acc * 100);
const color = acc >= 0.9 ? 'text-fb-good' : (acc >= 0.5 ? 'text-fb-mid' : 'text-fb-low'); const color = acc >= 0.9 ? 'text-fb-good' : (acc >= 0.5 ? 'text-fb-mid' : 'text-fb-low');
return '<span class="' + color + ' text-xs font-bold">' + pct + '%</span>'; return '<span class="' + color + ' text-xs font-bold">' + pct + '%</span>';
} }
+2 -1
View File
@@ -21,7 +21,8 @@
function accuracyPct(hits, misses) { function accuracyPct(hits, misses) {
const judged = hits + misses; const judged = hits + misses;
if (judged <= 0) return null; if (judged <= 0) return null;
return Math.round((hits / Math.max(1, judged)) * 100); // Floor, never round: 100% must mean every judged note was hit.
return Math.floor((hits / Math.max(1, judged)) * 100);
} }
function calculateLivePerformanceState({ hits = 0, misses = 0, streak = 0, bestStreak = 0 } = {}) { function calculateLivePerformanceState({ hits = 0, misses = 0, streak = 0, bestStreak = 0 } = {}) {
+1 -1
View File
@@ -73,7 +73,7 @@
? ' data-play-fn="' + esc(playFn) + '"' + (arrIdx != null ? ' data-play-arr="' + arrIdx + '"' : '') ? ' data-play-fn="' + esc(playFn) + '"' + (arrIdx != null ? ' data-play-arr="' + arrIdx + '"' : '')
: ''; : '';
const acc = (isAlbum && typeof opts.acc === 'number') const acc = (isAlbum && typeof opts.acc === 'number')
? '<span class="text-xs font-bold shrink-0 ' + (opts.acc >= 0.9 ? 'text-fb-good' : opts.acc >= 0.5 ? 'text-fb-mid' : 'text-fb-low') + '">' + Math.round(opts.acc * 100) + '%</span>' ? '<span class="text-xs font-bold shrink-0 ' + (opts.acc >= 0.9 ? 'text-fb-good' : opts.acc >= 0.5 ? 'text-fb-mid' : 'text-fb-low') + '">' + Math.floor(opts.acc * 100) + '%</span>'
: ''; : '';
const pin = (isAlbum && s.arrangement) const pin = (isAlbum && s.arrangement)
? '<span class="ml-2 text-[0.625rem] bg-fb-primary/20 text-fb-primary font-bold px-1.5 py-0.5 rounded-sm" title="Pinned arrangement">' + esc(s.arrangement) + '</span>' : ''; ? '<span class="ml-2 text-[0.625rem] bg-fb-primary/20 text-fb-primary font-bold px-1.5 py-0.5 rounded-sm" title="Pinned arrangement">' + esc(s.arrangement) + '</span>' : '';
+1 -1
View File
@@ -232,7 +232,7 @@
host.innerHTML = host.innerHTML =
'<ol class="space-y-2">' + rows.map((s, i) => { '<ol class="space-y-2">' + rows.map((s, i) => {
const acc = Number(s.best_accuracy) || 0; const acc = Number(s.best_accuracy) || 0;
const pct = Math.round(acc * 100); const pct = Math.floor(acc * 100);
const score = Number(s.best_score) || 0; const score = Number(s.best_score) || 0;
return '<li data-fn="' + esc(s.filename) + '" class="flex items-center gap-3 cursor-pointer rounded-md px-2 py-1.5 hover:bg-fb-card transition">' + return '<li data-fn="' + esc(s.filename) + '" class="flex items-center gap-3 cursor-pointer rounded-md px-2 py-1.5 hover:bg-fb-card transition">' +
'<span class="w-5 text-center text-fb-textDim font-semibold shrink-0">' + (i + 1) + '</span>' + '<span class="w-5 text-center text-fb-textDim font-semibold shrink-0">' + (i + 1) + '</span>' +
+1 -1
View File
@@ -265,7 +265,7 @@
const onboarding = st.onboarding || {}; const onboarding = st.onboarding || {};
if (onboarding.calibration_status === 'completed') return; // raced a 100% run if (onboarding.calibration_status === 'completed') return; // raced a 100% run
const pending = onboarding.calibration_status === 'pending'; const pending = onboarding.calibration_status === 'pending';
const pct = Math.max(0, Math.min(100, Math.round((detail.accuracy || 0) * 100))); const pct = Math.max(0, Math.min(100, Math.floor((detail.accuracy || 0) * 100)));
const overlay = document.createElement('div'); const overlay = document.createElement('div');
overlay.id = 'v3-calibration-retry'; overlay.id = 'v3-calibration-retry';
+3 -2
View File
@@ -482,7 +482,8 @@
function accuracyBadge(filename, variant) { function accuracyBadge(filename, variant) {
const acc = state.accuracy[filename]; const acc = state.accuracy[filename];
if (acc == null) return ''; if (acc == null) return '';
const pct = Math.round(acc * 100); // Floor, never round: 100% must mean every note hit.
const pct = Math.floor(acc * 100);
if (variant === 'tree') { if (variant === 'tree') {
const color = acc >= MASTERY_ACCURACY ? 'text-fb-good' : acc >= 0.5 ? 'text-fb-mid' : 'text-fb-low'; const color = acc >= MASTERY_ACCURACY ? 'text-fb-good' : acc >= 0.5 ? 'text-fb-mid' : 'text-fb-low';
return '<span class="fb-acc-badge text-xs font-bold ' + color + '">' + pct + '%</span>'; return '<span class="fb-acc-badge text-xs font-bold ' + color + '">' + pct + '%</span>';
@@ -1312,7 +1313,7 @@
c.year ? String(c.year) : ''] c.year ? String(c.year) : '']
.filter(Boolean).join(' · '); .filter(Boolean).join(' · ');
const acc = (typeof c.best_accuracy === 'number') const acc = (typeof c.best_accuracy === 'number')
? '<span class="font-bold ' + (c.best_accuracy >= MASTERY_ACCURACY ? 'text-fb-good' : c.best_accuracy >= 0.5 ? 'text-fb-mid' : 'text-fb-low') + '">' + Math.round(c.best_accuracy * 100) + '%</span>' ? '<span class="font-bold ' + (c.best_accuracy >= MASTERY_ACCURACY ? 'text-fb-good' : c.best_accuracy >= 0.5 ? 'text-fb-mid' : 'text-fb-low') + '">' + Math.floor(c.best_accuracy * 100) + '%</span>'
: '<span class="text-fb-textDim/60">not played</span>'; : '<span class="text-fb-textDim/60">not played</span>';
return '<div role="radio" aria-checked="' + (checked ? 'true' : 'false') + '" tabindex="0" data-ch="' + esc(c.filename) + '"' + return '<div role="radio" aria-checked="' + (checked ? 'true' : 'false') + '" tabindex="0" data-ch="' + esc(c.filename) + '"' +
' title="' + (checked ? esc(prefLabel) : 'Make this the preferred chart') + '"' + ' title="' + (checked ? esc(prefLabel) : 'Make this the preferred chart') + '"' +
+2 -1
View File
@@ -170,7 +170,8 @@ test('DOM text updates after hit and miss events', () => {
runtime.onHit(); runtime.onHit();
runtime.onMiss(); runtime.onMiss();
assert.equal(els.percent.textContent, '67%'); // Floored, not rounded: 100% must mean every judged note was hit.
assert.equal(els.percent.textContent, '66%');
assert.equal(els.hits.textContent, 'Hits 2 / 3'); assert.equal(els.hits.textContent, 'Hits 2 / 3');
assert.equal(els.streak.textContent, 'Streak 0'); assert.equal(els.streak.textContent, 'Streak 0');
assert.match(els.state.textContent, /Recovering/); assert.match(els.state.textContent, /Recovering/);