Claude Änderungen

This commit is contained in:
jamulix 2025-10-27 14:01:46 +01:00 committed by jamulix (aider)
commit 9bf31eb626
9 changed files with 1436 additions and 474 deletions

463
README.md
View file

@ -1,54 +1,61 @@
# Typewriter Tutor - Tipptrainer
# 🎹 Typewriter Tutor - Adaptiver Tipptrainer
Ein kleiner webbasierter Tipptrainer, der Ihnen hilft, das Zehnfingerschreiben zu erlernen und Ihre Tippgeschwindigkeit zu verbessern.
Ein moderner, webbasierter Tipptrainer mit **adaptivem Metronom** und umfangreichen Statistiken. Das Programm passt sich automatisch Ihrer Tippgeschwindigkeit an und hilft Ihnen, das Zehnfingerschreiben systematisch zu erlernen.
(Nur ein minimales Übungsprojekt, um den Umgang mit dem Kommandozeilen-KI-Programmiertool [Aider](https://aider.chat/) zu erlernen. )
> 💡 **Hinweis:** Dieses Projekt dient auch als Übungsprojekt für das KI-Programmiertool [Aider](https://aider.chat/).
## Funktionen
## ✨ Hauptfunktionen
- **Lektionen-basiertes Training**: Strukturierte Lektionen von Grundlagen bis zu Programmiertexten
- **Echtzeit-Statistiken**: Anzeige von Tippgeschwindigkeit (ZPM, WPM) und Fehlerrate
- **Fortschrittsverfolgung**: Persistente Speicherung Ihrer Lernfortschritte in SQLite-Datenbank
- **Detaillierte Statistik-Seite**: Diagramme zur Visualisierung Ihrer Leistung über die Zeit
### 🎯 **Adaptives Metronom**
- **Automatische Geschwindigkeitsanpassung**: Das Metronom passt sich dynamisch Ihrer Tippgeschwindigkeit an
- **5 intelligente Anpassungsregeln**: Beschleunigt bei hoher Genauigkeit, bremst bei Fehlern
- **Zielgenauigkeit**: Hält Sie im optimalen Bereich von 91-95% Genauigkeit
- **Zwei Modi**:
- **Automatisch**: Adaptives Metronom (empfohlen)
- **Explizit**: Feste Geschwindigkeit (40-200 BPM)
- **Streak-Bonus**: Belohnt fehlerfreies Tippen mit höherer Geschwindigkeit
### 📊 **Umfangreiche Statistiken**
- **Echtzeit-Metriken**: ZPM (Zeichen/Minute), WPM (Wörter/Minute), Fehlerrate
- **Detaillierte Diagramme**: Visualisierung Ihrer Fortschritte mit Chart.js
- **Lektionsbasierte Auswertung**: Separate Statistiken für jede Lektion
- **Historische Daten**: Verfolgen Sie Ihre Entwicklung über die Zeit
- **Datenexport**: CSV und JSON-Export Ihrer Statistiken
### 🎨 **Moderne Benutzeroberfläche**
- **Direct Input**: Tippen Sie direkt im Vorlagenfeld - kein separates Eingabefeld
- **Visuelles Feedback**:
- ✅ Grün für korrekte Zeichen
- ❌ Rot für Fehler mit orangefarbener Anzeige des falsch eingegebenen Zeichens
- 📍 Blinkender Cursor zeigt Ihre Position
- **Dark/Light Mode**: Komfortable Arbeit bei Tag und Nacht
- **Responsive Design**: Funktioniert auf Desktop und mobilen Geräten
- **Dark/Light Mode**: Anpassbare Oberfläche
- **Flexible Lektionen-Auswahl**: Direkter Zugriff auf beliebige Lektionen
- **Direkte Tastatureingabe**: Tippen Sie direkt im Vorlagenfeld - kein separates Eingabefeld mehr nötig
- **Visuelles Feedback**:
- Blinkender Cursor zeigt die aktuelle Position
- Korrekte Zeichen werden grün hervorgehoben
- Falsche Zeichen werden rot markiert mit orangefarbener Anzeige des falsch eingegebenen Zeichens
- **Intuitive Steuerung**:
- Pfeiltasten zur Cursor-Bewegung
- Backspace zur Korrektur
- Kompakte Button-Anordnung mit "Weiter", "Leistung", "Statistik" und "Beenden"
- **Platzsparendes Design**: Optimierte Anordnung der Elemente für beste Übersicht
- **Anti-Cheat-Schutz**: Verhindert Copy-Paste und andere unerlaubte Hilfen
- **Hilfe-System**: Integrierte Hilfe mit detaillierten Erklärungen
## Technologien
### 🔧 **Erweiterte Features**
- **ErrorLock**: Optional müssen Fehler korrigiert werden, bevor weitergeschrieben werden kann
- **Pause-Funktion**: Unterbrechen und Fortsetzen jederzeit möglich
- **Flexible Lektionsauswahl**: 43+ Lektionen von Grundlagen bis Programmierung
- **Fortschrittsverfolgung**: Automatisches Speichern Ihres Fortschritts
- **Anti-Cheat**: Verhindert Copy-Paste und unerlaubte Hilfen
- **Backend**: Python mit Flask Framework
- **Frontend**: HTML, CSS, JavaScript mit Bootstrap 5
- **Datenbank**: SQLite mit SQLAlchemy ORM
- **Diagramme**: Chart.js für statistische Auswertungen
- **Datenpersistenz**:
- Lektionen: JSON-basierte Lektionen in `data/lessons.json`
- Fortschritt und Statistiken: SQLite-Datenbank in `instance/typewriter.db`
## 🚀 Installation
## Datenbank
### Voraussetzungen
- Python 3.8 oder höher
- pip (Python Package Manager)
Die Datenbank (`instance/typewriter.db`) wird automatisch erstellt und mit den Tabellen initialisiert, wenn die Anwendung zum ersten Mal gestartet wird. Die Lektionen werden aus `data/lessons.json` geladen.
### Schnellstart
## Installation
1. Repository klonen:
1. **Repository klonen oder Download:**
```bash
git clone https://github.com/jamulix/typewriter.git
git clone https://github.com/jamulix/typewriter.git
cd typewriter
```
Oder laden Sie das letzte [Release](https://github.com/jamulix/typewriter/tags) als Zip-Datei herunter. ```(https://github.com/jamulix/typewriter/tags)``` Klicken Sie auf das oberste Release (meist „Latest“). Der Download startet sofort. Entpacken Sie die Datei. Dann geht es im Verzeichnis ```typewriter``` weiter.
2. Python-Umgebung einrichten (empfohlen):
Alternativ: [Neuestes Release herunterladen](https://github.com/jamulix/typewriter/tags)
2. **Virtuelle Umgebung erstellen (empfohlen):**
```bash
python -m venv venv
source venv/bin/activate # Linux/Mac
@ -56,113 +63,353 @@ source venv/bin/activate # Linux/Mac
venv\Scripts\activate # Windows
```
3. Abhängigkeiten installieren:
3. **Abhängigkeiten installieren:**
```bash
pip install flask flask-sqlalchemy
pip install -r requirements.txt
```
4. Anwendung starten:
4. **Datenbank initialisieren (optional, wird automatisch beim Start erstellt):**
```bash
# Bei erster Installation oder für Updates:
alembic upgrade head
```
5. **Anwendung starten:**
```bash
python app.py
```
5. Im Browser öffnen: http://localhost:5000
6. **Im Browser öffnen:**
```
http://localhost:5000
```
## Projektstruktur
## 📁 Projektstruktur
```
typewriter/
├── app.py # Haupt-Flask-Anwendung
├── models.py # Datenbankmodelle
├── app.py # Flask-Hauptanwendung
├── models.py # SQLAlchemy Datenbankmodelle
├── test_app.py # Unit-Tests (18 Tests)
├── test_metronome_velocity.py # Metronom-Tests (22 Tests)
├── requirements.txt # Python-Dependencies
├── .env # Secret Key (automatisch generiert)
├── .gitignore # Git-Ignore-Konfiguration
├── data/
│ └── lessons.json # Lektionen-Daten
│ └── lessons.json # 43 Lektionen (JSON)
├── static/
│ ├── css/
│ │ └── style.css # Styling
│ └── js/
│ └── script.js # Client-seitige Logik
│ │ └── style.css # Styling (Dark/Light Mode)
│ ├── js/
│ │ └── script.js # Client-Logik & Adaptives Metronom
│ └── favicon.svg # Schreibmaschinen-Icon
├── templates/
│ ├── index.html # Haupttemplate
│ └── statistics.html # Statistik-Seite
│ ├── index.html # Hauptseite (Training)
│ ├── statistics.html # Statistik-Diagramme
│ └── settings.html # Einstellungen (Metronom, Anzeige)
├── alembic/ # Datenbank-Migrationen
│ ├── env.py
│ ├── versions/
│ │ └── 09de7cfb8b74_initial_migration.py
│ └── alembic.ini
├── instance/
│ └── typewriter.db # SQLite-Datenbank (wird automatisch erstellt)
└── README.md
│ └── typewriter.db # SQLite-Datenbank (automatisch erstellt)
├── README.md # Diese Datei
├── CLAUDE.md # Projektdokumentation für KI
├── MIGRATIONS.md # Alembic-Anleitung
└── README_METRONOME_TESTS.md # Metronom-Test-Dokumentation
```
## Verwendung
## 🎮 Verwendung
1. Starten Sie die Anwendung und öffnen Sie sie im Browser
2. Wählen Sie eine Lektion aus der Lektionsleiste
3. Tippen Sie den angezeigten Text direkt im Vorlagenfeld - der Cursor zeigt Ihre aktuelle Position
4. Ihre Statistiken (ZPM, WPM und Fehlerrate) werden in Echtzeit aktualisiert
5. Nutzen Sie die Funktionen:
- **Weiter**: Nächsten Text laden
- **Leistung**: Detaillierte Leistungsstatistiken ein-/ausblenden
- **Statistik**: Zur Statistik-Seite navigieren
### Grundlegende Bedienung
1. **Training starten**: Öffnen Sie die Anwendung im Browser
2. **Lektion wählen**: Klicken Sie auf eine Lektionsnummer in der Lektionsleiste
3. **Tippen**: Beginnen Sie direkt zu tippen - der Cursor zeigt Ihre Position
4. **Statistiken**: Werden in Echtzeit angezeigt (optional über "Leistung" einblendbar)
5. **Navigation**:
- **Weiter**: Nächste Lektion
- **Leistung**: Detaillierte Statistiken ein-/ausblenden
- **Statistik**: Zur Statistik-Seite mit Diagrammen
- **Beenden**: Sitzung beenden
6. Bewegen Sie den Cursor mit Pfeiltasten und korrigieren Sie Fehler mit Backspace
7. Wechseln Sie zur nächsten Lektion oder besuchen Sie die Statistik-Seite
## Zeitmessung und Statistik
### Steuerung
Die Zeitmessung beginnt mit dem ersten Tastendruck und läuft kontinuierlich. Folgende Metriken werden berechnet:
| Taste/Aktion | Funktion |
|--------------|----------|
| **Buchstaben/Zahlen** | Zeichen eingeben |
| **Leertaste** | Leerzeichen |
| **Enter** | Zeilenumbruch (falls im Text vorhanden) |
| **Pfeiltaste ←/→** | Cursor bewegen |
| **Backspace** | Zeichen löschen |
| **Lautsprecher-Icon** | Metronom ein/aus |
| **Schloss-Icon** | ErrorLock ein/aus |
| **Play/Pause-Icon** | Training pausieren/fortsetzen |
| **Mond/Sonne-Icon** | Dark/Light Mode wechseln |
| **Zahnrad-Icon** | Einstellungen öffnen |
- **Tastaturanschläge**: Jeder Tastendruck wird gezählt (inklusive Backspace und Pfeiltasten)
- **Korrekte Zeichen**: Zeichen, die exakt mit der Vorlage übereinstimmen
- **Falsche Zeichen**: Tastaturanschläge minus korrekte Zeichen
- **Zeichen pro Minute (ZPM)**: (Korrekte Zeichen / verstrichene Zeit in Sekunden) * 60
- **Fehlerrate** in Prozent: (Falsche Zeichen * 100) / Korrekte Zeichen
- **Wörter pro Minute (WPM)**: (Korrekte Zeichen / 5) / (verstrichene Zeit in Minuten)
### Einstellungen
## Besondere Features
Über das Zahnrad-Icon (⚙️) gelangen Sie zu den Einstellungen:
### Direkte Tastatureingabe
- Tastatureingaben werden direkt im Vorlagenfeld erfasst
- Kein separates Eingabefeld mehr erforderlich
- Fokus bleibt immer auf dem Text
- Cursor-Position wird durch blinkenden Unterstrich visualisiert
**Metronom-Einstellungen:**
- **Modus**: Automatisch (adaptiv) oder Explizit (feste Geschwindigkeit)
- **Geschwindigkeit**: 40-200 ZPM (nur im Explizit-Modus)
- **Ziel-Fehlerrate**: 1-20% (nur im Automatik-Modus)
### Visuelles Feedback
- **Korrekte Zeichen**: Grün hervorgehoben
- **Falsche Zeichen**: Rot markiert mit orangefarbener Überlagerung des falsch eingegebenen Zeichens
- **Cursor**: Blinkender blauer Unterstrich (im Dark-Mode cyan) unter dem aktuellen Zeichen
**Statistik-Anzeige:**
- **ZPM** (Zeichen pro Minute) oder **WPM** (Wörter pro Minute)
### Erweiterte Steuerung
- **Pfeiltasten Links/Rechts**: Bewegen den Cursor im Text
- **Backspace**: Löscht Zeichen an der Cursor-Position
- **Enter**: Wird automatisch erkannt, wenn im Vorlagentext vorhanden
## 📈 Statistiken und Metriken
## Statistik-Seite
### Berechnungsformeln
Die Anwendung bietet eine detaillierte Statistik-Seite unter `/statistics` mit:
```
Zeichen pro Minute (ZPM) = (Korrekte Zeichen / Zeit in Sekunden) × 60
- **Interaktiven Diagrammen**: Zeigt Änderung der ZPM, Fehlerrate und WPM über die Zeit
- **Lektionsauswahl**: Wählen Sie verschiedene Lektionen zur Analyse aus
- **Historische Daten**: Verfolgen Sie Ihre Fortschritte pro Lektion über mehrere Sitzungen
Wörter pro Minute (WPM) = (Korrekte Zeichen / 5) / (Zeit in Minuten)
## Entwickler-Funktionen
Fehlerrate (%) = (Falsche Tastenanschläge / Gesamte Tastenanschläge) × 100
- `/debug` - Debug-Informationen zur Lektionen-Ladung und Konfiguration
- `/repair_json` - JSON-Reparatur-Endpoint für beschädigte Lektionsdateien
- `/recreate_lessons` - Lektionen neu erstellen (Fallback)
- `/update_progress` - Fortschritt aktualisieren
- `/next_text` - Nächsten Text anfordern
- `/set_lesson` - Lektion setzen
- `/end_session` - Sitzung beenden
- `/save_lesson_statistics` - Lektionsstatistiken speichern
- `/get_lesson_statistics/<int:lesson_index>` - Lektionsstatistiken abrufen
Genauigkeit (%) = 100 - Fehlerrate
```
## Datenbank-Modelle
### Statistik-Seite
- **Progress**: Speichert den aktuellen Fortschritt (aktuelle Lektion, Position)
- **Statistic**: Allgemeine Tippstatistiken
- **LessonStatistic**: Detaillierte Statistiken pro Lektion mit Zeitstempel
Die Statistik-Seite (`/statistics`) bietet:
- **Interaktive Diagramme**: ZPM/WPM und Fehlerrate über die Zeit
- **Lektionsauswahl**: Separate Statistiken für jede Lektion
- **Datenexport**: CSV und JSON-Download Ihrer Statistiken
- **Verlaufsanalyse**: Sehen Sie Ihre Fortschritte über mehrere Sitzungen
## Lizenz
## 🧪 Tests
MIT License
Das Projekt enthält umfangreiche Test-Suites:
## Autor
### App-Tests (18 Tests)
```bash
python -m unittest test_app -v
```
(c) 2025 Dieter Schlüter <dieter.schlueter@linix.de>
Testet:
- Statistik-Berechnungen
- API-Endpoints
- Input-Validierung
- Datenbank-Operationen
- CSV/JSON-Export
### Metronom-Tests (22 Tests)
```bash
python test_metronome_velocity.py
```
Testet:
- Alle 5 Anpassungsregeln
- Realistische Szenarien (Anfänger bis Experte)
- Edge Cases und Grenzfälle
- Enthält detaillierte Simulation
Siehe [`README_METRONOME_TESTS.md`](README_METRONOME_TESTS.md) für Details.
## 🔧 Entwicklung
### Datenbank-Migrationen
Das Projekt verwendet Alembic für Datenbank-Schema-Änderungen:
```bash
# Neue Migration erstellen
alembic revision --autogenerate -m "Beschreibung"
# Migrationen anwenden
alembic upgrade head
# Migration rückgängig machen
alembic downgrade -1
```
Siehe [`MIGRATIONS.md`](MIGRATIONS.md) für vollständige Anleitung.
### API-Endpoints
**Öffentliche Endpoints:**
- `GET /` - Hauptseite (Training)
- `GET /statistics` - Statistik-Seite mit Diagrammen
- `GET /settings` - Einstellungsseite
- `GET /export/statistics/csv` - CSV-Export
- `GET /export/statistics/json` - JSON-Export
**API-Endpoints (AJAX):**
- `POST /update_progress` - Fortschritt aktualisieren
- `POST /next_text` - Nächste Lektion laden
- `POST /set_lesson` - Lektion wechseln
- `POST /save_lesson_statistics` - Lektionsstatistiken speichern
- `GET /get_lesson_statistics/<int:lesson_index>` - Statistiken abrufen
- `POST /save_settings` - Einstellungen speichern
- `POST /set_metronome` - Metronom-Einstellungen
- `POST /end_session` - Sitzung beenden
**Debug-Endpoints (nur im Debug-Modus):**
- `GET /debug` - Debug-Informationen
- `POST /repair_json` - JSON-Reparatur
- `POST /recreate_lessons` - Lektionen neu erstellen
### Sicherheitsfeatures
- ✅ **CSRF-Protection**: Flask-WTF schützt vor Cross-Site Request Forgery
- ✅ **Input-Validierung**: Alle POST-Endpoints validieren Eingaben
- ✅ **Secret Key**: Automatisch generiert und sicher in `.env` gespeichert (0o600)
- ✅ **XSS-Prevention**: HTML-Escaping bei DOM-Manipulation
- ✅ **SQL-Injection-Schutz**: SQLAlchemy ORM mit Parameterisierung
- ✅ **Anti-Cheat**: Copy-Paste im Vorlagenfeld deaktiviert
## 🎯 Adaptives Metronom - Details
Das adaptive Metronom ist das Herzstück des Trainers:
### Anpassungsregeln
| Regel | Bedingung | Aktion | Änderung |
|-------|-----------|--------|----------|
| **#1** | 15+ fehlerfreie Zeichen (Streak) | Belohnung | +3% |
| **#2** | Genauigkeit > 95% | Zu leicht | +2% |
| **#3** | Genauigkeit < 85% | Zu schwer | -8% |
| **#4** | Genauigkeit 85-91% | Leicht unter Ziel | -3% |
| **#5** | Genauigkeit 91-95% | Im Zielbereich | +1% |
### Funktionsweise
1. **Sliding Window**: Speichert die letzten 50 Tasteneingaben
2. **Genauigkeitsberechnung**: Prozentsatz korrekter Eingaben
3. **Regelauswahl**: Hierarchische Prüfung der 5 Regeln
4. **Geschwindigkeitsanpassung**: Multiplikation mit Faktor
5. **Grenzen**: Minimum 40 BPM, Maximum 200 BPM
**Beispiel:** Anfänger mit 70% Genauigkeit
- Start: 60 BPM
- Nach 50 Eingaben: ~40 BPM (Minimum erreicht)
- System bremst ab für weniger Fehler
**Beispiel:** Experte mit 98% Genauigkeit
- Start: 60 BPM
- Nach 100 Eingaben: ~200 BPM (Maximum erreicht)
- Plus Streak-Boni bei 15+ korrekten Zeichen
Siehe [`README_METRONOME_TESTS.md`](README_METRONOME_TESTS.md) für detaillierte Erklärung und Tests.
## 📊 Datenbank-Schema
### Tabellen
**Progress** - Aktueller Fortschritt
```sql
id INTEGER PRIMARY KEY
current_text_index INTEGER DEFAULT 0 -- Aktuelle Lektion
current_position INTEGER DEFAULT 0 -- Cursor-Position
last_text TEXT DEFAULT '' -- Letzte Eingabe
```
**Statistic** - Allgemeine Statistiken (Legacy)
```sql
id INTEGER PRIMARY KEY
total_chars INTEGER DEFAULT 0
correct_chars INTEGER DEFAULT 0
incorrect_chars INTEGER DEFAULT 0
error_rate FLOAT DEFAULT 0.0
typing_speed FLOAT DEFAULT 0.0
duration_per_char FLOAT DEFAULT 0.0
accuracy FLOAT DEFAULT 0.0
wpm FLOAT DEFAULT 0.0
```
**LessonStatistic** - Lektionsstatistiken (mit Indexes)
```sql
id INTEGER PRIMARY KEY
lesson_index INTEGER NOT NULL -- Index: idx_lesson_created
chars_per_minute FLOAT DEFAULT 0.0
error_rate FLOAT DEFAULT 0.0
wpm FLOAT DEFAULT 0.0
created_at DATETIME DEFAULT NOW() -- Index: idx_lesson_created
```
## 🤝 Beitragen
Contributions sind willkommen! Bitte:
1. Fork das Repository
2. Erstelle einen Feature-Branch (`git checkout -b feature/AmazingFeature`)
3. Führe Tests aus (`python -m unittest test_app -v`)
4. Commit deine Änderungen (`git commit -m 'Add AmazingFeature'`)
5. Push zum Branch (`git push origin feature/AmazingFeature`)
6. Öffne einen Pull Request
### Code-Qualität
- ✅ Alle Tests müssen bestehen
- ✅ Neue Features sollten Tests haben
- ✅ Code sollte dokumentiert sein (Docstrings)
- ✅ Befolge die bestehende Code-Struktur
## 📝 Changelog
### Version 2.0 (Aktuell)
- ✅ Adaptives Metronom mit 5 Anpassungsregeln
- ✅ Einstellungsseite für Metronom und Anzeige
- ✅ CSV/JSON-Export der Statistiken
- ✅ CSRF-Protection mit Flask-WTF
- ✅ Umfassende Input-Validierung
- ✅ Secret Key Management mit .env
- ✅ Alembic für Datenbank-Migrationen
- ✅ 40 Unit-Tests (18 App + 22 Metronom)
- ✅ Accessibility-Verbesserungen (ARIA-Labels)
- ✅ Performance-Optimierung (DOM-Rendering)
- ✅ Custom Favicon (Schreibmaschine)
- ✅ Strukturiertes Logging
- ✅ Type Hints in Python-Code
### Version 1.0
- ✅ Grundlegende Tipptrainer-Funktionalität
- ✅ 43 Lektionen von Grundlagen bis Programmierung
- ✅ Echtzeit-Statistiken
- ✅ Diagramme mit Chart.js
- ✅ Dark/Light Mode
- ✅ Direct Input im Vorlagenfeld
## 🐛 Bekannte Einschränkungen
- **Metronom-Difficulty**: Parameter `current_difficulty` wird aktuell nicht genutzt (immer 1.0)
- **JavaScript-Module**: `script.js` ist mit 1089 Zeilen sehr groß (sollte in Module aufgeteilt werden)
- **Type Hints**: Nicht alle Funktionen haben vollständige Type Hints
Diese können bei Bedarf in zukünftigen Versionen verbessert werden.
## 📄 Lizenz
MIT License - siehe [LICENSE](LICENSE) Datei für Details.
## 👨‍💻 Autor
**Dieter Schlüter**
📧 dieter.schlueter@linix.de
🌐 [GitHub: @jamulix](https://github.com/jamulix)
---
## 🙏 Danksagungen
- [Flask](https://flask.palletsprojects.com/) - Web-Framework
- [Bootstrap 5](https://getbootstrap.com/) - UI-Framework
- [Chart.js](https://www.chartjs.org/) - Diagramme
- [Lucide Icons](https://lucide.dev/) - Icon-Set
- [Aider](https://aider.chat/) - KI-Programmiertool für Entwicklung
---
**Viel Erfolg beim Training! 🚀**

519
app.py
View file

@ -1,13 +1,68 @@
import os
import json
import re
from flask import Flask, render_template, request, jsonify, session
import logging
import csv
import secrets
from io import StringIO
from typing import Dict, List, Any, Optional
from flask import Flask, render_template, request, jsonify, session, Response, make_response
from flask_wtf.csrf import CSRFProtect
from models import db, Progress, Statistic, LessonStatistic
# Logging konfigurieren
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(__name__)
def get_or_create_secret_key() -> str:
"""
Holt den Secret Key aus der .env Datei oder generiert einen neuen.
Die .env Datei ist nur vom User schreib- und lesbar (0o600).
"""
env_path = os.path.join(os.path.dirname(__file__), '.env')
# Versuche Secret Key aus .env zu laden
if os.path.exists(env_path):
try:
with open(env_path, 'r') as f:
for line in f:
line = line.strip()
if line.startswith('SECRET_KEY='):
secret_key = line.split('=', 1)[1].strip()
if secret_key:
logger.info("Secret Key aus .env geladen")
return secret_key
except Exception as e:
logger.warning(f"Fehler beim Lesen der .env Datei: {e}")
# Secret Key generieren und speichern
logger.info("Generiere neuen Secret Key...")
secret_key = secrets.token_hex(32) # 64 Zeichen Hex-String
try:
with open(env_path, 'w') as f:
f.write(f"SECRET_KEY={secret_key}\n")
# Setze Dateiberechtigungen auf 0o600 (nur User kann lesen/schreiben)
os.chmod(env_path, 0o600)
logger.info(f"Neuer Secret Key generiert und in .env gespeichert (Berechtigungen: 0o600)")
except Exception as e:
logger.error(f"Fehler beim Speichern der .env Datei: {e}")
logger.warning("Verwende temporären Secret Key für diese Session")
return secret_key
app = Flask(__name__)
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///typewriter.db'
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
app.secret_key = 'your-secret-key-here' # Für Session verwenden
app.secret_key = get_or_create_secret_key()
# CSRF-Schutz aktivieren
csrf = CSRFProtect(app)
db.init_app(app)
# Fallback-Lektionen für den Fall, dass die JSON-Datei nicht geladen werden kann
@ -22,77 +77,77 @@ FALLBACK_LESSONS = [
DATA_PATH = os.path.join(app.root_path, "data", "lessons.json")
lessons = []
def repair_json(json_string):
def repair_json(json_string: str) -> str:
"""Versucht, häufige JSON-Fehler automatisch zu reparieren"""
try:
# Repariere fehlende Kommas zwischen Objekten
# Sucht nach } followed by { ohne Komma dazwischen
repaired = re.sub(r'\}\s*\n\s*\{', '},\n{', json_string)
# Repariere fehlende Kommas am Ende von Objekten im Array
repaired = re.sub(r'\}\s*\n\s*\]', '}\n]', repaired)
# Repariere fehlende Anführungszeichen bei Keys
repaired = re.sub(r'(\w+)\s*:', r'"\1":', repaired)
# Repariere fehlende schließende Klammern
brackets_open = repaired.count('[') + repaired.count('{')
brackets_close = repaired.count(']') + repaired.count('}')
if brackets_open > brackets_close:
repaired += ']' * (brackets_open - brackets_close)
return repaired
except Exception as e:
print(f"Fehler beim Reparieren des JSON: {e}")
logger.error(f"Fehler beim Reparieren des JSON: {e}")
return json_string
def load_lessons():
def load_lessons() -> None:
"""Lädt Lektionen aus der JSON-Datei oder verwendet Fallback"""
global lessons
try:
if os.path.exists(DATA_PATH):
with open(DATA_PATH, "r", encoding="utf-8") as f:
content = f.read().strip()
# Versuche zuerst das originale JSON zu laden
try:
lessons = json.loads(content)
print(f"Erfolgreich {len(lessons)} Lektionen aus {DATA_PATH} geladen")
logger.info(f"Erfolgreich {len(lessons)} Lektionen aus {DATA_PATH} geladen")
return
except json.JSONDecodeError as e:
print(f"JSON-Fehler in {DATA_PATH}: {e}")
print(f"Position: Zeile {e.lineno}, Spalte {e.colno}")
logger.error(f"JSON-Fehler in {DATA_PATH}: {e}")
logger.error(f"Position: Zeile {e.lineno}, Spalte {e.colno}")
# Versuche das JSON zu reparieren
print("Versuche JSON automatisch zu reparieren...")
logger.info("Versuche JSON automatisch zu reparieren...")
repaired_content = repair_json(content)
try:
lessons = json.loads(repaired_content)
print(f"Erfolgreich {len(lessons)} Lektionen nach Reparatur geladen")
logger.info(f"Erfolgreich {len(lessons)} Lektionen nach Reparatur geladen")
# Speichere die reparierte Version als Backup
backup_path = DATA_PATH + ".repaired"
with open(backup_path, "w", encoding="utf-8") as f:
json.dump(lessons, f, indent=2, ensure_ascii=False)
print(f"Reparierte Version gespeichert als: {backup_path}")
logger.info(f"Reparierte Version gespeichert als: {backup_path}")
return
except json.JSONDecodeError as e2:
print(f"Automatische Reparatur fehlgeschlagen: {e2}")
print("Verwende Fallback-Lektionen")
logger.error(f"Automatische Reparatur fehlgeschlagen: {e2}")
logger.warning("Verwende Fallback-Lektionen")
lessons = FALLBACK_LESSONS
else:
print(f"Warnung: Datei {DATA_PATH} nicht gefunden. Verwende Fallback-Lektionen.")
logger.warning(f"Datei {DATA_PATH} nicht gefunden. Verwende Fallback-Lektionen.")
lessons = FALLBACK_LESSONS
except Exception as e:
print(f"Unerwarteter Fehler beim Laden der Lektionen: {e}")
print("Verwende Fallback-Lektionen")
logger.error(f"Unerwarteter Fehler beim Laden der Lektionen: {e}")
logger.warning("Verwende Fallback-Lektionen")
lessons = FALLBACK_LESSONS
def create_sample_lessons():
def create_sample_lessons() -> List[Dict[str, Any]]:
"""Erstellt eine korrekte Beispiel-JSON-Datei falls nötig"""
sample_lessons = [
{
@ -130,56 +185,30 @@ def create_sample_lessons():
try:
with open(DATA_PATH, "w", encoding="utf-8") as f:
json.dump(sample_lessons, f, indent=2, ensure_ascii=False)
print(f"Beispiel-Lektionen erstellt in: {DATA_PATH}")
logger.info(f"Beispiel-Lektionen erstellt in: {DATA_PATH}")
return sample_lessons
except Exception as e:
print(f"Fehler beim Erstellen der Beispiel-Lektionen: {e}")
logger.error(f"Fehler beim Erstellen der Beispiel-Lektionen: {e}")
return FALLBACK_LESSONS
# Datenbanktabellen erstellen 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()
print("Datenbanktabellen wurden initialisiert")
# Prüfe ob die lesson_statistic Tabelle die alte Spalte chars_per_second hat
from sqlalchemy import inspect, text
inspector = inspect(db.engine)
if 'lesson_statistic' in inspector.get_table_names():
columns = [col['name'] for col in inspector.get_columns('lesson_statistic')]
if 'chars_per_second' in columns and 'chars_per_minute' not in columns:
print("Aktualisiere Datenbanktabelle lesson_statistic...")
# Temporäre Tabelle erstellen
db.session.execute(text('''
CREATE TABLE lesson_statistic_new (
id INTEGER PRIMARY KEY,
lesson_index INTEGER NOT NULL,
chars_per_minute FLOAT DEFAULT 0.0,
error_rate FLOAT DEFAULT 0.0,
wpm FLOAT DEFAULT 0.0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
'''))
# Daten umrechnen und übertragen (chars_per_second * 60)
db.session.execute(text('''
INSERT INTO lesson_statistic_new (id, lesson_index, chars_per_minute, error_rate, wpm, created_at)
SELECT id, lesson_index, chars_per_second * 60, error_rate, wpm, created_at
FROM lesson_statistic
'''))
# Alte Tabelle löschen und neue umbenennen
db.session.execute(text('DROP TABLE lesson_statistic'))
db.session.execute(text('ALTER TABLE lesson_statistic_new RENAME TO lesson_statistic'))
db.session.commit()
print("Datenbanktabelle lesson_statistic erfolgreich aktualisiert")
logger.info("Datenbanktabellen wurden initialisiert")
logger.info("HINWEIS: Für Schema-Änderungen verwende Alembic Migrationen (siehe MIGRATIONS.md)")
# Versuche Lektionen zu laden
load_lessons()
# Falls keine Lektionen geladen wurden, erstelle Beispieldaten
if not lessons:
print("Keine Lektionen verfügbar, erstelle Beispiel-Lektionen...")
logger.warning("Keine Lektionen verfügbar, erstelle Beispiel-Lektionen...")
lessons = create_sample_lessons()
print(f"{len(lessons)} Lektionen geladen")
logger.info(f"{len(lessons)} Lektionen geladen")
@app.route('/')
def index():
@ -193,13 +222,13 @@ def index():
if lessons:
initial_text = lessons[0]['text']
progress = Progress(
current_text_index=0,
current_position=0,
current_text_index=0,
current_position=0,
last_text=initial_text
)
db.session.add(progress)
db.session.commit()
print(f"Neuen Fortschritt initialisiert mit Text: {initial_text[:50]}...")
logger.info(f"Neuen Fortschritt initialisiert mit Text: {initial_text[:50]}...")
else:
# Fallback, falls lessons immer noch leer sein sollte
progress = Progress(
@ -221,8 +250,8 @@ def index():
progress.current_position = 0
progress.last_text = current_text
db.session.commit()
print(f"Rendering Index - Current Text Länge: {len(current_text)} Zeichen")
logger.debug(f"Rendering Index - Current Text Länge: {len(current_text)} Zeichen")
# Bestimme den Lektionstitel
current_lesson_title = "Unbekannte Lektion"
@ -237,11 +266,24 @@ def index():
metronome_mode = session.get('metronome_mode', 'automatic')
metronome_speed = session.get('metronome_speed', 60)
target_error_rate = session.get('target_error_rate', 5)
speed_display = session.get('speed_display', 'zpm')
# Lade die gespeicherte Benutzereingabe und Cursor-Position aus der Datenbank
user_input = progress.last_text
cursor_position = progress.current_position
# Lade zusätzliche Zustandsdaten aus der Session
total_elapsed_time = session.get('total_elapsed_time', 0)
key_stroke_count = session.get('key_stroke_count', 0)
is_paused = session.get('is_paused', False)
return render_template('index.html',
current_text=current_text,
current_position=progress.current_position,
last_text=progress.last_text,
current_position=cursor_position,
last_text=user_input,
total_elapsed_time=total_elapsed_time,
key_stroke_count=key_stroke_count,
is_paused=is_paused,
lessons=lessons,
current_lesson_title=current_lesson_title,
current_lesson_index=progress.current_text_index,
@ -250,10 +292,12 @@ def index():
metronome_sound=metronome_sound,
metronome_mode=metronome_mode,
metronome_speed=metronome_speed,
target_error_rate=target_error_rate)
target_error_rate=target_error_rate,
speed_display=speed_display)
except Exception as e:
print(f"Fehler in index(): {e}")
logger.error(f"Fehler in index(): {e}", exc_info=True)
# Auch im Fehlerfall Metronom-Standardwerte übergeben
return render_template('index.html',
current_text="Fehler beim Laden der Lektionen",
@ -267,6 +311,11 @@ def index():
@app.route('/debug')
def debug():
"""Debug-Route zum Überprüfen der Lektionen und Konfiguration"""
# Debug-Endpoint nur im Debug-Modus verfügbar
if not app.debug:
from flask import abort
abort(404)
progress = Progress.query.first()
# Versuche, den Inhalt der JSON-Datei zu lesen
@ -371,33 +420,64 @@ def update_progress():
"""Aktualisiert den Tippfortschritt mit echter Zeitmessung"""
try:
data = request.get_json()
if not data:
return jsonify({'error': 'Keine Daten empfangen'}), 400
# Input-Validierung
user_input = data.get('user_input', '')
current_text = data.get('current_text', '')
elapsed_time = data.get('elapsed_time', 0) # in Sekunden
# Validiere numerische Werte
try:
elapsed_time = float(data.get('elapsed_time', 0))
cursor_position = int(data.get('cursor_position', len(user_input)))
total_elapsed_time = float(data.get('total_elapsed_time', 0))
key_stroke_count = int(data.get('key_stroke_count', 0))
except (ValueError, TypeError):
return jsonify({'error': 'Ungültige numerische Werte'}), 400
# Validiere Bereiche
if elapsed_time < 0 or total_elapsed_time < 0 or key_stroke_count < 0:
return jsonify({'error': 'Negative Werte sind nicht erlaubt'}), 400
if cursor_position < 0:
return jsonify({'error': 'Cursor-Position muss >= 0 sein'}), 400
is_paused = bool(data.get('is_paused', False))
# Hole den aktuellen Fortschritt
progress = Progress.query.first()
if progress:
# Aktualisiere den Fortschritt
progress.current_position = len(user_input)
progress.last_text = user_input
# Speichere in der Datenbank
db.session.commit()
# Berechne Statistiken mit echter Zeit
stats = calculate_statistics(user_input, current_text, elapsed_time)
return jsonify(stats)
try:
# Aktualisiere den Fortschritt
progress.current_position = cursor_position
progress.last_text = user_input
# Speichere in der Datenbank
db.session.commit()
# Speichere zusätzliche Zustandsdaten in der Session
session['total_elapsed_time'] = total_elapsed_time
session['key_stroke_count'] = key_stroke_count
session['is_paused'] = is_paused
# Berechne Statistiken mit echter Zeit
stats = calculate_statistics(user_input, current_text, elapsed_time)
return jsonify(stats)
except Exception as db_error:
db.session.rollback()
logger.error(f"Datenbank-Fehler beim Speichern des Fortschritts: {db_error}", exc_info=True)
return jsonify({'error': 'Fehler beim Speichern des Fortschritts'}), 500
else:
return jsonify({'error': 'Progress nicht gefunden'}), 400
except Exception as e:
print(f"Fehler in update_progress: {e}")
logger.error(f"Fehler in update_progress: {e}", exc_info=True)
return jsonify({'error': 'Interner Serverfehler'}), 500
def calculate_statistics(user_input, current_text, elapsed_time):
def calculate_statistics(user_input: str, current_text: str, elapsed_time: float) -> Dict[str, Any]:
"""Berechnet Tippstatistiken mit echter Zeitmessung"""
correct_chars = 0
incorrect_chars = 0
@ -472,9 +552,9 @@ def next_text():
'total': len(lessons),
'title': lesson_title
})
except Exception as e:
print(f"Fehler in next_text: {e}")
logger.error(f"Fehler in next_text: {e}", exc_info=True)
return jsonify({'error': 'Interner Serverfehler'}), 500
@app.route('/set_lesson', methods=['POST'])
@ -508,9 +588,9 @@ def set_lesson():
'text': new_lesson['text'],
'title': lesson_title
})
except Exception as e:
print(f"Fehler in set_lesson: {e}")
logger.error(f"Fehler in set_lesson: {e}", exc_info=True)
return jsonify({'error': 'Interner Serverfehler'}), 500
@app.route('/save_lesson_statistics', methods=['POST'])
@ -518,29 +598,57 @@ def save_lesson_statistics():
"""Speichert die Statistiken für eine abgeschlossene Lektion"""
try:
data = request.get_json()
if not data:
return jsonify({'error': 'Keine Daten empfangen'}), 400
# Input-Validierung
lesson_index = data.get('lesson_index')
chars_per_minute = data.get('chars_per_minute')
error_rate = data.get('error_rate')
wpm = data.get('wpm')
if lesson_index is None:
return jsonify({'error': 'Lektionsindex fehlt'}), 400
# Validiere Typen und Bereiche
try:
lesson_index = int(lesson_index)
chars_per_minute = float(data.get('chars_per_minute', 0))
error_rate = float(data.get('error_rate', 0))
wpm = float(data.get('wpm', 0))
except (ValueError, TypeError):
return jsonify({'error': 'Ungültige Datentypen'}), 400
# Bereichsvalidierung
if lesson_index < 0 or lesson_index >= len(lessons):
return jsonify({'error': 'Ungültiger Lektionsindex'}), 400
if chars_per_minute < 0 or error_rate < 0 or wpm < 0:
return jsonify({'error': 'Negative Werte sind nicht erlaubt'}), 400
if error_rate > 100:
return jsonify({'error': 'Fehlerrate kann nicht über 100% sein'}), 400
logger.debug(f"Empfangene Statistik: lesson_index={lesson_index}, chars_per_minute={chars_per_minute}, error_rate={error_rate}, wpm={wpm}")
# Neue Statistik für die Lektion erstellen
lesson_stat = LessonStatistic(
lesson_index=lesson_index,
chars_per_minute=chars_per_minute,
error_rate=error_rate,
wpm=wpm
)
db.session.add(lesson_stat)
db.session.commit()
return jsonify({'status': 'success', 'message': 'Lektionsstatistiken gespeichert'})
try:
lesson_stat = LessonStatistic(
lesson_index=lesson_index,
chars_per_minute=chars_per_minute,
error_rate=error_rate,
wpm=wpm
)
db.session.add(lesson_stat)
db.session.commit()
logger.info("Statistik erfolgreich in Datenbank gespeichert")
return jsonify({'status': 'success', 'message': 'Lektionsstatistiken gespeichert'})
except Exception as db_error:
db.session.rollback()
logger.error(f"Datenbank-Fehler beim Speichern der Statistik: {db_error}", exc_info=True)
return jsonify({'error': 'Fehler beim Speichern der Statistik'}), 500
except Exception as e:
print(f"Fehler in save_lesson_statistics: {e}")
logger.error(f"Fehler in save_lesson_statistics: {e}", exc_info=True)
return jsonify({'error': 'Interner Serverfehler'}), 500
@app.route('/statistics')
@ -557,37 +665,130 @@ def statistics():
has_data = LessonStatistic.query.filter_by(lesson_index=i).first() is not None
lesson_has_data.append(has_data)
# Hole die Einstellung für die Geschwindigkeitsanzeige aus der Session
speed_display = session.get('speed_display', 'zpm')
return render_template('statistics.html',
lessons=lessons,
current_lesson_index=current_lesson_index,
lesson_has_data=lesson_has_data)
lesson_has_data=lesson_has_data,
speed_display=speed_display)
except Exception as e:
print(f"Fehler in statistics(): {e}")
logger.error(f"Fehler in statistics(): {e}", exc_info=True)
return render_template('statistics.html',
lessons=[],
current_lesson_index=0,
lesson_has_data=[])
lesson_has_data=[],
speed_display='zpm')
@app.route('/get_lesson_statistics/<int:lesson_index>')
def get_lesson_statistics(lesson_index):
def get_lesson_statistics(lesson_index: int) -> Response:
"""Gibt die Statistiken für eine bestimmte Lektion zurück"""
try:
stats = LessonStatistic.query.filter_by(lesson_index=lesson_index)\
.order_by(LessonStatistic.created_at.asc())\
.all()
data = {
'dates': [stat.created_at.strftime('%y-%m-%d') for stat in stats],
'dates': [stat.created_at.strftime('%Y-%m-%d') for stat in stats],
'chars_per_minute': [stat.chars_per_minute for stat in stats],
'error_rate': [stat.error_rate for stat in stats],
'wpm': [stat.wpm for stat in stats]
}
return jsonify(data)
except Exception as e:
print(f"Fehler in get_lesson_statistics: {e}")
logger.error(f"Fehler in get_lesson_statistics: {e}", exc_info=True)
return jsonify({'error': 'Interner Serverfehler'}), 500
@app.route('/export/statistics/csv')
def export_statistics_csv() -> Response:
"""Exportiert alle Statistiken als CSV-Datei"""
try:
# Hole alle Statistiken
stats = LessonStatistic.query.order_by(
LessonStatistic.lesson_index.asc(),
LessonStatistic.created_at.asc()
).all()
# CSV erstellen
output = StringIO()
writer = csv.writer(output)
# Header
writer.writerow(['Datum', 'Lektion-Nr', 'Lektionstitel', 'Zeichen/Min', 'Fehlerrate %', 'Wörter/Min'])
# Daten
for stat in stats:
lesson_title = "Unbekannt"
if stat.lesson_index < len(lessons):
lesson_title = lessons[stat.lesson_index].get('title', f"Lektion {stat.lesson_index + 1}")
writer.writerow([
stat.created_at.strftime('%Y-%m-%d %H:%M:%S'),
stat.lesson_index + 1,
lesson_title,
f"{stat.chars_per_minute:.1f}",
f"{stat.error_rate:.1f}",
f"{stat.wpm:.2f}"
])
# Response erstellen
response = make_response(output.getvalue())
response.headers['Content-Type'] = 'text/csv; charset=utf-8'
response.headers['Content-Disposition'] = 'attachment; filename=typewriter_statistiken.csv'
logger.info(f"CSV-Export erfolgreich: {len(stats)} Einträge")
return response
except Exception as e:
logger.error(f"Fehler beim CSV-Export: {e}", exc_info=True)
return jsonify({'error': 'Fehler beim Export'}), 500
@app.route('/export/statistics/json')
def export_statistics_json() -> Response:
"""Exportiert alle Statistiken als JSON-Datei"""
try:
# Hole alle Statistiken
stats = LessonStatistic.query.order_by(
LessonStatistic.lesson_index.asc(),
LessonStatistic.created_at.asc()
).all()
# JSON-Daten erstellen
from datetime import datetime
export_data = {
'export_date': datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
'total_entries': len(stats),
'statistics': []
}
for stat in stats:
lesson_title = "Unbekannt"
if stat.lesson_index < len(lessons):
lesson_title = lessons[stat.lesson_index].get('title', f"Lektion {stat.lesson_index + 1}")
export_data['statistics'].append({
'date': stat.created_at.strftime('%Y-%m-%d %H:%M:%S'),
'lesson_number': stat.lesson_index + 1,
'lesson_title': lesson_title,
'chars_per_minute': round(stat.chars_per_minute, 1),
'error_rate': round(stat.error_rate, 1),
'words_per_minute': round(stat.wpm, 2)
})
# Response erstellen
response = make_response(json.dumps(export_data, ensure_ascii=False, indent=2))
response.headers['Content-Type'] = 'application/json; charset=utf-8'
response.headers['Content-Disposition'] = 'attachment; filename=typewriter_statistiken.json'
logger.info(f"JSON-Export erfolgreich: {len(stats)} Einträge")
return response
except Exception as e:
logger.error(f"Fehler beim JSON-Export: {e}", exc_info=True)
return jsonify({'error': 'Fehler beim Export'}), 500
@app.route('/settings')
def settings():
"""Einstellungsseite"""
@ -598,12 +799,40 @@ def save_settings():
"""Speichert die Einstellungen in der Session"""
try:
data = request.get_json()
session['metronome_mode'] = data.get('metronome_mode', 'automatic')
session['metronome_speed'] = data.get('metronome_speed', 60)
session['target_error_rate'] = data.get('target_error_rate', 5)
if not data:
return jsonify({'error': 'Keine Daten empfangen'}), 400
# Validiere metronome_mode
metronome_mode = data.get('metronome_mode', 'automatic')
if metronome_mode not in ['automatic', 'explicit']:
return jsonify({'error': 'Ungültiger Metronom-Modus'}), 400
# Validiere numerische Werte
try:
metronome_speed = int(data.get('metronome_speed', 60))
target_error_rate = int(data.get('target_error_rate', 5))
except (ValueError, TypeError):
return jsonify({'error': 'Ungültige numerische Werte'}), 400
# Bereichsvalidierung
if not (20 <= metronome_speed <= 300):
return jsonify({'error': 'Metronom-Geschwindigkeit muss zwischen 20 und 300 liegen'}), 400
if not (0 <= target_error_rate <= 100):
return jsonify({'error': 'Fehlerrate muss zwischen 0 und 100 liegen'}), 400
# Validiere speed_display
speed_display = data.get('speed_display', 'zpm')
if speed_display not in ['zpm', 'wpm']:
return jsonify({'error': 'Ungültige Geschwindigkeitsanzeige'}), 400
session['metronome_mode'] = metronome_mode
session['metronome_speed'] = metronome_speed
session['target_error_rate'] = target_error_rate
session['speed_display'] = speed_display
return jsonify({'status': 'success'})
except Exception as e:
print(f"Fehler in save_settings: {e}")
logger.error(f"Fehler in save_settings: {e}", exc_info=True)
return jsonify({'error': 'Interner Serverfehler'}), 500
@app.route('/set_metronome', methods=['POST'])
@ -611,12 +840,30 @@ def set_metronome():
"""Setzt die Metronom-Einstellungen in der Session"""
try:
data = request.get_json()
session['metronome_enabled'] = data.get('enabled', False)
session['metronome_bpm'] = data.get('bpm', 60)
session['metronome_sound'] = data.get('sound', 'beep')
if not data:
return jsonify({'error': 'Keine Daten empfangen'}), 400
# Validierung
enabled = bool(data.get('enabled', False))
try:
bpm = int(data.get('bpm', 60))
except (ValueError, TypeError):
return jsonify({'error': 'Ungültiger BPM-Wert'}), 400
if not (20 <= bpm <= 300):
return jsonify({'error': 'BPM muss zwischen 20 und 300 liegen'}), 400
sound = data.get('sound', 'beep')
if sound not in ['beep', 'click', 'woodblock']:
sound = 'beep' # Fallback auf Standard
session['metronome_enabled'] = enabled
session['metronome_bpm'] = bpm
session['metronome_sound'] = sound
return jsonify({'status': 'success'})
except Exception as e:
print(f"Fehler in set_metronome: {e}")
logger.error(f"Fehler in set_metronome: {e}", exc_info=True)
return jsonify({'error': 'Interner Serverfehler'}), 500
@app.route('/end_session', methods=['POST'])
@ -626,14 +873,14 @@ def end_session():
# Hier könnten erweiterte Statistiken gespeichert werden
return jsonify({'status': 'success', 'message': 'Session beendet'})
except Exception as e:
print(f"Fehler in end_session: {e}")
logger.error(f"Fehler in end_session: {e}", exc_info=True)
return jsonify({'error': 'Interner Serverfehler'}), 500
if __name__ == '__main__':
print("=" * 50)
print("Tipptrainer gestartet")
print(f"Lektionen geladen: {len(lessons)}")
print(f"Debug-Info verfügbar unter: http://127.0.0.1:5000/debug")
print("=" * 50)
logger.info("=" * 50)
logger.info("Tipptrainer gestartet")
logger.info(f"Lektionen geladen: {len(lessons)}")
logger.info(f"Debug-Info verfügbar unter: http://127.0.0.1:5000/debug")
logger.info("=" * 50)
app.run(debug=True, host='0.0.0.0', port=5000)

View file

@ -88,5 +88,130 @@
"lesson": 4.6,
"title": "04.06 Lernkontrolle durch Wortübungen",
"text": "alle See sah das half sie Gisela eilige diese Aale des Hasses\nKlage die Höfe es half diese öde Höhle Lage dies Haff Sie sah\n\nsehe Halle sage Heike helle Höfe öde Höhle die Heide die Haie\nsehe Halle sage Heike helle Höfe öde Höhle die Heide die Haie\n\ngedeihe Klage heikel Heilige fehle Felge eilig Lage lege Igel\ngedeihe Klage heikel Heilige fehle Felge eilig Lage lege Igel"
},
{
"lesson": 5.1,
"title": "Einschreibübung",
"text": "asedsa ölkilö asedsa ölkilö asedsa ölkilö asedsa ölkilö kikik\nasedsa ölkilö asedsa ölkilö asedsa ölkilö asedsa ölkilö kikik\n\nAsEdSa ÖlKiLö AsEdSa ÖlKiLö AsEdSa ÖlKiLö AsEdSa ÖlKiLö AsEdS\nJkIkLö FdEdSa JkIkLö FdEdSa JkIkLö FdEdSa JkIkLö FdEdSa JkIkL\n\nsage siege liege geige lasse diese jedes alles da sie lieh es\nsage siege liege geige lasse diese jedes alles da sie lieh es"
},
{
"lesson": 5.2,
"title": "05.2 Erarbeitung der Griffe c, Komma und Semikolon",
"text": "dedcd kik,k dedcd kik,k dedcd kik,k dedcd kik,k dedcd kik,k c\nfdcdf jk,kj fdcdf jk,kj fdcdf jk,kj fdcdf jk,kj fdcdf jk,kj ,\n\njk;ölkj fdcasdf jk;ölkj fdcasdf jk;ölkj fdcasdf jk;ölkj fdcas\njk;ölkj fdcasdf jk;ölkj fdcasdf jk;ölkj fdcasdf jk;ölkj fdcas\n\nach, ich, dich, sich, schlage, Celle, Chef, Dach, Fach, Sache\nach, ich, dich, sich, schlage, Celle, Chef, Dach, Fach, Sache\n\nlache, lege, leise, leihe, Leiche, Lacke, Jacke, Hacke, Hecke\nlache, lege, leise, leihe, Leiche, Lacke, Jacke, Hacke, Hecke"
},
{
"lesson": 5.3,
"title": "05.3 Festigung durch Wendungen",
"text": "lache leise, diese Sache, dieses Fach, dieses Dach, die Cella\nlache leise, diese Sache, dieses Fach, dieses Dach, die Cella\n\ndie Fische, ach alle, lese diese Sache leise, dieses Cedille;\ndie Fische, ach alle, lese diese Sache leise, dieses Cedille;\n\nlöse das Dach, leihe die Sache, lache Chef, ach diese Deiche;\nlöse das Dach, leihe die Sache, lache Chef, ach diese Deiche;\n\ndie Eiche, die Leiche, die Fische, die Fischlaiche, Schleife;\ndie Eiche, die Leiche, die Fische, die Fischlaiche, Schleife;"
},
{
"lesson": 5.4,
"title": "05.4 Konzentrationstraining durch Griffübungen",
"text": "asdcdef jk,kilö asdcef jk,ilö asecf ji,lö fedcsa ölik,j ec i,\nasdcdef jk,kilö asdcef jk,ilö asecf ji,lö fedcsa ölik,j ec i,\n\nasdfedc jklöik, asdfced jklö,ki fedc öik, fesa ölij fcsa öl,j\nasdfedc jklöik, asdfced jklö,ki fedc öik, fesa ölij fcsa öl,j"
},
{
"lesson": 5.5,
"title": "05.5 Festigung durch Wortübungen",
"text": "Öle Aale Liesel Sessel Klasse Diesel Jaffa Fliege Hilfe Galle\nÖle Aale Liesel Sessel Klasse Diesel Jaffa Fliege Hilfe Galle\n\ndie Öle, die Aale, die Liesel, die Sessel, die Klasse, Diesel\ndie Fliege, die Hilfe, die Galle, die Geige, die Liege, Jaffa\n\ndie Eiche, die Deiche, die Asche, die Fische, die Deichsel da\ndiese Eiche, diese Deiche, diese Asche, diese Fische, Cedille\n\ndie Eidechse, die Gleiche, die Leiche, diese Hechelei, Kachel\nja die Eidechse, ja die Gleiche, ja die Leiche, ja die Kachel"
},
{
"lesson": 5.6,
"title": "05.6 Lernkontrolle durch Satzübungen",
"text": "Liesel sage es Helga, ich sage das Lied leise; ach Liese half\neilig; Adelheid, dich sah ich eilig; ach Liesel ich leihe dir\nSchleife; die Klasse lese leise das Lied, siehe die Eidechse;\n\nDas Jagdglas lag ja da; als Heidi diese Eidechse sah, da lief\nsie; ja, als Adelheid die eisige See sah, da half sie Liesel;\n\nIch sah die helle Diele, ich sah die Heide, ich sah die Höfe;\nAch, jede sah das Dach; ja, dieses Dach; siehe die Heidehöfe;"
},
{
"lesson": 6.1,
"title": "05.7 Einschreibübung",
"text": "asdfjklö asdefölkij asdfjklö asdefölkij asdfjklö asdefölkikj\nölkjasdf ölk,kasdcd ölkjasdf ölk,kasdcd ölkjasdf ölk,kasdcdf\n\nedcsafik,löj edcsafik,löj edcsafik,öj edcsafik,öj edcsafik,öj\njöl,kifascde jöl,kifascde jöl,kifascde jöl,kifascde jöl,kifas\nErarbeitung der Griffe r und u\n\nasdfrf ölkjuj asdfrf ölkjuj asdfrf ölkjuj asdfrf ölkjuj frfju\nfrfjuj frfjuj frdesa jukilö frdesa frfjuj frfjuj frdesa jukil\n\nfrfded jujkik frfdcd jujk,k frfded jujkik frfdcd jujk,k rfded\nujk,ki rfdcde ujk,ki rfdcde ujk,ki rfdcde ujk,ki rfdcde ujk,k\n\nfra juö frs jul frd juk örl aus örk aud örj auf fru jur fr ju\nruj urf fua jrö dua krö sua lrö kuj drf luj srf öuj arf rf uj"
},
{
"lesson": 6.2,
"title": "06.2 Erarbeitung der Griffe r und u",
"text": "asdfrf ölkjuj asdfrf ölkjuj asdfrf ölkjuj asdfrf ölkjuj frfju\nfrfjuj frfjuj frdesa jukilö frdesa frfjuj frfjuj frdesa jukil\n\nfrfded jujkik frfdcd jujk,k frfded jujkik frfdcd jujk,k rfded\nujk,ki rfdcde ujk,ki rfdcde ujk,ki rfdcde ujk,ki rfdcde ujk,k\n\nfra juö frs jul frd juk örl aus örk aud örj auf fru jur fr ju\nruj urf fua jrö dua krö sua lrö kuj drf luj srf öuj arf rf uj"
},
{
"lesson": 6.3,
"title": "06.3 Festigung durch Wortübungen",
"text": "frage, frage, frage, laufe, laufe, laufe, frage, laufe, rauf,\nfrage, raufe, frage, kaufe, frage, raufe, frage, kaufe, rief,\n\neure, drei, klar, eure, drei, klar, eure, drei, klar, eure, d\nrede, sehr, hier, rede, sehr, hier, rede, sehr, hier, rede, s\n\nsehr eilige, alles ruhig, jeder laufe, gerade hier, gehe aus,\nsehr ruhige, alles eilig, jeder kaufe, gerade alle, geradeaus\n\ndie Frage, die Freude, die Ruhe, der Karl, die Karla, Kuckuck\ndie Frage, die Freude, die Ruhe, der Karl, die Karla, Kuckuck\n\ndie Kugelfische, der Hilferuf, der Kuckucksruf, die Ruhelage,\ndie Kugelfische, der Hilferuf, der Kuckucksruf, die Ruhelage,"
},
{
"lesson": 6.4,
"title": "06.4 Lernkontrolle durch Geläufigkeitssätze",
"text": "Sage Adelheid, hier höre jeder diese Kuckucksrufe sehr leise;\nSage Adelheid, hier höre jeder diese Kuckucksrufe sehr leise;\nSage Adelheid, hier höre jeder diese Kuckucksrufe sehr leise;\nSage Adelheid, hier höre jeder diese Kuckucksrufe sehr leise;\nSage Adelheid, hier höre jeder diese Kuckucksrufe sehr leise;\n\nKarla sage sehr leise, dieser Löffel gehöre sicherlich Adele;\nKarla sage sehr leise, dieser Löffel gehöre sicherlich Adele;\nKarla sage sehr leise, dieser Löffel gehöre sicherlich Adele;\nKarla sage sehr leise, dieser Löffel gehöre sicherlich Adele;\nKarla sage sehr leise, dieser Löffel gehöre sicherlich Adele;"
},
{
"lesson": 6.5,
"title": "06.5 Schreibsicherheit durch Griffübungen = Fingergymnastik",
"text": "frfr juju frfr juju frfr juju frfr juju frfr juju frfr juju r\nfruj jurf fruj jurf fruj jurf fruj jurf fruj jurf fruj jurf u\n\nfra juö fra juö fra juö fra juö fra juö fra juö fra juö fra r\nraf uöj raf uöj raf uöj raf uöj raf uöj raf uöj raf uöj raf u\n\nrsf ulj rsf ulj rsf ulj rsf ulj rsf ulj rsf ulj rsf ulj rsf r\nrdf ukj rdf ukj rdf ukj rdf ukj rdf ukj rdf ukj rdf ukj rdf u\n\nörf auj örf auj örf auj örf auj örf auj örf auj örf auj örf r\nöre aui öre aui öre aui öre aui öre aui öre aui öre aui öre u\n\nref uij ref uij ref uij ref uij ref uij ref uij ref uij ref e\naer öiu aer öiu aer öiu aer öiu aer öiu aer öiu aer öiu aer i\n\nasderfd ölkiujk asderfd ölkiujk asderfd ölkiujk asderfd ölkiu\ndfredas kjuiklö dfredas kjuiklö dfredas kjuiklö dfredas uiklö"
},
{
"lesson": 6.6,
"title": "06.6 Festigung durch Wortübungen",
"text": "Hirse Haus Rede Reeder Reederei Dur Kur Uhr Ruhr Kuli Ulli Öl\nHirse Haus Rede Reeder Reederei Dur Kur Uhr Ruhr Kuli Ulli Öl\n\nJura Frau Erde Ilse Juli Frage Iller Erdöl Jahr Friede Julius\nJura Frau Erde Ilse Juli Frage Iller Erdöl Jahr Friede Julius"
},
{
"lesson": 6.7,
"title": "06.7 Lernkontrolle durch Wendungen",
"text": "auf dieser Reise, das Ufer der Saale, raufe die Haare, das Ei\nauf dieser Reise, das Ufer der Saale, raufe die Haare, das Ei\n\nrufe Julius her, rufe Frau Krause, Adelheid lege alles darauf\nsei ruhig Ilse, sieh da Ulli, auch Karla sah die Fischerei da"
},
{
"lesson": 7.1,
"title": "07.1 Erarbeitung der Griffe v und m",
"text": "asdfvf ölkjmj asdfvf ölkjmj asdfvf ölkjmj asdfvf ölkjmj asdfv\nfvfdsa jmjklö fvfdsa jmjklö fvfdsa jmjklö fvfdsa jmjklö fvfds\n\nfvfrfvf jmjujmj fvfrfvf jmjujmj fvfrfvf jmjujmj fvfrfvf jujmj\nfrfvfrd jujmjuk frfvfrs jujmjul frfvfra jujmjuö frvfrvf jumjö\n\nfvfredsa jmjuiklö fvfredsa jmjuiklö fvfredsa jmjuiklö fvfreds\nfvfredca jmjuik,ö fvfredca jmjuik,ö fvfredca jmjuik,ö fvfredc\n\nver mir vie mie vei mei ver mir vie mie vei mei ver mir vie m\nmer vir mie vie mei vei mer vir mie vie mei vei mer vir mie v"
},
{
"lesson": 7.2,
"title": "07.2 Festigung durch Wortübungen",
"text": "viel mehr verlese mehrfach vielfach mache vermache mische das\nviele vieles vieler vielem mehr mehre mehrere mehreres vieler\n\nvermehre verlasse verkaufe vermache verlese verliere verkaufe\nvergehe vermerke vermische vermelde vergesse verlaufe verlese\n\nvermiese, vermahle, vermale, versehe, vergehe, verlege, viele\nvermerke, vergesse, verlaufe, versuche, vermerke, vermöge es,\n\nVieh, Meer, Vers, Mimik, Villa, Muskel, Vier, Mieder, Villach\nVermerk, Mega, Vikar, Mischer, Verruf, Mull, Virus, Maulesel."
},
{
"lesson": 7.3,
"title": "07.3 Festigung durch Wendungen",
"text": "sehr viel Vieh, auf dem Meer, merke viele Verse, diese Villa,\nder kluge Vermerk, vermerke ihre Versuche, das Villacher Vieh\n\nalle die Vermerke, der aufmerksame Mischer, der fremde Vikar,\nmehrere Filme, immer mehr Verkehr, immer viele Versuchsfilme,\n\nimmer die Maare, immer die Meere, auf dem Mars, mehrere Male,\nverkaufe im Mai, verkaufe die Vase, vermische das viele Mehl,"
},
{
"lesson": 7.4,
"title": "07.4 Schreibsicherheit durch Griffübungen = Fingergymnastik",
"text": "rfvdsa ujmklö rfvdsa ujmklö rfvdsa ujmklö rfvdsa ujmklö rfvds\nedcrfv ik,ujm edcrfv ik,ujm edcrfv ik,ujm edcrfv ik,ujm edcrf\n\nfjruvmdkeic, fjruvmdkeic, fjruvmdkeic, fjruvmdkeic, fjruvmdke\n,kiujmcderfv ,kiujmcderfv ,kiujmcderfv ,kiujmcderfv ,kiujmcde\n\nfjvmdkc,eidk fjvmdkc,eidk fjvmdkc,eidk fjvmdkc,eidk fjvmdkcei\nkdie,ckdmvjf kdie,ckdmvjf kdie,ckdmvjf kdie,ckdmvjf ieckdmvjf"
},
{
"lesson": 7.5,
"title": "07.5 Festigung durch Wendungen",
"text": "viele Lacke, jede Jacke, diese Hecke, dieser Fleck, ja Flachs\ndiese Lacke, diese Hacke, jede Decke, der Flachs, die Fackel,\n\ndieser Vermerk, decke das Dach, recke die Decke, dieser Mars,\nversuche die Lachse, die Meereslage, mehrere Maler, die Eiche"
},
{
"lesson": 7.6,
"title": "07.6 Lernkontrolle durch Satzübungen",
"text": "Adelheid vermerke dir die Reihe der Versuche der Marschierer;\nAdele versuche die Lachse; Friedel sieh diese Messer im Sack;\n\nAdam vermerke es, ich vergesse diese Sache sicherlich gleich;\nFriedel ich verleihe im Juli die Decke, die ich dir vermache;\n\nDie Kauffrau verkaufe im Juli frische Meeresfische aus Kiel;\nIm Kaufhaus Meier kaufe ich immer mehrere frische Seefische;\n\nSage Helga, die Filme aus dem Kaufhaus kaufe ich sicher auch;\nLege die Filme auf die Jacke, dieses merke ich mir sicherlich"
},
{
"lesson": 8.1,
"title": "08.1 Erarbeitung der Griffe t und z",
"text": "jzjö ftfa jzjö ftfa jzjö ftfa jzjö ftfa jzjö ftfa jzjö ftfa z\njhzj fgtf jhzj fgtf jhzj fgtf jhzj fgtf jhzj fgtf jhzj fgtf t\n\nfrtgf juzhj frtgf juzhj frtgf juzhj frtgf juzhj frtgf juzhj z\njhzuj fgtrf jhzuj fgtrf jhzuj fgtrf jhzuj fgtrf jhzuj fgtrf t\n\njuzfrt juzfrt juzfrt juzfrt juzfrt juzfrt juzfrt juzfrt juzfr\ntrfzuj trfzuj trfzuj trfzuj trfzuj trfzuj trfzuj trfzuj trfzu\n\nfjrutz fjrutz fjrutz fjrutz fjrutz fjrutz fjrutz fjrutz fjrut\ntzurjf tzurjf tzurjf tzurjf tzurjf tzurjf tzurjf tzurjf tzurj\n\nzer ter zer ter zer ter zer ter zer ter zer ter zer ter zer t\nzur tur zur tur zur tur zur tur zur tur zur tur zur tur zur z"
},
{
"lesson": 8.2,
"title": "08.02 Festigung durch Wortübungen",
"text": "trage trete treffe trat tretet geht guter ziehe zage letztere\ntrage trete treffe trat tretet geht guter ziehe zage letztere\n\nzerfalle zertrete zermale zergehe zergliedere zerstreue zerre\nzerfalle zertrete zermale zergehe zergliedere zerstreue zerre\n\nZerrerei, zeige, Zeiger, zerfalle, Zerfall, ziehe es, Zieher,\nZerrerei, zeige, Zeiger, zerfalle, Zerfall, ziehe es, Zieher,\n\nZiege, Ziegel, Ziegelei, Ziegeldach, Ziegelstreicher, Ziegel,\nZiel, Zielkamera, Zielkauf, Zielgerade, diese Zielsicherheit,\n\nzugemauert, zugeheftet, zugemutet, zumeist, zugleich, zuletzt\nZuzug, Zugtier, Zugverkehr, Zulieferer, Zurichterei, Zurichter"
},
{
"lesson": 8.3,
"title": "08.3 Festigung durch Wendungen",
"text": "zertrete die Ziegel, zerhacke die Ziegel, zerstreue das Zeug,\nzeige die Zulage, zeige die Zielkamera, zeige diese Ziegelei,\n\nviel Verkehr, viel Zugverkehr, viele Zulieferer, der Zielkauf\njetzt zur Schau gestellt, richtet die Zeit, seit kurzer Zeit,"
},
{
"lesson": 8.4,
"title": "08.4 Schreibsicherheit durch Griffübungen",
"text": "jzjklö ftfdsa jzjklö ftfdsa jzjklö ftfdsa jzjklö ftfdsa jzftö\nölkjzj asdftf ölkjzj asdftf ölkjzj asdftf ölkjzj asdftf ötfzj\n\nfgtfdsa jhzjklö fgtfdsa jhzjklö fgtfdsa jhzjklö fgtfdsa fjztu\nasdftgf ölkjzhj asdftgf ölkjzhj asdftgf ölkjzhj asdftgf öaztl\n\nfjghtzdkeirutzghfj fjghtzdkeirutzghfj fjghtzdkeirutzghfj fjgh\nöasldkfjrueidkslaö öasldkfjrueidkslaö öasldkfjrueidkslaö öasl"
},
{
"lesson": 8.5,
"title": "08.5 Lernkontrolle durch Satzübungen",
"text": "Kurt Zeiger macht heute die Fahrt mit Zacharias zur Adria mit\nZacharias Ziegeler eilte mit dem Zug immer heiter um die Erde\n\nDie Zufahrt zur Garage ist gut gestreut, dieses ist immer gut\nJetzt herrschte fast eisige Stille, darum rettete er das Tier\n\nKurt Zart hört leise dem Leser zu, ja er ist sehr aufmerksam;\nGerd Zielazeck ist der gute Stegreifdichter, der immer lacht;\n\nJeder hat aufmerksam die Ersatzteile aus Edelstahl gesichtet;\nHerr Fritz Zart macht jetzt diese mutige Aussage zur Tatzeit;\n\nDer Arzt machte zur Zeit viele Versuche, das ist sehr richtig\nFrau Kraus geht mit ihrer Katze zum Tierarzt, das ist richtig\n\nJulius Ziegler geht als starker Sieger direkt auf das Ziel zu\nStatt der Tiere aus dem Zirkus zeigt Karla die Katze Mizemaus\n\nDieser Zufahrtssteg ist seit heute mit Stahlgitter zugemacht;\nDie Zufahrt zur Garage der Reiter ist sehr dauerhaft verlegt;"
},
{
"lesson": 100,
"title": "Test",
"text": "asdf\njklö\nasdf\njklö\nasdf\njklö\nasdf\njklö\nasdf\njklö"
}
]

View file

@ -21,8 +21,12 @@ class Statistic(db.Model):
class LessonStatistic(db.Model):
id = db.Column(db.Integer, primary_key=True)
lesson_index = db.Column(db.Integer, nullable=False)
lesson_index = db.Column(db.Integer, nullable=False, index=True) # Index für schnellere Queries
chars_per_minute = db.Column(db.Float, default=0.0)
error_rate = db.Column(db.Float, default=0.0)
wpm = db.Column(db.Float, default=0.0)
created_at = db.Column(db.DateTime, default=db.func.now())
created_at = db.Column(db.DateTime, default=db.func.now(), index=True) # Index für zeitbasierte Queries
__table_args__ = (
db.Index('idx_lesson_created', 'lesson_index', 'created_at'), # Composite Index
)

View file

@ -21,12 +21,15 @@ body {
}
@keyframes blink {
0%, 100% {
0% {
opacity: 1;
}
50% {
opacity: 0;
}
100% {
opacity: 1;
}
}
@keyframes pulse {
@ -167,6 +170,11 @@ body {
.progress-stats-container {
transition: all 0.3s ease;
display: none;
}
.progress-stats-container.visible {
display: block;
}
.card {

View file

@ -30,6 +30,9 @@ document.addEventListener('DOMContentLoaded', function() {
// Lucide Icons initialisieren
lucide.createIcons();
// CSRF-Token aus Meta-Tag holen
const csrfToken = document.querySelector('meta[name="csrf-token"]').getAttribute('content');
// Theme initialisieren
initializeTheme();
document.getElementById('theme-toggle').addEventListener('click', toggleTheme);
@ -49,13 +52,21 @@ document.addEventListener('DOMContentLoaded', function() {
let fullText = window.fullText || "Kein Text verfügbar. Bitte laden Sie die Seite neu.";
let currentCharIndex = window.currentCharIndex || 0;
let lastStartTime = null;
let totalElapsedTime = 0;
let isPaused = false;
let totalElapsedTime = window.totalElapsedTime || 0; // Aus der Session geladen
let isPaused = window.isPaused || false; // Aus der Session geladen
let timerInterval = null;
let userInput = ''; // Gesamte Eingabe
let keyStrokeCount = 0; // Zähler für alle Tastaturanschläge (inkl. Backspace)
let userInput = window.lastText || ''; // Gesamte Eingabe - aus der Datenbank geladen
let keyStrokeCount = window.keyStrokeCount || 0; // Zähler für alle Tastaturanschläge (inkl. Backspace) - aus der Session geladen
let lines = fullText.split('\n');
let userScrolled = false; // Flag für manuelles Scrollen
let firstVisibleLine = 0; // Index der obersten sichtbaren Zeile
// HTML-Escape-Funktion für sichere DOM-Manipulation
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// Volume-Toggle initialisieren - Steuert jetzt das Metronom
// Standardmäßig ist das Metronom an (volume-2 angezeigt)
@ -66,15 +77,78 @@ document.addEventListener('DOMContentLoaded', function() {
volumeOn.style.display = 'block';
volumeOff.style.display = 'none';
// Adaptive Metronom-Klasse
class AdaptiveMetronome {
constructor() {
this.speed = 60; // Startgeschwindigkeit in BPM
this.accuracy_history = []; // Letzte 50 Ergebnisse (1=korrekt, 0=falsch)
this.consecutive_correct = 0; // Aufeinanderfolgende korrekte Zeichen
this.target_accuracy = 0.96; // Zielgenauigkeit (96%)
}
process_keystroke(is_correct, current_difficulty = 1.0) {
// 1. History aktualisieren
this.accuracy_history.push(is_correct ? 1.0 : 0.0);
if (this.accuracy_history.length > 50) {
this.accuracy_history.shift();
}
// 2. Konsekutive korrekte Zeichen zählen
if (is_correct) {
this.consecutive_correct += 1;
} else {
this.consecutive_correct = 0;
}
// 3. Geschwindigkeit anpassen
return this._adjust_speed(current_difficulty);
}
_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);
// Mehrere Anpassungsstrategien kombinieren:
if (this.consecutive_correct >= 15) {
// Belohnung für längere Fehlerfreiheit
this.speed *= 1.03;
} else if (current_accuracy > adjusted_target + 0.04) {
// Zu einfach - stärker beschleunigen
this.speed *= 1.02;
} else if (current_accuracy < adjusted_target - 0.06) {
// Zu schwierig - stärker bremsen
this.speed *= 0.92;
} else if (current_accuracy < adjusted_target) {
// Leicht unter Ziel - sanft bremsen
this.speed *= 0.97;
} else {
// Im Zielbereich - sanft beschleunigen
this.speed *= 1.01;
}
// Geschwindigkeitsgrenzen
this.speed = Math.max(40, Math.min(200, this.speed));
return this.speed;
}
}
// Metronom State - Initialisierung
let metronomeInterval = null;
let audioContext = null;
let metronomeSound = window.metronomeSound || 'beep';
let metronomeMode = window.metronomeMode || 'automatic'; // automatic or explicit
let targetErrorRate = window.targetErrorRate || 5; // Prozent
let speedDisplay = window.speedDisplay || 'zpm'; // zpm or wpm
let currentErrorRate = 0;
let currentSpeed = 0; // Aktuelle Tippgeschwindigkeit in ZPM
// Adaptive Metronom-Instanz
let adaptiveMetronome = new AdaptiveMetronome();
// Metronom-Geschwindigkeit basierend auf Modus initialisieren
let metronomeBPM;
if (metronomeMode === 'explicit') {
@ -172,23 +246,6 @@ document.addEventListener('DOMContentLoaded', function() {
}
}
// Funktion zur automatischen Anpassung der Metronom-Geschwindigkeit
function adjustMetronomeSpeed() {
if (metronomeMode === 'automatic') {
if (currentErrorRate < targetErrorRate) {
// Fehlerrate ist niedrig -> Geschwindigkeit erhöhen
metronomeBPM = Math.min(600, metronomeBPM + 10);
} else {
// Fehlerrate ist hoch -> Geschwindigkeit verringern
metronomeBPM = Math.max(60, metronomeBPM - 10);
}
// Starte Metronom mit neuer Geschwindigkeit neu
if (metronomeEnabled && !isPaused) {
startMetronome();
}
}
}
// Starte Metronom standardmäßig
if (!isPaused) {
@ -213,6 +270,7 @@ document.addEventListener('DOMContentLoaded', function() {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
},
body: JSON.stringify({
enabled: metronomeEnabled,
@ -278,6 +336,9 @@ document.addEventListener('DOMContentLoaded', function() {
}
const targetTextElement = document.getElementById('target-text');
if (!targetTextElement) {
console.error('Target text element not found');
}
const nextBtn = document.getElementById('next-btn');
const endBtn = document.getElementById('end-btn');
@ -299,20 +360,20 @@ document.addEventListener('DOMContentLoaded', function() {
// Event-Listener für manuelles Scrollen
targetTextElement.addEventListener('scroll', function() {
userScrolled = true;
// Aktualisiere firstVisibleLine basierend auf der Scroll-Position
const lineHeight = targetTextElement.scrollHeight / lines.length;
firstVisibleLine = Math.floor(targetTextElement.scrollTop / lineHeight);
});
// Berechne die aktuelle Zeile basierend auf currentCharIndex
function getCurrentLineIndex() {
let total = 0;
let charCount = 0;
for (let i = 0; i < lines.length; i++) {
let lineLength = lines[i].length;
if (i < lines.length - 1) {
lineLength += 1; // Für den Zeilenumbruch
}
if (currentCharIndex < total + lineLength) {
const lineLength = lines[i].length + (i < lines.length - 1 ? 1 : 0); // +1 für Zeilenumbruch außer bei der letzten Zeile
if (currentCharIndex < charCount + lineLength) {
return i;
}
total += lineLength;
charCount += lineLength;
}
return lines.length - 1;
}
@ -351,44 +412,33 @@ document.addEventListener('DOMContentLoaded', function() {
// Tastatureingaben direkt erfassen
document.addEventListener('keydown', function(event) {
// Verhindere Standardaktionen für Steuertasten, außer Backspace, Enter und Pfeiltasten
// Verhindere Standardaktionen für Steuertasten
if (event.ctrlKey || event.metaKey) {
event.preventDefault();
return;
}
// Im Pause-Modus sind keine Tasten erlaubt (Cursor-Position bleibt erhalten)
// Im Pause-Modus sind keine Tasten erlaubt
if (isPaused) {
// Blockiere alle Tasten im Pause-Modus
event.preventDefault();
return;
}
// Behandle Pfeiltasten (nur im Play-Modus)
// Behandle Pfeiltasten
if (event.key === 'ArrowLeft') {
event.preventDefault();
if (currentCharIndex > 0) {
keyStrokeCount++;
if (!lastStartTime && !isPaused) {
lastStartTime = new Date();
}
currentCharIndex--;
updateTextDisplay();
updateStatistics();
}
return;
}
if (event.key === 'ArrowRight') {
event.preventDefault();
if (currentCharIndex < userInput.length) {
keyStrokeCount++;
if (!lastStartTime && !isPaused) {
lastStartTime = new Date();
}
if (currentCharIndex < fullText.length) {
currentCharIndex++;
updateTextDisplay();
updateStatistics();
}
return;
}
@ -396,31 +446,24 @@ document.addEventListener('DOMContentLoaded', function() {
// Behandle Backspace
if (event.key === 'Backspace') {
event.preventDefault();
if (userInput.length > 0 && currentCharIndex > 0) {
keyStrokeCount++;
if (!lastStartTime && !isPaused) {
lastStartTime = new Date();
}
if (currentCharIndex > 0) {
userInput = userInput.slice(0, currentCharIndex - 1) + userInput.slice(currentCharIndex);
currentCharIndex--;
keyStrokeCount++;
if (!lastStartTime) {
lastStartTime = new Date();
}
updateTextDisplay();
updateStatistics();
}
return;
}
// Behandle Enter (falls im Text vorhanden)
// Behandle Enter
if (event.key === 'Enter') {
event.preventDefault();
if (currentCharIndex < fullText.length && fullText[currentCharIndex] === '\n') {
userInput = userInput.slice(0, currentCharIndex) + '\n' + userInput.slice(currentCharIndex);
currentCharIndex++;
keyStrokeCount++;
if (!lastStartTime && !isPaused) {
lastStartTime = new Date();
}
updateTextDisplay();
updateStatistics();
processKeystroke('\n', true);
}
return;
}
@ -430,68 +473,124 @@ document.addEventListener('DOMContentLoaded', function() {
return;
}
// Prüfe auf ErrorLock: Wenn aktiviert und currentCharIndex nicht am Ende des korrekten Präfixes, blockiere Eingabe
if (errorLock && currentCharIndex !== getCorrectPrefixLength()) {
event.preventDefault();
return;
// ErrorLock prüfen
if (errorLock) {
const correctPrefixLength = getCorrectPrefixLength();
if (currentCharIndex !== correctPrefixLength) {
event.preventDefault();
return;
}
}
// Behandle normale Zeichen
// Normale Zeichen
event.preventDefault();
if (currentCharIndex < fullText.length) {
// Wenn der Cursor innerhalb des bereits eingegebenen Textes steht, ersetze das Zeichen
// Andernfalls hänge das Zeichen an
if (currentCharIndex < userInput.length) {
// Ersetze das Zeichen an der aktuellen Position
userInput = userInput.slice(0, currentCharIndex) + event.key + userInput.slice(currentCharIndex + 1);
} else {
// Hänge das Zeichen am Ende an
userInput = userInput.slice(0, currentCharIndex) + event.key + userInput.slice(currentCharIndex);
}
currentCharIndex++;
keyStrokeCount++;
if (!lastStartTime && !isPaused) {
lastStartTime = new Date();
}
updateTextDisplay();
updateStatistics();
// Wenn die gesamte Lektion abgeschlossen ist
if (currentCharIndex === fullText.length) {
// Speichere die Statistiken für diese Lektion
const lessonIndex = window.currentLessonIndex;
const charsPerMinute = parseInt(speedElement.textContent);
const errorRate = parseFloat(errorRateElement.textContent);
const wpm = parseInt(wpmElement.textContent);
fetch('/save_lesson_statistics', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
lesson_index: lessonIndex,
chars_per_minute: charsPerMinute,
error_rate: errorRate,
wpm: wpm
})
})
.then(response => response.json())
.then(data => {
if (data.status === 'success') {
alert('Lektion beendet! Statistiken wurden gespeichert.');
} else {
alert('Lektion beendet, aber Statistiken konnten nicht gespeichert werden.');
}
})
.catch(error => {
console.error('Fehler beim Speichern der Statistiken:', error);
alert('Lektion beendet! (Fehler beim Speichern der Statistiken)');
});
}
processKeystroke(event.key, event.key === fullText[currentCharIndex]);
}
});
function processKeystroke(key, isCorrect) {
// Füge Zeichen hinzu oder ersetze es
if (currentCharIndex < userInput.length) {
userInput = userInput.slice(0, currentCharIndex) + key + userInput.slice(currentCharIndex + 1);
} else {
userInput = userInput.slice(0, currentCharIndex) + key + userInput.slice(currentCharIndex);
}
currentCharIndex++;
keyStrokeCount++;
if (!lastStartTime) {
lastStartTime = new Date();
}
// Metronom anpassen
if (metronomeMode === 'automatic') {
metronomeBPM = adaptiveMetronome.process_keystroke(isCorrect, 1.0);
if (metronomeEnabled && !isPaused) {
startMetronome();
}
}
updateTextDisplay();
updateStatistics();
// Lektion abschließen
if (currentCharIndex === fullText.length) {
setTimeout(saveAndResetLesson, 100);
}
}
function saveAndResetLesson() {
const wasPlaying = !isPaused;
if (wasPlaying) {
isPaused = true;
if (lastStartTime) {
totalElapsedTime += (new Date() - lastStartTime);
lastStartTime = null;
}
stopMetronome();
}
const lessonIndex = window.currentLessonIndex;
const speedElement = document.getElementById('speed');
const errorRateElement = document.getElementById('error-rate');
const wpmElement = document.getElementById('wpm');
const charsPerMinute = speedElement ? parseInt(speedElement.textContent) : 0;
const errorRate = errorRateElement ? parseFloat(errorRateElement.textContent) : 0;
const wpm = wpmElement ? parseInt(wpmElement.textContent) : 0;
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
})
})
.then(response => response.json())
.then(data => {
if (data.status === 'success') {
console.log('Lektion beendet! Statistiken gespeichert.');
}
})
.catch(error => {
console.error('Fehler beim Speichern:', error);
})
.finally(() => {
currentCharIndex = 0;
userInput = '';
userScrolled = false;
targetTextElement.scrollTop = 0;
totalElapsedTime = 0;
lastStartTime = null;
keyStrokeCount = 0;
if (wasPlaying) {
isPaused = false;
lastStartTime = new Date();
const playPausePause = document.getElementById('play-pause-pause');
const playPausePlay = document.getElementById('play-pause-play');
if (playPausePause && playPausePlay) {
playPausePause.style.display = 'block';
playPausePlay.style.display = 'none';
}
}
updateTextDisplay();
updateStatistics();
if (metronomeEnabled && !isPaused) {
startMetronome();
}
});
}
// Funktion zum Aktualisieren der Lektionsmarkierung
function updateLessonHighlight(lessonIndex) {
@ -516,12 +615,13 @@ document.addEventListener('DOMContentLoaded', function() {
clearInterval(timerInterval);
timerInterval = null;
}
startTime = null;
lastStartTime = null;
fetch('/set_lesson', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
},
body: JSON.stringify({
lesson_index: lessonIndex
@ -540,7 +640,9 @@ document.addEventListener('DOMContentLoaded', function() {
isPaused = false;
keyStrokeCount = 0; // Zurücksetzen des Tastaturanschlagszählers
lines = fullText.split('\n');
userScrolled = false; // Reset Scroll-Flag
userScrolled = false;
firstVisibleLine = 0;
targetTextElement.scrollTop = 0;
updateTextDisplay();
document.getElementById('lesson-title').textContent = data.title;
// Setze Play/Pause-Toggle zurück auf Pause
@ -558,6 +660,7 @@ document.addEventListener('DOMContentLoaded', function() {
// Nächster Text Button
if (nextBtn) {
nextBtn.addEventListener('click', function() {
console.log('Next button clicked');
// Stoppe Timer und setze Zeit zurück
if (timerInterval) {
clearInterval(timerInterval);
@ -568,6 +671,7 @@ document.addEventListener('DOMContentLoaded', function() {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
}
})
.then(response => response.json())
@ -584,13 +688,22 @@ document.addEventListener('DOMContentLoaded', function() {
isPaused = false;
keyStrokeCount = 0; // Zurücksetzen des Tastaturanschlagszählers
lines = fullText.split('\n');
userScrolled = false; // Reset Scroll-Flag
userScrolled = false;
firstVisibleLine = 0;
targetTextElement.scrollTop = 0;
updateTextDisplay();
// Aktualisiere den Lektionstitel
document.getElementById('lesson-title').textContent = data.title;
// Aktualisiere den Lektionstitel, falls das Element existiert
const lessonTitleElement = document.getElementById('lesson-title');
if (lessonTitleElement) {
lessonTitleElement.textContent = data.title;
}
// Setze Play/Pause-Toggle zurück auf Pause
document.getElementById('play-pause-pause').style.display = 'block';
document.getElementById('play-pause-play').style.display = 'none';
const playPausePause = document.getElementById('play-pause-pause');
const playPausePlay = document.getElementById('play-pause-play');
if (playPausePause && playPausePlay) {
playPausePause.style.display = 'block';
playPausePlay.style.display = 'none';
}
updateStatistics();
// Aktualisiere die Markierung der aktuellen Lektion
updateLessonHighlight(data.index);
@ -600,16 +713,20 @@ document.addEventListener('DOMContentLoaded', function() {
alert('Fehler beim Laden des nächsten Textes: ' + error.message);
});
});
} else {
console.error('Next button not found');
}
// Beenden Button
if (endBtn) {
endBtn.addEventListener('click', function() {
console.log('End button clicked');
// Hier könnten wir die aktuelle Statistik speichern
fetch('/end_session', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
}
})
.then(response => response.json())
@ -624,6 +741,8 @@ document.addEventListener('DOMContentLoaded', function() {
alert('Fehler beim Beenden der Sitzung: ' + error.message);
});
});
} else {
console.error('End button not found');
}
// Statistik aktualisieren
@ -650,34 +769,88 @@ document.addEventListener('DOMContentLoaded', function() {
let incorrectKeyStrokes = totalKeyStrokes - correctKeyStrokes;
let charsPerMinute = elapsedTime > 0 ? (correctKeyStrokes / elapsedTime) * 60 : 0;
let errorRate = correctKeyStrokes > 0 ? (incorrectKeyStrokes * 100.0) / correctKeyStrokes : 0;
let errorRate = totalKeyStrokes > 0 ? (incorrectKeyStrokes * 100.0) / totalKeyStrokes : 0;
let wpm = elapsedTime > 0 ? (correctKeyStrokes / 5) / (elapsedTime / 60) : 0;
// Aktualisiere die Anzeige
speedElement.textContent = Math.round(charsPerMinute);
errorRateElement.textContent = errorRate.toFixed(1);
correctElement.textContent = correctKeyStrokes;
incorrectElement.textContent = incorrectKeyStrokes;
totalElement.textContent = totalKeyStrokes;
wpmElement.textContent = Math.round(wpm);
// Hole alle Statistik-Elemente
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');
// Aktualisiere die Anzeige basierend auf der gewählten Geschwindigkeitsanzeige, falls Elemente existieren
if (speedDisplay === 'wpm') {
if (speedElement) speedElement.textContent = Math.round(wpm);
if (speedLabel) speedLabel.textContent = 'WPM:';
} else {
if (speedElement) speedElement.textContent = Math.round(charsPerMinute);
if (speedLabel) speedLabel.textContent = 'ZPM:';
}
if (errorRateElement) errorRateElement.textContent = errorRate.toFixed(1);
if (correctElement) correctElement.textContent = correctKeyStrokes;
if (incorrectElement) incorrectElement.textContent = incorrectKeyStrokes;
if (totalElement) totalElement.textContent = totalKeyStrokes;
if (wpmElement) wpmElement.textContent = Math.round(wpm);
// Speichere aktuelle Werte für Metronom-Anpassung
currentSpeed = Math.round(charsPerMinute);
currentErrorRate = errorRate;
// Passe Metronom-Geschwindigkeit automatisch an
adjustMetronomeSpeed();
// Aktualisiere die Textfarben
updateTextDisplay();
}
// Hilfsfunktion zum Überprüfen, ob eine Zeile komplett korrekt eingegeben wurde
function isLineComplete(lineIndex) {
if (lineIndex >= lines.length) return false;
// Berechne Start- und Endindex der Zeile im Gesamttext
let startIndex = 0;
for (let i = 0; i < lineIndex; i++) {
startIndex += lines[i].length + 1; // +1 für Zeilenumbruch
}
const endIndex = startIndex + lines[lineIndex].length;
// Prüfe, ob alle Zeichen der Zeile korrekt eingegeben wurden
for (let i = startIndex; i < endIndex; i++) {
if (i >= userInput.length || userInput[i] !== fullText[i]) {
return false;
}
}
// Für Zeilen, die nicht die letzte sind: Prüfe auch den Zeilenumbruch
if (lineIndex < lines.length - 1) {
const newlineIndex = endIndex;
if (newlineIndex >= userInput.length || userInput[newlineIndex] !== '\n') {
return false;
}
}
return true;
}
// Funktion zum automatischen Scrollen
function checkAutoScroll() {
// Prüfe, ob die oberste sichtbare Zeile komplett korrekt eingegeben wurde
if (isLineComplete(firstVisibleLine)) {
// Scrolle um eine Zeile nach unten
const lineHeight = targetTextElement.scrollHeight / lines.length;
firstVisibleLine++;
targetTextElement.scrollTop = firstVisibleLine * lineHeight;
}
}
// Hilfsfunktion zum Hervorheben eines Zeichens
function highlightChar(char, type, isCursor = false, incorrectChar = null) {
let className = `highlight-${type}`;
if (isCursor) {
className += ' cursor-char';
}
// Escape HTML-Zeichen für Sicherheit
const escapedChar = escapeHtml(char);
if (char === ' ') {
return `<span class="${className} highlight-space">·</span>`;
} else if (char === '\t') {
@ -686,82 +859,103 @@ document.addEventListener('DOMContentLoaded', function() {
return `<span class="${className} highlight-newline">↵</span><br>`;
} else {
if (type === 'incorrect' && incorrectChar !== null) {
return `<span class="${className} incorrect-char-container">${char}<span class="incorrect-char-overlay">${incorrectChar}</span></span>`;
const escapedIncorrectChar = escapeHtml(incorrectChar);
return `<span class="${className} incorrect-char-container">${escapedChar}<span class="incorrect-char-overlay">${escapedIncorrectChar}</span></span>`;
} else {
return `<span class="${className}">${char}</span>`;
return `<span class="${className}">${escapedChar}</span>`;
}
}
}
// Cache für die letzte gerenderte Version
let lastRenderedUserInputLength = -1;
let lastCursorPosition = -1;
let lastCurrentLineIndex = -1;
// Text hervorheben mit natürlichen Zeilenumbrüchen
function updateTextDisplay() {
const currentLineIndex = getCurrentLineIndex();
// Vereinfachte Version: Zeige den gesamten Text mit Zeilenumbrüchen an
let newHTML = '';
// Zeige alle Zeilen an
const startLine = 0;
const endLine = lines.length;
let charCount = 0;
// Berechne die Startposition jeder Zeile im Gesamttext
let lineStarts = [];
let currentStart = 0;
for (let i = 0; i < lines.length; i++) {
lineStarts[i] = currentStart;
currentStart += lines[i].length + 1; // +1 für den Zeilenumbruch
}
for (let j = startLine; j < endLine; j++) {
for (let j = 0; j < lines.length; j++) {
const line = lines[j];
const lineStart = lineStarts[j];
const lineEnd = lineStart + line.length;
let highlightedLine = '';
for (let i = 0; i < line.length; i++) {
const currentChar = line[i];
const absoluteIndex = lineStart + i;
const isCursor = (absoluteIndex === currentCharIndex);
if (absoluteIndex < userInput.length) {
if (userInput[absoluteIndex] === currentChar) {
const isCursor = (charCount === currentCharIndex);
if (charCount < userInput.length) {
if (userInput[charCount] === currentChar) {
highlightedLine += highlightChar(currentChar, 'correct', isCursor);
} else {
highlightedLine += highlightChar(currentChar, 'incorrect', isCursor, userInput[absoluteIndex]);
highlightedLine += highlightChar(currentChar, 'incorrect', isCursor, userInput[charCount]);
}
} else {
highlightedLine += highlightChar(currentChar, 'neutral', isCursor);
}
charCount++;
}
// Füge den Zeilenumbruch nach jeder Zeile hinzu (außer der letzten)
// Zeilenumbruch nach jeder Zeile (außer der letzten)
if (j < lines.length - 1) {
const newlinePos = lineStart + line.length;
const newlineChar = '\n';
const isCursor = (newlinePos === currentCharIndex);
if (newlinePos < userInput.length) {
if (userInput[newlinePos] === newlineChar) {
highlightedLine += highlightChar(newlineChar, 'correct', isCursor);
const isCursor = (charCount === currentCharIndex);
if (charCount < userInput.length) {
if (userInput[charCount] === '\n') {
highlightedLine += highlightChar('\n', 'correct', isCursor);
} else {
highlightedLine += highlightChar(newlineChar, 'incorrect', isCursor, userInput[newlinePos]);
highlightedLine += highlightChar('\n', 'incorrect', isCursor, userInput[charCount]);
}
} else {
highlightedLine += highlightChar(newlineChar, 'neutral', isCursor);
highlightedLine += highlightChar('\n', 'neutral', isCursor);
}
charCount++;
}
// Füge die Zeile als div hinzu, markiere die aktuelle Zeile
// Füge die Zeile als div hinzu
const currentLineIndex = getCurrentLineIndex();
newHTML += `<div class="text-line ${j === currentLineIndex ? 'current-line' : ''}">${highlightedLine}</div>`;
}
targetTextElement.innerHTML = newHTML;
// Scroll zur aktuellen Zeile, wenn der Benutzer nicht manuell gescrollt hat
// Automatisches Scrollen: aktuelle Zeile soll oben angezeigt werden
if (!userScrolled) {
const currentLineElement = targetTextElement.querySelector('.current-line');
if (currentLineElement) {
currentLineElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
// Verwende setTimeout, um sicherzustellen, dass das DOM vollständig gerendert ist
setTimeout(() => {
const currentLineIndex = getCurrentLineIndex();
const lineElements = targetTextElement.getElementsByClassName('text-line');
if (lineElements.length > 0) {
// Verwende die tatsächliche Höhe der ersten Zeile
const lineHeight = lineElements[0].offsetHeight;
const containerHeight = targetTextElement.clientHeight;
// Falls Zeilenhöhe 0 ist, versuche es erneut nach 10ms
if (lineHeight === 0) {
setTimeout(updateTextDisplay, 10);
return;
}
// Berechne die gewünschte Scroll-Position: aktuelle Zeile soll oben erscheinen
let desiredScrollTop = currentLineIndex * lineHeight;
// Begrenze die Scroll-Position auf den maximal möglichen Wert
const maxScrollTop = targetTextElement.scrollHeight - containerHeight;
desiredScrollTop = Math.min(desiredScrollTop, maxScrollTop);
desiredScrollTop = Math.max(0, desiredScrollTop);
// Setze die Scroll-Position
targetTextElement.scrollTop = desiredScrollTop;
}
}, 0);
}
}
// Einfache Cursor-Aktualisierung (wird jetzt in updateTextDisplay integriert)
// Diese Funktion ist nicht mehr notwendig, da der Cursor in updateTextDisplay gesetzt wird
// Funktion zum Überprüfen und Scrollen, wenn die oberste Zeile komplett korrekt ist
function checkAndScrollTopLine() {
// Wenn wir nicht mehr in der ersten Zeile sind und die oberste Zeile in der Anzeige komplett grün ist
@ -787,13 +981,29 @@ document.addEventListener('DOMContentLoaded', function() {
// Event-Listener für Statistik-Link (navigiert zur Statistik-Seite)
document.querySelectorAll('.statistics-link').forEach(link => {
link.addEventListener('click', function() {
console.log('Statistics link clicked');
// Speichere Fortschritt synchron vor dem Navigieren
saveProgress();
// Navigiere zur Statistik-Seite
window.location.href = '/statistics';
});
});
// Toggle-Funktionalität für Fortschritt und Statistik (beide gleichzeitig)
function initializeProgressStatsToggle() {
function initializeProgressStatsToggle(retryCount = 0) {
const performanceBtn = document.getElementById('performance-btn');
const progressContainer = document.querySelector('.progress-stats-container');
if (!performanceBtn || !progressContainer) {
if (retryCount < 3) {
console.warn('Performance button or progress container not found, retrying...');
setTimeout(() => initializeProgressStatsToggle(retryCount + 1), 500);
} else {
console.warn('Performance button or progress container not found after retries');
}
return;
}
// Beim ersten Start abgeschaltet, sonst gespeicherten Zustand verwenden
const progressStatsVisible = localStorage.getItem('progressStatsVisible') === 'true';
@ -803,7 +1013,7 @@ document.addEventListener('DOMContentLoaded', function() {
// Event-Listener für Performance-Button
performanceBtn.addEventListener('click', function() {
const isVisible = document.querySelector('.progress-stats-container').style.display === 'block';
const isVisible = progressContainer.classList.contains('visible');
setProgressStatsVisibility(!isVisible);
localStorage.setItem('progressStatsVisible', !isVisible);
});
@ -813,27 +1023,89 @@ document.addEventListener('DOMContentLoaded', function() {
const progressContainer = document.querySelector('.progress-stats-container');
const performanceBtn = document.getElementById('performance-btn');
if (!progressContainer || !performanceBtn) {
return;
}
if (visible) {
progressContainer.style.display = 'block';
progressContainer.classList.add('visible');
performanceBtn.classList.remove('btn-light-custom');
performanceBtn.classList.add('btn-primary');
} else {
progressContainer.style.display = 'none';
progressContainer.classList.remove('visible');
performanceBtn.classList.remove('btn-primary');
performanceBtn.classList.add('btn-light-custom');
}
}
// Initialisiere Statistik und Textfarben
updateTextDisplay();
updateStatistics();
// Speichere Fortschritt beim Verlassen der Seite
window.addEventListener('beforeunload', function() {
saveProgress();
});
// Funktion zum Speichern des Fortschritts
function saveProgress() {
const data = {
user_input: userInput,
cursor_position: currentCharIndex,
current_text: fullText,
elapsed_time: totalElapsedTime / 1000,
total_elapsed_time: totalElapsedTime,
key_stroke_count: keyStrokeCount,
is_paused: isPaused
};
// Verwende fetch mit keepalive für zuverlässiges Speichern beim Seitenverlassen
// keepalive stellt sicher, dass der Request auch nach dem Schließen der Seite abgeschlossen wird
fetch('/update_progress', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
},
body: JSON.stringify(data),
keepalive: true // Wichtig: Request bleibt aktiv auch nach Seitenverlassen
}).catch(error => {
console.error('Fehler beim Speichern des Fortschritts:', error);
});
}
// Setze den Play/Pause-Button basierend auf dem geladenen Zustand
if (isPaused) {
document.getElementById('play-pause-pause').style.display = 'none';
document.getElementById('play-pause-play').style.display = 'block';
} else {
document.getElementById('play-pause-pause').style.display = 'block';
document.getElementById('play-pause-play').style.display = 'none';
}
// Initialisiere Statistik und Textfarben mit Verzögerung für zuverlässige Darstellung
const initializeDisplay = () => {
updateTextDisplay();
updateStatistics();
// Zweiter Versuch nach kurzer Verzögerung für fallback
setTimeout(() => {
updateTextDisplay();
updateStatistics();
}, 100);
};
// Warte auf nächstes Animation Frame für zuverlässige Darstellung
requestAnimationFrame(initializeDisplay);
// Initialisiere Toggle-Funktionalität
initializeProgressStatsToggle();
// Setze Fokus auf den Body für Tastatureingaben
document.body.focus();
// Debug-Informationen
console.log('Script initialized');
console.log('Full text length:', fullText.length);
console.log('Current char index:', currentCharIndex);
console.log('User input length:', userInput.length);
});
// Fehlerbehandlung für globale Ereignisse

View file

@ -3,7 +3,10 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="csrf-token" content="{{ csrf_token() }}">
<title>Typewriter Trainer</title>
<link rel="icon" type="image/svg+xml" href="{{ url_for('static', filename='favicon.svg') }}">
<link rel="alternate icon" href="{{ url_for('static', filename='favicon.svg') }}" type="image/svg+xml">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
<script src="https://unpkg.com/lucide@latest/dist/umd/lucide.js"></script>
@ -17,13 +20,19 @@
<div class="d-flex justify-content-between align-items-center">
<h2 class="mb-0">Typewriter Trainer</h2>
<div class="d-flex flex-row-reverse align-items-center gap-2">
<div id="settings-toggle" style="width: 24px; height: 24px; cursor: pointer;">
<i data-lucide="settings" style="width: 24px; height: 24px;"></i>
</div>
<div id="theme-toggle" style="width: 24px; height: 24px; cursor: pointer; position: relative;">
<i data-lucide="sun" id="theme-toggle-sun" style="width: 24px; height: 24px; position: absolute; top: 0; left: 0;"></i>
<i data-lucide="moon" id="theme-toggle-moon" style="width: 24px; height: 24px; position: absolute; top: 0; left: 0; display: none;"></i>
</div>
<button id="settings-toggle"
style="width: 24px; height: 24px; cursor: pointer; border: none; background: none; padding: 0; color: white;"
aria-label="Einstellungen öffnen"
type="button">
<i data-lucide="settings" style="width: 24px; height: 24px;" aria-hidden="true"></i>
</button>
<button id="theme-toggle"
style="width: 24px; height: 24px; cursor: pointer; position: relative; border: none; background: none; padding: 0; color: white;"
aria-label="Zwischen hellem und dunklem Design wechseln"
type="button">
<i data-lucide="sun" id="theme-toggle-sun" style="width: 24px; height: 24px; position: absolute; top: 0; left: 0;" aria-hidden="true"></i>
<i data-lucide="moon" id="theme-toggle-moon" style="width: 24px; height: 24px; position: absolute; top: 0; left: 0; display: none;" aria-hidden="true"></i>
</button>
<div id="lock-toggle" style="width: 24px; height: 24px; cursor: pointer; position: relative;">
<i data-lucide="lock" id="lock-closed" style="width: 24px; height: 24px; position: absolute; top: 0; left: 0;"></i>
<i data-lucide="unlock" id="lock-open" style="width: 24px; height: 24px; position: absolute; top: 0; left: 0; display: none;"></i>
@ -36,9 +45,12 @@
<i data-lucide="pause" id="play-pause-pause" style="width: 24px; height: 24px; position: absolute; top: 0; left: 0;"></i>
<i data-lucide="play" id="play-pause-play" style="width: 24px; height: 24px; position: absolute; top: 0; left: 0; display: none;"></i>
</div>
<div id="help-toggle" style="width: 24px; height: 24px; cursor: pointer;">
<i data-lucide="help-circle" style="width: 24px; height: 24px;"></i>
</div>
<button id="help-toggle"
style="width: 24px; height: 24px; cursor: pointer; border: none; background: none; padding: 0; color: white;"
aria-label="Hilfe öffnen"
type="button">
<i data-lucide="help-circle" style="width: 24px; height: 24px;" aria-hidden="true"></i>
</button>
</div>
</div>
</div>
@ -62,13 +74,23 @@
<!-- Textanzeige mit automatischer Höhe -->
<div class="mb-4">
<h5 class="card-title">Vorlage: <span id="lesson-title">{{ current_lesson_title }}</span></h5>
<div id="target-text" class="border p-3 rounded" style="max-height: 200px; overflow: auto; white-space: pre-wrap; font-family: monospace;">
<!-- Text wird zeilenweise durch JavaScript eingefügt -->
<div id="target-text"
class="border p-3 rounded"
style="max-height: 200px; overflow: auto; white-space: pre-wrap; font-family: monospace;"
role="region"
aria-label="Tippvorlage"
aria-live="polite"
tabindex="0">
<!-- Text wird durch JavaScript eingefügt -->
</div>
</div>
<script>
window.fullText = {{ current_text|tojson }};
window.currentCharIndex = 0;
window.currentCharIndex = {{ current_position }};
window.lastText = {{ last_text|tojson }};
window.totalElapsedTime = {{ total_elapsed_time }};
window.keyStrokeCount = {{ key_stroke_count }};
window.isPaused = {{ is_paused|tojson }};
// Metronom-Einstellungen von Flask übernehmen
window.metronomeEnabled = {{ metronome_enabled|tojson }};
window.metronomeBPM = {{ metronome_bpm|tojson }};
@ -82,21 +104,27 @@
<!-- Lektionen -->
<div class="mb-4">
<h5 class="card-title">Lektionen</h5>
<div class="d-flex flex-wrap justify-content-start" id="lessons-container">
<div class="d-flex flex-wrap justify-content-start"
id="lessons-container"
role="navigation"
aria-label="Lektionsauswahl">
{% for lesson in lessons %}
<div class="mb-1 me-1" style="width: 40px;">
<div class="lesson-link {% if loop.index0 == current_lesson_index %}fw-bold text-danger{% else %}text-primary{% endif %}"
style="cursor: pointer; font-size: 0.9rem; text-align: center; white-space: nowrap; min-height: 24px; display: flex; align-items: center; justify-content: center;"
data-lesson-index="{{ loop.index0 }}">
<button class="lesson-link {% if loop.index0 == current_lesson_index %}fw-bold text-danger{% else %}text-primary{% endif %}"
style="cursor: pointer; font-size: 0.9rem; text-align: center; white-space: nowrap; min-height: 24px; display: flex; align-items: center; justify-content: center; border: none; background: none; padding: 0;"
data-lesson-index="{{ loop.index0 }}"
aria-label="Lektion {{ lesson.lesson if lesson.lesson is defined else loop.index0 + 1 }}"
aria-current="{% if loop.index0 == current_lesson_index %}page{% else %}false{% endif %}"
type="button">
{{ lesson.lesson if lesson.lesson is defined else loop.index0 + 1 }}
</div>
</button>
</div>
{% endfor %}
</div>
</div>
<!-- Fortschritt und Statistik mit Toggle-Funktion -->
<div class="progress-stats-container mb-4" style="display: none;">
<div class="progress-stats-container mb-4">
<div class="row">
<div class="col-md-6">
<div class="card bg-light">
@ -104,15 +132,15 @@
<ul class="list-group list-group-flush">
<li class="list-group-item d-flex justify-content-between align-items-center">
Korrekte Zeichen
<span id="correct" class="badge bg-success rounded-pill">0</span>
<span id="correct" class="badge bg-success rounded-pill" role="status" aria-live="polite">0</span>
</li>
<li class="list-group-item d-flex justify-content-between align-items-center">
Falsche Zeichen
<span id="incorrect" class="badge bg-danger rounded-pill">0</span>
<span id="incorrect" class="badge bg-danger rounded-pill" role="status" aria-live="polite">0</span>
</li>
<li class="list-group-item d-flex justify-content-between align-items-center">
Tastaturanschläge
<span id="total" class="badge bg-secondary rounded-pill">0</span>
<span id="total" class="badge bg-secondary rounded-pill" role="status" aria-live="polite">0</span>
</li>
</ul>
</div>

View file

@ -3,7 +3,10 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="csrf-token" content="{{ csrf_token() }}">
<title>Einstellungen - Typewriter Trainer</title>
<link rel="icon" type="image/svg+xml" href="{{ url_for('static', filename='favicon.svg') }}">
<link rel="alternate icon" href="{{ url_for('static', filename='favicon.svg') }}" type="image/svg+xml">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
<script src="https://unpkg.com/lucide@latest/dist/umd/lucide.js"></script>
@ -52,8 +55,8 @@
<!-- Explizite Geschwindigkeitseinstellung -->
<div id="explicitSettings" class="mb-3" style="display: none;">
<label for="metronomeSpeed" class="form-label">Metronom-Geschwindigkeit (ZPM): <span id="speedValue">60</span></label>
<input type="range" class="form-range" id="metronomeSpeed" min="60" max="600" step="10" value="60">
<small class="form-text text-muted">Einstellbereich: 60 - 600 Zeichen pro Minute</small>
<input type="range" class="form-range" id="metronomeSpeed" min="40" max="200" step="5" value="60">
<small class="form-text text-muted">Einstellbereich: 40 - 200 Zeichen pro Minute</small>
</div>
<!-- Automatische Einstellungen -->
@ -64,6 +67,26 @@
</div>
</div>
<!-- Statistik-Anzeige-Einstellungen -->
<div class="mb-4">
<h5 class="card-title">Statistik-Anzeige</h5>
<div class="mb-3">
<label class="form-label">Geschwindigkeitsanzeige</label>
<div class="form-check">
<input class="form-check-input" type="radio" name="speedDisplay" id="zpmDisplay" value="zpm" checked>
<label class="form-check-label" for="zpmDisplay">
Zeichen pro Minute (ZPM)
</label>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" name="speedDisplay" id="wpmDisplay" value="wpm">
<label class="form-check-label" for="wpmDisplay">
Wörter pro Minute (WPM)
</label>
</div>
</div>
</div>
<div class="d-flex justify-content-end">
<button id="saveSettings" class="btn btn-primary">Einstellungen speichern</button>
</div>
@ -135,16 +158,20 @@
const metronomeMode = document.querySelector('input[name="metronomeMode"]:checked').value;
const metronomeSpeed = document.getElementById('metronomeSpeed').value;
const targetErrorRate = document.getElementById('targetErrorRate').value;
const speedDisplay = document.querySelector('input[name="speedDisplay"]:checked').value;
const csrfToken = document.querySelector('meta[name="csrf-token"]').getAttribute('content');
fetch('/save_settings', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
},
body: JSON.stringify({
metronome_mode: metronomeMode,
metronome_speed: parseInt(metronomeSpeed),
target_error_rate: parseInt(targetErrorRate)
target_error_rate: parseInt(targetErrorRate),
speed_display: speedDisplay
})
})
.then(response => response.json())

View file

@ -3,7 +3,10 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="csrf-token" content="{{ csrf_token() }}">
<title>Statistik - Typewriter Trainer</title>
<link rel="icon" type="image/svg+xml" href="{{ url_for('static', filename='favicon.svg') }}">
<link rel="alternate icon" href="{{ url_for('static', filename='favicon.svg') }}" type="image/svg+xml">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
@ -29,6 +32,18 @@
</div>
</div>
<div class="card-body">
<!-- Export-Buttons -->
<div class="mb-3">
<div class="btn-group" role="group">
<a href="/export/statistics/csv" class="btn btn-sm btn-outline-primary" download>
<i data-lucide="download" style="width: 16px; height: 16px;"></i> CSV Export
</a>
<a href="/export/statistics/json" class="btn btn-sm btn-outline-primary" download>
<i data-lucide="download" style="width: 16px; height: 16px;"></i> JSON Export
</a>
</div>
</div>
<!-- Lektionen Auswahl -->
<div class="mb-4">
<h5 class="card-title">Lektion auswählen</h5>
@ -154,6 +169,16 @@
data: {
labels: data.dates,
datasets: [
{% if speed_display == 'wpm' %}
{
label: 'WPM (Wörter pro Minute)',
data: data.wpm,
borderColor: 'blue',
backgroundColor: 'rgba(0, 0, 255, 0.1)',
tension: 0.1,
yAxisID: 'y'
},
{% else %}
{
label: 'ZPM (Zeichen pro Minute)',
data: data.chars_per_minute,
@ -162,6 +187,7 @@
tension: 0.1,
yAxisID: 'y'
},
{% endif %}
{
label: 'Err (Fehler in %)',
data: data.error_rate,
@ -169,14 +195,6 @@
backgroundColor: 'rgba(255, 0, 0, 0.1)',
tension: 0.1,
yAxisID: 'y1'
},
{
label: 'WPM (Wörter pro Minute)',
data: data.wpm,
borderColor: 'green',
backgroundColor: 'rgba(0, 255, 0, 0.1)',
tension: 0.1,
yAxisID: 'y2'
}
]
},
@ -203,10 +221,10 @@
position: 'left',
title: {
display: true,
text: 'ZPM'
text: '{% if speed_display == "wpm" %}WPM{% else %}ZPM{% endif %}'
},
min: 0,
suggestedMax: Math.max(...data.chars_per_minute) * 1.1 || 1
suggestedMax: Math.max(...{% if speed_display == "wpm" %}data.wpm{% else %}data.chars_per_minute{% endif %}) * 1.1 || 1
},
y1: {
type: 'linear',
@ -222,20 +240,6 @@
drawOnChartArea: false,
},
},
y2: {
type: 'linear',
display: true,
position: 'right',
title: {
display: true,
text: 'WPM'
},
min: 0,
suggestedMax: Math.max(...data.wpm) * 1.1 || 1,
grid: {
drawOnChartArea: false,
},
}
}
}
});