From c8e1451c0fe071bb84a037ccd0355a74b98fd3d5 Mon Sep 17 00:00:00 2001 From: jamulix Date: Tue, 28 Oct 2025 19:03:01 +0100 Subject: [PATCH] Update auf Version 0.4.0 --- HILFE.md | 2 + VERSION | 2 +- ...dd_training_duration_to_lessonstatistic.py | 32 + app.py | 43 +- claude_start.txt => claude_start_prompt.txt | 0 images/Quertz_10_Finger_Layout.jpg | Bin 0 -> 62875 bytes models.py | 1 + static/js/main.js | 24 + static/js/metronome.js | 10 +- static/js/script.js.old | 1121 ----------------- static/js/session.js | 17 + static/js/statistics.js | 6 +- templates/hilfe.html | 65 +- templates/index.html | 8 +- templates/session_end.html | 111 +- templates/statistics.html | 39 +- templates/welcome.html | 171 ++- 17 files changed, 344 insertions(+), 1308 deletions(-) create mode 100644 alembic/versions/1106215c45af_add_training_duration_to_lessonstatistic.py rename claude_start.txt => claude_start_prompt.txt (100%) create mode 100644 images/Quertz_10_Finger_Layout.jpg delete mode 100644 static/js/script.js.old diff --git a/HILFE.md b/HILFE.md index c26f37f..6bedbe3 100644 --- a/HILFE.md +++ b/HILFE.md @@ -2,6 +2,8 @@ Willkommen beim Typewriter Trainer! Diese Hilfe erklärt alle Funktionen und gibt Tipps für effektives Tipptraining. +![Deutsche Tastatur](images/Quertz_10_Finger_Layout.jpg) + --- ## 🎯 Was ist Typewriter Trainer? diff --git a/VERSION b/VERSION index 268b033..fb7a04c 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -v0.3.0 +v0.4.0 diff --git a/alembic/versions/1106215c45af_add_training_duration_to_lessonstatistic.py b/alembic/versions/1106215c45af_add_training_duration_to_lessonstatistic.py new file mode 100644 index 0000000..cbcca25 --- /dev/null +++ b/alembic/versions/1106215c45af_add_training_duration_to_lessonstatistic.py @@ -0,0 +1,32 @@ +"""Add training_duration to LessonStatistic + +Revision ID: 1106215c45af +Revises: 5fcbe91de50b +Create Date: 2025-10-28 16:32:19.853276 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '1106215c45af' +down_revision: Union[str, Sequence[str], None] = '5fcbe91de50b' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('lesson_statistic', sa.Column('training_duration', sa.Float(), nullable=True)) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('lesson_statistic', 'training_duration') + # ### end Alembic commands ### diff --git a/app.py b/app.py index f528840..f05d90a 100644 --- a/app.py +++ b/app.py @@ -6,7 +6,7 @@ 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 import Flask, render_template, request, jsonify, session, Response, make_response, send_from_directory from flask_wtf.csrf import CSRFProtect from models import db, Progress, Statistic, LessonStatistic, DailyPractice, UserSettings from datetime import datetime, date, timedelta @@ -155,11 +155,19 @@ def load_lessons() -> None: except json.JSONDecodeError as e2: logger.error(f"Automatische Reparatur fehlgeschlagen: {e2}") - logger.warning("Verwende Fallback-Lektionen") + logger.warning("Verwende Fallback-Lektionen und erstelle neue Lektionen-Datei") lessons = FALLBACK_LESSONS + # Speichere Fallback-Lektionen für zukünftige Verwendung + with open(DATA_PATH, "w", encoding="utf-8") as f: + json.dump(lessons, f, indent=2, ensure_ascii=False) + logger.info(f"Fallback-Lektionen gespeichert in: {DATA_PATH}") else: - logger.warning(f"Datei {DATA_PATH} nicht gefunden. Verwende Fallback-Lektionen.") + logger.warning(f"Datei {DATA_PATH} nicht gefunden. Verwende Fallback-Lektionen und erstelle Datei.") lessons = FALLBACK_LESSONS + # Speichere Fallback-Lektionen für zukünftige Verwendung + with open(DATA_PATH, "w", encoding="utf-8") as f: + json.dump(lessons, f, indent=2, ensure_ascii=False) + logger.info(f"Fallback-Lektionen gespeichert in: {DATA_PATH}") except Exception as e: logger.error(f"Unerwarteter Fehler beim Laden der Lektionen: {e}") @@ -415,6 +423,7 @@ def train(): # Lade zusätzliche Zustandsdaten aus der Session total_elapsed_time = session.get('total_elapsed_time', 0) + net_training_time = session.get('net_training_time', 0) key_stroke_count = session.get('key_stroke_count', 0) is_paused = session.get('is_paused', False) @@ -423,6 +432,7 @@ def train(): current_position=cursor_position, last_text=user_input, total_elapsed_time=total_elapsed_time, + net_training_time=net_training_time, key_stroke_count=key_stroke_count, is_paused=is_paused, lessons=lessons, @@ -574,12 +584,13 @@ def update_progress(): 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)) + net_training_time = float(data.get('net_training_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: + if elapsed_time < 0 or total_elapsed_time < 0 or net_training_time < 0 or key_stroke_count < 0: return jsonify({'error': 'Negative Werte sind nicht erlaubt'}), 400 if cursor_position < 0: @@ -601,6 +612,7 @@ def update_progress(): # Speichere zusätzliche Zustandsdaten in der Session session['total_elapsed_time'] = total_elapsed_time + session['net_training_time'] = net_training_time session['key_stroke_count'] = key_stroke_count session['is_paused'] = is_paused @@ -754,6 +766,7 @@ def save_lesson_statistics(): chars_per_minute = float(data.get('chars_per_minute', 0)) error_rate = float(data.get('error_rate', 0)) wpm = float(data.get('wpm', 0)) + training_duration = float(data.get('training_duration', 0)) except (ValueError, TypeError): return jsonify({'error': 'Ungültige Datentypen'}), 400 @@ -761,13 +774,13 @@ def save_lesson_statistics(): 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: + if chars_per_minute < 0 or error_rate < 0 or wpm < 0 or training_duration < 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}") + logger.debug(f"Empfangene Statistik: lesson_index={lesson_index}, chars_per_minute={chars_per_minute}, error_rate={error_rate}, wpm={wpm}, training_duration={training_duration}") # Neue Statistik für die Lektion erstellen try: @@ -775,7 +788,8 @@ def save_lesson_statistics(): lesson_index=lesson_index, chars_per_minute=chars_per_minute, error_rate=error_rate, - wpm=wpm + wpm=wpm, + training_duration=training_duration ) db.session.add(lesson_stat) @@ -835,7 +849,8 @@ def get_lesson_statistics(lesson_index: int) -> Response: '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] + 'wpm': [stat.wpm for stat in stats], + 'training_duration': [stat.training_duration for stat in stats] } return jsonify(data) @@ -956,6 +971,11 @@ def hilfe(): logger.error(f"Fehler beim Laden der Hilfe: {e}") return render_template('hilfe.html', hilfe_html="

