Claude Änderungen
This commit is contained in:
parent
a546cfc3ec
commit
9bf31eb626
9 changed files with 1436 additions and 474 deletions
463
README.md
463
README.md
|
|
@ -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
519
app.py
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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ö"
|
||||
}
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue