mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-08-11 11:19:24 +00:00
feat(v3 library): practice-aware home — Repertoire meter + "Keep practicing" shelf (#635)
* feat(v3 library): practice-aware home — Repertoire meter + "Keep practicing" shelf The Songs page opened cold into a flat sorted grid. This adds a practice-aware front door on the unfiltered grid, built entirely from data already on hand (no new endpoints, no new stored state): - Repertoire meter — "Repertoire: N of M songs · K in progress" + a bar, counting songs at/above the same mastery threshold the green accuracy badge uses (>= 0.9 best accuracy) over the unfiltered library total. Reads state.accuracy (/api/stats/best, already loaded for the card badges) and the unfiltered /api/library/stats total. - "Keep practicing" shelf — a horizontal row of recently-played, not-yet- mastered songs (newest first, click to play). Reads /api/stats/recent. Both show ONLY on the grid view when not searching/filtering/selecting (the front-door context), refresh after a song is scored (applyScoreRefresh), and collapse on an empty library. Soft-gamification only: descriptive encouragement (goal-gradient / endowed-progress), never content-gating, decay, or nagging — the practice-accuracy "continue" rail a media server can't do. Frontend-only: static/v3/songs.js (renderLibraryHome / _repertoireCounts / libHomeVisible, wired through reload() + applyScoreRefresh), static/v3/v3.css. Came out of the library design charrette (UX + gamification lenses' top pick). Stacked on the A–Z rail branch (feat/v3-library-az-rail) since both touch static/v3/songs.js; merge that PR first (or retarget). Tests: tests/js/v3_keep_practicing.test.js (threshold, front-door gating, shelf filter, denominator, render/reload/score-refresh wiring, click-to-play). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QbexxfTt8q2tAn436MqGWF * fix(v3 library): correct practice-aware home for review P1/P2/P3 Addresses the PR #635 review findings (manual + Codex): P1 correctness - Gate the Repertoire meter + "Keep practicing" shelf to the LOCAL provider (libHomeVisible). They read local practice stats (state.accuracy / /api/stats/recent); on a remote provider they mixed a local mastered count with a remote song total (e.g. "85 of 80") and the shelf played local files while browsing a remote library. - Shelf now gates on the per-SONG best (state.accuracy[filename] = MAX across arrangements, what the green badge shows) and dedupes by filename, instead of the per-arrangement recents row — so a "keep practicing" card can no longer show a green "mastered" badge, and a song can't appear twice. P2 robustness - renderLibraryHome fetches /api/library/stats + /api/stats/recent together (Promise.all) and a _homeToken generation guard discards a stale render so a slow response can't repaint a home the grid already moved past. P3 polish - accuracyBadge references MASTERY_ACCURACY instead of a bare 0.9, so the badge and the meter/shelf can't drift from "the same mastery threshold". Tests updated (v3_keep_practicing.test.js): provider gating, per-song deduped shelf, Promise.all + token. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: byrongamatos <xasiklas@gmail.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
byrongamatos
parent
6a71577e05
commit
6a6efc793a
+131
-3
@@ -349,11 +349,11 @@
|
||||
if (acc == null) return '';
|
||||
const pct = Math.round(acc * 100);
|
||||
if (variant === 'tree') {
|
||||
const color = acc >= 0.9 ? '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>';
|
||||
}
|
||||
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 color = acc >= MASTERY_ACCURACY ? 'bg-fb-good' : (acc >= 0.5 ? 'bg-fb-mid' : 'bg-fb-low');
|
||||
const text = acc >= 0.5 && acc < MASTERY_ACCURACY ? 'text-black' : 'text-white';
|
||||
return '<span class="fb-acc-badge absolute bottom-0 right-0 ' + color + '/90 ' + text + ' px-2 py-0.5 rounded-tl-md text-xs font-bold flex items-center gap-1">' +
|
||||
'<svg class="w-3 h-3" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><circle cx="12" cy="12" r="4"/></svg>' + pct + '%</span>';
|
||||
}
|
||||
@@ -404,6 +404,126 @@
|
||||
const keys = Array.from(_dirtyScores);
|
||||
_dirtyScores.clear();
|
||||
keys.forEach(repaintAccuracy);
|
||||
// A new score shifts the repertoire meter + the keep-practicing shelf.
|
||||
renderLibraryHome();
|
||||
}
|
||||
|
||||
// ── Practice-aware library home (repertoire meter + "Keep practicing") ─────
|
||||
// Both read data we already have: state.accuracy (/api/stats/best =
|
||||
// {filename: best_accuracy}) and /api/stats/recent. A song is "in your
|
||||
// repertoire" at the same threshold the green accuracy badge uses (>= 0.9);
|
||||
// a started song below that is "in progress". This is descriptive
|
||||
// encouragement — it never gates content, decays, or nags (the goal-gradient
|
||||
// / endowed-progress idea, kept healthy).
|
||||
const MASTERY_ACCURACY = 0.9;
|
||||
|
||||
function _repertoireCounts() {
|
||||
let mastered = 0, learning = 0;
|
||||
for (const v of Object.values(state.accuracy || {})) {
|
||||
if (typeof v !== 'number') continue;
|
||||
if (v >= MASTERY_ACCURACY) mastered++; else learning++;
|
||||
}
|
||||
return { mastered, learning };
|
||||
}
|
||||
|
||||
// The home block is the unfiltered "front door": shown on the grid view when
|
||||
// the user isn't running a focused query (search / filter) or selecting.
|
||||
// Local provider only — the meter's mastered count and the shelf both read
|
||||
// local practice stats (state.accuracy / /api/stats/recent), so on a remote
|
||||
// provider they'd mix local numerators with a remote song total and play
|
||||
// local files while browsing a remote library. Hide it there.
|
||||
function libHomeVisible() {
|
||||
return state.view === 'grid' && state.provider === 'local'
|
||||
&& !state.selectMode && !state.q && activeFilterCount() === 0;
|
||||
}
|
||||
|
||||
let _homeToken = 0;
|
||||
async function renderLibraryHome() {
|
||||
const host = document.getElementById('v3-lib-home');
|
||||
if (!host) return;
|
||||
if (!libHomeVisible()) { host.classList.add('hidden'); return; }
|
||||
// A newer render (view/filter/score change) supersedes this one so a
|
||||
// slow response can't repaint a home the grid already moved past.
|
||||
const myToken = ++_homeToken;
|
||||
// Unfiltered library size for the meter denominator (the grid's
|
||||
// state.total tracks the active filter; the meter is library-wide) +
|
||||
// recently-played rows for the shelf, fetched together.
|
||||
const [stats, recent] = await Promise.all([
|
||||
jget('/api/library/stats?provider=' + enc(state.provider)),
|
||||
jget('/api/stats/recent?limit=24'),
|
||||
]);
|
||||
if (_homeToken !== myToken || !libHomeVisible()) { // changed mid-fetch
|
||||
if (_homeToken === myToken) host.classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
const total = (stats && (stats.total_songs ?? stats.total)) || 0;
|
||||
if (total <= 0) { host.classList.add('hidden'); return; } // empty library
|
||||
// Shelf = recently-played, not-yet-mastered songs, newest first. Mastery
|
||||
// is per-SONG (state.accuracy = MAX best across arrangements, what the
|
||||
// green badge shows) — recents are per-(song,arrangement), so dedupe by
|
||||
// filename and gate on the song's best, keeping the shelf and its badges
|
||||
// consistent (no green-badged "keep practicing" card, no dupes).
|
||||
const acc = state.accuracy || {};
|
||||
const seen = new Set();
|
||||
const shelf = (Array.isArray(recent) ? recent : [])
|
||||
.filter((r) => {
|
||||
if (!r || seen.has(r.filename)) return false;
|
||||
const best = acc[r.filename];
|
||||
if (typeof best !== 'number' || best >= MASTERY_ACCURACY) return false;
|
||||
seen.add(r.filename);
|
||||
return true;
|
||||
})
|
||||
.slice(0, 8);
|
||||
|
||||
const { mastered, learning } = _repertoireCounts();
|
||||
const pct = Math.max(0, Math.min(100, Math.round((mastered / total) * 100)));
|
||||
const meter =
|
||||
'<div class="v3-rep-meter">' +
|
||||
'<div class="flex items-baseline justify-between gap-3 mb-1">' +
|
||||
'<span class="text-sm font-semibold text-fb-text">Repertoire</span>' +
|
||||
'<span class="text-xs text-fb-textDim">' + mastered + ' of ' + total + ' song' + (total === 1 ? '' : 's') +
|
||||
(learning ? ' · ' + learning + ' in progress' : '') + '</span>' +
|
||||
'</div>' +
|
||||
'<div class="v3-rep-track"><div class="v3-rep-fill" style="width:' + pct + '%"></div></div>' +
|
||||
'</div>';
|
||||
|
||||
let shelfHtml = '';
|
||||
if (shelf.length) {
|
||||
const cards = shelf.map((r) =>
|
||||
'<button class="v3-kp-card group text-left" data-kp="' + esc(r.filename) + '" data-arr="' + esc(r.arrangement != null ? r.arrangement : '') + '" title="' + esc(r.title) + '">' +
|
||||
'<div class="relative aspect-square rounded-lg overflow-hidden bg-fb-card">' +
|
||||
'<img src="' + esc(r.art_url) + '" alt="" loading="lazy" decoding="async" class="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105" onerror="this.style.visibility=\'hidden\'">' +
|
||||
accuracyBadge(r.filename) +
|
||||
'</div>' +
|
||||
'<div class="mt-1 text-sm text-fb-text truncate">' + esc(r.title) + '</div>' +
|
||||
'<div class="text-xs text-fb-textDim truncate">' + esc(r.artist) + '</div>' +
|
||||
'</button>').join('');
|
||||
shelfHtml =
|
||||
'<section class="v3-kp-shelf mt-4">' +
|
||||
'<h3 class="text-sm font-semibold text-fb-text mb-2">Keep practicing</h3>' +
|
||||
'<div class="v3-kp-row">' + cards + '</div>' +
|
||||
'</section>';
|
||||
}
|
||||
|
||||
host.innerHTML = meter + shelfHtml;
|
||||
host.classList.remove('hidden');
|
||||
// Wire shelf cards → play (mirrors playCard's local path; recents are
|
||||
// always local-library rows, so no provider sync is needed).
|
||||
host.querySelectorAll('.v3-kp-card').forEach((btn) => btn.addEventListener('click', () => {
|
||||
const fn = btn.getAttribute('data-kp');
|
||||
const arr = btn.getAttribute('data-arr');
|
||||
if (!fn || !window.playSong) return;
|
||||
_saveLibraryScrollSnapshot();
|
||||
window.playSong(enc(fn), arr === '' ? undefined : Number(arr));
|
||||
}));
|
||||
}
|
||||
|
||||
// Toggle/refresh the home block on view/sort/filter/search changes.
|
||||
function updateLibraryHome() {
|
||||
const host = document.getElementById('v3-lib-home');
|
||||
if (!host) return;
|
||||
if (!libHomeVisible()) { host.classList.add('hidden'); return; }
|
||||
renderLibraryHome();
|
||||
}
|
||||
|
||||
// Source format of a song — prefer the server's `format` field, fall back
|
||||
@@ -1115,6 +1235,9 @@
|
||||
// Refresh the A–Z jump rail (shows only for the grid + alphabetical
|
||||
// sorts; hides itself otherwise). Independent of the grid load.
|
||||
refreshRail();
|
||||
// Refresh the practice-aware home (repertoire meter + keep-practicing
|
||||
// shelf); hides itself when searching/filtering/selecting or off-grid.
|
||||
updateLibraryHome();
|
||||
{ const _fc = document.getElementById('lib-folder-controls'); if (_fc) _fc.style.display = state.view === 'folder' ? 'flex' : 'none'; }
|
||||
if (state.view === 'folder') {
|
||||
_applyMainScrollTop(0);
|
||||
@@ -1176,6 +1299,11 @@
|
||||
'<button id="v3-songs-select" class="' + ctrl + (state.selectMode ? ' bg-fb-primary text-white' : '') + '">Select</button>' +
|
||||
'<button id="v3-songs-upload" class="' + ctrl + '">Upload</button>' +
|
||||
'</div></div></div>' +
|
||||
// Practice-aware library home: a repertoire progress meter + a
|
||||
// "Keep practicing" shelf of started-but-not-mastered songs. Shown
|
||||
// only on the grid view when not searching/filtering/selecting
|
||||
// (renderLibraryHome + updateLibraryHome). Empty/absent → collapses.
|
||||
'<div id="v3-lib-home" class="hidden mb-5"></div>' +
|
||||
'<div id="v3-songs-grid" class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6 gap-4"></div>' +
|
||||
'<div id="v3-songs-tree" class="hidden"></div>' +
|
||||
'<div id="lib-folder-controls" style="display:none"></div>' +
|
||||
|
||||
@@ -1189,3 +1189,33 @@ html.fb-immersive #v3-main > .screen.active {
|
||||
@media (max-height: 640px) {
|
||||
.v3-azrail-letter { font-size: .55rem; padding: 0 4px; }
|
||||
}
|
||||
|
||||
/* — Practice-aware library home: repertoire meter + "Keep practicing" shelf — */
|
||||
#v3-lib-home.hidden { display: none; }
|
||||
.v3-rep-meter { max-width: 30rem; }
|
||||
.v3-rep-track {
|
||||
height: 6px;
|
||||
border-radius: 999px;
|
||||
background: rgba(148, 163, 184, .22); /* fb-textDim @ low alpha */
|
||||
overflow: hidden;
|
||||
}
|
||||
.v3-rep-fill {
|
||||
height: 100%;
|
||||
border-radius: 999px;
|
||||
background: #0ea5e9; /* fb-primary */
|
||||
transition: width .4s ease;
|
||||
}
|
||||
/* Horizontal, scroll-snapping shelf of fixed-width cards. */
|
||||
.v3-kp-row {
|
||||
display: flex;
|
||||
gap: .75rem;
|
||||
overflow-x: auto;
|
||||
scroll-snap-type: x proximity;
|
||||
padding-bottom: 6px;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
.v3-kp-card {
|
||||
flex: 0 0 8.5rem;
|
||||
width: 8.5rem;
|
||||
scroll-snap-align: start;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user