/*
* 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).') + '
' +
'
' +
'Play it now ' +
(pending ? 'Skip for now ' : '') +
'
';
}
function addPathCard(available) {
if (!available || !available.length) return '';
return '' +
'
Start a new path ' +
'
' +
available.map((p) =>
'+ ' + esc(p.name) + ' '
).join('') + '
';
}
function render() {
const root = document.getElementById(SCREEN_ID);
if (!root) return;
const st = window.v3Progression && window.v3Progression.get();
if (!st) {
root.innerHTML = '';
return;
}
const wallet = st.wallet || { balance: 0, lifetime_db: 0 };
root.innerHTML =
'' +
// Hero: rank + wallet
'
' +
'
' +
'
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
' +
'
Open Unlockables → ' +
'
' +
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 + '
' +
'
' +
'Continue ' +
(skipped ? 'Play again ' : '') +
'
';
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?
' +
'
' +
'Try again ' +
(pending ? 'Skip — take Rank 1 ' : '') +
'Not now ' +
'
';
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();
}
})();