Files
Tobias Nauen e79260231f first commit
2026-04-29 15:57:37 +02:00

394 lines
15 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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();