From 6a6efc793a642b45ec67df454c6958a8f5db4683 Mon Sep 17 00:00:00 2001 From: ChrisBeWithYou Date: Mon, 29 Jun 2026 01:56:31 -0500 Subject: [PATCH] =?UTF-8?q?feat(v3=20library):=20practice-aware=20home=20?= =?UTF-8?q?=E2=80=94=20Repertoire=20meter=20+=20"Keep=20practicing"=20shel?= =?UTF-8?q?f=20(#635)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 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) --------- Co-authored-by: Claude Opus 4.8 Co-authored-by: byrongamatos --- CHANGELOG.md | 1 + static/v3/songs.js | 134 +++++++++++++++++++++++++++- static/v3/v3.css | 30 +++++++ tests/js/v3_keep_practicing.test.js | 74 +++++++++++++++ 4 files changed, 236 insertions(+), 3 deletions(-) create mode 100644 tests/js/v3_keep_practicing.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 15e2e65..b230ad4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### 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`. - **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`. diff --git a/static/v3/songs.js b/static/v3/songs.js index aa0d730..06180d3 100644 --- a/static/v3/songs.js +++ b/static/v3/songs.js @@ -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 '' + pct + '%'; } - 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 '' + '' + pct + '%'; } @@ -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 = + '
' + + '
' + + 'Repertoire' + + '' + mastered + ' of ' + total + ' song' + (total === 1 ? '' : 's') + + (learning ? ' · ' + learning + ' in progress' : '') + '' + + '
' + + '
' + + '
'; + + let shelfHtml = ''; + if (shelf.length) { + const cards = shelf.map((r) => + '').join(''); + shelfHtml = + '
' + + '

Keep practicing

' + + '
' + cards + '
' + + '
'; + } + + 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 @@ '' + '' + '' + + // 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. + '' + '
' + '' + '' + diff --git a/static/v3/v3.css b/static/v3/v3.css index 7c1a70a..b13dd2c 100644 --- a/static/v3/v3.css +++ b/static/v3/v3.css @@ -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; +} diff --git a/tests/js/v3_keep_practicing.test.js b/tests/js/v3_keep_practicing.test.js new file mode 100644 index 0000000..9e395cc --- /dev/null +++ b/tests/js/v3_keep_practicing.test.js @@ -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', + ); +});