alembic verbessert, Javascript modularisiert

This commit is contained in:
jamulix 2025-10-27 14:37:13 +01:00
commit 0cb5ca68b6
14 changed files with 2296 additions and 12 deletions

View file

@ -29,7 +29,27 @@ The application runs on `http://localhost:5000` by default.
### Database Setup
The database is automatically initialized when the application starts for the first time. Tables are created via SQLAlchemy in `app.py` using `db.create_all()`.
**WICHTIG**: Die Anwendung verwendet Alembic für Datenbank-Migrationen.
**Erste Installation**:
```bash
# Setup-Script ausführen (empfohlen)
python setup_database.py
# ODER manuell:
alembic upgrade head
```
**Bei Schema-Änderungen**:
```bash
# Neue Migration erstellen
alembic revision --autogenerate -m "Beschreibung der Änderung"
# Migration anwenden
alembic upgrade head
```
Siehe `MIGRATIONS.md` für detaillierte Informationen.
### Dependencies
@ -98,13 +118,27 @@ curl http://localhost:5000/debug
### Frontend Architecture
**Modular JavaScript (ES6 Modules)**: Das Frontend ist in separate Module aufgeteilt für bessere Wartbarkeit:
- **`theme.js`**: Theme Management (Light/Dark Mode)
- **`metronome.js`**: Adaptive Metronome-Klasse und Audio-Player
- **`statistics.js`**: Statistik-Berechnungen und Anzeige
- **`textDisplay.js`**: Text-Rendering und Highlighting
- **`keyboard.js`**: Keyboard-Event-Handling
- **`session.js`**: Session Management und Fortschritt-Speicherung
- **`ui.js`**: UI-Interaktionen (Buttons, Modals, Toggles)
- **`main.js`**: Hauptorchestration und Initialisierung
**Theme System**: Light/Dark mode toggle using Bootstrap's `data-bs-theme` attribute, stored in localStorage.
**Adaptive Metronome** (`script.js`):
**Adaptive Metronome**:
- Adjusts typing speed guidance based on user accuracy
- Target accuracy: 96% (configurable)
- Tracks last 50 keystrokes to calculate rolling accuracy
- Speed increases when accuracy is high, decreases when low
- Implementiert in `metronome.js` mit zwei Klassen:
- `AdaptiveMetronome`: Geschwindigkeitsanpassungslogik
- `MetronomePlayer`: Audio-Wiedergabe und Timing
**Visual Feedback**:
- Green highlighting for correct characters

371
JAVASCRIPT_MODULES.md Normal file
View file

