/* * fee[dB]ack v0.3.0 — Progress screen (spec 010). * * Mastery Rank hero, per-path challenge checklists ("x of y to level up"), * the calibration challenge card, add-a-path tiles, daily/weekly quests with * reset countdowns, and the Decibels balance with a shop link. State comes * from window.v3Progression (progression-core.js); re-renders on * `progression:updated` and on screen activation. * * Vanilla JS, no framework (constitution P-II). */ (function () { 'use strict'; const sm = window.feedBack; const SCREEN_ID = 'v3-progress'; const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ( { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); const fmtDb = (n) => Number(n || 0).toLocaleString() + ' dB'; function resetsIn(iso) { const t = new Date(iso).getTime(); if (!Number.isFinite(t)) return ''; let mins = Math.max(0, Math.round((t - Date.now()) / 60000)); const days = Math.floor(mins / 1440); mins -= days * 1440; const hours = Math.floor(mins / 60); mins -= hours * 60; if (days > 0) return 'resets in ' + days + 'd ' + hours + 'h'; if (hours > 0) return 'resets in ' + hours + 'h ' + mins + 'm'; return 'resets in ' + mins + 'm'; } function progressBar(count, target, done) { const pct = target > 0 ? Math.min(100, Math.round((count / target) * 100)) : 0; return '
' + '
'; } const checkIcon = ''; function challengeRow(c) { return '
' + (c.completed ? checkIcon : '') + '
' + '
' + '' + esc(c.title) + '' + '' + c.count + '/' + c.target + '
' + '

' + esc(c.description) + '

' + (c.completed ? '' : '
' + progressBar(c.count, c.target, false) + '
') + '
'; } function pathCard(p) { let body; if (p.next) { const remaining = Math.max(0, p.next.required - p.next.completed); body = '

Level ' + p.next.level + ' — complete ' + '' + remaining + ' more challenge' + (remaining === 1 ? '' : 's') + ' (' + p.next.completed + ' of ' + p.next.required + ' done)

' + '
' + p.next.challenges.map(challengeRow).join('') + '
'; } else { body = '

Path mastered — max level reached!

'; } return '
' + '
' + '

' + esc(p.name) + '

' + 'Level ' + p.level + ' / ' + p.max_level + '' + '
' + body + '
'; } function questRow(q) { return '
' + (q.completed ? checkIcon : '') + '
' + '
' + '' + esc(q.title) + '' + '+' + Number(q.reward_db || 0).toLocaleString() + ' dB
' + '

' + esc(q.description) + ' (' + q.count + '/' + q.target + ')

' + (q.completed ? '' : '
' + progressBar(q.count, q.target, false) + '
') + '
'; } function questCard(title, block) { if (!block) return ''; const rows = (block.quests || []).map(questRow).join('') || '

No quests available.

'; return '
' + '
' + '

' + title + '

' + '' + esc(resetsIn(block.resets_at)) + '
' + '
' + rows + '
'; } function calibrationCard(onboarding) { if (!onboarding || onboarding.calibration_status === 'completed') return ''; const pending = onboarding.calibration_status === 'pending'; return '
' + '
' + '
' + '

Calibration challenge

' + '

Play the fee[dB]ack Diagnostic with note detection and finish at ' + '100% accuracy' + (pending ? ' to reach Mastery Rank 1.' : ' to prove your setup (you skipped this — rank already granted).') + '

' + '
' + '' + (pending ? '' : '') + '
'; } function addPathCard(available) { if (!available || !available.length) return ''; return '
' + '

Start a new path

' + '
' + available.map((p) => '' ).join('') + '
'; } function render() { const root = document.getElementById(SCREEN_ID); if (!root) return; const st = window.v3Progression && window.v3Progression.get(); if (!st) { root.innerHTML = '

Loading progress…

'; return; } const wallet = st.wallet || { balance: 0, lifetime_db: 0 }; root.innerHTML = '
' + // Hero: rank + wallet '
' + '
' + '
Mastery Rank
' + '
' + st.mastery_rank + '
' + '

Onboarding' + ((st.onboarding || {}).calibration_status === 'pending' ? ' (0)' : ' (1)') + (st.paths || []).map((p) => ' + ' + esc(p.name) + ' (' + p.level + ')').join('') + '

' + '
' + '
Decibels
' + '
' + fmtDb(wallet.balance) + '
' + '
' + fmtDb(wallet.lifetime_db) + ' earned lifetime
' + '' + '
' + calibrationCard(st.onboarding) + // Paths ((st.paths || []).length ? '
' + st.paths.map(pathCard).join('') + '
' : '

Pick an instrument path below to start earning Mastery Rank.

') + addPathCard(st.available_paths) + // Quests '
' + questCard('Daily quests', (st.quests || {}).daily) + questCard('Weekly quests', (st.quests || {}).weekly) + '
'; const shopBtn = root.querySelector('[data-prog-shop]'); if (shopBtn) shopBtn.addEventListener('click', () => window.showScreen && window.showScreen('v3-shop')); const cal = root.querySelector('[data-prog-calibrate]'); if (cal) cal.addEventListener('click', () => { const fn = (st.onboarding || {}).diagnostic_filename; if (fn && typeof window.playSong === 'function') window.playSong(fn); }); const skip = root.querySelector('[data-prog-skip]'); if (skip) skip.addEventListener('click', async () => { try { const res = await fetch('/api/progression/onboarding', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'skip' }), }); if (res.ok) window.v3Progression && window.v3Progression.refresh(); } catch (e) { /* offline */ } }); root.querySelectorAll('[data-prog-add-path]').forEach((b) => { b.addEventListener('click', async () => { b.disabled = true; try { await fetch('/api/progression/paths', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ add: [b.getAttribute('data-prog-add-path')] }), }); } catch (e) { /* offline */ } window.v3Progression && window.v3Progression.refresh(); }); }); } function launchDiagnostic(onboarding) { const fn = (onboarding || {}).diagnostic_filename; if (fn && typeof window.playSong === 'function') window.playSong(fn); } function priorCalibrationStatus(detail) { // Honor an explicit prior_calibration_status even when it is null (the // emitter always includes it). Truthiness would treat null as "missing" // and fall back to the cached state, which may already have flipped to // 'completed' in racey delivery paths — suppressing the success overlay. if (detail && Object.prototype.hasOwnProperty.call(detail, 'prior_calibration_status')) { return detail.prior_calibration_status; } const st = (window.v3Progression && window.v3Progression.get()) || {}; return (st.onboarding || {}).calibration_status; } // ── Calibration success prompt ─────────────────────────────────────────── // Fired when progression reports calibration_completed (100% diagnostic). // Reads prior onboarding status before async refresh lands so pending vs // skipped copy stays accurate. function showCalibrationSuccess(detail) { if (document.getElementById('v3-calibration-success')) return; document.getElementById('v3-calibration-retry')?.remove(); const prior = priorCalibrationStatus(detail || {}); if (prior === 'completed') return; const pending = prior === 'pending'; const skipped = prior === 'skipped'; let body; if (pending) { body = 'You finished Basic Guitar Diagnostic at 100% accuracy. Mastery Rank 1 is ready.'; } else if (skipped) { body = 'You finished Basic Guitar Diagnostic at 100% accuracy. Your input and note detection setup is verified.'; } else { body = 'You finished Basic Guitar Diagnostic at 100% accuracy. Your setup is verified.'; } const st = (window.v3Progression && window.v3Progression.get()) || {}; const onboarding = st.onboarding || {}; const overlay = document.createElement('div'); overlay.id = 'v3-calibration-success'; overlay.className = 'fixed inset-0 z-[200] bg-black/60 backdrop-blur-sm flex items-center justify-center p-4'; overlay.innerHTML = '
' + '
' + '

Setup verified!

' + '

' + body + '

' + '
' + '' + (skipped ? '' : '') + '
'; document.body.appendChild(overlay); overlay.querySelector('[data-cal-success-continue]').addEventListener('click', () => overlay.remove()); const replay = overlay.querySelector('[data-cal-success-replay]'); if (replay) replay.addEventListener('click', () => { overlay.remove(); launchDiagnostic(onboarding); }); } // ── Calibration retry prompt ───────────────────────────────────────────── // Fired by stats-recorder when the diagnostic sloppak finished scored but // below 100% (and calibration isn't completed yet): offer another go, and // — while calibration is still pending — the skip-to-Rank-1 escape hatch. function showCalibrationRetry(detail) { document.getElementById('v3-calibration-retry')?.remove(); document.getElementById('v3-calibration-success')?.remove(); const st = (window.v3Progression && window.v3Progression.get()) || {}; const onboarding = st.onboarding || {}; if (onboarding.calibration_status === 'completed') return; // raced a 100% run const pending = onboarding.calibration_status === 'pending'; const pct = Math.max(0, Math.min(100, Math.round((detail.accuracy || 0) * 100))); const overlay = document.createElement('div'); overlay.id = 'v3-calibration-retry'; overlay.className = 'fixed inset-0 z-[200] bg-black/60 backdrop-blur-sm flex items-center justify-center p-4'; overlay.innerHTML = '
' + '
🎯
' + '

' + (pct >= 90 ? 'So close!' : 'Calibration not passed') + '

' + '

You finished the calibration run at ' + '' + pct + '% — it takes ' + '100% to complete' + (pending ? ' and reach Mastery Rank 1' : '') + '. Want another go?

' + '
' + '' + (pending ? '' : '') + '' + '
'; document.body.appendChild(overlay); overlay.querySelector('[data-cal-retry]').addEventListener('click', () => { overlay.remove(); launchDiagnostic(onboarding); }); const skip = overlay.querySelector('[data-cal-skip]'); if (skip) skip.addEventListener('click', async () => { skip.disabled = true; try { await fetch('/api/progression/onboarding', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'skip' }), }); } catch (e) { /* offline — still skippable from the Progress screen */ } overlay.remove(); window.v3Progression && window.v3Progression.refresh(); }); overlay.querySelector('[data-cal-close]').addEventListener('click', () => overlay.remove()); } function boot() { render(); if (sm && typeof sm.on === 'function') { sm.on('progression:updated', render); sm.on('progression:calibration-attempt', (e) => showCalibrationRetry((e && e.detail) || {})); sm.on('progression:calibration-completed', (e) => { showCalibrationSuccess((e && e.detail) || {}); }); sm.on('screen:changed', (e) => { if (e && e.detail && e.detail.id === SCREEN_ID) { window.v3Progression && window.v3Progression.refresh(); render(); } }); } } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', boot, { once: true }); } else { boot(); } })();