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 0000000..82d29ff Binary files /dev/null and b/images/Quertz_10_Finger_Layout.jpg differ 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 - -
-