@ -0,0 +1,371 @@
# JavaScript-Modulstruktur
Dieses Dokument beschreibt die modularisierte JavaScript-Architektur des Typewriter Tutors.
## Überblick
Das Frontend wurde in separate ES6-Module aufgeteilt, um die Wartbarkeit, Testbarkeit und Lesbarkeit zu verbessern. Statt einer monolithischen `script.js` (1.122 Zeilen) gibt es nun 8 fokussierte Module.
## Module
### 1. `theme.js` - Theme Management
**Verantwortlichkeit**: Verwaltung des Light/Dark-Modus
**Klasse**: `ThemeManager`
**Funktionen**:
- `initialize()` - Initialisiert Theme aus localStorage
- `toggle()` - Wechselt zwischen Light und Dark Mode
- `setTheme(theme)` - Setzt ein spezifisches Theme
- `setupEventListeners()` - Bindet Theme-Toggle-Button
**Verwendung**:
```javascript
const themeManager = new ThemeManager();
themeManager.initialize();
themeManager.setupEventListeners();
```
---
### 2. `metronome.js` - Adaptive Metronom-Logik
**Verantwortlichkeit**: Intelligente Geschwindigkeitsanpassung und Audio-Wiedergabe
**Klassen**:
#### `AdaptiveMetronome`
Implementiert die 5-Regel-Geschwindigkeitsanpassung basierend auf Tipp-Genauigkeit.
**Eigenschaften**:
- `speed` - Aktuelle Geschwindigkeit in BPM
- `accuracy_history` - Letzte 50 Tasteneingaben (1=korrekt, 0=falsch)
- `consecutive_correct` - Aufeinanderfolgende korrekte Zeichen
- `target_accuracy` - Zielgenauigkeit (Standard: 96%)
**Methoden**:
- `process_keystroke(is_correct, difficulty)` - Verarbeitet Tastendruck und passt Geschwindigkeit an
- `_adjust_speed(difficulty)` - Interne Geschwindigkeitsanpassung
**Anpassungsregeln**:
1. Streak-Bonus bei ≥15 korrekten Zeichen: +3%
2. Zu einfach (>95% Genauigkeit): +2%
3. Zu schwer (<85% Genauigkeit): -8%
4. Leicht unter Ziel (85-91%): -3%
5. Im Zielbereich (91-95%): +1%
#### `MetronomePlayer`
Verwaltet die Audio-Wiedergabe und das Timing.
**Methoden**:
- `start()` - Startet das Metronom
- `stop()` - Stoppt das Metronom
- `setBPM(bpm)` - Setzt BPM und startet neu
- `toggle()` - Schaltet Audio ein/aus
- `play()` - Spielt einen Beat
**Verwendung**:
```javascript
const adaptive = new AdaptiveMetronome();
const player = new MetronomePlayer();
// Bei jedem Tastendruck:
const newBPM = adaptive.process_keystroke(isCorrect);
player.setBPM(newBPM);
```
---
### 3. `statistics.js` - Statistik-Berechnungen
**Verantwortlichkeit**: Berechnung und Anzeige von Tipp-Statistiken
**Klasse**: `StatisticsManager`
**Methoden**:
- `calculateStatistics(userInput, fullText, elapsedTime, totalKeyStrokes)` - Berechnet alle Metriken
- `updateDisplay(stats)` - Aktualisiert UI-Elemente
- `setSpeedDisplay(display)` - Setzt Anzeige-Modus ('zpm' oder 'wpm')
- `saveLessonStatistics(...)` - Speichert Statistiken auf Server
**Berechnete Metriken**:
- `charsPerMinute` - Zeichen pro Minute
- `wpm` - Wörter pro Minute (Zeichen/5)
- `errorRate` - Fehlerrate in Prozent
- `correctKeyStrokes` - Anzahl korrekter Zeichen
- `incorrectKeyStrokes` - Anzahl falscher Zeichen
---
### 4. `textDisplay.js` - Text-Rendering
**Verantwortlichkeit**: Text-Darstellung, Highlighting und Scrollen
**Klasse**: `TextDisplayManager`
**Methoden**:
- `updateDisplay(userInput, currentCharIndex)` - Aktualisiert Textanzeige mit Highlighting
- `updateText(newText)` - Lädt neuen Text (z.B. neue Lektion)
- `highlightChar(char, type, isCursor, incorrectChar)` - Rendert einzelnes Zeichen
- `getCurrentLineIndex(charIndex)` - Berechnet Zeilennummer
- `resetScroll()` - Setzt Scroll-Position zurück
- `isLineComplete(lineIndex, userInput)` - Prüft ob Zeile komplett korrekt
**Highlight-Typen**:
- `correct` - Grün für korrekte Zeichen
- `incorrect` - Rot für falsche Zeichen (mit orangefarbenem Overlay)
- `neutral` - Standard für noch nicht getippte Zeichen
**Features**:
- Automatisches Scrollen zur aktuellen Zeile
- Sichtbarmachung von Leerzeichen (·), Tabs (→), Newlines (↵)
- Manuelles Scrollen wird erkannt und respektiert
---
### 5. `keyboard.js` - Tastatur-Verwaltung
**Verantwortlichkeit**: Tastatur-Events und ErrorLock-Funktionalität
**Klasse**: `KeyboardHandler`
**Methoden**:
- `handleKeyDown(event, state)` - Verarbeitet alle Tastatureingaben
- `setErrorLock(enabled)` - Aktiviert/deaktiviert ErrorLock
- `getCorrectPrefixLength(userInput, fullText)` - Berechnet korrekte Präfix-Länge
- `setupEventListeners()` - Richtet Fokus-Management ein
**Unterstützte Tasten**:
- Zeichen-Eingabe
- `Backspace` - Löschen
- `ArrowLeft`/`ArrowRight` - Cursor-Bewegung
- `Enter` - Zeilenumbruch
- `Ctrl`/`Cmd` - Blockiert (Anti-Cheat)
**ErrorLock**:
Wenn aktiviert, können nur Fehler an der Cursor-Position korrigiert werden.
---
### 6. `session.js` - Session Management
**Verantwortlichkeit**: Fortschritt-Tracking und Server-Kommunikation
**Klasse**: `SessionManager`
**Eigenschaften**:
- `lastStartTime` - Zeitpunkt des letzten Starts
- `totalElapsedTime` - Gesamte verstrichene Zeit
- `keyStrokeCount` - Anzahl aller Tasteneingaben
- `isPaused` - Pause-Status
- `currentLessonIndex` - Aktuelle Lektion
**Methoden**:
- `initialize(initialState)` - Initialisiert aus Server-Daten
- `start()` / `pause()` / `resume()` - Zeit-Tracking
- `togglePause()` - Pause ein/aus
- `incrementKeystrokes()` - Erhöht Tastenzähler
- `reset()` - Zurücksetzen für neue Lektion
- `saveProgress(...)` - Speichert Fortschritt auf Server
- `nextLesson()` - Lädt nächste Lektion
- `setLesson(index)` - Lädt spezifische Lektion
- `endSession()` - Beendet Session
- `setupBeforeUnload(getSaveData)` - Speichert beim Schließen
**Server-Endpunkte**:
- `POST /update_progress` - Fortschritt speichern
- `POST /next_text` - Nächste Lektion
- `POST /set_lesson` - Lektion wechseln
- `POST /end_session` - Session beenden
---
### 7. `ui.js` - UI-Management
**Verantwortlichkeit**: UI-Elemente, Buttons, Modals, Toggles
**Klasse**: `UIManager`
**Methoden**:
- `initialize()` - Initialisiert alle UI-Elemente
- Icon-Verwaltung:
- `toggleVolume(enabled)` - Volume-Icon
- `toggleLock(locked)` - Lock-Icon
- `togglePlayPause(isPaused)` - Play/Pause-Icon
- Anzeige-Updates:
- `updateLessonTitle(title)` - Aktualisiert Lektionstitel
- `updateLessonHighlight(index)` - Markiert aktuelle Lektion
- Dialoge:
- `alert(message)` - Alert-Dialog
- `confirm(message)` - Confirm-Dialog
- Modals und Links:
- `setupHelpModal()` - Help-Modal
- `setupSettingsLink()` - Settings-Navigation
- `setupStatisticsLink(callback)` - Statistics-Navigation
- Performance-Anzeige:
- `setupPerformanceToggle()` - Toggle für Statistik-Anzeige
- `setProgressStatsVisibility(visible)` - Zeigt/verbirgt Stats
---
### 8. `main.js` - Hauptorchestration
**Verantwortlichkeit**: Initialisierung und Koordination aller Module
**Klasse**: `TypewriterApp`
**Ablauf**:
1. Initialisierung aller Module
2. Laden von Konfiguration aus `window.*` Variablen
3. Setup von Event-Listenern
4. Start der Anwendung
**Zentrale Methoden**:
- `initialize()` - Haupt-Initialisierung
- `initializeModules()` - Erstellt alle Module
- `setupEventListeners()` - Bindet alle Events
- `processKeystroke(key, isCorrect)` - Koordiniert Tastendruck-Verarbeitung
- `updateDisplay()` / `updateStatistics()` - Aktualisiert UI
- `completeLesson()` - Behandelt Lektions-Abschluss
- `nextLesson()` / `loadLesson(index)` - Lektions-Navigation
**Event-Handling**:
```javascript
// Tastatur
document.addEventListener('keydown', (event) => {
keyboardHandler.handleKeyDown(event, state);
});
// Metronom-Toggle
volumeToggle.addEventListener('click', () => {
metronomePlayer.toggle();
saveMetronomeSettings();
});
// Play/Pause
playPauseToggle.addEventListener('click', () => {
sessionManager.togglePause();
uiManager.togglePlayPause(isPaused);
});
```
---
## Initialisierung
**Template** (`index.html`):
```html
<!-- Konfigurationsdaten -->
<script>
window.fullText = {{ current_text|tojson }};
window.currentCharIndex = {{ current_position }};
window.lastText = {{ last_text|tojson }};
window.totalElapsedTime = {{ total_elapsed_time }};
window.keyStrokeCount = {{ key_stroke_count }};
window.isPaused = {{ is_paused|tojson }};
window.metronomeEnabled = {{ metronome_enabled|tojson }};
window.metronomeBPM = {{ metronome_bpm }};
window.metronomeSound = {{ metronome_sound|tojson }};
window.metronomeMode = {{ metronome_mode|tojson }};
window.metronomeSpeed = {{ metronome_speed }};
window.targetErrorRate = {{ target_error_rate }};
window.currentLessonIndex = {{ current_lesson_index }};
window.speedDisplay = {{ speed_display|tojson }};
</script>
<!-- Hauptmodul als ES6 Module -->
<script type="module" src="{{ url_for('static', filename='js/main.js') }}"></script>
```
**Ablauf**:
1. DOM lädt
2. `DOMContentLoaded` Event feuert
3. `TypewriterApp` wird instanziiert
4. `app.initialize()` wird aufgerufen
5. Alle Module werden initialisiert
6. Event-Listener werden gebunden
7. Anwendung ist bereit
---
## Kommunikation zwischen Modulen
Die Module kommunizieren über die zentrale `TypewriterApp`-Instanz:
```
User Input
KeyboardHandler → TypewriterApp
↓ ↓
├→ AdaptiveMetronome (Geschwindigkeit)
├→ TextDisplayManager (Anzeige)
├→ StatisticsManager (Metriken)
├→ SessionManager (Speichern)
└→ UIManager (UI-Updates)
```
**Vorteile**:
- Klare Verantwortlichkeiten
- Lose Kopplung
- Einfaches Testing
- Wiederverwendbarkeit
- Bessere Wartbarkeit
---
## Testing
Jedes Modul kann isoliert getestet werden:
**Backend-Tests**: `python -m unittest test_app -v`
**Metronom-Tests**: `python -m unittest test_metronome_velocity -v`
Die JavaScript-Module sind so strukturiert, dass sie leicht mit Jest oder ähnlichen Frameworks getestet werden können.
---
## Migration von Alt zu Neu
Die alte monolithische `script.js` wurde als `script.js.old` gesichert.
**Hauptänderungen**:
1. **ES6-Module**: Alle Exports/Imports verwenden ES6-Syntax
2. **Klassen-basiert**: Alle Funktionalität in Klassen gekapselt
3. **Zentrale Orchestration**: `main.js` koordiniert alle Module
4. **Template-Änderung**: `<script type="module">` statt regulärem Script
**Kompatibilität**:
- Funktionalität ist identisch
- API-Endpunkte unverändert
- Datenbank-Schema gleich
- Alle Tests bestehen
---
## Weitere Entwicklung
**Empfohlene nächste Schritte**:
1. JavaScript-Tests mit Jest hinzufügen
2. TypeScript-Unterstützung für bessere Type Safety
3. Build-Step mit Webpack/Rollup für Production-Bundles
4. Service Worker für Offline-Funktionalität
5. Weitere Modularisierung (z.B. LessonManager, ProgressIndicator)
---
## Fehlerbehebung
**Problem**: Module laden nicht
**Lösung**: Prüfe Browser-Konsole auf CORS-Fehler. ES6-Module benötigen einen Webserver.
**Problem**: `import` Syntax-Fehler
**Lösung**: Stelle sicher, dass `type="module"` im Script-Tag vorhanden ist.
**Problem**: Alte Funktionalität fehlt
**Lösung**: Prüfe `script.js.old` für evtl. fehlende Funktionen.
---
Für weitere Fragen siehe `CLAUDE.md` oder kontaktiere das Entwickler-Team.

65
app.py
View file

