stenodojo

stenodojo
git clone git@git.zachrice.app:repos/stenodojo.git
Log | Files | Refs

app.js (55655B)


      1 (function(){var m=document.cookie.match(/(?:^| )theme=([^;]+)/);document.documentElement.setAttribute('data-theme',m?m[1]:'dark')})();
      2 
      3 function getCookie(name) {
      4     const match = document.cookie.match(new RegExp('(^| )' + name + '=([^;]+)'));
      5     return match ? match[2] : null;
      6 }
      7 
      8 function setCookie(name, value, days) {
      9     const d = new Date();
     10     d.setTime(d.getTime() + (days * 86400000));
     11     document.cookie = name + '=' + value + ';expires=' + d.toUTCString() + ';path=/;SameSite=Lax';
     12 }
     13 
     14 function getCSSVar(name) {
     15     return getComputedStyle(document.documentElement).getPropertyValue(name).trim();
     16 }
     17 
     18 /* ===== UTILITY FUNCTIONS ===== */
     19 
     20 function escapeHtml(str) {
     21     if (typeof str !== 'string') return '';
     22     const div = document.createElement('div');
     23     div.textContent = str;
     24     return div.innerHTML;
     25 }
     26 
     27 function formatTime(ms) {
     28     if (ms < 1000) return ms + 'ms';
     29     if (ms < 60000) return (ms / 1000).toFixed(1) + 's';
     30     const minutes = Math.floor(ms / 60000);
     31     const seconds = Math.floor((ms % 60000) / 1000);
     32     return minutes + ':' + String(seconds).padStart(2, '0');
     33 }
     34 
     35 function formatWpm(wpm) {
     36     if (typeof wpm !== 'number' || isNaN(wpm)) return '0.0';
     37     return wpm.toFixed(1);
     38 }
     39 
     40 function formatAccuracy(pct) {
     41     if (typeof pct !== 'number' || isNaN(pct)) return '0%';
     42     return Math.round(pct) + '%';
     43 }
     44 
     45 function formatDuration(seconds) {
     46     if (!seconds || seconds < 60) return (seconds || 0) + 's';
     47     const minutes = Math.floor(seconds / 60);
     48     if (minutes < 60) return minutes + 'm';
     49     const hours = Math.floor(minutes / 60);
     50     const remainMin = minutes % 60;
     51     return hours + 'h ' + remainMin + 'm';
     52 }
     53 
     54 function accuracyClass(pct) {
     55     if (pct >= 90) return 'good';
     56     if (pct >= 70) return 'mid';
     57     return 'low';
     58 }
     59 
     60 
     61 /* ===== MONKEYTYPE-STYLE PRACTICE SESSION ===== */
     62 
     63 class PracticeSession {
     64     constructor(mode, lessonId) {
     65         this.mode = mode;
     66         this.lessonId = lessonId;
     67         this.sentenceCount = 10;
     68         this.sessionId = null;
     69         this.words = [];
     70         this.wordIndex = 0;
     71         this.typedChars = '';
     72         this._expectsSpace = false;
     73         this._spaceOk = null;
     74         this._activeSpaceEl = null;
     75         this._punctSpaceEl = null;
     76         this._expectedBuf = '';
     77         this._expectedBufStack = [];
     78         this._missedSpaces = new Set();
     79         this.consumedLength = 0;
     80         this.consumedStack = [];
     81         this.wordStartTime = null;
     82         this.sessionStartTime = null;
     83         this.timerInterval = null;
     84         this.ended = false;
     85 
     86         this.stats = {
     87             attempted: 0,
     88             correct: 0,
     89             streak: 0,
     90             bestStreak: 0,
     91             totalTimeMs: 0,
     92             results: [],
     93         };
     94 
     95         this.els = {
     96             loading: document.getElementById('loading'),
     97             area: document.getElementById('practice-area'),
     98             typingDisplay: document.getElementById('typing-display'),
     99             typingWords: document.getElementById('typing-words'),
    100             typingInput: document.getElementById('typing-input'),
    101             stenoHint: document.getElementById('steno-hint'),
    102             stenoStrokeText: document.getElementById('steno-stroke-text'),
    103             hintsPanel: document.getElementById('hints-panel'),
    104             hintsList: document.getElementById('hints-list'),
    105             phraseHints: document.getElementById('phrase-hints'),
    106             phraseHintsList: document.getElementById('phrase-hints-list'),
    107             typedTextDisplay: document.getElementById('typed-text-display'),
    108             stenoKeyboards: document.getElementById('steno-keyboards'),
    109             statTime: document.getElementById('stat-time'),
    110             statAccuracy: document.getElementById('stat-accuracy'),
    111             statStreak: document.getElementById('stat-streak'),
    112             statWpm: document.getElementById('stat-wpm'),
    113             statProgress: document.getElementById('stat-progress'),
    114             summary: document.getElementById('session-summary'),
    115             summaryAccuracy: document.getElementById('summary-accuracy'),
    116             summaryWords: document.getElementById('summary-words'),
    117             summaryWpm: document.getElementById('summary-wpm'),
    118             summaryStreak: document.getElementById('summary-streak'),
    119             summaryWeakWords: document.getElementById('summary-weak-words'),
    120             endBtn: document.getElementById('end-session-btn'),
    121         };
    122 
    123         this._bindEvents();
    124         this._setupConfig();
    125     }
    126 
    127     _bindEvents() {
    128         // The hidden input captures all keystrokes
    129         this.els.typingInput.addEventListener('input', () => this._onInput());
    130 
    131         this.els.typingInput.addEventListener('keydown', (e) => {
    132             if (e.key === 'Tab' || e.key === 'Escape') {
    133                 e.preventDefault();
    134             }
    135         });
    136 
    137         // Click on the typing display focuses the input
    138         this.els.typingDisplay.addEventListener('click', () => {
    139             this.els.typingInput.focus();
    140         });
    141 
    142         this.els.typingInput.addEventListener('focus', () => {
    143             this.els.typingDisplay.classList.add('typing-display--focused');
    144         });
    145         this.els.typingInput.addEventListener('blur', () => {
    146             this.els.typingDisplay.classList.remove('typing-display--focused');
    147         });
    148 
    149         this.els.endBtn.addEventListener('click', () => this.end());
    150 
    151         // Keep input focused
    152         document.addEventListener('keydown', (e) => {
    153             if (this.ended) return;
    154             if (e.target.tagName === 'INPUT' && e.target !== this.els.typingInput) return;
    155             if (document.activeElement !== this.els.typingInput) {
    156                 this.els.typingInput.focus();
    157             }
    158         });
    159     }
    160 
    161     _setupConfig() {
    162         const configEl = document.getElementById('session-config');
    163         const startBtn = document.getElementById('start-session-btn');
    164         if (!configEl || !startBtn) {
    165             this.start();
    166             return;
    167         }
    168 
    169         const btns = configEl.querySelectorAll('.session-config__btn');
    170         btns.forEach(btn => {
    171             btn.addEventListener('click', () => {
    172                 btns.forEach(b => b.classList.remove('session-config__btn--active'));
    173                 btn.classList.add('session-config__btn--active');
    174                 this.sentenceCount = parseInt(btn.dataset.count) || 10;
    175             });
    176         });
    177 
    178         startBtn.addEventListener('click', () => {
    179             configEl.innerHTML = '<div class="spinner"></div><p>Loading session...</p>';
    180             this.start();
    181         });
    182     }
    183 
    184     async start() {
    185         try {
    186             const body = { mode: this.mode };
    187             if (this.lessonId) {
    188                 body.lesson_id = this.lessonId;
    189                 body.sentence_count = this.sentenceCount;
    190             }
    191 
    192             const resp = await fetch('/api/session/start', {
    193                 method: 'POST',
    194                 headers: { 'Content-Type': 'application/json' },
    195                 body: JSON.stringify(body),
    196             });
    197             const data = await resp.json();
    198 
    199             if (data.error) {
    200                 this.els.loading.innerHTML = '<p style="color: var(--error);">' + escapeHtml(data.error) + '</p>';
    201                 return;
    202             }
    203 
    204             this.sessionId = data.session_id;
    205             this.words = data.words || [];
    206             this.isSentence = !!data.is_sentence;
    207 
    208             this.els.loading.style.display = 'none';
    209             this.els.area.style.display = 'flex';
    210 
    211             if (typeof gsap !== 'undefined') {
    212                 const tl = gsap.timeline({ defaults: { ease: 'power2.out', clearProps: 'all' } });
    213                 tl.from('.typing-display', { y: 20, opacity: 0, duration: 0.4 })
    214                   .from('.steno-hint', { y: 20, opacity: 0, duration: 0.4 }, '-=0.25')
    215                   .from('.hints-panel', { x: 20, opacity: 0, duration: 0.4 }, '-=0.2')
    216                   .from('.session-stats', { y: 15, opacity: 0, duration: 0.35 }, '-=0.15');
    217             }
    218 
    219             this._renderWords();
    220             this._updateHints();
    221             this._updateStats();
    222 
    223             this.els.typingInput.focus();
    224         } catch (err) {
    225             this.els.loading.innerHTML = '<p style="color: var(--error);">Failed to start session.</p>';
    226         }
    227     }
    228 
    229     _renderWords() {
    230         const container = this.els.typingWords;
    231         container.innerHTML = '';
    232         this._activeSpaceEl = null;
    233         this._punctSpaceEl = null;
    234 
    235         // Determine which upcoming words are covered by phrase hints
    236         const phraseSet = new Set();
    237         const currentWord = this.words[this.wordIndex];
    238         if (currentWord && currentWord.phrase_hints) {
    239             currentWord.phrase_hints.forEach(ph => {
    240                 for (let j = 0; j < ph.words_covered; j++) {
    241                     phraseSet.add(this.wordIndex + j);
    242                 }
    243             });
    244         }
    245 
    246         this.words.forEach((w, i) => {
    247             const wordEl = document.createElement('span');
    248             wordEl.className = 'tw-word';
    249             wordEl.dataset.index = i;
    250 
    251             if (i < this.wordIndex) {
    252                 const result = this.stats.results[i];
    253                 if (result && result.correct) {
    254                     wordEl.classList.add('tw-word--correct');
    255                 } else {
    256                     wordEl.classList.add('tw-word--incorrect');
    257                 }
    258                 wordEl.textContent = w.word;
    259             } else if (i === this.wordIndex) {
    260                 wordEl.classList.add('tw-word--active');
    261                 this._renderActiveWord(wordEl, w.word);
    262             } else {
    263                 wordEl.textContent = w.word;
    264                 if (phraseSet.has(i)) {
    265                     wordEl.classList.add('tw-word--phrase');
    266                 }
    267             }
    268 
    269             if (w.is_punct) wordEl.classList.add('tw-word--punct');
    270             container.appendChild(wordEl);
    271 
    272             if (i < this.words.length - 1) {
    273                 const next = this.words[i + 1];
    274                 if (!next.is_punct) {
    275                     if (this.isSentence) {
    276                         const spaceEl = document.createElement('span');
    277                         if (i + 1 < this.wordIndex && this._missedSpaces.has(i + 1)) {
    278                             spaceEl.className = 'tw-space-sep tw-space-sep--missed';
    279                         } else if (i + 1 < this.wordIndex) {
    280                             spaceEl.className = 'tw-space-sep tw-space-sep--done';
    281                         } else {
    282                             spaceEl.className = 'tw-space-sep';
    283                         }
    284                         spaceEl.textContent = '␣';
    285                         if (i + 1 === this.wordIndex) {
    286                             this._activeSpaceEl = spaceEl;
    287                         }
    288                         // Track space after active punct token
    289                         if (i === this.wordIndex && w.is_punct) {
    290                             this._punctSpaceEl = spaceEl;
    291                         }
    292                         container.appendChild(spaceEl);
    293                     } else {
    294                         container.appendChild(document.createTextNode(' '));
    295                     }
    296                 }
    297             }
    298         });
    299 
    300         this._scrollToActive();
    301     }
    302 
    303     _renderActiveWord(wordEl, expectedWord) {
    304         wordEl.innerHTML = '';
    305         const typed = this.typedChars;
    306 
    307         for (let i = 0; i < expectedWord.length; i++) {
    308             // Insert cursor before the next untyped character
    309             if (i === typed.length) {
    310                 const cursor = document.createElement('span');
    311                 cursor.className = 'tw-cursor';
    312                 wordEl.appendChild(cursor);
    313             }
    314 
    315             const charSpan = document.createElement('span');
    316             if (i < typed.length) {
    317                 if (typed[i] === expectedWord[i]) {
    318                     charSpan.className = 'tw-char tw-char--correct';
    319                 } else {
    320                     charSpan.className = 'tw-char tw-char--incorrect';
    321                 }
    322                 charSpan.textContent = expectedWord[i];
    323             } else {
    324                 charSpan.className = 'tw-char';
    325                 charSpan.textContent = expectedWord[i];
    326             }
    327             wordEl.appendChild(charSpan);
    328         }
    329 
    330         // Extra typed characters beyond the word length
    331         if (typed.length > expectedWord.length) {
    332             for (let i = expectedWord.length; i < typed.length; i++) {
    333                 const charSpan = document.createElement('span');
    334                 charSpan.className = 'tw-char tw-char--extra';
    335                 charSpan.textContent = typed[i];
    336                 wordEl.appendChild(charSpan);
    337             }
    338             const cursor = document.createElement('span');
    339             cursor.className = 'tw-cursor';
    340             wordEl.appendChild(cursor);
    341         }
    342     }
    343 
    344     _scrollToActive() {
    345         const active = this.els.typingWords.querySelector('.tw-word--active');
    346         if (!active) return;
    347 
    348         const container = this.els.typingDisplay;
    349         const padding = parseFloat(getComputedStyle(container).paddingTop) || 0;
    350         container.scrollTop = Math.max(0, active.offsetTop - padding);
    351     }
    352 
    353     _onInput() {
    354         if (this.ended) return;
    355         if (this.isSentence) return this._onInputSentence();
    356 
    357         const raw = this.els.typingInput.value;
    358 
    359         // Undo: Plover's * sends backspaces — if input shrank past consumed boundary, revert words
    360         let didUndo = false;
    361         while (raw.length < this.consumedLength && this.wordIndex > 0) {
    362             this.wordIndex--;
    363             this.consumedLength = this.consumedStack.pop() || 0;
    364             const last = this.stats.results.pop();
    365             if (last) {
    366                 this.stats.attempted--;
    367                 this.stats.totalTimeMs -= last.timeMs;
    368                 if (last.correct) this.stats.correct--;
    369             }
    370             this.stats.streak = 0;
    371             for (let i = this.stats.results.length - 1; i >= 0; i--) {
    372                 if (this.stats.results[i].correct) this.stats.streak++;
    373                 else break;
    374             }
    375             didUndo = true;
    376         }
    377         if (didUndo) {
    378             this.wordStartTime = performance.now();
    379             this._renderWords();
    380             this._updateHints();
    381             this._updateStats();
    382         }
    383 
    384         if (this.wordIndex >= this.words.length) return;
    385 
    386         // Current word's text = everything past consumed, with leading whitespace stripped
    387         let current = raw.substring(this.consumedLength);
    388         const trimmed = current.replace(/^\s+/, '');
    389 
    390         // Ignore empty input before session starts (Plover focus noise)
    391         if (!trimmed && !this.sessionStartTime) return;
    392 
    393         if (!this.sessionStartTime && trimmed) {
    394             this.sessionStartTime = performance.now();
    395             this.wordStartTime = performance.now();
    396             this.timerInterval = setInterval(() => {
    397                 if (this.sessionStartTime && !this.ended) {
    398                     const elapsed = performance.now() - this.sessionStartTime;
    399                     this.els.statTime.textContent = formatTime(Math.round(elapsed));
    400                 }
    401             }, 200);
    402         }
    403 
    404         this.typedChars = trimmed;
    405         if (this.els.typedTextDisplay) {
    406             this.els.typedTextDisplay.textContent = trimmed || ' ';
    407         }
    408         const expected = this.words[this.wordIndex].word;
    409 
    410         // Multi-word output: typed text contains expected word + space + more
    411         if (trimmed.length > expected.length) {
    412             const head = trimmed.substring(0, expected.length);
    413             const tail = trimmed.substring(expected.length);
    414             if (head === expected && /^\s/.test(tail)) {
    415                 this.typedChars = head;
    416                 this.consumedStack.push(this.consumedLength);
    417                 this.consumedLength = raw.length - tail.replace(/^\s+/, '').length;
    418                 this._submitCurrentWord();
    419                 this._onInput();
    420                 return;
    421             }
    422         }
    423 
    424         // Auto-match: exact match (case matters for steno capitalization)
    425         if (trimmed === expected) {
    426             this.consumedStack.push(this.consumedLength);
    427             this.consumedLength = raw.length;
    428             this._submitCurrentWord();
    429             return;
    430         }
    431 
    432         // Update character feedback on active word
    433         const activeEl = this.els.typingWords.querySelector('.tw-word--active');
    434         if (activeEl) {
    435             this._renderActiveWord(activeEl, expected);
    436         }
    437         this._updateStats();
    438     }
    439 
    440     _onInputSentence() {
    441         const raw = this.els.typingInput.value;
    442         const buf = raw.trimStart();
    443 
    444         if (!buf && !this.sessionStartTime) return;
    445 
    446         if (!this.sessionStartTime && buf) {
    447             this.sessionStartTime = performance.now();
    448             this.wordStartTime = performance.now();
    449             this.timerInterval = setInterval(() => {
    450                 if (this.sessionStartTime && !this.ended) {
    451                     const elapsed = performance.now() - this.sessionStartTime;
    452                     this.els.statTime.textContent = formatTime(Math.round(elapsed));
    453                 }
    454             }, 200);
    455         }
    456 
    457         if (this.wordIndex >= this.words.length) return;
    458 
    459         // Undo detection: buffer no longer matches our tracked expected prefix
    460         while (this.wordIndex > 0 && this._expectedBufStack.length > 0) {
    461             if (buf.length < this._expectedBuf.length ||
    462                 buf.substring(0, this._expectedBuf.length) !== this._expectedBuf) {
    463                 this._expectedBuf = this._expectedBufStack.pop();
    464                 this.wordIndex--;
    465                 this._missedSpaces.delete(this.wordIndex);
    466                 const last = this.stats.results.pop();
    467                 if (last) {
    468                     this.stats.attempted--;
    469                     this.stats.totalTimeMs -= last.timeMs;
    470                     if (last.correct) this.stats.correct--;
    471                 }
    472                 this.stats.streak = 0;
    473                 for (let i = this.stats.results.length - 1; i >= 0; i--) {
    474                     if (this.stats.results[i].correct) this.stats.streak++;
    475                     else break;
    476                 }
    477                 this.wordStartTime = performance.now();
    478                 this._renderWords();
    479                 this._updateHints();
    480                 this._updateStats();
    481             } else {
    482                 break;
    483             }
    484         }
    485 
    486         // Advance through tokens, tolerating missed spaces
    487         let advanced = false;
    488         while (this.wordIndex < this.words.length) {
    489             const token = this.words[this.wordIndex];
    490             const needsSpace = !token.is_punct && this._expectedBuf.length > 0;
    491             const withSpace = this._expectedBuf + (needsSpace ? ' ' : '') + token.word;
    492             const noSpace = needsSpace ? (this._expectedBuf + token.word) : null;
    493 
    494             if (buf.length >= withSpace.length &&
    495                 buf.substring(0, withSpace.length) === withSpace) {
    496                 this._expectedBufStack.push(this._expectedBuf);
    497                 this._expectedBuf = withSpace;
    498                 this.typedChars = token.word;
    499                 this._submitCurrentWord();
    500                 advanced = true;
    501             } else if (noSpace && buf.length >= noSpace.length &&
    502                        buf.substring(0, noSpace.length) === noSpace) {
    503                 this._expectedBufStack.push(this._expectedBuf);
    504                 this._expectedBuf = noSpace;
    505                 this._missedSpaces.add(this.wordIndex);
    506                 this.typedChars = token.word;
    507                 this._submitCurrentWord();
    508                 advanced = true;
    509             } else {
    510                 break;
    511             }
    512         }
    513 
    514         if (advanced) return;
    515 
    516         // Show partial-typing feedback on the active token
    517         const token = this.words[this.wordIndex];
    518         const afterPrev = buf.substring(this._expectedBuf.length);
    519 
    520         const expectsSpace = !token.is_punct && this._expectedBuf.length > 0;
    521         this._expectsSpace = expectsSpace;
    522         if (expectsSpace) {
    523             this._spaceOk = afterPrev.length > 0 ? afterPrev[0] === ' ' : null;
    524             this.typedChars = afterPrev.replace(/^\s?/, '');
    525         } else {
    526             this._spaceOk = null;
    527             this.typedChars = token.is_punct ? afterPrev : afterPrev.replace(/^\s+/, '');
    528         }
    529 
    530         if (this.els.typedTextDisplay) {
    531             this.els.typedTextDisplay.textContent = this.typedChars || ' ';
    532         }
    533 
    534         // Update space separator colors
    535         if (this._activeSpaceEl) {
    536             if (this._spaceOk === true) {
    537                 this._activeSpaceEl.className = 'tw-space-sep tw-space-sep--correct';
    538             } else if (this._spaceOk === false) {
    539                 this._activeSpaceEl.className = 'tw-space-sep tw-space-sep--incorrect';
    540             } else {
    541                 this._activeSpaceEl.className = 'tw-space-sep';
    542             }
    543         }
    544         // Highlight space after active punct to show the stroke includes it
    545         if (this._punctSpaceEl && token.is_punct && this.typedChars.length > 0) {
    546             this._punctSpaceEl.className = 'tw-space-sep tw-space-sep--pending';
    547         }
    548 
    549         const activeEl = this.els.typingWords.querySelector('.tw-word--active');
    550         if (activeEl) {
    551             this._renderActiveWord(activeEl, token.word);
    552         }
    553         this._updateStats();
    554     }
    555 
    556     async _submitCurrentWord() {
    557         if (this.wordIndex >= this.words.length) return;
    558 
    559         const word = this.words[this.wordIndex];
    560         const typed = this.typedChars;
    561         const timeMs = Math.round(performance.now() - this.wordStartTime);
    562         const correct = typed === word.word;
    563 
    564         this.stats.attempted++;
    565         this.stats.totalTimeMs += timeMs;
    566 
    567         if (correct) {
    568             this.stats.correct++;
    569             this.stats.streak++;
    570             if (this.stats.streak > this.stats.bestStreak) {
    571                 this.stats.bestStreak = this.stats.streak;
    572             }
    573         } else {
    574             this.stats.streak = 0;
    575         }
    576 
    577         this.stats.results.push({
    578             word: word.word,
    579             typed: typed,
    580             correct: correct,
    581             timeMs: timeMs,
    582             strokes: word.word_hints && word.word_hints[0] ? word.word_hints[0].stroke : '',
    583         });
    584 
    585         // Submit to server (fire and forget for speed)
    586         fetch('/api/word/submit', {
    587             method: 'POST',
    588             headers: { 'Content-Type': 'application/json' },
    589             body: JSON.stringify({
    590                 session_id: this.sessionId,
    591                 word: word.word,
    592                 typed: typed,
    593                 time_ms: timeMs,
    594                 word_index: this.wordIndex,
    595             }),
    596         }).catch(() => {});
    597 
    598         // Advance (don't clear input — Plover manages the text buffer)
    599         this.wordIndex++;
    600         this.typedChars = '';
    601         if (this.els.typedTextDisplay) {
    602             this.els.typedTextDisplay.textContent = ' ';
    603         }
    604         this.wordStartTime = performance.now();
    605 
    606         this._updateStats();
    607 
    608         if (this.wordIndex >= this.words.length) {
    609             this.end();
    610             return;
    611         }
    612 
    613         this._renderWords();
    614         this._updateHints();
    615     }
    616 
    617     _updateHints() {
    618         if (this.wordIndex >= this.words.length) return;
    619 
    620         const word = this.words[this.wordIndex];
    621         const wordHints = word.word_hints || [];
    622         const phraseHints = word.phrase_hints || [];
    623 
    624         // Phrase hints in side panel (already sorted longest-first by server)
    625         if (phraseHints.length > 0 && this.els.phraseHintsList) {
    626             this.els.phraseHints.style.display = 'block';
    627             let html = '';
    628             phraseHints.forEach(ph => {
    629                 const strokesStr = ph.strokes.join(' or ');
    630                 html += '<div class="phrase-hint-item' + (ph.is_single_stroke ? ' phrase-hint-item--single' : '') + '">';
    631                 html += '<span class="phrase-hint-item__phrase">"' + escapeHtml(ph.phrase) + '"</span>';
    632                 html += '<span class="phrase-hint-item__words">' + ph.words_covered + ' words</span>';
    633                 if (strokesStr) {
    634                     html += '<span class="phrase-hint-item__stroke">' + escapeHtml(strokesStr) + '</span>';
    635                 }
    636                 html += '</div>';
    637             });
    638             this.els.phraseHintsList.innerHTML = html;
    639         } else if (this.els.phraseHints) {
    640             this.els.phraseHints.style.display = 'none';
    641         }
    642 
    643         // Build keyboard visualizations
    644         this._renderKeyboards(phraseHints, wordHints, word);
    645 
    646         // Word-level strokes in side panel
    647         if (wordHints.length > 0) {
    648             let html = '';
    649             wordHints.forEach((hint, i) => {
    650                 const cls = i === 0 ? 'hint-item hint-item--best' : 'hint-item';
    651                 const strokeLabel = hint.stroke_count === 1 ? '1 stroke' : hint.stroke_count + ' strokes';
    652                 html += '<div class="' + cls + '" data-hint-index="' + i + '">';
    653                 html += '<span class="hint-item__stroke">' + escapeHtml(hint.stroke) + '</span>';
    654                 const labelTag = hint.label ? ' <span class="hint-item__label">' + escapeHtml(hint.label) + '</span>' : '';
    655                 html += '<span class="hint-item__meta">' + strokeLabel + ', ' + hint.key_count + ' keys' + labelTag + '</span>';
    656                 html += '</div>';
    657             });
    658             this.els.hintsList.innerHTML = html;
    659 
    660             this.els.hintsList.querySelectorAll('.hint-item').forEach(el => {
    661                 el.addEventListener('click', () => {
    662                     const idx = parseInt(el.dataset.hintIndex);
    663                     if (wordHints[idx] && wordHints[idx].keys) {
    664                         this._renderKeyboards(phraseHints, wordHints, word, idx);
    665                     }
    666                 });
    667             });
    668         } else if (word.fingerspell) {
    669             this.els.hintsList.innerHTML = '<div class="hint-item hint-item--empty">Fingerspell: spell out each letter</div>';
    670         } else {
    671             this.els.hintsList.innerHTML = '<div class="hint-item hint-item--empty">No strokes found</div>';
    672         }
    673 
    674         // Punctuation hints below word strokes
    675         const punctHints = word.punct_hints || [];
    676         if (punctHints.length > 0) {
    677             let punctHtml = '';
    678             punctHints.forEach(ph => {
    679                 punctHtml += '<div class="hint-item hint-item--punct">';
    680                 punctHtml += '<span class="hint-item__stroke">' + escapeHtml(ph.char) + ' → ' + escapeHtml(ph.stroke) + '</span>';
    681                 punctHtml += '</div>';
    682             });
    683             this.els.hintsList.innerHTML += punctHtml;
    684         }
    685 
    686         // Capitalization hints
    687         const capHints = word.cap_hints || [];
    688         if (capHints.length > 0) {
    689             const capLabels = {
    690                 'cap_next': 'Capitalize',
    691                 'cap_next_attach': 'Cap (no space)',
    692                 'cap_retro': 'Retro cap',
    693                 'upper_next': 'ALL CAPS',
    694                 'upper_next_attach': 'ALL CAPS (no space)',
    695                 'upper_retro': 'Retro ALL CAPS',
    696                 'lower_next': 'Lowercase',
    697                 'lower_retro': 'Retro lowercase',
    698             };
    699             let capHtml = '';
    700             capHints.forEach(ch => {
    701                 const label = capLabels[ch.type] || ch.type;
    702                 capHtml += '<div class="hint-item hint-item--cap">';
    703                 capHtml += '<span class="hint-item__stroke">' + escapeHtml(label) + ' → ' + escapeHtml(ch.stroke) + '</span>';
    704                 capHtml += '</div>';
    705             });
    706             this.els.hintsList.innerHTML += capHtml;
    707         }
    708     }
    709 
    710     _renderKeyboards(phraseHints, wordHints, word, wordHintIdx) {
    711         const container = this.els.stenoKeyboards;
    712         container.innerHTML = '';
    713         if (wordHintIdx === undefined) wordHintIdx = 0;
    714 
    715         const bestPhrase = phraseHints.length > 0 ? phraseHints[0] : null;
    716         const bestWord = wordHints.length > 0 ? wordHints[wordHintIdx] : null;
    717 
    718         if (bestPhrase && bestPhrase.keys && bestPhrase.keys.length > 0) {
    719             const strokeParts = (bestPhrase.strokes[0] || '').split('/');
    720             this._appendKeyboardGroup(container, 'Phrase', bestPhrase.strokes[0], bestPhrase.keys, 'phrase', strokeParts);
    721         }
    722 
    723         if (bestWord && bestWord.keys && bestWord.keys.length > 0) {
    724             const strokeParts = bestWord.stroke.split('/');
    725             this._appendKeyboardGroup(container, 'Word', bestWord.stroke, bestWord.keys, 'word', strokeParts);
    726         } else if (word.fingerspell) {
    727             const section = document.createElement('div');
    728             section.className = 'steno-keyboard-section';
    729             section.innerHTML = '<div class="steno-hint__header">'
    730                 + '<span class="steno-hint__title">Word</span>'
    731                 + '<span class="steno-stroke">fingerspell</span>'
    732                 + '</div>';
    733             container.appendChild(section);
    734         }
    735     }
    736 
    737     _appendKeyboardGroup(container, label, strokeText, keyGroups, type, strokeParts) {
    738         const isPhrase = type === 'phrase';
    739         const titleClass = isPhrase ? ' steno-hint__title--phrase' : '';
    740         const strokeClass = isPhrase ? ' steno-stroke--phrase' : '';
    741 
    742         if (keyGroups.length === 1) {
    743             const section = document.createElement('div');
    744             section.className = 'steno-keyboard-section';
    745             section.innerHTML = '<div class="steno-hint__header">'
    746                 + '<span class="steno-hint__title' + titleClass + '">' + escapeHtml(label) + '</span>'
    747                 + '<span class="steno-stroke' + strokeClass + '">' + escapeHtml(strokeText) + '</span>'
    748                 + '</div>';
    749             const kbWrap = document.createElement('div');
    750             kbWrap.className = 'steno-hint__keyboard';
    751             const svg = this._createKeyboardSVG(isPhrase);
    752             this._highlightKeysOn(svg, keyGroups[0]);
    753             kbWrap.appendChild(svg);
    754             section.appendChild(kbWrap);
    755             container.appendChild(section);
    756         } else {
    757             const section = document.createElement('div');
    758             section.className = 'steno-keyboard-section steno-keyboard-section--multi';
    759             section.innerHTML = '<div class="steno-hint__header">'
    760                 + '<span class="steno-hint__title' + titleClass + '">' + escapeHtml(label) + '</span>'
    761                 + '<span class="steno-stroke' + strokeClass + '">' + escapeHtml(strokeText) + '</span>'
    762                 + '</div>';
    763             const row = document.createElement('div');
    764             row.className = 'steno-keyboards-row';
    765             keyGroups.forEach((keys, i) => {
    766                 const cell = document.createElement('div');
    767                 cell.className = 'steno-keyboard-cell';
    768                 if (strokeParts && strokeParts[i]) {
    769                     const partLabel = document.createElement('div');
    770                     partLabel.className = 'steno-keyboard-cell__label' + (isPhrase ? ' steno-keyboard-cell__label--phrase' : '');
    771                     partLabel.textContent = strokeParts[i];
    772                     cell.appendChild(partLabel);
    773                 }
    774                 const svg = this._createKeyboardSVG(isPhrase);
    775                 this._highlightKeysOn(svg, keys);
    776                 cell.appendChild(svg);
    777                 row.appendChild(cell);
    778             });
    779             section.appendChild(row);
    780             container.appendChild(section);
    781         }
    782     }
    783 
    784     _createKeyboardSVG(isPhrase) {
    785         const ns = 'http://www.w3.org/2000/svg';
    786         const svg = document.createElementNS(ns, 'svg');
    787         svg.setAttribute('class', 'steno-keyboard' + (isPhrase ? ' steno-keyboard--phrase' : ''));
    788         svg.setAttribute('viewBox', '0 0 520 170');
    789         const keys = [
    790             { x: 10, y: 5, w: 500, h: 28, key: '#', lx: 260, ly: 24, small: true },
    791             { x: 10, y: 40, w: 44, h: 72, key: 'S-', lx: 32, ly: 80 },
    792             { x: 58, y: 40, w: 44, h: 34, key: 'T-', lx: 80, ly: 62 },
    793             { x: 106, y: 40, w: 44, h: 34, key: 'P-', lx: 128, ly: 62 },
    794             { x: 154, y: 40, w: 44, h: 34, key: 'H-', lx: 176, ly: 62 },
    795             { x: 210, y: 40, w: 44, h: 72, key: '*', lx: 232, ly: 80 },
    796             { x: 266, y: 40, w: 44, h: 34, key: '-F', lx: 288, ly: 62 },
    797             { x: 314, y: 40, w: 44, h: 34, key: '-P', lx: 336, ly: 62 },
    798             { x: 362, y: 40, w: 44, h: 34, key: '-L', lx: 384, ly: 62 },
    799             { x: 410, y: 40, w: 44, h: 34, key: '-T', lx: 432, ly: 62 },
    800             { x: 458, y: 40, w: 44, h: 34, key: '-D', lx: 480, ly: 62 },
    801             { x: 58, y: 78, w: 44, h: 34, key: 'K-', lx: 80, ly: 100 },
    802             { x: 106, y: 78, w: 44, h: 34, key: 'W-', lx: 128, ly: 100 },
    803             { x: 154, y: 78, w: 44, h: 34, key: 'R-', lx: 176, ly: 100 },
    804             { x: 266, y: 78, w: 44, h: 34, key: '-R', lx: 288, ly: 100 },
    805             { x: 314, y: 78, w: 44, h: 34, key: '-B', lx: 336, ly: 100 },
    806             { x: 362, y: 78, w: 44, h: 34, key: '-G', lx: 384, ly: 100 },
    807             { x: 410, y: 78, w: 44, h: 34, key: '-S', lx: 432, ly: 100 },
    808             { x: 458, y: 78, w: 44, h: 34, key: '-Z', lx: 480, ly: 100 },
    809             { x: 130, y: 120, w: 52, h: 34, key: 'A', lx: 156, ly: 142 },
    810             { x: 186, y: 120, w: 52, h: 34, key: 'O', lx: 212, ly: 142 },
    811             { x: 282, y: 120, w: 52, h: 34, key: 'E', lx: 308, ly: 142 },
    812             { x: 338, y: 120, w: 52, h: 34, key: 'U', lx: 364, ly: 142 },
    813         ];
    814         const labelMap = {
    815             '#': '#', 'S-': 'S', 'T-': 'T', 'P-': 'P', 'H-': 'H', '*': '*',
    816             '-F': 'F', '-P': 'P', '-L': 'L', '-T': 'T', '-D': 'D',
    817             'K-': 'K', 'W-': 'W', 'R-': 'R',
    818             '-R': 'R', '-B': 'B', '-G': 'G', '-S': 'S', '-Z': 'Z',
    819             'A': 'A', 'O': 'O', 'E': 'E', 'U': 'U',
    820         };
    821         keys.forEach(k => {
    822             const rect = document.createElementNS(ns, 'rect');
    823             rect.setAttribute('x', k.x);
    824             rect.setAttribute('y', k.y);
    825             rect.setAttribute('width', k.w);
    826             rect.setAttribute('height', k.h);
    827             rect.setAttribute('rx', '4');
    828             rect.setAttribute('class', 'steno-key');
    829             rect.setAttribute('data-key', k.key);
    830             svg.appendChild(rect);
    831             const text = document.createElementNS(ns, 'text');
    832             text.setAttribute('x', k.lx);
    833             text.setAttribute('y', k.ly);
    834             text.setAttribute('class', 'steno-key-label' + (k.small ? ' steno-key-label--small' : ''));
    835             text.textContent = labelMap[k.key] || k.key;
    836             svg.appendChild(text);
    837         });
    838         return svg;
    839     }
    840 
    841     _highlightKeysOn(svgEl, keys) {
    842         if (!keys || !svgEl) return;
    843         keys.forEach(key => {
    844             const el = svgEl.querySelector('.steno-key[data-key="' + CSS.escape(key) + '"]');
    845             if (el) {
    846                 el.classList.add('active');
    847                 const nextText = el.nextElementSibling;
    848                 if (nextText && nextText.tagName === 'text') {
    849                     nextText.style.fill = getCSSVar('--text-on-color');
    850                 }
    851             }
    852         });
    853     }
    854 
    855     _updateStats() {
    856         const elapsed = performance.now() - (this.sessionStartTime || performance.now());
    857         this.els.statTime.textContent = formatTime(Math.round(elapsed));
    858 
    859         const accuracy = this.stats.attempted > 0
    860             ? (this.stats.correct / this.stats.attempted) * 100
    861             : 0;
    862         this.els.statAccuracy.textContent = formatAccuracy(accuracy);
    863         this.els.statStreak.textContent = this.stats.streak;
    864 
    865         const avgWpm = this.stats.totalTimeMs > 0 && this.stats.attempted > 0
    866             ? (this.stats.attempted / (this.stats.totalTimeMs / 60000))
    867             : 0;
    868         this.els.statWpm.textContent = formatWpm(avgWpm) + ' WPM';
    869 
    870         this.els.statProgress.textContent = this.wordIndex + '/' + this.words.length;
    871     }
    872 
    873     async end() {
    874         if (this.ended) return;
    875         this.ended = true;
    876         if (this.timerInterval) {
    877             clearInterval(this.timerInterval);
    878             this.timerInterval = null;
    879         }
    880 
    881         try {
    882             const resp = await fetch('/api/session/end', {
    883                 method: 'POST',
    884                 headers: { 'Content-Type': 'application/json' },
    885                 body: JSON.stringify({ session_id: this.sessionId }),
    886             });
    887             await resp.json();
    888         } catch (err) {}
    889 
    890         this._showSummary();
    891     }
    892 
    893     _showSummary() {
    894         const accuracy = this.stats.attempted > 0
    895             ? (this.stats.correct / this.stats.attempted) * 100
    896             : 0;
    897         const avgWpm = this.stats.totalTimeMs > 0 && this.stats.attempted > 0
    898             ? (this.stats.attempted / (this.stats.totalTimeMs / 60000))
    899             : 0;
    900 
    901         const accClass = accuracyClass(accuracy);
    902         this.els.summaryAccuracy.textContent = formatAccuracy(accuracy);
    903         this.els.summaryAccuracy.className = 'summary-accuracy summary-accuracy--' + accClass;
    904 
    905         this.els.summaryWords.textContent = this.stats.correct + ' / ' + this.stats.attempted;
    906         this.els.summaryWpm.textContent = formatWpm(avgWpm);
    907         this.els.summaryStreak.textContent = this.stats.bestStreak;
    908 
    909         const errors = this.stats.results.filter(r => !r.correct);
    910         const slowest = this.stats.results
    911             .filter(r => r.correct)
    912             .sort((a, b) => b.timeMs - a.timeMs)
    913             .slice(0, 5);
    914 
    915         if (errors.length > 0) {
    916             let html = '<div class="summary-weak-words__title">Errors (' + errors.length + ')</div>';
    917             html += '<div class="summary-errors__list">';
    918             errors.slice(0, 15).forEach(w => {
    919                 html += '<div class="error-detail">';
    920                 html += '<div class="error-detail__words">';
    921                 html += '<span class="error-detail__expected">' + escapeHtml(w.word) + '</span>';
    922                 html += '<span class="error-detail__arrow">&rarr;</span>';
    923                 html += '<span class="error-detail__typed">' + escapeHtml(w.typed || '(empty)') + '</span>';
    924                 html += '</div>';
    925                 if (w.strokes) {
    926                     html += '<span class="error-detail__stroke">' + escapeHtml(w.strokes) + '</span>';
    927                 }
    928                 html += '</div>';
    929             });
    930             html += '</div>';
    931             if (slowest.length > 0) {
    932                 html += '<div class="summary-weak-words__title" style="margin-top:1rem;">Slowest Correct</div>';
    933                 html += '<div class="summary-weak-words__list">';
    934                 slowest.forEach(w => {
    935                     html += '<span class="weak-word-chip">';
    936                     html += '<span class="weak-word-chip__word">' + escapeHtml(w.word) + '</span>';
    937                     html += '<span class="weak-word-chip__stroke">' + formatTime(w.timeMs) + '</span>';
    938                     html += '</span>';
    939                 });
    940                 html += '</div>';
    941             }
    942             this.els.summaryWeakWords.innerHTML = html;
    943         } else {
    944             this.els.summaryWeakWords.innerHTML = '<p style="text-align:center;color:var(--success);font-weight:600;">Perfect session!</p>';
    945         }
    946 
    947         this.els.summary.style.display = 'flex';
    948 
    949         if (typeof gsap !== 'undefined') {
    950             const stl = gsap.timeline({ defaults: { clearProps: 'all' } });
    951             stl.from('.modal', { y: 30, scale: 0.95, opacity: 0, duration: 0.35, ease: 'back.out(1.2)' })
    952                .from('.summary-accuracy', { scale: 0.5, opacity: 0, duration: 0.5, ease: 'back.out(1.5)' }, '-=0.1')
    953                .from('.summary-stats .summary-stat', { y: 15, opacity: 0, duration: 0.3, stagger: 0.08, ease: 'power2.out' }, '-=0.2');
    954         }
    955     }
    956 }
    957 
    958 
    959 /* ===== STATS PAGE ===== */
    960 
    961 async function loadStats() {
    962     let overview, daily, heatmap, words, sessions;
    963 
    964     try {
    965         const results = await Promise.allSettled([
    966             fetch('/api/stats/overview').then(r => r.json()),
    967             fetch('/api/stats/daily?days=30').then(r => r.json()),
    968             fetch('/api/stats/heatmap').then(r => r.json()),
    969             fetch('/api/stats/words?sort=accuracy&order=asc&limit=50').then(r => r.json()),
    970             fetch('/api/stats/sessions?limit=20').then(r => r.json())
    971         ]);
    972 
    973         overview = results[0].status === 'fulfilled' ? results[0].value : null;
    974         daily = results[1].status === 'fulfilled' ? results[1].value : null;
    975         heatmap = results[2].status === 'fulfilled' ? results[2].value : null;
    976         words = results[3].status === 'fulfilled' ? results[3].value : null;
    977         sessions = results[4].status === 'fulfilled' ? results[4].value : null;
    978     } catch (err) {
    979         console.error('Failed to load stats:', err);
    980         return;
    981     }
    982 
    983     if (overview) renderOverview(overview);
    984     if (daily) {
    985         renderWpmChart(daily);
    986         renderDailyChart(daily);
    987     }
    988     if (heatmap) renderHeatmap(heatmap);
    989     if (words) renderWordTable(words);
    990     if (sessions) renderSessionTable(sessions);
    991 }
    992 
    993 function renderOverview(data) {
    994     const setEl = (id, val) => {
    995         const el = document.getElementById(id);
    996         if (el) el.textContent = val;
    997     };
    998 
    999     setEl('overview-sessions', data.total_sessions || 0);
   1000     setEl('overview-words', data.total_words || 0);
   1001     setEl('overview-mastered', data.words_mastered || 0);
   1002     setEl('overview-accuracy', formatAccuracy(data.avg_accuracy || 0));
   1003     setEl('overview-wpm', formatWpm(data.avg_wpm || 0));
   1004     setEl('overview-time', formatDuration(data.total_time_seconds || 0));
   1005     setEl('overview-streak', data.best_streak || 0);
   1006 }
   1007 
   1008 function renderWpmChart(daily) {
   1009     const ctx = document.getElementById('wpm-chart');
   1010     if (!ctx) return;
   1011 
   1012     const labels = daily.map(d => d.date);
   1013     const wpmData = daily.map(d => d.avg_wpm || 0);
   1014     const accData = daily.map(d => d.accuracy || 0);
   1015 
   1016     new Chart(ctx, {
   1017         type: 'line',
   1018         data: {
   1019             labels: labels,
   1020             datasets: [
   1021                 {
   1022                     label: 'WPM',
   1023                     data: wpmData,
   1024                     borderColor: getCSSVar('--chart-blue'),
   1025                     backgroundColor: getCSSVar('--accent-glow'),
   1026                     borderWidth: 2,
   1027                     fill: true,
   1028                     tension: 0.3,
   1029                     yAxisID: 'y',
   1030                     pointRadius: 3,
   1031                     pointBackgroundColor: getCSSVar('--chart-blue')
   1032                 },
   1033                 {
   1034                     label: 'Accuracy %',
   1035                     data: accData,
   1036                     borderColor: getCSSVar('--chart-green'),
   1037                     backgroundColor: getCSSVar('--success-glow'),
   1038                     borderWidth: 2,
   1039                     fill: false,
   1040                     tension: 0.3,
   1041                     yAxisID: 'y1',
   1042                     pointRadius: 3,
   1043                     pointBackgroundColor: getCSSVar('--chart-green'),
   1044                     borderDash: [5, 5]
   1045                 }
   1046             ]
   1047         },
   1048         options: {
   1049             responsive: true,
   1050             maintainAspectRatio: false,
   1051             interaction: { mode: 'index', intersect: false },
   1052             plugins: { legend: { labels: { color: getCSSVar('--text-secondary'), font: { size: 12 } } } },
   1053             scales: {
   1054                 x: { ticks: { color: getCSSVar('--text-muted'), maxRotation: 45, font: { size: 10 } }, grid: { color: getCSSVar('--border-light') } },
   1055                 y: { type: 'linear', position: 'left', title: { display: true, text: 'WPM', color: getCSSVar('--chart-blue') }, ticks: { color: getCSSVar('--chart-blue') }, grid: { color: getCSSVar('--border-light') }, beginAtZero: true },
   1056                 y1: { type: 'linear', position: 'right', title: { display: true, text: 'Accuracy %', color: getCSSVar('--chart-green') }, ticks: { color: getCSSVar('--chart-green') }, grid: { drawOnChartArea: false }, min: 0, max: 100 }
   1057             }
   1058         }
   1059     });
   1060 }
   1061 
   1062 function renderDailyChart(daily) {
   1063     const ctx = document.getElementById('daily-chart');
   1064     if (!ctx) return;
   1065 
   1066     const labels = daily.map(d => d.date);
   1067     const correctData = daily.map(d => d.words_correct || 0);
   1068     const incorrectData = daily.map(d => (d.words_total || 0) - (d.words_correct || 0));
   1069 
   1070     new Chart(ctx, {
   1071         type: 'bar',
   1072         data: {
   1073             labels: labels,
   1074             datasets: [
   1075                 { label: 'Correct', data: correctData, backgroundColor: getCSSVar('--chart-green'), borderRadius: 3 },
   1076                 { label: 'Incorrect', data: incorrectData, backgroundColor: getCSSVar('--chart-red'), borderRadius: 3 }
   1077             ]
   1078         },
   1079         options: {
   1080             responsive: true,
   1081             maintainAspectRatio: false,
   1082             plugins: { legend: { labels: { color: getCSSVar('--text-secondary'), font: { size: 12 } } } },
   1083             scales: {
   1084                 x: { stacked: true, ticks: { color: getCSSVar('--text-muted'), maxRotation: 45, font: { size: 10 } }, grid: { color: getCSSVar('--border-light') } },
   1085                 y: { stacked: true, ticks: { color: getCSSVar('--text-secondary') }, grid: { color: getCSSVar('--border-light') }, beginAtZero: true, title: { display: true, text: 'Words', color: getCSSVar('--text-secondary') } }
   1086             }
   1087         }
   1088     });
   1089 }
   1090 
   1091 function renderHeatmap(data) {
   1092     const container = document.getElementById('heatmap-container');
   1093     if (!container) return;
   1094 
   1095     const days = Array.isArray(data) ? data : (data.days || []);
   1096     const countMap = {};
   1097     let maxCount = 1;
   1098     days.forEach(d => {
   1099         countMap[d.date] = d.count || 0;
   1100         if (d.count > maxCount) maxCount = d.count;
   1101     });
   1102 
   1103     const today = new Date();
   1104     const oneDay = 86400000;
   1105     const startDate = new Date(today.getTime() - 364 * oneDay);
   1106     const startDow = startDate.getDay();
   1107     const alignedStart = new Date(startDate.getTime() - startDow * oneDay);
   1108     const totalDays = Math.ceil((today.getTime() - alignedStart.getTime()) / oneDay) + 1;
   1109     const totalWeeks = Math.ceil(totalDays / 7);
   1110 
   1111     const months = [];
   1112     let lastMonth = -1;
   1113     for (let w = 0; w < totalWeeks; w++) {
   1114         const d = new Date(alignedStart.getTime() + w * 7 * oneDay);
   1115         const m = d.getMonth();
   1116         if (m !== lastMonth) {
   1117             lastMonth = m;
   1118             months.push({ week: w, label: d.toLocaleDateString('en-US', { month: 'short' }) });
   1119         }
   1120     }
   1121 
   1122     let html = '<div class="heatmap-months" style="padding-left:0;">';
   1123     months.forEach((m, i) => {
   1124         const nextWeek = (i + 1 < months.length) ? months[i + 1].week : totalWeeks;
   1125         const colSpan = nextWeek - m.week;
   1126         html += '<span class="heatmap-month" style="width:' + (colSpan * 17) + 'px;">' + escapeHtml(m.label) + '</span>';
   1127     });
   1128     html += '</div><div class="heatmap">';
   1129 
   1130     for (let w = 0; w < totalWeeks; w++) {
   1131         for (let d = 0; d < 7; d++) {
   1132             const date = new Date(alignedStart.getTime() + (w * 7 + d) * oneDay);
   1133             const dateStr = date.toISOString().split('T')[0];
   1134             const count = countMap[dateStr] || 0;
   1135             let level = 0;
   1136             if (count > 0) {
   1137                 const ratio = count / maxCount;
   1138                 if (ratio <= 0.25) level = 1;
   1139                 else if (ratio <= 0.5) level = 2;
   1140                 else if (ratio <= 0.75) level = 3;
   1141                 else level = 4;
   1142             }
   1143             const isFuture = date > today;
   1144             html += '<div class="heatmap-cell' + (isFuture ? ' heatmap-cell--future' : '') + '" data-level="' + (isFuture ? 0 : level) + '" title="' + escapeHtml(dateStr + ': ' + count + ' words') + '"></div>';
   1145         }
   1146     }
   1147     html += '</div>';
   1148     html += '<div class="heatmap-legend"><span>Less</span>';
   1149     for (let i = 0; i <= 4; i++) {
   1150         html += '<div class="heatmap-legend__cell heatmap-cell" data-level="' + i + '" style="display:inline-block;"></div>';
   1151     }
   1152     html += '<span>More</span></div>';
   1153     container.innerHTML = html;
   1154 }
   1155 
   1156 function renderWordTable(data) {
   1157     const tbody = document.getElementById('word-table-body');
   1158     if (!tbody) return;
   1159     const words = Array.isArray(data) ? data : (data.words || []);
   1160     if (words.length === 0) {
   1161         tbody.innerHTML = '<tr><td colspan="5" class="table-empty">No word data yet. Start practicing!</td></tr>';
   1162         return;
   1163     }
   1164     let html = '';
   1165     words.forEach(w => {
   1166         const accCls = accuracyClass(w.accuracy || 0);
   1167         html += '<tr><td><strong>' + escapeHtml(w.word) + '</strong></td>';
   1168         html += '<td class="steno-stroke">' + escapeHtml(w.strokes || '--') + '</td>';
   1169         html += '<td>' + (w.attempts || 0) + '</td>';
   1170         html += '<td class="accuracy-cell accuracy--' + accCls + '">' + formatAccuracy(w.accuracy || 0) + '</td>';
   1171         html += '<td>' + formatTime(w.avg_time || 0) + '</td></tr>';
   1172     });
   1173     tbody.innerHTML = html;
   1174     _setupTableSort('word-table', words);
   1175 }
   1176 
   1177 function _setupTableSort(tableId, data) {
   1178     const table = document.getElementById(tableId);
   1179     if (!table) return;
   1180     const headers = table.querySelectorAll('th[data-sort]');
   1181     let currentSort = null;
   1182     let currentOrder = 'asc';
   1183 
   1184     headers.forEach(th => {
   1185         th.addEventListener('click', () => {
   1186             const field = th.dataset.sort;
   1187             if (currentSort === field) {
   1188                 currentOrder = currentOrder === 'asc' ? 'desc' : 'asc';
   1189             } else {
   1190                 currentSort = field;
   1191                 currentOrder = 'asc';
   1192             }
   1193             const sorted = [...data].sort((a, b) => {
   1194                 let va = a[field], vb = b[field];
   1195                 if (typeof va === 'string') va = va.toLowerCase();
   1196                 if (typeof vb === 'string') vb = vb.toLowerCase();
   1197                 if (va < vb) return currentOrder === 'asc' ? -1 : 1;
   1198                 if (va > vb) return currentOrder === 'asc' ? 1 : -1;
   1199                 return 0;
   1200             });
   1201             const tbody = table.querySelector('tbody');
   1202             let html = '';
   1203             sorted.forEach(w => {
   1204                 const accCls = accuracyClass(w.accuracy || 0);
   1205                 html += '<tr><td><strong>' + escapeHtml(w.word) + '</strong></td>';
   1206                 html += '<td class="steno-stroke">' + escapeHtml(w.strokes || '--') + '</td>';
   1207                 html += '<td>' + (w.attempts || 0) + '</td>';
   1208                 html += '<td class="accuracy-cell accuracy--' + accCls + '">' + formatAccuracy(w.accuracy || 0) + '</td>';
   1209                 html += '<td>' + formatTime(w.avg_time || 0) + '</td></tr>';
   1210             });
   1211             tbody.innerHTML = html;
   1212         });
   1213     });
   1214 }
   1215 
   1216 function renderSessionTable(data) {
   1217     const tbody = document.getElementById('session-table-body');
   1218     if (!tbody) return;
   1219     const sessions = Array.isArray(data) ? data : (data.sessions || []);
   1220     if (sessions.length === 0) {
   1221         tbody.innerHTML = '<tr><td colspan="6" class="table-empty">No sessions yet.</td></tr>';
   1222         return;
   1223     }
   1224     let html = '';
   1225     sessions.forEach(s => {
   1226         const accCls = accuracyClass(s.accuracy || 0);
   1227         html += '<tr>';
   1228         html += '<td>' + escapeHtml(s.date || s.date_str || '--') + '</td>';
   1229         html += '<td><span class="badge badge--' + escapeHtml(s.mode || '') + '">' + escapeHtml(s.mode || '--') + '</span></td>';
   1230         html += '<td>' + (s.words_correct || 0) + ' / ' + (s.words_attempted || 0) + '</td>';
   1231         html += '<td class="accuracy-cell accuracy--' + accCls + '">' + formatAccuracy(s.accuracy || 0) + '</td>';
   1232         html += '<td>' + formatWpm(s.wpm || 0) + '</td>';
   1233         html += '<td>' + formatDuration(s.duration_seconds || 0) + '</td>';
   1234         html += '</tr>';
   1235     });
   1236     tbody.innerHTML = html;
   1237 }
   1238 
   1239 
   1240 /* ===== PAGE INITIALIZATION ===== */
   1241 
   1242 document.addEventListener('DOMContentLoaded', () => {
   1243     const practiceEl = document.getElementById('practice-container');
   1244     if (practiceEl) {
   1245         const mode = practiceEl.dataset.mode;
   1246         const lessonId = practiceEl.dataset.lesson || null;
   1247         new PracticeSession(mode, lessonId);
   1248     }
   1249 
   1250     const statsEl = document.getElementById('stats-container');
   1251     if (statsEl) {
   1252         loadStats();
   1253     }
   1254 
   1255     // Mobile nav hamburger
   1256     const hamburger = document.getElementById('nav-hamburger');
   1257     const navLinks = document.getElementById('nav-links');
   1258     if (hamburger && navLinks) {
   1259         hamburger.addEventListener('click', () => {
   1260             navLinks.classList.toggle('open');
   1261             hamburger.classList.toggle('active');
   1262         });
   1263         navLinks.querySelectorAll('.nav-link').forEach(link => {
   1264             link.addEventListener('click', () => {
   1265                 navLinks.classList.remove('open');
   1266                 hamburger.classList.remove('active');
   1267             });
   1268         });
   1269     }
   1270 
   1271     // Theme toggle
   1272     const themeToggle = document.getElementById('theme-toggle');
   1273     const themeIcon = document.getElementById('theme-icon');
   1274     function updateThemeIcon() {
   1275         if (!themeIcon) return;
   1276         const t = document.documentElement.getAttribute('data-theme') || 'dark';
   1277         themeIcon.textContent = t === 'dark' ? '☀' : '☽';
   1278     }
   1279     if (themeToggle) {
   1280         updateThemeIcon();
   1281         themeToggle.addEventListener('click', () => {
   1282             const cur = document.documentElement.getAttribute('data-theme') || 'dark';
   1283             const next = cur === 'dark' ? 'light' : 'dark';
   1284             document.documentElement.setAttribute('data-theme', next);
   1285             setCookie('theme', next, 365);
   1286             updateThemeIcon();
   1287         });
   1288     }
   1289 
   1290     // GSAP page entrance animations
   1291     if (typeof gsap !== 'undefined') {
   1292         gsap.from('.nav-brand', { x: -10, opacity: 0, duration: 0.3, ease: 'power2.out', clearProps: 'all' });
   1293         gsap.from('.nav-links li', { y: -8, opacity: 0, duration: 0.25, stagger: 0.05, delay: 0.1, ease: 'power2.out', clearProps: 'all' });
   1294 
   1295         if (document.querySelector('.dashboard')) {
   1296             const tl = gsap.timeline({ defaults: { ease: 'power2.out', clearProps: 'all' } });
   1297             tl.from('.hero-stats .stat-card', { y: 10, opacity: 0, duration: 0.35, stagger: 0.03 }, 0)
   1298               .from('.dashboard .section-title', { opacity: 0, duration: 0.35 }, 0)
   1299               .from('.lesson-grid', { opacity: 0, duration: 0.35 }, 0)
   1300               .from('.lesson-card', { y: 6, opacity: 0, duration: 0.35, stagger: 0.02 }, 0)
   1301               .from('.mode-grid', { opacity: 0, duration: 0.35 }, 0)
   1302               .from('.mode-card', { y: 6, opacity: 0, duration: 0.35, stagger: 0.02 }, 0)
   1303               .from('.today-progress, .recent-sessions', { y: 6, opacity: 0, duration: 0.35 }, 0);
   1304         }
   1305 
   1306         if (document.querySelector('.stats-page')) {
   1307             const tl = gsap.timeline({ defaults: { ease: 'power2.out', clearProps: 'all' } });
   1308             tl.from('.stats-page .page-title', { y: -10, opacity: 0, duration: 0.35 })
   1309               .from('.stats-overview .stat-card', { y: 20, opacity: 0, duration: 0.35, stagger: 0.06 }, '-=0.15')
   1310               .from('.stats-section', { y: 25, opacity: 0, duration: 0.4, stagger: 0.1 }, '-=0.1');
   1311         }
   1312 
   1313         if (document.querySelector('.settings-page')) {
   1314             const tl = gsap.timeline({ defaults: { ease: 'power2.out', clearProps: 'all' } });
   1315             tl.from('.settings-page .page-title', { y: -10, opacity: 0, duration: 0.35 })
   1316               .from('.settings-section', { y: 20, opacity: 0, duration: 0.35, stagger: 0.1 }, '-=0.15');
   1317         }
   1318     }
   1319 });