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">→</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 });