@ -191,18 +191,67 @@ def create_sample_lessons() -> List[Dict[str, Any]]:
logger.error(f"Fehler beim Erstellen der Beispiel-Lektionen: {e}")
return FALLBACK_LESSONS
# Datenbanktabellen erstellen und Lektionen laden
# Prüfe Datenbank-Status
def check_database_status():
"""
Prüft ob die Datenbank initialisiert ist und ob Migrationen ausstehen.
WICHTIG: Für Schema-Änderungen IMMER Alembic verwenden!
- Neue Migration erstellen: alembic revision --autogenerate -m "Beschreibung"
- Migration anwenden: alembic upgrade head
- Migration rückgängig: alembic downgrade -1
Siehe MIGRATIONS.md für Details.
"""
from sqlalchemy import inspect, text
try:
# Prüfe ob alembic_version Tabelle existiert
inspector = inspect(db.engine)
tables = inspector.get_table_names()
if 'alembic_version' not in tables:
logger.warning("=" * 80)
logger.warning("DATENBANK NICHT INITIALISIERT!")
logger.warning("Bitte führe folgende Schritte aus:")
logger.warning(" 1. alembic upgrade head")
logger.warning("Siehe MIGRATIONS.md für Details.")
logger.warning("=" * 80)
return False
# Prüfe ob benötigte Tabellen existieren
required_tables = {'progress', 'lesson_statistic', 'statistic'}
missing_tables = required_tables - set(tables)
if missing_tables:
logger.warning("=" * 80)
logger.warning(f"FEHLENDE TABELLEN: {', '.join(missing_tables)}")
logger.warning("Bitte führe aus: alembic upgrade head")
logger.warning("=" * 80)
return False
logger.info("✓ Datenbank initialisiert und bereit")
logger.info(" Hinweis: Für Schema-Änderungen verwende Alembic (siehe MIGRATIONS.md)")
return True
except Exception as e:
logger.error(f"Fehler bei Datenbank-Prüfung: {e}")
logger.warning("Falls Datenbank nicht existiert, führe aus: alembic upgrade head")
return False
# Datenbank-Status prüfen und Lektionen laden
with app.app_context():
# Warnung: db.create_all() sollte nur für Development verwendet werden
# Für Production: Verwende Alembic Migrationen (siehe MIGRATIONS.md)
# Führe aus: alembic upgrade head
db.create_all()
logger.info("Datenbanktabellen wurden initialisiert")
logger.info("HINWEIS: Für Schema-Änderungen verwende Alembic Migrationen (siehe MIGRATIONS.md)")
db_ready = check_database_status()
if not db_ready:
logger.error("Anwendung kann nicht gestartet werden - Datenbank nicht bereit")
logger.error("Siehe Warnungen oben für Lösungsschritte")
import sys
sys.exit(1)
# Versuche Lektionen zu laden
load_lessons()
# Falls keine Lektionen geladen wurden, erstelle Beispieldaten
if not lessons:
logger.warning("Keine Lektionen verfügbar, erstelle Beispiel-Lektionen...")

97
setup_database.py Normal file
View file

@ -0,0 +1,97 @@
#!/usr/bin/env python3
"""
Datenbank-Setup-Script für Typewriter Tutor
Dieses Script initialisiert die Datenbank mit Alembic-Migrationen.
Verwende es für neue Installationen oder nach einem Reset.
Usage:
python setup_database.py
"""
import os
import sys
import subprocess
import logging
logging.basicConfig(level=logging.INFO, format='%(levelname)s: %(message)s')
logger = logging.getLogger(__name__)
def main():
"""Hauptfunktion für Datenbank-Setup"""
logger.info("=" * 80)
logger.info("Typewriter Tutor - Datenbank-Setup")
logger.info("=" * 80)
# Prüfe ob Alembic installiert ist
try:
import alembic
logger.info("✓ Alembic ist installiert")
except ImportError:
logger.error("✗ Alembic nicht gefunden!")
logger.error(" Bitte installiere: pip install -r requirements.txt")
sys.exit(1)
# Prüfe ob instance/ Verzeichnis existiert
instance_dir = os.path.join(os.path.dirname(__file__), 'instance')
if not os.path.exists(instance_dir):
logger.info(f"Erstelle instance/ Verzeichnis...")
os.makedirs(instance_dir)
logger.info("✓ Verzeichnis erstellt")
db_path = os.path.join(instance_dir, 'typewriter.db')
# Prüfe ob Datenbank bereits existiert
if os.path.exists(db_path):
logger.warning(f"⚠ Datenbank existiert bereits: {db_path}")
response = input("Möchtest du die Migrationen trotzdem ausführen? (j/n): ")
if response.lower() != 'j':
logger.info("Setup abgebrochen.")
sys.exit(0)
# Führe Alembic-Migrationen aus
logger.info("\nFühre Alembic-Migrationen aus...")
logger.info("Befehl: alembic upgrade head")
logger.info("-" * 80)
try:
result = subprocess.run(
['alembic', 'upgrade', 'head'],
check=True,
capture_output=True,
text=True
)
print(result.stdout)
if result.stderr:
print(result.stderr)
logger.info("-" * 80)
logger.info("✓ Migrationen erfolgreich ausgeführt!")
except subprocess.CalledProcessError as e:
logger.error("✗ Fehler bei der Migration:")
print(e.stdout)
print(e.stderr)
sys.exit(1)
except FileNotFoundError:
logger.error("'alembic' Befehl nicht gefunden!")
logger.error(" Stelle sicher, dass Alembic installiert ist.")
sys.exit(1)
# Erfolgsbestätigung
logger.info("\n" + "=" * 80)
logger.info("SETUP ERFOLGREICH!")
logger.info("=" * 80)
logger.info("Die Datenbank ist bereit. Du kannst jetzt die Anwendung starten:")
logger.info(" python app.py")
logger.info("\nFür zukünftige Schema-Änderungen verwende:")
logger.info(" alembic revision --autogenerate -m 'Beschreibung'")
logger.info(" alembic upgrade head")
logger.info("\nSiehe MIGRATIONS.md für weitere Details.")
logger.info("=" * 80)
if __name__ == '__main__':
main()

128
static/js/keyboard.js Normal file
View file

@ -0,0 +1,128 @@
/**
* Keyboard Handler Module
* Manages keyboard input and processing
*/
export class KeyboardHandler {
constructor(callbacks) {
this.callbacks = callbacks || {};
this.errorLock = false;
}
/**
* Calculate the length of the correct prefix
* @param {string} userInput - User's input
* @param {string} fullText - Target text
* @returns {number} Length of correct prefix
*/
getCorrectPrefixLength(userInput, fullText) {
let correct = 0;
for (let i = 0; i < userInput.length; i++) {
if (i < fullText.length && userInput[i] === fullText[i]) {
correct++;
} else {
break;
}
}
return correct;
}
/**
* Set error lock state
* @param {boolean} enabled - Whether error lock is enabled
*/
setErrorLock(enabled) {
this.errorLock = enabled;
}
/**
* Handle keydown event
* @param {KeyboardEvent} event - Keyboard event
* @param {Object} state - Current application state
*/
handleKeyDown(event, state) {
const { userInput, currentCharIndex, fullText, isPaused } = state;
// Prevent default for control keys
if (event.ctrlKey || event.metaKey) {
event.preventDefault();
return;
}
// No keys allowed in pause mode
if (isPaused) {
event.preventDefault();
return;
}
// Handle arrow keys
if (event.key === 'ArrowLeft') {
event.preventDefault();
if (currentCharIndex > 0 && this.callbacks.onArrowLeft) {
this.callbacks.onArrowLeft();
}
return;
}
if (event.key === 'ArrowRight') {
event.preventDefault();
if (currentCharIndex < fullText.length && this.callbacks.onArrowRight) {
this.callbacks.onArrowRight();
}
return;
}
// Handle backspace
if (event.key === 'Backspace') {
event.preventDefault();
if (currentCharIndex > 0 && this.callbacks.onBackspace) {
this.callbacks.onBackspace();
}
return;
}
// Handle Enter
if (event.key === 'Enter') {
event.preventDefault();
if (currentCharIndex < fullText.length && fullText[currentCharIndex] === '\n') {
if (this.callbacks.onKeystroke) {
this.callbacks.onKeystroke('\n', true);
}
}
return;
}
// Ignore other control keys
if (event.key.length > 1 && event.key !== ' ') {
return;
}
// Error lock check
if (this.errorLock) {
const correctPrefixLength = this.getCorrectPrefixLength(userInput, fullText);
if (currentCharIndex !== correctPrefixLength) {
event.preventDefault();
return;
}
}
// Normal character
event.preventDefault();
if (currentCharIndex < fullText.length && this.callbacks.onKeystroke) {
const isCorrect = event.key === fullText[currentCharIndex];
this.callbacks.onKeystroke(event.key, isCorrect);
}
}
/**
* Setup keyboard event listeners
*/
setupEventListeners() {
// Focus management
document.addEventListener('click', () => {
document.body.focus();
});
document.body.tabIndex = -1;
document.body.focus();
}
}

