first commit

This commit is contained in:
Tobias Nauen
2026-04-29 15:57:37 +02:00
commit e79260231f
15 changed files with 1216 additions and 0 deletions

393
js/app.js Normal file
View File

@@ -0,0 +1,393 @@
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();