394 lines
15 KiB
JavaScript
394 lines
15 KiB
JavaScript
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 =
|
||
`<span style="color:var(--accent2)">${stringNames[stringNumber - 1]} string</span>, ` +
|
||
`fret <span style="color:var(--accent2)">${currentQ.fret}</span>`;
|
||
|
||
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 =
|
||
`<span class="prompt-note">${noteDisplay(currentQ.targetNote, getSharpsOnly())}</span>` +
|
||
`<span style="font-size:1rem; color:var(--text-dim); margin-left:12px;">in frets ${currentQ.fretStart}–${currentQ.fretEnd}</span>`;
|
||
|
||
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 `
|
||
<div class="mode-stat-row">
|
||
<div class="mode-stat-label">${label}</div>
|
||
<div class="mode-stat-nums">${mStat.correct}/${mStat.total}</div>
|
||
<div class="acc-bar-wrap">
|
||
<div class="acc-bar" style="width:${acc}%; background:${accColor(acc)}"></div>
|
||
<span>${mStat.total ? acc + '%' : '—'}</span>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
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 = `
|
||
<div class="stats-page">
|
||
<div class="stats-overview">
|
||
<div class="stat-card">
|
||
<div class="stat-value">${overallAcc}%</div>
|
||
<div class="stat-label">Accuracy</div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="stat-value">${ls.total}</div>
|
||
<div class="stat-label">Questions</div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="stat-value">${ls.bestStreak}</div>
|
||
<div class="stat-label">Best Streak</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="stats-modes">
|
||
<div class="stats-section-title">By Mode</div>
|
||
${modeRow('Name the Note', ls.mode1)}
|
||
${modeRow('Find Occurrences', ls.mode2)}
|
||
</div>
|
||
|
||
${noteRows.length ? `
|
||
<div class="stats-notes">
|
||
<div class="stats-section-title">Accuracy by Note</div>
|
||
<table class="note-table">
|
||
<thead><tr><th>Note</th><th>Correct</th><th>Seen</th><th>Accuracy</th></tr></thead>
|
||
<tbody>
|
||
${noteRows.map(r => `
|
||
<tr>
|
||
<td class="note-cell">${noteDisplay(r.note, false)}</td>
|
||
<td>${r.correct}</td>
|
||
<td>${r.total}</td>
|
||
<td>
|
||
<div class="acc-bar-wrap">
|
||
<div class="acc-bar" style="width:${r.acc}%; background:${accColor(r.acc)}"></div>
|
||
<span>${r.acc}%</span>
|
||
</div>
|
||
</td>
|
||
</tr>`).join('')}
|
||
</tbody>
|
||
</table>
|
||
</div>` : '<div class="stats-empty">No questions answered yet — start practising!</div>'}
|
||
|
||
<button class="reset-stats-btn" id="resetStatsBtn">Reset All Stats</button>
|
||
</div>
|
||
`;
|
||
|
||
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();
|