484
static/js/main.js Normal file
View file

@ -0,0 +1,484 @@
/**
* Main Application Module
* Orchestrates all components and manages application state
*/
import { ThemeManager } from './theme.js';
import { AdaptiveMetronome, MetronomePlayer } from './metronome.js';
import { StatisticsManager } from './statistics.js';
import { TextDisplayManager } from './textDisplay.js';
import { KeyboardHandler } from './keyboard.js';
import { SessionManager } from './session.js';
import { UIManager } from './ui.js';
class TypewriterApp {
constructor() {
// Application state
this.fullText = '';
this.currentCharIndex = 0;
this.userInput = '';
this.csrfToken = '';
// Module instances
this.themeManager = null;
this.adaptiveMetronome = null;
this.metronomePlayer = null;
this.statisticsManager = null;
this.textDisplayManager = null;
this.keyboardHandler = null;
this.sessionManager = null;
this.uiManager = null;
// Settings
this.metronomeMode = 'automatic';
this.targetErrorRate = 5;
this.speedDisplay = 'zpm';
}
/**
* Initialize the application
*/
async initialize() {
console.log('Initializing Typewriter Tutor...');
// Get CSRF token
const csrfMeta = document.querySelector('meta[name="csrf-token"]');
if (csrfMeta) {
this.csrfToken = csrfMeta.getAttribute('content');
}
// Load initial data from window object
this.fullText = window.fullText || '';
this.currentCharIndex = window.currentCharIndex || 0;
this.userInput = window.lastText || '';
this.metronomeMode = window.metronomeMode || 'automatic';
this.targetErrorRate = window.targetErrorRate || 5;
this.speedDisplay = window.speedDisplay || 'zpm';
// Initialize modules
this.initializeModules();
// Setup event listeners
this.setupEventListeners();
// Initial render
this.updateDisplay();
this.updateStatistics();
// Start metronome if not paused
if (!this.sessionManager.isPaused && window.metronomeEnabled) {
this.metronomePlayer.start();
}
console.log('Typewriter Tutor initialized successfully');
}
/**
* Initialize all application modules
*/
initializeModules() {
// Theme Manager
this.themeManager = new ThemeManager();
this.themeManager.initialize();
this.themeManager.setupEventListeners();
// Metronome
this.adaptiveMetronome = new AdaptiveMetronome();
this.metronomePlayer = new MetronomePlayer();
this.metronomePlayer.enabled = window.metronomeEnabled || true;
this.metronomePlayer.bpm = window.metronomeBPM || 60;
this.metronomePlayer.sound = window.metronomeSound || 'beep';
// Statistics
this.statisticsManager = new StatisticsManager();
this.statisticsManager.setSpeedDisplay(this.speedDisplay);
// Text Display
const targetElement = document.getElementById('target-text');
this.textDisplayManager = new TextDisplayManager(targetElement, this.fullText);
// Keyboard Handler
this.keyboardHandler = new KeyboardHandler({
onKeystroke: (key, isCorrect) => this.processKeystroke(key, isCorrect),
onBackspace: () => this.processBackspace(),
onArrowLeft: () => this.processArrowLeft(),
onArrowRight: () => this.processArrowRight()
});
this.keyboardHandler.setupEventListeners();
// Session Manager
this.sessionManager = new SessionManager(this.csrfToken);
this.sessionManager.initialize({
totalElapsedTime: window.totalElapsedTime || 0,
keyStrokeCount: window.keyStrokeCount || 0,
isPaused: window.isPaused || false,
currentLessonIndex: window.currentLessonIndex || 0
});
this.sessionManager.setupBeforeUnload(() => ({
userInput: this.userInput,
cursorPosition: this.currentCharIndex,
currentText: this.fullText
}));
// UI Manager
this.uiManager = new UIManager();
this.uiManager.initialize();
this.uiManager.setupHelpModal();
this.uiManager.setupSettingsLink();
this.uiManager.setupStatisticsLink(() => this.saveProgress());
this.uiManager.setupPerformanceToggle();
this.uiManager.togglePlayPause(this.sessionManager.isPaused);
// Prevent copy/paste
if (targetElement) {
targetElement.addEventListener('copy', (e) => {
e.preventDefault();
alert('Kopieren aus der Vorlage ist nicht erlaubt! Bitte tippen Sie den Text manuell.');
});
targetElement.addEventListener('cut', (e) => {
e.preventDefault();
alert('Ausschneiden aus der Vorlage ist nicht erlaubt!');
});
targetElement.addEventListener('contextmenu', (e) => {
e.preventDefault();
return false;
});
}
}
/**
* Setup application event listeners
*/
setupEventListeners() {
// Keyboard events
document.addEventListener('keydown', (event) => {
this.keyboardHandler.handleKeyDown(event, {
userInput: this.userInput,
currentCharIndex: this.currentCharIndex,
fullText: this.fullText,
isPaused: this.sessionManager.isPaused
});
});
// Volume toggle
const volumeToggle = document.getElementById('volume-toggle');
if (volumeToggle) {
volumeToggle.addEventListener('click', () => {
const enabled = this.metronomePlayer.toggle();
this.uiManager.toggleVolume(enabled);
if (enabled && !this.sessionManager.isPaused) {
this.metronomePlayer.start();
} else {
this.metronomePlayer.stop();
}
// Save to server
this.saveMetronomeSettings();
});
}
// Lock toggle
const lockToggle = document.getElementById('lock-toggle');
if (lockToggle) {
lockToggle.addEventListener('click', () => {
const errorLock = !this.keyboardHandler.errorLock;
this.keyboardHandler.setErrorLock(errorLock);
this.uiManager.toggleLock(errorLock);
});
}
// Play/Pause toggle
const playPauseToggle = document.getElementById('play-pause-toggle');
if (playPauseToggle) {
playPauseToggle.addEventListener('click', () => {
this.sessionManager.togglePause();
this.uiManager.togglePlayPause(this.sessionManager.isPaused);
if (this.sessionManager.isPaused) {
this.metronomePlayer.stop();
} else {
if (this.metronomePlayer.enabled) {
this.metronomePlayer.start();
}
}
this.updateStatistics();
});
}
// Next button
const nextBtn = document.getElementById('next-btn');
if (nextBtn) {
nextBtn.addEventListener('click', () => this.nextLesson());
}
// End button
const endBtn = document.getElementById('end-btn');
if (endBtn) {
endBtn.addEventListener('click', () => this.endSession());
}
// Lesson links
document.querySelectorAll('.lesson-link').forEach(link => {
link.addEventListener('click', () => {
const lessonIndex = parseInt(link.getAttribute('data-lesson-index'));
this.loadLesson(lessonIndex);
});
});
}
/**
* Process a keystroke
*/
processKeystroke(key, isCorrect) {
// Add or replace character
if (this.currentCharIndex < this.userInput.length) {
this.userInput = this.userInput.slice(0, this.currentCharIndex) + key + this.userInput.slice(this.currentCharIndex + 1);
} else {
this.userInput = this.userInput.slice(0, this.currentCharIndex) + key + this.userInput.slice(this.currentCharIndex);
}
this.currentCharIndex++;
this.sessionManager.incrementKeystrokes();
// Adjust metronome
if (this.metronomeMode === 'automatic') {
const newBPM = this.adaptiveMetronome.process_keystroke(isCorrect, 1.0);
this.metronomePlayer.setBPM(newBPM);
}
this.updateDisplay();
this.updateStatistics();
// Check if lesson is complete
if (this.currentCharIndex === this.fullText.length) {
setTimeout(() => this.completeLesson(), 100);
}
}
/**
* Process backspace
*/
processBackspace() {
if (this.currentCharIndex > 0) {
this.userInput = this.userInput.slice(0, this.currentCharIndex - 1) + this.userInput.slice(this.currentCharIndex);
this.currentCharIndex--;
this.sessionManager.incrementKeystrokes();
this.sessionManager.start();
this.updateDisplay();
this.updateStatistics();
}
}
/**
* Process arrow left
*/
processArrowLeft() {
if (this.currentCharIndex > 0) {
this.currentCharIndex--;
this.updateDisplay();
}
}
/**
* Process arrow right
*/
processArrowRight() {
if (this.currentCharIndex < this.fullText.length) {
this.currentCharIndex++;
this.updateDisplay();
}
}
/**
* Update text display
*/
updateDisplay() {
this.textDisplayManager.updateDisplay(this.userInput, this.currentCharIndex);
}
/**
* Update statistics display
*/
updateStatistics() {
const elapsedTime = this.sessionManager.getElapsedTime();
const stats = this.statisticsManager.calculateStatistics(
this.userInput,
this.fullText,
elapsedTime,
this.sessionManager.keyStrokeCount
);
this.statisticsManager.updateDisplay(stats);
}
/**
* Save current progress
*/
async saveProgress() {
try {
await this.sessionManager.saveProgress(
this.userInput,
this.currentCharIndex,
this.fullText
);
} catch (error) {
console.error('Error saving progress:', error);
}
}
/**
* Save metronome settings
*/
async saveMetronomeSettings() {
try {
await fetch('/set_metronome', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': this.csrfToken
},
body: JSON.stringify({
enabled: this.metronomePlayer.enabled,
bpm: this.metronomePlayer.bpm,
sound: this.metronomePlayer.sound,
mode: this.metronomeMode,
speed: this.metronomePlayer.bpm,
target_error_rate: this.targetErrorRate
})
});
} catch (error) {
console.error('Error saving metronome settings:', error);
}
}
/**
* Complete current lesson
*/
async completeLesson() {
const wasPlaying = !this.sessionManager.isPaused;
if (wasPlaying) {
this.sessionManager.pause();
this.metronomePlayer.stop();
}
// Get final statistics
const elapsedTime = this.sessionManager.getElapsedTime();
const stats = this.statisticsManager.calculateStatistics(
this.userInput,
this.fullText,
elapsedTime,
this.sessionManager.keyStrokeCount
);
// Save lesson statistics
try {
await this.statisticsManager.saveLessonStatistics(
this.sessionManager.currentLessonIndex,
stats.charsPerMinute,
stats.errorRate,
stats.wpm,
this.csrfToken
);
console.log('Lektion beendet! Statistiken gespeichert.');
} catch (error) {
console.error('Fehler beim Speichern:', error);
}
// Reset for same lesson
this.currentCharIndex = 0;
this.userInput = '';
this.sessionManager.reset();
this.textDisplayManager.resetScroll();
if (wasPlaying) {
this.sessionManager.resume();
this.uiManager.showPause();
}
this.updateDisplay();
this.updateStatistics();
if (this.metronomePlayer.enabled && !this.sessionManager.isPaused) {
this.metronomePlayer.start();
}
}
/**
* Load next lesson
*/
async nextLesson() {
try {
const data = await this.sessionManager.nextLesson();
this.fullText = data.text;
this.currentCharIndex = 0;
this.userInput = '';
this.sessionManager.reset();
this.sessionManager.resume();
this.textDisplayManager.updateText(this.fullText);
this.textDisplayManager.resetScroll();
this.uiManager.updateLessonTitle(data.title);
this.uiManager.updateLessonHighlight(data.index);
this.uiManager.showPause();
this.updateDisplay();
this.updateStatistics();
} catch (error) {
this.uiManager.alert('Fehler beim Laden des nächsten Textes: ' + error.message);
}
}
/**
* Load specific lesson
*/
async loadLesson(lessonIndex) {
try {
const data = await this.sessionManager.setLesson(lessonIndex);
this.fullText = data.text;
this.currentCharIndex = 0;
this.userInput = '';
this.sessionManager.reset();
this.sessionManager.resume();
this.textDisplayManager.updateText(this.fullText);
this.textDisplayManager.resetScroll();
this.uiManager.updateLessonTitle(data.title);
this.uiManager.updateLessonHighlight(lessonIndex);
this.uiManager.showPause();
this.updateDisplay();
this.updateStatistics();
} catch (error) {
this.uiManager.alert('Fehler: ' + error.message);
}
}
/**
* End session
*/
async endSession() {
try {
await this.sessionManager.endSession();
this.uiManager.alert('Sitzung beendet. Fortschritt gespeichert.');
window.location.href = '/';
} catch (error) {
this.uiManager.alert('Fehler beim Beenden der Sitzung: ' + error.message);
}
}
}
// Initialize application when DOM is ready
document.addEventListener('DOMContentLoaded', () => {
const app = new TypewriterApp();
app.initialize();
});
// Error handling
window.addEventListener('error', (e) => {
console.error('JavaScript Fehler:', e.error);
});

