mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-08-14 04:31:21 +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
@@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
- **Practice-aware library home — a "Repertoire" meter + a "Keep practicing" shelf on the v3 Songs page.** The library opened cold into a flat sorted grid; now the unfiltered grid front door leads with two practice-aware surfaces built entirely from data already on hand (no new endpoints or stored state). A **Repertoire meter** shows how much of your library you can actually play — *"Repertoire: 12 of 80 songs · 7 in progress"* with a progress bar — counting songs at or above the same mastery threshold the green accuracy badge uses (≥ 90% best accuracy) over the unfiltered library total. A **"Keep practicing" shelf** is a horizontal row of your recently-played-but-not-yet-mastered songs (newest first, click to play) — the practice-accuracy-driven "continue" rail a media server can't do. Both reuse `/api/stats/best` (already loaded for the card badges) + `/api/stats/recent`; they show **only** on the grid view when you aren't searching/filtering/selecting, refresh after a song is scored, and collapse to nothing on an empty library. Soft-gamification only — descriptive encouragement (goal-gradient / endowed-progress), never content-gating, decay, or nagging. Frontend-only: `static/v3/songs.js` (`renderLibraryHome`/`_repertoireCounts`), `static/v3/v3.css`. Came out of the library design charrette (the UX + gamification lenses' top pick). Tests: `tests/js/v3_keep_practicing.test.js`.
|
||||||
- **A–Z fast-scroll rail on the v3 Songs grid.** A vertical letter rail (Plex/Radarr/iOS-contacts pattern) pinned to the right edge next to the scrollbar lets you jump the library to a starting letter — tap a letter, drag to scrub with a live letter bubble, or arrow-key between letters. It shows **only** for the grid view + alphabetical (artist/title) sorts, and only offers letters actually present in the current sort **and filter set**, so a tap always lands on a real card (absent letters are dimmed + non-interactive). Because the grid is forward-only, server-paged infinite scroll, a jump pages through to the target card and scrolls to it (a newer jump supersedes an in-flight one); a keyset-seek + virtualized window is the noted scaling follow-up for very large libraries. Backend: `/api/library/stats` now accepts `sort` and returns an additive `sort_letters` map (songs-per-first-letter of the active sort column — artist or title), filter-synced; the legacy `letters` (distinct-artist) field is unchanged for the dashboard + classic tree. Frontend: `static/v3/songs.js` (`refreshRail`/`jumpToLetter`, cards tagged with `data-letter`), `static/v3/v3.css` (`.v3-azrail`). The classic (v2) tree already had letter selection; this brings the new grid to parity. Tests: `tests/test_library_filters.py` (sort_letters artist/title + song-vs-artist counting), `tests/test_library_providers.py` (sort forwarded to providers), `tests/js/v3_az_rail.test.js`.
|
- **A–Z fast-scroll rail on the v3 Songs grid.** A vertical letter rail (Plex/Radarr/iOS-contacts pattern) pinned to the right edge next to the scrollbar lets you jump the library to a starting letter — tap a letter, drag to scrub with a live letter bubble, or arrow-key between letters. It shows **only** for the grid view + alphabetical (artist/title) sorts, and only offers letters actually present in the current sort **and filter set**, so a tap always lands on a real card (absent letters are dimmed + non-interactive). Because the grid is forward-only, server-paged infinite scroll, a jump pages through to the target card and scrolls to it (a newer jump supersedes an in-flight one); a keyset-seek + virtualized window is the noted scaling follow-up for very large libraries. Backend: `/api/library/stats` now accepts `sort` and returns an additive `sort_letters` map (songs-per-first-letter of the active sort column — artist or title), filter-synced; the legacy `letters` (distinct-artist) field is unchanged for the dashboard + classic tree. Frontend: `static/v3/songs.js` (`refreshRail`/`jumpToLetter`, cards tagged with `data-letter`), `static/v3/v3.css` (`.v3-azrail`). The classic (v2) tree already had letter selection; this brings the new grid to parity. Tests: `tests/test_library_filters.py` (sort_letters artist/title + song-vs-artist counting), `tests/test_library_providers.py` (sort forwarded to providers), `tests/js/v3_az_rail.test.js`.
|
||||||
- **Playlists get content-dependent covers + custom art.** Playlist cards were a tiny `🎵` emoji on an empty square. Now a playlist's cover reflects its contents: **empty → the icon**, **a few songs → the first song's album art**, **4+ songs → a 2×2 art mosaic**. You can also **upload a custom cover** (a "Cover" button in the playlist detail view → image picker; "Remove cover" reverts to the content view). `MetadataDB.list_playlists()` now returns each playlist's first few song `art_urls`; `GET /api/playlists` and `GET /api/playlists/{id}` add `cover_url` when a custom cover exists. New routes `POST` / `GET` / `DELETE /api/playlists/{id}/cover` store a small PNG thumbnail under `CONFIG_DIR/playlist_covers/` (PIL-converted, like song-art upload); the cover is removed with the playlist. Frontend: `playlistCoverHtml(p)` in `static/v3/playlists.js`. Tests: `tests/test_playlists_api.py` (art_urls + cover roundtrip / reject-non-image / delete-cleanup), `tests/js/v3_playlist_cover.test.js`.
|
- **Playlists get content-dependent covers + custom art.** Playlist cards were a tiny `🎵` emoji on an empty square. Now a playlist's cover reflects its contents: **empty → the icon**, **a few songs → the first song's album art**, **4+ songs → a 2×2 art mosaic**. You can also **upload a custom cover** (a "Cover" button in the playlist detail view → image picker; "Remove cover" reverts to the content view). `MetadataDB.list_playlists()` now returns each playlist's first few song `art_urls`; `GET /api/playlists` and `GET /api/playlists/{id}` add `cover_url` when a custom cover exists. New routes `POST` / `GET` / `DELETE /api/playlists/{id}/cover` store a small PNG thumbnail under `CONFIG_DIR/playlist_covers/` (PIL-converted, like song-art upload); the cover is removed with the playlist. Frontend: `playlistCoverHtml(p)` in `static/v3/playlists.js`. Tests: `tests/test_playlists_api.py` (art_urls + cover roundtrip / reject-non-image / delete-cleanup), `tests/js/v3_playlist_cover.test.js`.
|
||||||
- **v3 Songs: "Add to playlist" is now on each song's ⋮ "More" menu.** Previously a song could only be added to a playlist through select-mode (the checkbox → batch bar). The per-card overflow menu now has an **Add to playlist** row that targets that one song, reusing the same picker (choose a listed number or type a new name to create it). The select-mode batch flow and the single-song menu now share one extracted `addFilenamesToPlaylist(filenames)` helper in `static/v3/songs.js` (both grid and tree rows, since they share `openCardMenu`). Tests: `tests/js/v3_add_to_playlist_menu.test.js`.
|
- **v3 Songs: "Add to playlist" is now on each song's ⋮ "More" menu.** Previously a song could only be added to a playlist through select-mode (the checkbox → batch bar). The per-card overflow menu now has an **Add to playlist** row that targets that one song, reusing the same picker (choose a listed number or type a new name to create it). The select-mode batch flow and the single-song menu now share one extracted `addFilenamesToPlaylist(filenames)` helper in `static/v3/songs.js` (both grid and tree rows, since they share `openCardMenu`). Tests: `tests/js/v3_add_to_playlist_menu.test.js`.
|
||||||
|
|||||||
+131
-3
@@ -349,11 +349,11 @@
|
|||||||
if (acc == null) return '';
|
if (acc == null) return '';
|
||||||
const pct = Math.round(acc * 100);
|
const pct = Math.round(acc * 100);
|
||||||
if (variant === 'tree') {
|
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>';
|
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 color = acc >= MASTERY_ACCURACY ? '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 < 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">' +
|
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>';
|
'<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);
|
const keys = Array.from(_dirtyScores);
|
||||||
_dirtyScores.clear();
|
_dirtyScores.clear();
|
||||||
keys.forEach(repaintAccuracy);
|
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
|
// 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
|
// Refresh the A–Z jump rail (shows only for the grid + alphabetical
|
||||||
// sorts; hides itself otherwise). Independent of the grid load.
|
// sorts; hides itself otherwise). Independent of the grid load.
|
||||||
refreshRail();
|
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'; }
|
{ const _fc = document.getElementById('lib-folder-controls'); if (_fc) _fc.style.display = state.view === 'folder' ? 'flex' : 'none'; }
|
||||||
if (state.view === 'folder') {
|
if (state.view === 'folder') {
|
||||||
_applyMainScrollTop(0);
|
_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-select" class="' + ctrl + (state.selectMode ? ' bg-fb-primary text-white' : '') + '">Select</button>' +
|
||||||
'<button id="v3-songs-upload" class="' + ctrl + '">Upload</button>' +
|
'<button id="v3-songs-upload" class="' + ctrl + '">Upload</button>' +
|
||||||
'</div></div></div>' +
|
'</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-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="v3-songs-tree" class="hidden"></div>' +
|
||||||
'<div id="lib-folder-controls" style="display:none"></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) {
|
@media (max-height: 640px) {
|
||||||
.v3-azrail-letter { font-size: .55rem; padding: 0 4px; }
|
.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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
// Pins the practice-aware library home in static/v3/songs.js:
|
||||||
|
// - a "Repertoire" progress meter (mastered / total library songs), and
|
||||||
|
// - a "Keep practicing" shelf (recently played, not yet mastered).
|
||||||
|
// Both reuse existing data (/api/stats/best already in state.accuracy, and
|
||||||
|
// /api/stats/recent) and are shown only on the unfiltered grid front door.
|
||||||
|
//
|
||||||
|
// Source-level only — same strategy as tests/js/v3_az_rail.test.js.
|
||||||
|
|
||||||
|
const { test } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
|
||||||
|
const SONGS_JS = path.join(__dirname, '..', '..', 'static', 'v3', 'songs.js');
|
||||||
|
const src = fs.readFileSync(SONGS_JS, 'utf8');
|
||||||
|
|
||||||
|
test('repertoire uses the same mastery threshold as the green accuracy badge', () => {
|
||||||
|
assert.match(src, /const\s+MASTERY_ACCURACY\s*=\s*0\.9/);
|
||||||
|
assert.match(
|
||||||
|
src,
|
||||||
|
/function\s+_repertoireCounts[\s\S]*?v\s*>=\s*MASTERY_ACCURACY\s*\)\s*mastered\+\+;\s*else\s+learning\+\+/,
|
||||||
|
'repertoire counts must bucket scored songs into mastered/learning at MASTERY_ACCURACY',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the home is the unfiltered grid front door, local provider only', () => {
|
||||||
|
assert.match(
|
||||||
|
src,
|
||||||
|
/function\s+libHomeVisible[\s\S]*?state\.view === 'grid'[\s\S]*?state\.provider === 'local'[\s\S]*?!state\.selectMode[\s\S]*?!state\.q[\s\S]*?activeFilterCount\(\)\s*===\s*0/,
|
||||||
|
'libHomeVisible must require grid view, the local provider, no select mode, no search, no active filters',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the shelf is recently-played, not-yet-mastered songs (per-song, deduped)', () => {
|
||||||
|
assert.match(src, /\/api\/stats\/recent\?limit=/);
|
||||||
|
// Mastery is gated on the per-SONG best (state.accuracy, what the badge
|
||||||
|
// shows), not the per-arrangement recents row, and each filename appears
|
||||||
|
// once — so no green-badged "keep practicing" card and no duplicates.
|
||||||
|
assert.match(
|
||||||
|
src,
|
||||||
|
/const\s+best\s*=\s*acc\[r\.filename\][\s\S]*?best\s*>=\s*MASTERY_ACCURACY/,
|
||||||
|
'the shelf must gate on the per-song best (state.accuracy) at MASTERY_ACCURACY',
|
||||||
|
);
|
||||||
|
assert.match(src, /seen\.has\(r\.filename\)/, 'the shelf must dedupe recents by filename');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the meter + shelf fetch together and a stale render is discarded', () => {
|
||||||
|
assert.match(src, /Promise\.all\(\[[\s\S]*?library\/stats[\s\S]*?stats\/recent/,
|
||||||
|
'the two reads must be issued together (Promise.all), not sequentially');
|
||||||
|
assert.match(src, /_homeToken[\s\S]*?_homeToken !== myToken/,
|
||||||
|
'a stale render must be superseded by a newer one via a token');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the repertoire denominator is the unfiltered library total', () => {
|
||||||
|
assert.match(src, /\/api\/library\/stats\?provider='/);
|
||||||
|
assert.match(src, /total_songs\s*\?\?\s*stats\.total/);
|
||||||
|
assert.match(src, /Math\.round\(\(mastered\s*\/\s*total\)\s*\*\s*100\)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the home + #v3-lib-home host are wired into render and reload', () => {
|
||||||
|
assert.match(src, /id="v3-lib-home"/, 'render() must include the #v3-lib-home host');
|
||||||
|
assert.match(src, /function reload\s*\([\s\S]*?updateLibraryHome\(\)/,
|
||||||
|
'reload() must refresh/toggle the home');
|
||||||
|
assert.match(src, /function applyScoreRefresh[\s\S]*?renderLibraryHome\(\)/,
|
||||||
|
'a new score must refresh the meter + shelf');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shelf cards play the song on click', () => {
|
||||||
|
assert.match(
|
||||||
|
src,
|
||||||
|
/querySelectorAll\('\.v3-kp-card'\)[\s\S]*?window\.playSong\(enc\(fn\)/,
|
||||||
|
'a shelf card click must call window.playSong with the recents filename',
|
||||||
|
);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user