Hilfe konnte nicht geladen werden.

") +@app.route('/images/') +def serve_images(filename): + """Serviert Bilder aus dem images-Verzeichnis""" + return send_from_directory('images', filename) + @app.route('/settings') def settings(): """Einstellungsseite""" @@ -1044,8 +1064,11 @@ def set_metronome(): 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 + # Erweiterte Validierung mit detaillierten Fehlermeldungen + if bpm < 20: + return jsonify({'error': 'BPM muss mindestens 20 sein'}), 400 + if bpm > 300: + return jsonify({'error': 'BPM darf maximal 300 sein'}), 400 sound = data.get('sound', 'beep') if sound not in ['beep', 'click', 'woodblock']: diff --git a/claude_start.txt b/claude_start_prompt.txt similarity index 100% rename from claude_start.txt rename to claude_start_prompt.txt diff --git a/images/Quertz_10_Finger_Layout.jpg b/images/Quertz_10_Finger_Layout.jpg new file mode 100644 index 0000000000000000000000000000000000000000..82d29ffdb4b6d1fd92f7a03aa41c1fe1899bfcda GIT binary patch literal 62875 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 MOLl#uk+%5%0D8Y|$N&HU literal 0 HcmV?d00001 diff --git a/models.py b/models.py index 03de880..b46a108 100644 --- a/models.py +++ b/models.py @@ -26,6 +26,7 @@ class LessonStatistic(db.Model): 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) + training_duration = db.Column(db.Float, default=0.0) # Trainingszeit in Sekunden created_at = db.Column(db.DateTime, default=db.func.now(), index=True) # Index für zeitbasierte Queries __table_args__ = ( diff --git a/static/js/main.js b/static/js/main.js index 54b3a3c..12d4eee 100644 --- a/static/js/main.js +++ b/static/js/main.js @@ -113,6 +113,7 @@ class TypewriterApp { this.sessionManager = new SessionManager(this.csrfToken); this.sessionManager.initialize({ totalElapsedTime: window.totalElapsedTime || 0, + netTrainingTime: window.netTrainingTime || 0, keyStrokeCount: window.keyStrokeCount || 0, isPaused: window.isPaused || false, currentLessonIndex: window.currentLessonIndex || 0 @@ -207,6 +208,7 @@ class TypewriterApp { } this.updateStatistics(); + this.updateTrainingTimeDisplay(); }); } @@ -294,12 +296,32 @@ class TypewriterApp { } } + /** + * Update training time display + */ + updateTrainingTimeDisplay() { + const netTime = this.sessionManager.getNetTrainingTime(); + const hours = Math.floor(netTime / 3600); + const minutes = Math.floor((netTime % 3600) / 60); + const seconds = Math.floor(netTime % 60); + + const timeStr = hours > 0 + ? `${hours}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}` + : `${minutes}:${String(seconds).padStart(2, '0')}`; + + const timeElement = document.getElementById('training-time-value'); + if (timeElement) { + timeElement.textContent = timeStr; + } + } + /** * Update text display * @param {boolean} isKeystroke - Whether this is triggered by a keystroke */ updateDisplay(isKeystroke = false) { this.textDisplayManager.updateDisplay(this.userInput, this.currentCharIndex, isKeystroke); + this.updateTrainingTimeDisplay(); } /** @@ -368,6 +390,7 @@ class TypewriterApp { // Get final statistics const elapsedTime = this.sessionManager.getElapsedTime(); + const trainingDuration = this.sessionManager.getNetTrainingTime(); const stats = this.statisticsManager.calculateStatistics( this.userInput, this.fullText, @@ -382,6 +405,7 @@ class TypewriterApp { stats.charsPerMinute, stats.errorRate, stats.wpm, + trainingDuration, this.csrfToken ); console.log('Lektion beendet! Statistiken gespeichert.'); diff --git a/static/js/metronome.js b/static/js/metronome.js index 3607137..e44cdcd 100644 --- a/static/js/metronome.js +++ b/static/js/metronome.js @@ -45,8 +45,8 @@ export class AdaptiveMetronome { this.speed *= 1.01; // In target range - gentle acceleration } - // Speed limits (configurable max_speed, absolute min 40, absolute max 200) - this.speed = Math.max(40, Math.min(Math.min(200, this.max_speed), this.speed)); + // Speed limits (configurable max_speed, absolute min 20, absolute max 200) + this.speed = Math.max(20, Math.min(Math.min(200, this.max_speed), this.speed)); return this.speed; } } @@ -75,6 +75,12 @@ export class MetronomePlayer { oscillator.type = 'sine'; oscillator.start(this.audioContext.currentTime); oscillator.stop(this.audioContext.currentTime + duration / 1000); + + // Clean up after sound completes + oscillator.onended = () => { + oscillator.disconnect(); + gainNode.disconnect(); + }; } createClickSound() { diff --git a/static/js/script.js.old b/static/js/script.js.old deleted file mode 100644 index 29fe0bc..0000000 --- a/static/js/script.js.old +++ /dev/null @@ -1,1121 +0,0 @@ -// Theme Management -function initializeTheme() { - // Beim ersten Start Light-Modus, sonst gespeicherten Modus verwenden - const savedTheme = localStorage.getItem('theme') || 'light'; - document.body.setAttribute('data-bs-theme', savedTheme); - updateThemeButton(savedTheme); -} - -function toggleTheme() { - const currentTheme = document.body.getAttribute('data-bs-theme'); - const newTheme = currentTheme === 'light' ? 'dark' : 'light'; - document.body.setAttribute('data-bs-theme', newTheme); - localStorage.setItem('theme', newTheme); - updateThemeButton(newTheme); -} - -function updateThemeButton(theme) { - const sunIcon = document.getElementById('theme-toggle-sun'); - const moonIcon = document.getElementById('theme-toggle-moon'); - if (theme === 'dark') { - sunIcon.style.display = 'none'; - moonIcon.style.display = 'block'; - } else { - sunIcon.style.display = 'block'; - moonIcon.style.display = 'none'; - } -} - -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); - - // Hilfe Modal initialisieren - document.getElementById('help-toggle').addEventListener('click', function() { - const helpModal = new bootstrap.Modal(document.getElementById('helpModal')); - helpModal.show(); - }); - - // Settings Link initialisieren - document.getElementById('settings-toggle').addEventListener('click', function() { - window.location.href = '/settings'; - }); - - // Aktuelle Textdaten - mit Fallback falls undefiniert - let fullText = window.fullText || "Kein Text verfügbar. Bitte laden Sie die Seite neu."; - let currentCharIndex = window.currentCharIndex || 0; - let lastStartTime = null; - let totalElapsedTime = window.totalElapsedTime || 0; // Aus der Session geladen - let isPaused = window.isPaused || false; // Aus der Session geladen - let timerInterval = null; - 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) - let metronomeEnabled = true; - const volumeOn = document.getElementById('volume-on'); - const volumeOff = document.getElementById('volume-off'); - // Initiale Anzeige: volume-2 anzeigen, volume-x verstecken - 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') { - metronomeBPM = window.metronomeSpeed || 60; - } else { - metronomeBPM = window.metronomeBPM || 60; - } - - // Metronom-Funktionen - function createBeepSound(vol, freq, duration) { - if (!audioContext) { - audioContext = new (window.AudioContext || window.webkitAudioContext)(); - } - const oscillator = audioContext.createOscillator(); - const gainNode = audioContext.createGain(); - - oscillator.connect(gainNode); - gainNode.connect(audioContext.destination); - - gainNode.gain.value = vol; - oscillator.frequency.value = freq; - oscillator.type = 'sine'; - - oscillator.start(); - setTimeout(function() { - oscillator.stop(); - }, duration); - } - - function createClickSound() { - if (!audioContext) { - audioContext = new (window.AudioContext || window.webkitAudioContext)(); - } - const bufferSize = audioContext.sampleRate * 0.01; - const buffer = audioContext.createBuffer(1, bufferSize, audioContext.sampleRate); - const data = buffer.getChannelData(0); - - for (let i = 0; i < bufferSize; i++) { - data[i] = Math.random() * 2 - 1; - } - - const source = audioContext.createBufferSource(); - source.buffer = buffer; - - const gainNode = audioContext.createGain(); - gainNode.gain.setValueAtTime(0.1, audioContext.currentTime); - gainNode.gain.exponentialRampToValueAtTime(0.001, audioContext.currentTime + 0.1); - - source.connect(gainNode); - gainNode.connect(audioContext.destination); - source.start(); - } - - function playMetronomeSound() { - // Wenn das Metronom ausgeschaltet ist, keinen Sound abspielen - if (!metronomeEnabled) { - // Aber visuelles Feedback trotzdem anzeigen - const cursorElement = document.querySelector('.cursor-char'); - if (cursorElement) { - cursorElement.style.animation = 'pulse 0.3s'; - setTimeout(() => { - cursorElement.style.animation = ''; - }, 300); - } - return; - } - - if (metronomeSound === 'beep') { - createBeepSound(0.1, 800, 50); - } else { - createClickSound(); - } - - // Visuelles Feedback: Cursor pulsieren lassen - const cursorElement = document.querySelector('.cursor-char'); - if (cursorElement) { - cursorElement.style.animation = 'pulse 0.3s'; - setTimeout(() => { - cursorElement.style.animation = ''; - }, 300); - } - } - - function startMetronome() { - stopMetronome(); - const interval = (60 / metronomeBPM) * 1000; - playMetronomeSound(); // Sofort abspielen - metronomeInterval = setInterval(playMetronomeSound, interval); - } - - function stopMetronome() { - if (metronomeInterval) { - clearInterval(metronomeInterval); - metronomeInterval = null; - } - } - - - // Starte Metronom standardmäßig - if (!isPaused) { - startMetronome(); - } - - document.getElementById('volume-toggle').addEventListener('click', function() { - metronomeEnabled = !metronomeEnabled; - if (metronomeEnabled) { - volumeOn.style.display = 'block'; - volumeOff.style.display = 'none'; - if (!isPaused) { - startMetronome(); - } - } else { - volumeOn.style.display = 'none'; - volumeOff.style.display = 'block'; - stopMetronome(); - } - // Einstellungen an Server senden - fetch('/set_metronome', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-CSRFToken': csrfToken - }, - body: JSON.stringify({ - enabled: metronomeEnabled, - bpm: metronomeBPM, - sound: metronomeSound, - mode: metronomeMode, - speed: metronomeBPM, // Verwende die aktuelle BPM - target_error_rate: targetErrorRate - }) - }); - }); - - // ErrorLock State - Standardmäßig geöffnet (errorLock = false) - let errorLock = false; - const lockClosed = document.getElementById('lock-closed'); - const lockOpen = document.getElementById('lock-open'); - // Initiale Anzeige: offenes Schloss anzeigen, geschlossenes verstecken - lockClosed.style.display = 'none'; - lockOpen.style.display = 'block'; - - - // Lock-Toggle initialisieren - document.getElementById('lock-toggle').addEventListener('click', function() { - if (errorLock) { - // Wechsel zu unlocked - errorLock = false; - lockClosed.style.display = 'none'; - lockOpen.style.display = 'block'; - } else { - // Wechsel zu locked - errorLock = true; - lockClosed.style.display = 'block'; - lockOpen.style.display = 'none'; - } - }); - - // Play/Pause-Toggle initialisieren - const playPauseToggle = document.getElementById('play-pause-toggle'); - if (playPauseToggle) { - playPauseToggle.addEventListener('click', function() { - if (isPaused) { - // Fortsetzen - isPaused = false; - lastStartTime = new Date(); - document.getElementById('play-pause-pause').style.display = 'block'; - document.getElementById('play-pause-play').style.display = 'none'; - if (metronomeEnabled) { - startMetronome(); - } - } else { - // Pausieren - isPaused = true; - if (lastStartTime !== null) { - totalElapsedTime += (new Date() - lastStartTime); - lastStartTime = null; - } - document.getElementById('play-pause-pause').style.display = 'none'; - document.getElementById('play-pause-play').style.display = 'block'; - stopMetronome(); - } - updateStatistics(); - }); - } - - 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'); - - // 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'); - - // Setze Fokus auf das Dokument, um Tastatureingaben zu erfassen - document.addEventListener('click', function() { - document.body.focus(); - }); - document.body.tabIndex = -1; - document.body.focus(); - - // 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 charCount = 0; - for (let i = 0; i < lines.length; i++) { - 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; - } - charCount += lineLength; - } - return lines.length - 1; - } - - // Funktion zur Berechnung der Länge des korrekten Präfixes - function getCorrectPrefixLength() { - let correct = 0; - for (let i = 0; i < userInput.length; i++) { - if (i < fullText.length && userInput[i] === fullText[i]) { - correct++; - } else { - break; - } - } - return correct; - } - - // Zeige den Text an - updateTextDisplay(); - - // Verhindere Copy & Paste für das Vorlagenfeld - targetTextElement.addEventListener('copy', function(event) { - event.preventDefault(); - alert('Kopieren aus der Vorlage ist nicht erlaubt! Bitte tippen Sie den Text manuell.'); - }); - - targetTextElement.addEventListener('cut', function(event) { - event.preventDefault(); - alert('Ausschneiden aus der Vorlage ist nicht erlaubt!'); - }); - - targetTextElement.addEventListener('contextmenu', function(event) { - event.preventDefault(); - return false; - }); - - // Tastatureingaben direkt erfassen - document.addEventListener('keydown', function(event) { - // Verhindere Standardaktionen für Steuertasten - if (event.ctrlKey || event.metaKey) { - event.preventDefault(); - return; - } - - // Im Pause-Modus sind keine Tasten erlaubt - if (isPaused) { - event.preventDefault(); - return; - } - - // Behandle Pfeiltasten - if (event.key === 'ArrowLeft') { - event.preventDefault(); - if (currentCharIndex > 0) { - currentCharIndex--; - updateTextDisplay(); - } - return; - } - - if (event.key === 'ArrowRight') { - event.preventDefault(); - if (currentCharIndex < fullText.length) { - currentCharIndex++; - updateTextDisplay(); - } - return; - } - - // Behandle Backspace - if (event.key === 'Backspace') { - event.preventDefault(); - if (currentCharIndex > 0) { - userInput = userInput.slice(0, currentCharIndex - 1) + userInput.slice(currentCharIndex); - currentCharIndex--; - keyStrokeCount++; - if (!lastStartTime) { - lastStartTime = new Date(); - } - updateTextDisplay(); - updateStatistics(); - } - return; - } - - // Behandle Enter - if (event.key === 'Enter') { - event.preventDefault(); - if (currentCharIndex < fullText.length && fullText[currentCharIndex] === '\n') { - processKeystroke('\n', true); - } - return; - } - - // Ignoriere andere Steuertasten - if (event.key.length > 1 && event.key !== ' ') { - return; - } - - // ErrorLock prüfen - if (errorLock) { - const correctPrefixLength = getCorrectPrefixLength(); - if (currentCharIndex !== correctPrefixLength) { - event.preventDefault(); - return; - } - } - - // Normale Zeichen - event.preventDefault(); - if (currentCharIndex < fullText.length) { - 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) { - document.querySelectorAll('.lesson-link').forEach(link => { - const index = parseInt(link.getAttribute('data-lesson-index')); - if (index === lessonIndex) { - link.classList.add('fw-bold', 'text-danger'); - link.classList.remove('text-primary'); - } else { - link.classList.remove('fw-bold', 'text-danger'); - link.classList.add('text-primary'); - } - }); - } - - // Event-Listener für Lektions-Links - document.querySelectorAll('.lesson-link').forEach(link => { - link.addEventListener('click', function() { - const lessonIndex = parseInt(this.getAttribute('data-lesson-index')); - // Stoppe Timer und setze Zeit zurück - if (timerInterval) { - clearInterval(timerInterval); - timerInterval = null; - } - lastStartTime = null; - - fetch('/set_lesson', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-CSRFToken': csrfToken - }, - body: JSON.stringify({ - lesson_index: lessonIndex - }) - }) - .then(response => response.json()) - .then(data => { - if (data.error) { - alert('Fehler: ' + data.error); - } else { - fullText = data.text; - currentCharIndex = 0; - userInput = ''; - lastStartTime = null; - totalElapsedTime = 0; - isPaused = false; - keyStrokeCount = 0; // Zurücksetzen des Tastaturanschlagszählers - lines = fullText.split('\n'); - userScrolled = false; - firstVisibleLine = 0; - targetTextElement.scrollTop = 0; - updateTextDisplay(); - document.getElementById('lesson-title').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'; - updateStatistics(); - // Aktualisiere die Markierung der aktuellen Lektion - updateLessonHighlight(lessonIndex); - } - }) - .catch(error => console.error('Fehler:', error)); - }); - }); - - // 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); - timerInterval = null; - } - - fetch('/next_text', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-CSRFToken': csrfToken - } - }) - .then(response => response.json()) - .then(data => { - if (data.error) { - alert('Fehler: ' + data.error); - return; - } - fullText = data.text; - currentCharIndex = 0; - userInput = ''; - lastStartTime = null; - totalElapsedTime = 0; - isPaused = false; - keyStrokeCount = 0; // Zurücksetzen des Tastaturanschlagszählers - lines = fullText.split('\n'); - userScrolled = false; - firstVisibleLine = 0; - targetTextElement.scrollTop = 0; - updateTextDisplay(); - // 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 - 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); - }) - .catch(error => { - console.error('Fehler:', error); - 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()) - .then(data => { - alert('Sitzung beendet. Fortschritt gespeichert.'); - // window.close(); // Funktioniert nicht in allen Browsern - // Stattdessen: Zur Startseite navigieren oder Seite neu laden - window.location.href = '/'; - }) - .catch(error => { - console.error('Fehler:', error); - alert('Fehler beim Beenden der Sitzung: ' + error.message); - }); - }); - } else { - console.error('End button not found'); - } - - // Statistik aktualisieren - function updateStatistics() { - // Berechne verstrichene Zeit - let elapsedTime = totalElapsedTime; - if (lastStartTime !== null) { - elapsedTime += (new Date() - lastStartTime); - } - elapsedTime = elapsedTime / 1000; // in Sekunden - - // Berechne Statistiken - let totalKeyStrokes = keyStrokeCount; // Verwende jetzt die Anzahl aller Tastaturanschläge (inkl. Backspace) - let correctKeyStrokes = 0; - - // Zähle korrekte Zeichen - for (let i = 0; i < userInput.length; i++) { - if (i < fullText.length && userInput[i] === fullText[i]) { - correctKeyStrokes++; - } else { - break; - } - } - - let incorrectKeyStrokes = totalKeyStrokes - correctKeyStrokes; - let charsPerMinute = elapsedTime > 0 ? (correctKeyStrokes / elapsedTime) * 60 : 0; - let errorRate = totalKeyStrokes > 0 ? (incorrectKeyStrokes * 100.0) / totalKeyStrokes : 0; - let wpm = elapsedTime > 0 ? (correctKeyStrokes / 5) / (elapsedTime / 60) : 0; - - // 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; - - // 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 `·`; - } else if (char === '\t') { - return ``; - } else if (char === '\n') { - return `
`; - } else { - if (type === 'incorrect' && incorrectChar !== null) { - const escapedIncorrectChar = escapeHtml(incorrectChar); - return `${escapedChar}${escapedIncorrectChar}`; - } else { - return `${escapedChar}`; - } - } - } - - // 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() { - // Vereinfachte Version: Zeige den gesamten Text mit Zeilenumbrüchen an - let newHTML = ''; - let charCount = 0; - - for (let j = 0; j < lines.length; j++) { - const line = lines[j]; - let highlightedLine = ''; - - for (let i = 0; i < line.length; i++) { - const currentChar = line[i]; - const isCursor = (charCount === currentCharIndex); - - if (charCount < userInput.length) { - if (userInput[charCount] === currentChar) { - highlightedLine += highlightChar(currentChar, 'correct', isCursor); - } else { - highlightedLine += highlightChar(currentChar, 'incorrect', isCursor, userInput[charCount]); - } - } else { - highlightedLine += highlightChar(currentChar, 'neutral', isCursor); - } - charCount++; - } - - // Zeilenumbruch nach jeder Zeile (außer der letzten) - if (j < lines.length - 1) { - const isCursor = (charCount === currentCharIndex); - if (charCount < userInput.length) { - if (userInput[charCount] === '\n') { - highlightedLine += highlightChar('\n', 'correct', isCursor); - } else { - highlightedLine += highlightChar('\n', 'incorrect', isCursor, userInput[charCount]); - } - } else { - highlightedLine += highlightChar('\n', 'neutral', isCursor); - } - charCount++; - } - - // Füge die Zeile als div hinzu - const currentLineIndex = getCurrentLineIndex(); - newHTML += `
${highlightedLine}
`; - } - - targetTextElement.innerHTML = newHTML; - - // Automatisches Scrollen: aktuelle Zeile soll oben angezeigt werden - if (!userScrolled) { - // 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 - if (currentLineIndex > 0) { - // Die oberste angezeigte Zeile ist die Zeile bei currentLineIndex - // Da wir jetzt alle Zeilen ab currentLineIndex anzeigen, müssen wir prüfen, ob die erste Zeile (currentLineIndex) komplett grün ist? - // Tatsächlich: Die oberste Zeile in der Anzeige ist die Zeile bei currentLineIndex. - // Aber wir haben die completedLines, die die abgeschlossenen Zeilen speichert. - // Stattdessen: Wir können prüfen, ob die aktuelle Eingabe für die aktuelle Zeile mindestens so lang ist wie die Zeile selbst? - // Das ist bereits in der keyup-Event-Behandlung für den Zeilenwechsel erledigt. - // Allerdings: Wir wollen scrollen, wenn der Benutzer die oberste Zeile in der Anzeige (die bereits abgeschlossen ist) verlässt. - // Da wir die completedLines haben, wissen wir, dass alle Zeilen vor currentLineIndex abgeschlossen sind. - // Daher: Einfach nach jedem Tastendruck scrollen, wenn wir nicht in der ersten Zeile sind? - // Das könnte zu viel sein. Stattdessen: Scrollen, wenn die aktuelle Zeile nicht die erste ist. - // Aber: Wir haben bereits beim Zeilenwechsel gescrollt. Also vielleicht reicht das? - // Der Benutzer wünscht: "Wenn die oberste Zeile des angezeigten Textes in der Vorlage korrekt eingegeben worden ist (grün ist), soll das Programm die Anzeige automatisch um eine Zeile herunter scrollen." - // Das bedeutet: Sobald eine Zeile abgeschlossen ist und wir zur nächsten Zeile wechseln, soll die Anzeige so scrollen, dass die nächste Zeile sichtbar wird. - // Da wir jetzt eine feste Höhe haben und alle verbleibenden Zeilen anzeigen, müssen wir beim Wechsel zur nächsten Zeile scrollen. - // Dies wird bereits im keyup-Event nach dem Zeilenwechsel erledigt. - } - } - - // 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(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'; - - // Setze initialen Zustand - setProgressStatsVisibility(progressStatsVisible); - - // Event-Listener für Performance-Button - performanceBtn.addEventListener('click', function() { - const isVisible = progressContainer.classList.contains('visible'); - setProgressStatsVisibility(!isVisible); - localStorage.setItem('progressStatsVisible', !isVisible); - }); - } - - function setProgressStatsVisibility(visible) { - const progressContainer = document.querySelector('.progress-stats-container'); - const performanceBtn = document.getElementById('performance-btn'); - - if (!progressContainer || !performanceBtn) { - return; - } - - if (visible) { - progressContainer.classList.add('visible'); - performanceBtn.classList.remove('btn-light-custom'); - performanceBtn.classList.add('btn-primary'); - } else { - progressContainer.classList.remove('visible'); - performanceBtn.classList.remove('btn-primary'); - performanceBtn.classList.add('btn-light-custom'); - } - } - - - // 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 -window.addEventListener('error', function(e) { - console.error('JavaScript Fehler:', e.error); -}); - -// Sicherstellen, dass der Body fokussiert ist -document.addEventListener('click', function() { - if (document.activeElement !== document.body) { - document.body.focus(); - } -}); diff --git a/static/js/session.js b/static/js/session.js index 1aa7950..b5981a7 100644 --- a/static/js/session.js +++ b/static/js/session.js @@ -8,6 +8,7 @@ export class SessionManager { this.csrfToken = csrfToken; this.lastStartTime = null; this.totalElapsedTime = 0; + this.netTrainingTime = 0; this.keyStrokeCount = 0; this.isPaused = false; this.currentLessonIndex = 0; @@ -19,6 +20,7 @@ export class SessionManager { */ initialize(initialState) { this.totalElapsedTime = initialState.totalElapsedTime || 0; + this.netTrainingTime = initialState.netTrainingTime || 0; this.keyStrokeCount = initialState.keyStrokeCount || 0; this.isPaused = initialState.isPaused || false; this.currentLessonIndex = initialState.currentLessonIndex || 0; @@ -40,6 +42,18 @@ export class SessionManager { return elapsed / 1000; // Convert to seconds } + /** + * Get net training time in seconds (without pauses) + * @returns {number} Net training time in seconds + */ + getNetTrainingTime() { + let netTime = this.netTrainingTime; + if (!this.isPaused && this.lastStartTime !== null) { + netTime += (new Date() - this.lastStartTime); + } + return netTime / 1000; + } + /** * Start or resume timing */ @@ -55,6 +69,7 @@ export class SessionManager { pause() { if (this.lastStartTime !== null) { this.totalElapsedTime += (new Date() - this.lastStartTime); + this.netTrainingTime += (new Date() - this.lastStartTime); this.lastStartTime = null; } this.isPaused = true; @@ -97,6 +112,7 @@ export class SessionManager { reset() { this.lastStartTime = null; this.totalElapsedTime = 0; + this.netTrainingTime = 0; this.keyStrokeCount = 0; this.isPaused = false; } @@ -116,6 +132,7 @@ export class SessionManager { current_text: currentText, elapsed_time: this.totalElapsedTime / 1000, total_elapsed_time: this.totalElapsedTime, + net_training_time: this.netTrainingTime, key_stroke_count: this.keyStrokeCount, is_paused: this.isPaused }; diff --git a/static/js/statistics.js b/static/js/statistics.js index e5fb8df..d22e62c 100644 --- a/static/js/statistics.js +++ b/static/js/statistics.js @@ -86,10 +86,11 @@ export class StatisticsManager { * @param {number} charsPerMinute - Characters per minute * @param {number} errorRate - Error rate percentage * @param {number} wpm - Words per minute + * @param {number} trainingDuration - Training duration in seconds * @param {string} csrfToken - CSRF token * @returns {Promise} Fetch promise */ - async saveLessonStatistics(lessonIndex, charsPerMinute, errorRate, wpm, csrfToken) { + async saveLessonStatistics(lessonIndex, charsPerMinute, errorRate, wpm, trainingDuration, csrfToken) { try { const response = await fetch('/save_lesson_statistics', { method: 'POST', @@ -101,7 +102,8 @@ export class StatisticsManager { lesson_index: lessonIndex, chars_per_minute: charsPerMinute, error_rate: errorRate, - wpm: wpm + wpm: wpm, + training_duration: trainingDuration }) }); diff --git a/templates/hilfe.html b/templates/hilfe.html index 96932e5..decfde0 100644 --- a/templates/hilfe.html +++ b/templates/hilfe.html @@ -7,12 +7,8 @@ + -
- -
-
-

📖 Typewriter Trainer - Hilfe

- -
-
+
+
+
+ +
+
+

Anleitung

+
+ + +
+
+
- -
-
- {{ hilfe_html|safe }} + +
+
+ {{ hilfe_html|safe }} + + +
+

Deutsches Tastatur-Layout

+ Deutsches Tastatur-Layout +

Standard-Deutsches Tastaturlayout (QWERTZ)

+
+
+
- + ← Zurück zum Trainer -
- -
-

🎉 Session beendet!

-

Großartige Arbeit! Hier ist deine Übungsübersicht der letzten 30 Tage.

-
+
+
+
+ +
+

🎉 Session beendet!

+

Großartige Arbeit! Hier ist deine Übungsübersicht der letzten 30 Tage.

+
- -
-

📊 Deine Statistiken

-
-
-
{{ total_minutes|int }}
-
Minuten insgesamt
+ +
+

📊 Deine Statistiken

+
+
+
{{ total_minutes|int }}
+
Minuten insgesamt
+
+
+
{{ days_practiced }}
+
Tage geübt
+
+
+
{{ (total_minutes / days_practiced)|round(1) if days_practiced > 0 else 0 }}
+
Ø Minuten/Tag
+
+
-
-
{{ days_practiced }}
-
Tage geübt
+ + +
+

Übungszeit der letzten 30 Tage

+
-
-
{{ (total_minutes / days_practiced)|round(1) if days_practiced > 0 else 0 }}
-
Ø Minuten/Tag
+ + +
+ Weiter üben +
- - -
-

Übungszeit der letzten 30 Tage

- -
- - -
- Weiter üben - -
@@ -226,8 +225,10 @@ } }); - // Zeige Abschiedsnachricht - document.body.innerHTML = '
👋 Auf Wiedersehen!
Du kannst dieses Fenster jetzt schließen.
'; + // Zeige Abschiedsnachricht mit Theme-Unterstützung + const bgColor = savedTheme === 'dark' ? '#1a1a1a' : '#f8f9fa'; + const textColor = savedTheme === 'dark' ? '#e0e0e0' : '#212529'; + document.body.innerHTML = '
👋 Auf Wiedersehen!
Du kannst dieses Fenster jetzt schließen.
'; } catch (error) { alert('Fehler beim Beenden: ' + error.message); } diff --git a/templates/statistics.html b/templates/statistics.html index 617dd18..90f3ac4 100644 --- a/templates/statistics.html +++ b/templates/statistics.html @@ -73,6 +73,11 @@
+ + +
+
+
@@ -158,12 +163,22 @@ function updateChart(data) { const ctx = document.getElementById('statisticsChart').getContext('2d'); - + // Zerstöre vorheriges Diagramm falls vorhanden if (currentChart) { currentChart.destroy(); } + // Konvertiere Trainingszeit von Sekunden in Minuten für Anzeige + const trainingMinutes = data.training_duration.map(sec => sec / 60); + + // Berechne Gesamttrainingszeit + const totalSeconds = data.training_duration.reduce((sum, sec) => sum + sec, 0); + const totalHours = Math.floor(totalSeconds / 3600); + const totalMinutes = Math.floor((totalSeconds % 3600) / 60); + document.getElementById('total-training-time').textContent = + `Gesamte Trainingszeit: ${totalHours}:${String(totalMinutes).padStart(2, '0')}`; + currentChart = new Chart(ctx, { type: 'line', data: { @@ -195,6 +210,14 @@ backgroundColor: 'rgba(255, 0, 0, 0.1)', tension: 0.1, yAxisID: 'y1' + }, + { + label: 'Zeit (Minuten)', + data: trainingMinutes, + borderColor: 'green', + backgroundColor: 'rgba(0, 255, 0, 0.1)', + tension: 0.1, + yAxisID: 'y2' } ] }, @@ -240,6 +263,20 @@ drawOnChartArea: false, }, }, + y2: { + type: 'linear', + display: true, + position: 'right', + title: { + display: true, + text: 'Zeit (Min)' + }, + min: 0, + suggestedMax: Math.max(...trainingMinutes) * 1.1 || 1, + grid: { + drawOnChartArea: false, + }, + }, } } }); diff --git a/templates/welcome.html b/templates/welcome.html index 07a175b..34f39fa 100644 --- a/templates/welcome.html +++ b/templates/welcome.html @@ -9,138 +9,123 @@ - - +
+
+
+
+
+ +
+
+ + + +
+
-
-
-
-
- -
- 🎹 -
+ +
+ 🎹 +
- -

Typewriter Trainer

-

{{ APP_VERSION }}

+ +

Typewriter Trainer

+

{{ APP_VERSION }}

- -

- Lernen Sie das Zehnfingersystem
- mit adaptivem Metronom -

+ +

+ Lernen Sie das Zehnfingersystem
+ mit adaptivem Metronom +

- -
-
    -
  • ✓ 43 aufbauende Lektionen
  • -
  • ✓ Echtzeit-Statistiken & Präzisions-Anzeige
  • -
  • ✓ Adaptives Metronom mit max. Geschwindigkeit
  • -
  • ✓ Dark/Light Mode & persistente Einstellungen
  • -
-
+ +
+
    +
  • ✓ 43 aufbauende Lektionen
  • +
  • ✓ Echtzeit-Statistiken & Präzisions-Anzeige
  • +
  • ✓ Adaptives Metronom mit max. Geschwindigkeit
  • +
  • ✓ Dark/Light Mode & persistente Einstellungen
  • +
+
- - + + - -
- - Hilfe & Anleitung - | - Einstellungen - + +
+ + Entwickelt mit ❤️ für effektives Tipptraining
+ + + GitHub + + | © 2025 Dieter Schlüter +
+
- - -
- - Entwickelt mit ❤️ für effektives Tipptraining
- - - GitHub - -
-