210
static/js/metronome.js Normal file
View file

@ -0,0 +1,210 @@
/**
* Adaptive Metronome Module
* Implements an intelligent metronome that adjusts speed based on typing accuracy
*/
export class AdaptiveMetronome {
constructor() {
this.speed = 60.0; // Starting speed in BPM
this.accuracy_history = []; // Last 50 results (1=correct, 0=incorrect)
this.consecutive_correct = 0; // Consecutive correct characters
this.target_accuracy = 0.96; // Target accuracy (96%)
}
/**
* Process a keystroke and adjust speed
* @param {boolean} is_correct - Whether the typed character was correct
* @param {number} current_difficulty - Difficulty factor (1.0 = normal)
* @returns {number} New BPM speed
*/
process_keystroke(is_correct, current_difficulty = 1.0) {
// 1. Update history
this.accuracy_history.push(is_correct ? 1.0 : 0.0);
if (this.accuracy_history.length > 50) {
this.accuracy_history.shift();
}
// 2. Count consecutive correct characters
if (is_correct) {
this.consecutive_correct += 1;
} else {
this.consecutive_correct = 0;
}
// 3. Adjust speed
return this._adjust_speed(current_difficulty);
}
/**
* Adjust speed based on current accuracy
* @param {number} difficulty - Difficulty factor
* @returns {number} New BPM speed
* @private
*/
_adjust_speed(difficulty) {
if (this.accuracy_history.length === 0) {
return this.speed;
}
let current_accuracy = this.accuracy_history.reduce((a, b) => a + b, 0) / this.accuracy_history.length;
let adjusted_target = this.target_accuracy - (difficulty * 0.05);
// Multiple adjustment strategies:
if (this.consecutive_correct >= 15) {
// Reward for sustained accuracy
this.speed *= 1.03;
} else if (current_accuracy > adjusted_target + 0.04) {
// Too easy - accelerate more
this.speed *= 1.02;
} else if (current_accuracy < adjusted_target - 0.06) {
// Too difficult - brake harder
this.speed *= 0.92;
} else if (current_accuracy < adjusted_target) {
// Slightly below target - gentle braking
this.speed *= 0.97;
} else {
// In target range - gentle acceleration
this.speed *= 1.01;
}
// Speed limits
this.speed = Math.max(40, Math.min(200, this.speed));
return this.speed;
}
}
export class MetronomePlayer {
constructor() {
this.audioContext = null;
this.interval = null;
this.enabled = true;
this.bpm = 60;
this.sound = 'beep';
}
/**
* Create a beep sound
* @param {number} vol - Volume (0-1)
* @param {number} freq - Frequency in Hz
* @param {number} duration - Duration in ms
*/
createBeepSound(vol, freq, duration) {
if (!this.audioContext) {
this.audioContext = new (window.AudioContext || window.webkitAudioContext)();
}
const oscillator = this.audioContext.createOscillator();
const gainNode = this.audioContext.createGain();
oscillator.connect(gainNode);
gainNode.connect(this.audioContext.destination);
gainNode.gain.value = vol;
oscillator.frequency.value = freq;
oscillator.type = 'sine';
oscillator.start();
setTimeout(() => {
oscillator.stop();
}, duration);
}
/**
* Create a click sound
*/
createClickSound() {
if (!this.audioContext) {
this.audioContext = new (window.AudioContext || window.webkitAudioContext)();
}
const bufferSize = this.audioContext.sampleRate * 0.01;
const buffer = this.audioContext.createBuffer(1, bufferSize, this.audioContext.sampleRate);
const data = buffer.getChannelData(0);
for (let i = 0; i < bufferSize; i++) {
data[i] = Math.random() * 2 - 1;
}
const source = this.audioContext.createBufferSource();
source.buffer = buffer;
const gainNode = this.audioContext.createGain();
gainNode.gain.setValueAtTime(0.1, this.audioContext.currentTime);
gainNode.gain.exponentialRampToValueAtTime(0.001, this.audioContext.currentTime + 0.1);
source.connect(gainNode);
gainNode.connect(this.audioContext.destination);
source.start();
}
/**
* Play metronome sound and provide visual feedback
*/
play() {
// Visual feedback
const cursorElement = document.querySelector('.cursor-char');
if (cursorElement) {
cursorElement.style.animation = 'pulse 0.3s';
setTimeout(() => {
cursorElement.style.animation = '';
}, 300);
}
// Audio feedback (only if enabled)
if (!this.enabled) {
return;
}
if (this.sound === 'beep') {
this.createBeepSound(0.1, 800, 50);
} else {
this.createClickSound();
}
}
/**
* Start the metronome
*/
start() {
this.stop();
const interval = (60 / this.bpm) * 1000;
this.play(); // Play immediately
this.interval = setInterval(() => this.play(), interval);
}
/**
* Stop the metronome
*/
stop() {
if (this.interval) {
clearInterval(this.interval);
this.interval = null;
}
}
/**
* Set BPM and restart if playing
* @param {number} bpm - Beats per minute
*/
setBPM(bpm) {
const wasPlaying = this.interval !== null;
this.bpm = bpm;
if (wasPlaying) {
this.start();
}
}
/**
* Toggle metronome on/off
*/
toggle() {
this.enabled = !this.enabled;
return this.enabled;
}
/**
* Set sound type
* @param {string} sound - 'beep' or 'click'
*/
setSound(sound) {
this.sound = sound;
}
}

