/* * fee[dB]ack v0.3.0 — Lessons (#v3-lessons). * * An fb-styled catalog over the external `tutorials` plugin (P25). It lists * lesson packs + lessons with progress and the unified level/XP/streak, and * the Start/Continue action deep-links into the plugin's OWN lesson view * (`plugin-tutorials`) which keeps the video + playSong + run/XP submission. * We do NOT fork lesson logic or add a second XP curve — completing a lesson * in the plugin posts to /api/plugins/minigames/runs → the unified core XP * store (P15), the same level the profile badge reads. * * Capability note (design/05 §0/§5): UI placement + plugin nav/screen are a * DEFERRED capability domain — consume /api/plugins + the plugin REST + the * legacy globals (navigate/showScreen), NOT capability dispatch. Every fetch * degrades gracefully (plugin absent / 404 / empty) and never blocks first * paint. Vanilla JS, fb-* tokens (constitution P-II). */ (function () { 'use strict'; const sm = window.feedBack; const PLUGIN_ID = 'tutorials'; const API = '/api/plugins/' + PLUGIN_ID; const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ( { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); const enc = encodeURIComponent; const jget = async (u) => { try { const r = await fetch(u); return r.ok ? r.json() : null; } catch (e) { return null; } }; // View state survives re-renders so returning from a plugin lesson lands // back on the pack the user was viewing. const state = { view: { kind: 'catalog' }, progress: null }; // ── progress helpers ─────────────────────────────────────────────────--- function lessonState(packId, lessonId) { const packs = (state.progress && state.progress.packs) || {}; const pack = packs[packId] || {}; const lessons = pack.lessons || {}; return lessons[lessonId] || null; } function packPassedCount(pack) { const lessons = (pack && pack.lessons) || []; return lessons.reduce((n, l) => { const st = lessonState(pack.id, l.id); return n + (st && st.passed ? 1 : 0); }, 0); } // The "next" lesson to Start/Continue: first not-passed, else the first. function nextLesson(pack) { const lessons = (pack && pack.lessons) || []; if (!lessons.length) return null; return lessons.find((l) => { const st = lessonState(pack.id, l.id); return !(st && st.passed); }) || lessons[0]; } // ── small view pieces ────────────────────────────────────────────────--- function techChips(techs, max) { const arr = Array.isArray(techs) ? techs : []; const shown = arr.slice(0, max || 6); const extra = arr.length - shown.length; let html = shown.map((t) => '' + esc(t) + '').join(''); if (extra > 0) html += '+' + extra + ''; return '
' + html + '
'; } function progressBar(passed, total) { const pct = total > 0 ? Math.round((passed / total) * 100) : 0; return '
' + '
' + '
' + '' + passed + '/' + total + '
'; } // Per-lesson status: mastered ⭐, passed ✓, else best-accuracy ramp / "—". function lessonStatus(packId, lessonId) { const st = lessonState(packId, lessonId); if (st && st.mastered) return '★ Mastered'; if (st && st.passed) return '✓ Passed'; if (st && st.best_accuracy != null && st.best_accuracy > 0) { const acc = st.best_accuracy; const pct = Math.round(acc * 100); const color = acc >= 0.9 ? 'text-fb-good' : (acc >= 0.5 ? 'text-fb-mid' : 'text-fb-low'); return '' + pct + '%'; } return 'Not started'; } // Unified rank/dB/streak strip (progression spec 010 — replaces the old // level/XP meter; lesson completions still earn dB via the unified store). function headerXp(prog) { const p = prog || { current_streak: 0, best_streak: 0 }; const progression = (window.v3Progression && window.v3Progression.get()) || null; const rank = progression ? progression.mastery_rank : 0; const wallet = (progression && progression.wallet) || { balance: 0 }; return '
' + '
' + 'Rank ' + rank + '' + '' + Number(wallet.balance || 0).toLocaleString() + ' dB' + '🔥 ' + (p.current_streak || 0) + '-day streak' + '
'; } function pageWrap(inner) { return '
' + inner + '
'; } function emptyState(title, body, ctaLabel, ctaScreen) { return pageWrap( '

Lessons

' + '
' + '
🎓
' + '

' + esc(title) + '

' + '

' + esc(body) + '

' + (ctaLabel ? '' : '') + '
'); } // ── catalog view ─────────────────────────────────────────────────────--- async function renderCatalog(root, prog) { // /packs returns { packs: [...] }; tolerate a bare array too. const res = await jget(API + '/packs'); const packs = res && Array.isArray(res.packs) ? res.packs : (Array.isArray(res) ? res : null); if (!packs) { root.innerHTML = emptyState('Lessons aren’t installed yet', 'The Tutorials plugin isn’t active. Enable it from the Plugins page to unlock guided lessons.', 'Open Plugins', 'v3-plugins'); wireGo(root); return; } if (!packs.length) { root.innerHTML = pageWrap( '

Lessons

' + headerXp(prog) + '
No lesson packs installed yet.
'); return; } const cards = packs.map((pk) => { const total = pk.lesson_count || 0; // Pack summaries don't carry per-lesson ids, so progress count uses // the progress store keyed by this pack (passed lessons we know of). const ps = (state.progress && state.progress.packs && state.progress.packs[pk.id]) || {}; const passed = ps.lessons ? Object.values(ps.lessons).filter((l) => l && l.passed).length : 0; const cover = pk.cover_url ? '' : '
🎸
'; const cont = passed > 0 && passed < total; return '
' + '' + '
' + '' + techChips(pk.techniques, 5) + progressBar(passed, total) + '
' + '' + '' + '
'; }).join(''); root.innerHTML = pageWrap( '

Lessons

' + headerXp(prog) + '
' + cards + '
'); root.querySelectorAll('[data-pack]').forEach((b) => b.addEventListener('click', () => openPack(b.getAttribute('data-pack')))); root.querySelectorAll('[data-start-pack]').forEach((b) => b.addEventListener('click', () => startPackById(b.getAttribute('data-start-pack')))); } // ── pack detail view ─────────────────────────────────────────────────--- async function renderPack(root, prog, packId) { const pack = await jget(API + '/packs/' + enc(packId)); if (!pack || !Array.isArray(pack.lessons)) { // Pack vanished (uninstalled / bad id) — fall back to the catalog. state.view = { kind: 'catalog' }; return renderCatalog(root, prog); } const lessons = pack.lessons; const passed = packPassedCount(pack); const cover = pack.cover_url ? '' : '
🎸
'; const next = nextLesson(pack); const contLabel = passed > 0 && passed < lessons.length ? 'Continue' : 'Start'; const rows = lessons.map((l, i) => { const thumb = l.thumb_url ? '' : '
' + (i + 1) + '
'; const st = lessonState(packId, l.id); const label = st && st.passed ? 'Replay' : 'Start'; return '
' + '
' + thumb + '
' + '
' + '
' + esc(l.title || ('Lesson ' + (i + 1))) + '
' + '
' + techChips(l.techniques, 5) + '
' + '
' + lessonStatus(packId, l.id) + '
' + '' + '
'; }).join(''); root.innerHTML = pageWrap( '' + headerXp(prog) + '
' + '
' + cover + '
' + '
' + '

' + esc(pack.title) + '

' + '
' + esc(pack.author || '') + '
' + '
' + techChips(pack.techniques, 12) + progressBar(passed, lessons.length) + (next ? '' : '') + '
' + '
' + rows + '
'); root.querySelector('[data-back]')?.addEventListener('click', () => { state.view = { kind: 'catalog' }; render(); }); root.querySelectorAll('[data-lesson]').forEach((b) => b.addEventListener('click', () => launchLesson(packId, b.getAttribute('data-lesson')))); } // ── navigation into the plugin's own lesson view ─────────────────────--- function launchLesson(packId, lessonId) { if (!packId || !lessonId) return; // When the lesson's song auto-exits (or the player is closed), return // to the lessons catalog to pick the next one — not the song library. // playSong() consumes this one-shot override even though the external // tutorials plugin owns the actual playSong call. The legitimate path // is lessons → plugin-tutorials → player; if the launch is abandoned // (plugin missing, deep-link fails, user backs out) the override would // otherwise linger and mis-route the next library song. So clear it on // the first navigation that leaves the launch flow (any screen other // than the tutorials waypoint or the player that consumes it). try { if (window.feedBack && typeof window.feedBack.setReturnScreen === 'function') { window.feedBack.setReturnScreen('v3-lessons'); if (sm && typeof sm.on === 'function' && typeof sm.off === 'function') { const clearStale = (e) => { const id = e && e.detail && e.detail.id; if (id === 'plugin-tutorials') return; // expected waypoint — keep waiting // 'player' means playSong already consumed the override; // anything else means the launch was abandoned. if (id !== 'player' && window.feedBack._nextReturnScreen === 'v3-lessons') { window.feedBack.setReturnScreen(null); } sm.off('screen:changed', clearStale); }; sm.on('screen:changed', clearStale); } } } catch (_e) { /* non-fatal */ } // navigate() stores nav params AND calls showScreen(); the tutorials // plugin's init() reads getNavParams() to deep-link to {packId, lessonId}. if (sm && typeof sm.navigate === 'function') { sm.navigate('plugin-tutorials', { packId: packId, lessonId: lessonId }); } else if (typeof window.showScreen === 'function') { // Fallback when navigate() is unavailable: stash the same nav params // navigate() would set so the tutorials plugin can still deep-link // instead of landing on the generic browse screen. if (sm) sm._navParams = { packId: packId, lessonId: lessonId }; window.showScreen('plugin-tutorials'); } } async function startPackById(packId) { // Fetch the manifest so we can resolve the pack's next lesson, then launch. const pack = await jget(API + '/packs/' + enc(packId)); const next = pack && Array.isArray(pack.lessons) ? nextLesson(pack) : null; if (next) launchLesson(packId, next.id); else openPack(packId); } function openPack(packId) { state.view = { kind: 'pack', packId: packId }; render(); } function wireGo(root) { root.querySelectorAll('[data-go]').forEach((b) => b.addEventListener('click', () => window.showScreen && window.showScreen(b.getAttribute('data-go')))); } // ── entry ────────────────────────────────────────────────────────────--- async function render() { const root = document.getElementById('v3-lessons'); if (!root) return; // Refresh unified progress (level/XP/streak) + per-lesson progress every // render so XP earned in the plugin shows when the user returns. const [prog, tut] = await Promise.all([ jget('/api/profile/progress'), jget(API + '/progress'), ]); state.progress = tut || { packs: {} }; if (state.view.kind === 'pack') await renderPack(root, prog, state.view.packId); else await renderCatalog(root, prog); } window.v3Lessons = { render: render }; if (sm && typeof sm.on === 'function') { sm.on('screen:changed', (e) => { if (e && e.detail && e.detail.id === 'v3-lessons') render(); }); sm.on('v3:profile-updated', () => { if (document.getElementById('v3-lessons')?.classList.contains('active')) render(); }); // Refresh the header rank/dB strip when progression state changes while // the screen is already visible (e.g. after a minigame run on this screen). sm.on('progression:updated', () => { if (document.getElementById('v3-lessons')?.classList.contains('active')) render(); }); } })();