import { noteDisplay, CHROMATIC, ENHARMONIC } from './music.js'; import { buildFretboard } from './fretboard.js'; import { genMode1Question, genMode2Question, evaluateMode2 } from './quiz.js'; // ─── Persistent stats ───────────────────────────────────────────────────────── const STORAGE_KEY = 'guitarTrainerStats'; function defaultLifetimeStats() { return { total: 0, correct: 0, bestStreak: 0, mode1: { total: 0, correct: 0 }, mode2: { total: 0, correct: 0 }, notes: {} }; } function loadLifetimeStats() { try { const raw = localStorage.getItem(STORAGE_KEY); if (raw) return { ...defaultLifetimeStats(), ...JSON.parse(raw) }; } catch {} return defaultLifetimeStats(); } function saveLifetimeStats() { localStorage.setItem(STORAGE_KEY, JSON.stringify(lifetimeStats)); } let lifetimeStats = loadLifetimeStats(); // ─── Note keyboard input ────────────────────────────────────────────────────── const NOTE_LETTERS = new Set(['a','b','c','d','e','f','g']); const FLAT_TO_SHARP = Object.fromEntries( Object.entries(ENHARMONIC).map(([sharp, flat]) => [flat, sharp]) ); let noteBuffer = ''; let noteTimer = null; function resolveNoteInput(raw) { const letter = raw[0].toUpperCase(); const mod = raw[1] || ''; if (mod === '#') { const n = letter + '#'; return CHROMATIC.includes(n) ? n : null; } if (mod === 'b') return FLAT_TO_SHARP[letter + 'b'] || null; return CHROMATIC.includes(letter) ? letter : null; } function submitNoteBuffer() { clearTimeout(noteTimer); noteTimer = null; const raw = noteBuffer; noteBuffer = ''; if (!raw || mode !== 1 || answered) return; const note = resolveNoteInput(raw); if (note) checkMode1(note); } // ─── State ──────────────────────────────────────────────────────────────────── let mode = 1; // 1, 2, or 'stats' let currentQ = null; let answered = false; let mode2Selection = new Set(); let stats = { correct: 0, total: 0, streak: 0 }; // ─── Settings helpers ───────────────────────────────────────────────────────── const getNumStrings = () => parseInt(document.getElementById('stringCount').value); const getSharpsOnly = () => document.getElementById('sharpsOnly').checked; const getNaturalOnly = () => document.getElementById('naturalOnly').checked; // ─── Rendering ──────────────────────────────────────────────────────────────── function renderQuestion() { answered = false; mode2Selection.clear(); clearTimeout(noteTimer); noteBuffer = ''; const fb = document.getElementById('feedback'); fb.textContent = ''; fb.className = 'feedback'; document.getElementById('nextBtn').disabled = true; if (mode === 1) renderMode1(); else renderMode2(); } function renderMode1() { currentQ = genMode1Question(getNumStrings(), getNaturalOnly()); const numStrings = getNumStrings(); const stringNames = ['1st', '2nd', '3rd', '4th', '5th', '6th', '7th']; const stringNumber = numStrings - currentQ.stringIdx; document.getElementById('promptLabel').textContent = 'What note is this?'; document.getElementById('promptText').innerHTML = `${stringNames[stringNumber - 1]} string, ` + `fret ${currentQ.fret}`; document.getElementById('fretboardContainer').innerHTML = buildFretboard({ numStrings, highlightPos: { stringIdx: currentQ.stringIdx, fret: currentQ.fret }, }); const btns = document.getElementById('noteButtons'); btns.innerHTML = ''; currentQ.choices.forEach((note, i) => { const btn = document.createElement('button'); btn.className = 'note-btn'; btn.textContent = noteDisplay(note, getSharpsOnly()); btn.dataset.note = note; if (i < 9) btn.title = `Press ${i + 1}`; btns.appendChild(btn); }); document.getElementById('noteButtons').style.display = 'flex'; document.getElementById('mode2Controls').style.display = 'none'; } function renderMode2() { currentQ = genMode2Question(getNumStrings(), getNaturalOnly()); document.getElementById('promptLabel').textContent = 'Find all occurrences of:'; document.getElementById('promptText').innerHTML = `${noteDisplay(currentQ.targetNote, getSharpsOnly())}` + `in frets ${currentQ.fretStart}–${currentQ.fretEnd}`; refreshMode2Fretboard(); document.getElementById('noteButtons').style.display = 'none'; document.getElementById('mode2Controls').style.display = 'flex'; } function refreshMode2Fretboard(showCorrect = false) { document.getElementById('fretboardContainer').innerHTML = buildFretboard({ numStrings: getNumStrings(), interactive: true, fretRange: [currentQ.fretStart, currentQ.fretEnd], mode2Sel: mode2Selection, mode2Correct: showCorrect ? currentQ.correctSet : null, showLabels: showCorrect, sharpsOnly: getSharpsOnly(), }); } // ─── Answer checking ────────────────────────────────────────────────────────── function recordAnswer(isCorrect, note) { lifetimeStats.total++; if (isCorrect) lifetimeStats.correct++; const modeKey = mode === 1 ? 'mode1' : 'mode2'; lifetimeStats[modeKey].total++; if (isCorrect) lifetimeStats[modeKey].correct++; if (!lifetimeStats.notes[note]) lifetimeStats.notes[note] = { total: 0, correct: 0 }; lifetimeStats.notes[note].total++; if (isCorrect) lifetimeStats.notes[note].correct++; if (stats.streak > lifetimeStats.bestStreak) lifetimeStats.bestStreak = stats.streak; saveLifetimeStats(); } function checkMode1(note) { if (answered) return; answered = true; stats.total++; const fb = document.getElementById('feedback'); document.querySelectorAll('.note-btn').forEach(b => { if (b.dataset.note === currentQ.note) b.classList.add('correct'); }); const clickedBtn = document.querySelector(`.note-btn[data-note="${note}"]`); const isCorrect = note === currentQ.note; if (isCorrect) { fb.textContent = '✓ Correct!'; fb.className = 'feedback correct'; stats.correct++; stats.streak++; } else { if (clickedBtn) clickedBtn.classList.add('wrong'); fb.textContent = `✗ It's ${currentQ.note}`; fb.className = 'feedback wrong'; stats.streak = 0; } recordAnswer(isCorrect, currentQ.note); updateStats(); document.getElementById('nextBtn').disabled = false; document.getElementById('fretboardContainer').innerHTML = buildFretboard({ numStrings: getNumStrings(), highlightPos: { stringIdx: currentQ.stringIdx, fret: currentQ.fret }, showLabels: true, sharpsOnly: getSharpsOnly(), }); } function checkMode2() { if (answered) return; answered = true; stats.total++; const { allCorrect, missed, extra } = evaluateMode2(mode2Selection, currentQ.correctSet); refreshMode2Fretboard(true); const fb = document.getElementById('feedback'); if (allCorrect) { const n = currentQ.correctSet.size; fb.textContent = `✓ Perfect! Found all ${n} position${n !== 1 ? 's' : ''}`; fb.className = 'feedback correct'; stats.correct++; stats.streak++; } else { fb.textContent = `✗ Missed ${missed}, extra ${extra} — green = correct positions`; fb.className = 'feedback wrong'; stats.streak = 0; } recordAnswer(allCorrect, currentQ.targetNote); updateStats(); document.getElementById('nextBtn').disabled = false; } function updateStats() { document.getElementById('statCorrect').textContent = stats.correct; document.getElementById('statTotal').textContent = stats.total; document.getElementById('statStreak').textContent = stats.streak; } // ─── Stats screen ───────────────────────────────────────────────────────────── function accColor(acc) { if (acc >= 80) return 'var(--green)'; if (acc >= 50) return 'var(--accent2)'; return 'var(--accent)'; } function modeRow(label, mStat) { const acc = mStat.total ? Math.round(mStat.correct / mStat.total * 100) : 0; return `
${label}
${mStat.correct}/${mStat.total}
${mStat.total ? acc + '%' : '—'}
`; } function renderStats() { const screen = document.getElementById('statsScreen'); const ls = lifetimeStats; const overallAcc = ls.total ? Math.round(ls.correct / ls.total * 100) : 0; const noteRows = Object.entries(ls.notes) .map(([note, { correct, total }]) => ({ note, correct, total, acc: Math.round(correct / total * 100) })) .sort((a, b) => a.acc - b.acc || b.total - a.total); screen.innerHTML = `
${overallAcc}%
Accuracy
${ls.total}
Questions
${ls.bestStreak}
Best Streak
By Mode
${modeRow('Name the Note', ls.mode1)} ${modeRow('Find Occurrences', ls.mode2)}
${noteRows.length ? `
Accuracy by Note
${noteRows.map(r => ` `).join('')}
NoteCorrectSeenAccuracy
${noteDisplay(r.note, false)} ${r.correct} ${r.total}
${r.acc}%
` : '
No questions answered yet — start practising!
'}
`; document.getElementById('resetStatsBtn').addEventListener('click', () => { if (confirm('Reset all lifetime stats? This cannot be undone.')) { lifetimeStats = defaultLifetimeStats(); saveLifetimeStats(); renderStats(); } }); } // ─── Controls ───────────────────────────────────────────────────────────────── function setMode(m) { mode = m; document.getElementById('tab1').classList.toggle('active', m === 1); document.getElementById('tab2').classList.toggle('active', m === 2); document.getElementById('tabStats').classList.toggle('active', m === 'stats'); const isStats = m === 'stats'; document.getElementById('quizArea').style.display = isStats ? 'none' : ''; document.getElementById('statsScreen').style.display = isStats ? 'flex' : 'none'; if (isStats) renderStats(); else renderQuestion(); } function resetQuiz() { stats = { correct: 0, total: 0, streak: 0 }; updateStats(); if (mode !== 'stats') renderQuestion(); } // ─── Event wiring ───────────────────────────────────────────────────────────── document.getElementById('tab1').addEventListener('click', () => setMode(1)); document.getElementById('tab2').addEventListener('click', () => setMode(2)); document.getElementById('tabStats').addEventListener('click', () => setMode('stats')); document.getElementById('nextBtn').addEventListener('click', renderQuestion); document.getElementById('submitBtn').addEventListener('click', checkMode2); document.getElementById('clearBtn').addEventListener('click', () => { if (!answered) { mode2Selection.clear(); refreshMode2Fretboard(); } }); document.getElementById('stringCount').addEventListener('change', resetQuiz); document.getElementById('sharpsOnly').addEventListener('change', resetQuiz); document.getElementById('naturalOnly').addEventListener('change', resetQuiz); document.getElementById('noteButtons').addEventListener('click', e => { const btn = e.target.closest('.note-btn'); if (btn) checkMode1(btn.dataset.note); }); document.getElementById('fretboardContainer').addEventListener('click', e => { if (mode !== 2 || answered) return; const cell = e.target.closest('[data-s]'); if (!cell) return; const s = parseInt(cell.dataset.s); const f = parseInt(cell.dataset.f); const key = `${s},${f}`; if (mode2Selection.has(key)) mode2Selection.delete(key); else mode2Selection.add(key); refreshMode2Fretboard(); }); document.addEventListener('keydown', e => { if (e.target.tagName === 'SELECT') return; if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); if (mode === 'stats') return; if (!document.getElementById('nextBtn').disabled) renderQuestion(); else if (mode === 2 && !answered) checkMode2(); } if (mode === 1 && !answered && e.key >= '1' && e.key <= '9') { const btns = document.querySelectorAll('.note-btn'); btns[parseInt(e.key) - 1]?.click(); } if (mode === 1 && !answered) { const k = e.key.toLowerCase(); if (noteBuffer && k === 'b') { noteBuffer += 'b'; submitNoteBuffer(); } else if (noteBuffer && k === '#') { noteBuffer += '#'; submitNoteBuffer(); } else if (NOTE_LETTERS.has(k)) { clearTimeout(noteTimer); noteBuffer = k; noteTimer = setTimeout(submitNoteBuffer, 600); } } if (e.key === 'Tab' && !e.shiftKey) { e.preventDefault(); if (mode === 1) setMode(2); else if (mode === 2) setMode('stats'); else setMode(1); } }); // ─── Init ───────────────────────────────────────────────────────────────────── renderQuestion();