255
static/js/session.js Normal file
View file

@ -0,0 +1,255 @@
/**
* Session Management Module
* Handles session state, progress saving, and lesson management
*/
export class SessionManager {
constructor(csrfToken) {
this.csrfToken = csrfToken;
this.lastStartTime = null;
this.totalElapsedTime = 0;
this.keyStrokeCount = 0;
this.isPaused = false;
this.currentLessonIndex = 0;
}
/**
* Initialize from server-provided state
* @param {Object} initialState - Initial state from server
*/
initialize(initialState) {
this.totalElapsedTime = initialState.totalElapsedTime || 0;
this.keyStrokeCount = initialState.keyStrokeCount || 0;
this.isPaused = initialState.isPaused || false;
this.currentLessonIndex = initialState.currentLessonIndex || 0;
if (!this.isPaused) {
this.lastStartTime = new Date();
}
}
/**
* Get elapsed time in seconds
* @returns {number} Elapsed time in seconds
*/
getElapsedTime() {
let elapsed = this.totalElapsedTime;
if (this.lastStartTime !== null) {
elapsed += (new Date() - this.lastStartTime);
}
return elapsed / 1000; // Convert to seconds
}
/**
* Start or resume timing
*/
start() {
if (!this.isPaused && this.lastStartTime === null) {
this.lastStartTime = new Date();
}
}
/**
* Pause timing
*/
pause() {
if (this.lastStartTime !== null) {
this.totalElapsedTime += (new Date() - this.lastStartTime);
this.lastStartTime = null;
}
this.isPaused = true;
}
/**
* Resume timing
*/
resume() {
this.isPaused = false;
this.lastStartTime = new Date();
}
/**
* Toggle pause state
* @returns {boolean} New pause state
*/
togglePause() {
if (this.isPaused) {
this.resume();
} else {
this.pause();
}
return this.isPaused;
}
/**
* Increment keystroke count
*/
incrementKeystrokes() {
this.keyStrokeCount++;
if (this.lastStartTime === null && !this.isPaused) {
this.lastStartTime = new Date();
}
}
/**
* Reset session state for new lesson
*/
reset() {
this.lastStartTime = null;
this.totalElapsedTime = 0;
this.keyStrokeCount = 0;
this.isPaused = false;
}
/**
* Save progress to server
* @param {string} userInput - User's current input
* @param {number} cursorPosition - Current cursor position
* @param {string} currentText - Current lesson text
* @param {boolean} keepalive - Whether to use keepalive option
* @returns {Promise} Fetch promise
*/
async saveProgress(userInput, cursorPosition, currentText, keepalive = false) {
const data = {
user_input: userInput,
cursor_position: cursorPosition,
current_text: currentText,
elapsed_time: this.totalElapsedTime / 1000,
total_elapsed_time: this.totalElapsedTime,
key_stroke_count: this.keyStrokeCount,
is_paused: this.isPaused
};
const options = {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': this.csrfToken
},
body: JSON.stringify(data)
};
if (keepalive) {
options.keepalive = true;
}
try {
const response = await fetch('/update_progress', options);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const result = await response.json();
return result;
} catch (error) {
console.error('Fehler beim Speichern des Fortschritts:', error);
throw error;
}
}
/**
* Load next lesson
* @returns {Promise<Object>} Lesson data
*/
async nextLesson() {
try {
const response = await fetch('/next_text', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': this.csrfToken
}
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
if (data.error) {
throw new Error(data.error);
}
this.currentLessonIndex = data.index;
return data;
} catch (error) {
console.error('Fehler beim Laden der nächsten Lektion:', error);
throw error;
}
}
/**
* Set specific lesson
* @param {number} lessonIndex - Index of the lesson
* @returns {Promise<Object>} Lesson data
*/
async setLesson(lessonIndex) {
try {
const response = await fetch('/set_lesson', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': this.csrfToken
},
body: JSON.stringify({
lesson_index: lessonIndex
})
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
if (data.error) {
throw new Error(data.error);
}
this.currentLessonIndex = lessonIndex;
return data;
} catch (error) {
console.error('Fehler beim Setzen der Lektion:', error);
throw error;
}
}
/**
* End session
* @returns {Promise} Fetch promise
*/
async endSession() {
try {
const response = await fetch('/end_session', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': this.csrfToken
}
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return await response.json();
} catch (error) {
console.error('Fehler beim Beenden der Session:', error);
throw error;
}
}
/**
* Setup beforeunload handler to save progress
* @param {Function} getSaveData - Function that returns data to save
*/
setupBeforeUnload(getSaveData) {
window.addEventListener('beforeunload', () => {
const data = getSaveData();
this.saveProgress(
data.userInput,
data.cursorPosition,
data.currentText,
true // Use keepalive
);
});
}
}

115
static/js/statistics.js Normal file
View file

@ -0,0 +1,115 @@
/**
* Statistics Module
* Handles calculation and display of typing statistics
*/
export class StatisticsManager {
constructor() {
this.speedDisplay = 'zpm'; // 'zpm' or 'wpm'
}
/**
* Calculate typing statistics
* @param {string} userInput - User's typed input
* @param {string} fullText - Target text
* @param {number} elapsedTime - Time elapsed in seconds
* @param {number} totalKeyStrokes - Total keystrokes including backspace
* @returns {Object} Statistics object
*/
calculateStatistics(userInput, fullText, elapsedTime, totalKeyStrokes) {
let correctKeyStrokes = 0;
// Count correct characters
for (let i = 0; i < userInput.length; i++) {
if (i < fullText.length && userInput[i] === fullText[i]) {
correctKeyStrokes++;
} else {
break;
}
}
let incorrectKeyStrokes = totalKeyStrokes - correctKeyStrokes;
let charsPerMinute = elapsedTime > 0 ? (correctKeyStrokes / elapsedTime) * 60 : 0;
let errorRate = totalKeyStrokes > 0 ? (incorrectKeyStrokes * 100.0) / totalKeyStrokes : 0;
let wpm = elapsedTime > 0 ? (correctKeyStrokes / 5) / (elapsedTime / 60) : 0;
return {
correctKeyStrokes,
incorrectKeyStrokes,
totalKeyStrokes,
charsPerMinute,
errorRate,
wpm
};
}
/**
* Update statistics display in the UI
* @param {Object} stats - Statistics object from calculateStatistics
*/
updateDisplay(stats) {
const speedElement = document.getElementById('speed');
const errorRateElement = document.getElementById('error-rate');
const correctElement = document.getElementById('correct');
const incorrectElement = document.getElementById('incorrect');
const totalElement = document.getElementById('total');
const wpmElement = document.getElementById('wpm');
const speedLabel = document.querySelector('#speed-label');
// Update display based on speed display preference
if (this.speedDisplay === 'wpm') {
if (speedElement) speedElement.textContent = Math.round(stats.wpm);
if (speedLabel) speedLabel.textContent = 'WPM:';
} else {
if (speedElement) speedElement.textContent = Math.round(stats.charsPerMinute);
if (speedLabel) speedLabel.textContent = 'ZPM:';
}
if (errorRateElement) errorRateElement.textContent = stats.errorRate.toFixed(1);
if (correctElement) correctElement.textContent = stats.correctKeyStrokes;
if (incorrectElement) incorrectElement.textContent = stats.incorrectKeyStrokes;
if (totalElement) totalElement.textContent = stats.totalKeyStrokes;
if (wpmElement) wpmElement.textContent = Math.round(stats.wpm);
}
/**
* Set speed display preference
* @param {string} display - 'zpm' or 'wpm'
*/
setSpeedDisplay(display) {
this.speedDisplay = display;
}
/**
* Save lesson statistics to server
* @param {number} lessonIndex - Index of the lesson
* @param {number} charsPerMinute - Characters per minute
* @param {number} errorRate - Error rate percentage
* @param {number} wpm - Words per minute
* @param {string} csrfToken - CSRF token
* @returns {Promise} Fetch promise
*/
async saveLessonStatistics(lessonIndex, charsPerMinute, errorRate, wpm, csrfToken) {
try {
const response = await fetch('/save_lesson_statistics', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
},
body: JSON.stringify({
lesson_index: lessonIndex,
chars_per_minute: charsPerMinute,
error_rate: errorRate,
wpm: wpm
})
});
const data = await response.json();
return data;
} catch (error) {
console.error('Fehler beim Speichern der Statistiken:', error);
throw error;
}
}
}

219
static/js/textDisplay.js Normal file
View file

@ -0,0 +1,219 @@
/**
* Text Display Module
* Handles text rendering, highlighting, and scrolling
*/
export class TextDisplayManager {
constructor(targetElement, fullText) {
this.targetElement = targetElement;
this.fullText = fullText;
this.lines = fullText.split('\n');
this.userScrolled = false;
this.firstVisibleLine = 0;
// Setup scroll listener
if (this.targetElement) {
this.targetElement.addEventListener('scroll', () => {
this.userScrolled = true;
const lineHeight = this.targetElement.scrollHeight / this.lines.length;
this.firstVisibleLine = Math.floor(this.targetElement.scrollTop / lineHeight);
});
}
}
/**
* Update the full text (e.g., when switching lessons)
* @param {string} newText - New text to display
*/
updateText(newText) {
this.fullText = newText;
this.lines = newText.split('\n');
this.userScrolled = false;
this.firstVisibleLine = 0;
if (this.targetElement) {
this.targetElement.scrollTop = 0;
}
}
/**
* HTML escape function for safe DOM manipulation
* @param {string} text - Text to escape
* @returns {string} Escaped HTML
*/
escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
/**
* Highlight a character with appropriate styling
* @param {string} char - Character to highlight
* @param {string} type - 'correct', 'incorrect', or 'neutral'
* @param {boolean} isCursor - Whether this is the cursor position
* @param {string|null} incorrectChar - The incorrect character typed (if applicable)
* @returns {string} HTML string for the highlighted character
*/
highlightChar(char, type, isCursor = false, incorrectChar = null) {
let className = `highlight-${type}`;
if (isCursor) {
className += ' cursor-char';
}
const escapedChar = this.escapeHtml(char);
if (char === ' ') {
return `<span class="${className} highlight-space">·</span>`;
} else if (char === '\t') {
return `<span class="${className} highlight-tab">→</span>`;
} else if (char === '\n') {
return `<span class="${className} highlight-newline">↵</span><br>`;
} else {
if (type === 'incorrect' && incorrectChar !== null) {
const escapedIncorrectChar = this.escapeHtml(incorrectChar);
return `<span class="${className} incorrect-char-container">${escapedChar}<span class="incorrect-char-overlay">${escapedIncorrectChar}</span></span>`;
} else {
return `<span class="${className}">${escapedChar}</span>`;
}
}
}
/**
* Calculate the current line index based on character position
* @param {number} currentCharIndex - Current character position
* @returns {number} Line index
*/
getCurrentLineIndex(currentCharIndex) {
let charCount = 0;
for (let i = 0; i < this.lines.length; i++) {
const lineLength = this.lines[i].length + (i < this.lines.length - 1 ? 1 : 0);
if (currentCharIndex < charCount + lineLength) {
return i;
}
charCount += lineLength;
}
return this.lines.length - 1;
}
/**
* Update the text display with highlighting
* @param {string} userInput - User's current input
* @param {number} currentCharIndex - Current cursor position
*/
updateDisplay(userInput, currentCharIndex) {
if (!this.targetElement) return;
let newHTML = '';
let charCount = 0;
for (let j = 0; j < this.lines.length; j++) {
const line = this.lines[j];
let highlightedLine = '';
for (let i = 0; i < line.length; i++) {
const currentChar = line[i];
const isCursor = (charCount === currentCharIndex);
if (charCount < userInput.length) {
if (userInput[charCount] === currentChar) {
highlightedLine += this.highlightChar(currentChar, 'correct', isCursor);
} else {
highlightedLine += this.highlightChar(currentChar, 'incorrect', isCursor, userInput[charCount]);
}
} else {
highlightedLine += this.highlightChar(currentChar, 'neutral', isCursor);
}
charCount++;
}
// Newline after each line (except the last)
if (j < this.lines.length - 1) {
const isCursor = (charCount === currentCharIndex);
if (charCount < userInput.length) {
if (userInput[charCount] === '\n') {
highlightedLine += this.highlightChar('\n', 'correct', isCursor);
} else {
highlightedLine += this.highlightChar('\n', 'incorrect', isCursor, userInput[charCount]);
}
} else {
highlightedLine += this.highlightChar('\n', 'neutral', isCursor);
}
charCount++;
}
const currentLineIndex = this.getCurrentLineIndex(currentCharIndex);
newHTML += `<div class="text-line ${j === currentLineIndex ? 'current-line' : ''}">${highlightedLine}</div>`;
}
this.targetElement.innerHTML = newHTML;
// Auto-scroll if user hasn't manually scrolled
if (!this.userScrolled) {
setTimeout(() => {
const currentLineIndex = this.getCurrentLineIndex(currentCharIndex);
const lineElements = this.targetElement.getElementsByClassName('text-line');
if (lineElements.length > 0) {
const lineHeight = lineElements[0].offsetHeight;
const containerHeight = this.targetElement.clientHeight;
if (lineHeight === 0) {
// Retry if height is 0
setTimeout(() => this.updateDisplay(userInput, currentCharIndex), 10);
return;
}
let desiredScrollTop = currentLineIndex * lineHeight;
const maxScrollTop = this.targetElement.scrollHeight - containerHeight;
desiredScrollTop = Math.min(desiredScrollTop, maxScrollTop);
desiredScrollTop = Math.max(0, desiredScrollTop);
this.targetElement.scrollTop = desiredScrollTop;
}
}, 0);
}
}
/**
* Reset scroll state
*/
resetScroll() {
this.userScrolled = false;
this.firstVisibleLine = 0;
if (this.targetElement) {
this.targetElement.scrollTop = 0;
}
}
/**
* Check if a line is completely correct
* @param {number} lineIndex - Index of the line to check
* @param {string} userInput - User's input
* @returns {boolean} True if line is complete and correct
*/
isLineComplete(lineIndex, userInput) {
if (lineIndex >= this.lines.length) return false;
let startIndex = 0;
for (let i = 0; i < lineIndex; i++) {
startIndex += this.lines[i].length + 1;
}
const endIndex = startIndex + this.lines[lineIndex].length;
for (let i = startIndex; i < endIndex; i++) {
if (i >= userInput.length || userInput[i] !== this.fullText[i]) {
return false;
}
}
if (lineIndex < this.lines.length - 1) {
const newlineIndex = endIndex;
if (newlineIndex >= userInput.length || userInput[newlineIndex] !== '\n') {
return false;
}
}
return true;
}
}

67
static/js/theme.js Normal file
View file

@ -0,0 +1,67 @@
/**
* Theme Management Module
* Handles light/dark theme switching and persistence
*/
export class ThemeManager {
constructor() {
this.currentTheme = 'light';
}
/**
* Initialize theme from localStorage or default to light mode
*/
initialize() {
const savedTheme = localStorage.getItem('theme') || 'light';
this.setTheme(savedTheme);
this.updateThemeButton(savedTheme);
}
/**
* Set the theme
* @param {string} theme - 'light' or 'dark'
*/
setTheme(theme) {
this.currentTheme = theme;
document.body.setAttribute('data-bs-theme', theme);
localStorage.setItem('theme', theme);
}
/**
* Toggle between light and dark theme
*/
toggle() {
const newTheme = this.currentTheme === 'light' ? 'dark' : 'light';
this.setTheme(newTheme);
this.updateThemeButton(newTheme);
}
/**
* Update the theme toggle button icons
* @param {string} theme - Current theme
*/
updateThemeButton(theme) {
const sunIcon = document.getElementById('theme-toggle-sun');
const moonIcon = document.getElementById('theme-toggle-moon');
if (sunIcon && moonIcon) {
if (theme === 'dark') {
sunIcon.style.display = 'none';
moonIcon.style.display = 'block';
} else {
sunIcon.style.display = 'block';
moonIcon.style.display = 'none';
}
}
}
/**
* Setup event listeners for theme toggle button
*/
setupEventListeners() {
const themeToggle = document.getElementById('theme-toggle');
if (themeToggle) {
themeToggle.addEventListener('click', () => this.toggle());
}
}
}

253
static/js/ui.js Normal file
View file

@ -0,0 +1,253 @@
/**
* UI Management Module
* Handles UI interactions, buttons, modals, and toggles
*/
export class UIManager {
constructor() {
this.volumeOn = null;
this.volumeOff = null;
this.lockClosed = null;
this.lockOpen = null;
this.playPausePause = null;
this.playPausePlay = null;
}
/**
* Initialize UI elements
*/
initialize() {
// Lucide Icons
if (typeof lucide !== 'undefined') {
lucide.createIcons();
}
// Get UI elements
this.volumeOn = document.getElementById('volume-on');
this.volumeOff = document.getElementById('volume-off');
this.lockClosed = document.getElementById('lock-closed');
this.lockOpen = document.getElementById('lock-open');
this.playPausePause = document.getElementById('play-pause-pause');
this.playPausePlay = document.getElementById('play-pause-play');
// Initialize states
this.showVolumeOn();
this.showLockOpen();
this.showPause();
}
/**
* Show volume on icon
*/
showVolumeOn() {
if (this.volumeOn) this.volumeOn.style.display = 'block';
if (this.volumeOff) this.volumeOff.style.display = 'none';
}
/**
* Show volume off icon
*/
showVolumeOff() {
if (this.volumeOn) this.volumeOn.style.display = 'none';
if (this.volumeOff) this.volumeOff.style.display = 'block';
}
/**
* Toggle volume icon
* @param {boolean} enabled - Whether volume is enabled
*/
toggleVolume(enabled) {
if (enabled) {
this.showVolumeOn();
} else {
this.showVolumeOff();
}
}
/**
* Show lock closed icon
*/
showLockClosed() {
if (this.lockClosed) this.lockClosed.style.display = 'block';
if (this.lockOpen) this.lockOpen.style.display = 'none';
}
/**
* Show lock open icon
*/
showLockOpen() {
if (this.lockClosed) this.lockClosed.style.display = 'none';
if (this.lockOpen) this.lockOpen.style.display = 'block';
}
/**
* Toggle lock icon
* @param {boolean} locked - Whether error lock is enabled
*/
toggleLock(locked) {
if (locked) {
this.showLockClosed();
} else {
this.showLockOpen();
}
}
/**
* Show pause icon (playing state)
*/
showPause() {
if (this.playPausePause) this.playPausePause.style.display = 'block';
if (this.playPausePlay) this.playPausePlay.style.display = 'none';
}
/**
* Show play icon (paused state)
*/
showPlay() {
if (this.playPausePause) this.playPausePause.style.display = 'none';
if (this.playPausePlay) this.playPausePlay.style.display = 'block';
}
/**
* Toggle play/pause icon
* @param {boolean} isPaused - Whether session is paused
*/
togglePlayPause(isPaused) {
if (isPaused) {
this.showPlay();
} else {
this.showPause();
}
}
/**
* Update lesson title in UI
* @param {string} title - Lesson title
*/
updateLessonTitle(title) {
const titleElement = document.getElementById('lesson-title');
if (titleElement) {
titleElement.textContent = title;
}
}
/**
* Update lesson highlight in sidebar
* @param {number} lessonIndex - Index of current lesson
*/
updateLessonHighlight(lessonIndex) {
document.querySelectorAll('.lesson-link').forEach(link => {
const index = parseInt(link.getAttribute('data-lesson-index'));
if (index === lessonIndex) {
link.classList.add('fw-bold', 'text-danger');
link.classList.remove('text-primary');
} else {
link.classList.remove('fw-bold', 'text-danger');
link.classList.add('text-primary');
}
});
}
/**
* Setup help modal
*/
setupHelpModal() {
const helpToggle = document.getElementById('help-toggle');
if (helpToggle) {
helpToggle.addEventListener('click', () => {
const helpModal = new bootstrap.Modal(document.getElementById('helpModal'));
helpModal.show();
});
}
}
/**
* Setup settings navigation
*/
setupSettingsLink() {
const settingsToggle = document.getElementById('settings-toggle');
if (settingsToggle) {
settingsToggle.addEventListener('click', () => {
window.location.href = '/settings';
});
}
}
/**
* Setup statistics navigation
*/
setupStatisticsLink(saveCallback) {
document.querySelectorAll('.statistics-link').forEach(link => {
link.addEventListener('click', () => {
console.log('Statistics link clicked');
if (saveCallback) {
saveCallback();
}
window.location.href = '/statistics';
});
});
}
/**
* Setup performance toggle
*/
setupPerformanceToggle() {
const performanceBtn = document.getElementById('performance-btn');
const progressContainer = document.querySelector('.progress-stats-container');
if (!performanceBtn || !progressContainer) {
return;
}
// Load saved state
const progressStatsVisible = localStorage.getItem('progressStatsVisible') === 'true';
this.setProgressStatsVisibility(progressStatsVisible);
// Event listener
performanceBtn.addEventListener('click', () => {
const isVisible = progressContainer.classList.contains('visible');
this.setProgressStatsVisibility(!isVisible);
localStorage.setItem('progressStatsVisible', !isVisible);
});
}
/**
* Set progress stats visibility
* @param {boolean} visible - Whether stats should be visible
*/
setProgressStatsVisibility(visible) {
const progressContainer = document.querySelector('.progress-stats-container');
const performanceBtn = document.getElementById('performance-btn');
if (!progressContainer || !performanceBtn) {
return;
}
if (visible) {
progressContainer.classList.add('visible');
performanceBtn.classList.remove('btn-light-custom');
performanceBtn.classList.add('btn-primary');
} else {
progressContainer.classList.remove('visible');
performanceBtn.classList.remove('btn-primary');
performanceBtn.classList.add('btn-light-custom');
}
}
/**
* Show alert message
* @param {string} message - Message to display
*/
alert(message) {
alert(message);
}
/**
* Confirm dialog
* @param {string} message - Message to display
* @returns {boolean} User's choice
*/
confirm(message) {
return confirm(message);
}
}

View file

@ -268,9 +268,11 @@
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<script src="https://kit.fontawesome.com/a076d05399.js" crossorigin="anonymous"></script>
<script>
// Übergib die aktuelle Lektionsnummer an JavaScript
// Übergib zusätzliche Konfiguration an JavaScript
window.currentLessonIndex = {{ current_lesson_index }};
window.speedDisplay = {{ speed_display|tojson }};
</script>
<script src="{{ url_for('static', filename='js/script.js') }}"></script>
<!-- Load modular JavaScript (ES6 modules) -->
<script type="module" src="{{ url_for('static', filename='js/main.js') }}"></script>
</body>
</html>