Update auf Version 0.4.0

This commit is contained in:
jamulix 2025-10-28 19:03:01 +01:00
commit c8e1451c0f
17 changed files with 344 additions and 1308 deletions

View file

@ -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?

View file

@ -1 +1 @@
v0.3.0
v0.4.0

View file

@ -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 ###

43
app.py
View file

@ -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="<p>Hilfe konnte nicht geladen werden.</p>")
@app.route('/images/<path:filename>')
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']:

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

View file

@ -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__ = (

View file

@ -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.');

View file

@ -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() {

File diff suppressed because it is too large Load diff

View file

@ -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
};

View file

@ -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
})
});

View file

@ -7,12 +7,8 @@
<link rel="icon" type="image/svg+xml" href="{{ url_for('static', filename='favicon.svg') }}">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
<script src="https://unpkg.com/lucide@latest/dist/umd/lucide.js"></script>
<style>
.hilfe-content {
padding: 2rem 1rem;
max-width: 900px;
margin: 0 auto;
}
.hilfe-content h1 {
border-bottom: 3px solid #0d6efd;
padding-bottom: 0.5rem;
@ -96,36 +92,53 @@
</style>
</head>
<body class="bg-light" data-bs-theme="light">
<div class="container">
<!-- Header -->
<div class="card shadow mt-4">
<div class="card-header bg-primary text-white d-flex justify-content-between align-items-center">
<h2 class="mb-0">📖 Typewriter Trainer - Hilfe</h2>
<button id="theme-toggle" class="btn btn-outline-light btn-sm" type="button">
<span id="theme-icon">🌙</span>
</button>
</div>
</div>
<div class="container py-4">
<div class="row justify-content-center">
<div class="col-12">
<!-- Header -->
<div class="card shadow">
<div class="card-header bg-primary text-white d-flex justify-content-between align-items-center">
<h2 class="mb-0">Anleitung</h2>
<div id="theme-toggle" style="cursor: pointer; position: relative;">
<i data-lucide="sun" id="theme-toggle-sun" style="width: 24px; height: 24px;"></i>
<i data-lucide="moon" id="theme-toggle-moon" style="width: 24px; height: 24px; display: none;"></i>
</div>
</div>
</div>
<!-- Hilfe-Inhalt -->
<div class="card shadow mt-3 mb-5">
<div class="card-body hilfe-content">
{{ hilfe_html|safe }}
<!-- Hilfe-Inhalt -->
<div class="card shadow mt-3 mb-4">
<div class="card-body hilfe-content">
{{ hilfe_html|safe }}
<!-- Deutsche Tastatur Grafik -->
<div class="mt-4 text-center">
<h3>Deutsches Tastatur-Layout</h3>
<img src="{{ url_for('static', filename='images/Quertz_10_Finger_Layout.jpg') }}"
alt="Deutsches Tastatur-Layout"
class="img-fluid mt-2"
style="max-width: 600px;">
<p class="mt-2 text-muted">Standard-Deutsches Tastaturlayout (QWERTZ)</p>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Zurück-Button -->
<a href="{{ url_for('index') }}" class="btn btn-primary btn-lg back-button shadow">
<a href="{{ url_for('train') }}" class="btn btn-primary btn-lg back-button shadow">
← Zurück zum Trainer
</a>
<!-- Scripts -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<script>
// Initialize Lucide Icons
lucide.createIcons();
// Theme Management
const themeToggle = document.getElementById('theme-toggle');
const themeIcon = document.getElementById('theme-icon');
const body = document.body;
// Load saved theme
@ -143,7 +156,15 @@
});
function updateThemeIcon(theme) {
themeIcon.textContent = theme === 'dark' ? '☀️' : '🌙';
const sunIcon = document.getElementById('theme-toggle-sun');
const moonIcon = document.getElementById('theme-toggle-moon');
if (theme === 'dark') {
sunIcon.style.display = 'none';
moonIcon.style.display = 'inline';
} else {
sunIcon.style.display = 'inline';
moonIcon.style.display = 'none';
}
}
// Smooth scroll for anchor links

View file

@ -89,6 +89,7 @@
window.currentCharIndex = {{ current_position }};
window.lastText = {{ last_text|tojson }};
window.totalElapsedTime = {{ total_elapsed_time }};
window.netTrainingTime = {{ net_training_time }};
window.keyStrokeCount = {{ key_stroke_count }};
window.isPaused = {{ is_paused|tojson }};
// Metronom-Einstellungen von Flask übernehmen
@ -104,7 +105,12 @@
<!-- Lektionen -->
<div class="mb-4">
<h5 class="card-title">Lektionen</h5>
<div class="d-flex justify-content-between align-items-center mb-2">
<h5 class="card-title mb-0">Lektionen</h5>
<span id="training-time-display" class="card-title mb-0" style="font-size: 1.25rem;">
Trainingszeit: <span id="training-time-value">00:00</span>
</span>
</div>
<div class="d-flex flex-wrap justify-content-start"
id="lessons-container"
role="navigation"

View file

@ -9,17 +9,12 @@
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
<style>
.session-end-container {
max-width: 900px;
margin: 0 auto;
padding: 1rem;
}
.header-section {
background: #6c757d;
color: white;
padding: 1.5rem;
padding: 1rem;
border-radius: 10px;
margin-bottom: 1.5rem;
margin-bottom: 1rem;
}
.header-section h2,
.header-section p {
@ -27,57 +22,57 @@
margin: 0;
}
.header-section p {
margin-top: 0.5rem;
margin-top: 0.25rem;
}
.stats-card {
background: #0d6efd;
color: white;
padding: 1rem;
border-radius: 10px;
margin-bottom: 1.5rem;
margin-bottom: 1rem;
box-shadow: 0 10px 30px rgba(0,0,0,0.2);
}
.stats-card h2 {
font-size: 1.3rem;
margin-bottom: 1rem;
font-size: 1.2rem;
margin-bottom: 0.75rem;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
gap: 0.75rem;
}
.stat-box {
background: rgba(255,255,255,0.1);
padding: 0.8rem;
padding: 0.6rem;
border-radius: 8px;
text-align: center;
backdrop-filter: blur(10px);
}
.stat-number {
font-size: 1.8rem;
font-size: 1.5rem;
font-weight: bold;
margin-bottom: 0.5rem;
margin-bottom: 0.25rem;
}
.stat-label {
font-size: 0.9rem;
font-size: 0.85rem;
opacity: 0.9;
}
.chart-container {
background: white;
padding: 1.5rem;
padding: 1rem;
border-radius: 10px;
box-shadow: 0 5px 15px rgba(0,0,0,0.1);
margin-bottom: 1.5rem;
margin-bottom: 1rem;
}
.chart-container h3 {
font-size: 1.2rem;
margin-bottom: 1rem;
font-size: 1.1rem;
margin-bottom: 0.75rem;
}
.action-buttons {
display: flex;
gap: 1rem;
gap: 0.75rem;
justify-content: center;
margin-top: 1.5rem;
margin-top: 1rem;
}
/* Dark Mode */
@ -101,43 +96,47 @@
</style>
</head>
<body class="bg-light" data-bs-theme="light">
<div class="session-end-container">
<!-- Header -->
<div class="header-section text-center">
<h2 class="fw-bold">🎉 Session beendet!</h2>
<p>Großartige Arbeit! Hier ist deine Übungsübersicht der letzten 30 Tage.</p>
</div>
<div class="container py-4">
<div class="row justify-content-center">
<div class="col-12">
<!-- Header -->
<div class="header-section text-center">
<h2 class="fw-bold">🎉 Session beendet!</h2>
<p>Großartige Arbeit! Hier ist deine Übungsübersicht der letzten 30 Tage.</p>
</div>
<!-- Statistik-Übersicht -->
<div class="stats-card">
<h2 class="text-center">📊 Deine Statistiken</h2>
<div class="stats-grid">
<div class="stat-box">
<div class="stat-number">{{ total_minutes|int }}</div>
<div class="stat-label">Minuten insgesamt</div>
<!-- Statistik-Übersicht -->
<div class="stats-card">
<h2 class="text-center">📊 Deine Statistiken</h2>
<div class="stats-grid">
<div class="stat-box">
<div class="stat-number">{{ total_minutes|int }}</div>
<div class="stat-label">Minuten insgesamt</div>
</div>
<div class="stat-box">
<div class="stat-number">{{ days_practiced }}</div>
<div class="stat-label">Tage geübt</div>
</div>
<div class="stat-box">
<div class="stat-number">{{ (total_minutes / days_practiced)|round(1) if days_practiced > 0 else 0 }}</div>
<div class="stat-label">Ø Minuten/Tag</div>
</div>
</div>
</div>
<div class="stat-box">
<div class="stat-number">{{ days_practiced }}</div>
<div class="stat-label">Tage geübt</div>
<!-- Balkendiagramm -->
<div class="chart-container">
<h3 class="text-center">Übungszeit der letzten 30 Tage</h3>
<canvas id="practiceChart" height="80"></canvas>
</div>
<div class="stat-box">
<div class="stat-number">{{ (total_minutes / days_practiced)|round(1) if days_practiced > 0 else 0 }}</div>
<div class="stat-label">Ø Minuten/Tag</div>
<!-- Action Buttons -->
<div class="action-buttons">
<a href="/train" class="btn btn-success">Weiter üben</a>
<button onclick="shutdownServer()" class="btn btn-danger">ENDE</button>
</div>
</div>
</div>
<!-- Balkendiagramm -->
<div class="chart-container">
<h3 class="text-center">Übungszeit der letzten 30 Tage</h3>
<canvas id="practiceChart" height="100"></canvas>
</div>
<!-- Action Buttons -->
<div class="action-buttons">
<a href="/train" class="btn btn-success btn-lg">Weiter üben</a>
<button onclick="shutdownServer()" class="btn btn-danger btn-lg">ENDE</button>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
@ -226,8 +225,10 @@
}
});
// Zeige Abschiedsnachricht
document.body.innerHTML = '<div style="display:flex;justify-content:center;align-items:center;height:100vh;font-size:2rem;text-align:center;">👋 Auf Wiedersehen!<br><small style="font-size:1rem;margin-top:1rem;display:block;">Du kannst dieses Fenster jetzt schließen.</small></div>';
// Zeige Abschiedsnachricht mit Theme-Unterstützung
const bgColor = savedTheme === 'dark' ? '#1a1a1a' : '#f8f9fa';
const textColor = savedTheme === 'dark' ? '#e0e0e0' : '#212529';
document.body.innerHTML = '<div style="display:flex;justify-content:center;align-items:center;height:100vh;font-size:2rem;text-align:center;background-color:' + bgColor + ';color:' + textColor + ';">👋 Auf Wiedersehen!<br><small style="font-size:1rem;margin-top:1rem;display:block;">Du kannst dieses Fenster jetzt schließen.</small></div>';
} catch (error) {
alert('Fehler beim Beenden: ' + error.message);
}

View file

@ -73,6 +73,11 @@
<div class="mb-4">
<canvas id="statisticsChart" width="400" height="200"></canvas>
</div>
<!-- Gesamttrainingszeit -->
<div class="mb-3">
<h6 id="total-training-time" class="text-muted"></h6>
</div>
</div>
</div>
</div>
@ -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,
},
},
}
}
});

View file

@ -9,138 +9,123 @@
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
<script src="https://unpkg.com/lucide@latest/dist/umd/lucide.js"></script>
<style>
.welcome-container {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
.welcome-card {
max-width: 600px;
width: 100%;
}
.logo-icon {
font-size: 5rem;
margin-bottom: 1rem;
font-size: 3.5rem;
margin-bottom: 0.75rem;
}
.feature-list {
text-align: left;
display: inline-block;
}
.feature-item {
font-size: 1.1rem;
margin: 0.5rem 0;
font-size: 0.95rem;
margin: 0.3rem 0;
}
.btn-start {
font-size: 1.3rem;
padding: 1rem 3rem;
border-radius: 50px;
font-weight: 600;
transition: all 0.3s;
border-color: #4169E1 !important;
color: #4169E1 !important;
}
.btn-start:hover {
transform: translateY(-2px);
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.2);
background-color: #4169E1 !important;
color: white !important;
}
.version-badge {
font-size: 0.9rem;
font-size: 0.85rem;
opacity: 0.7;
}
.footer-icons {
position: fixed;
top: 20px;
right: 20px;
.header-icons {
display: flex;
gap: 15px;
gap: 10px;
}
.footer-icons button {
.header-icons button {
background: none;
border: none;
cursor: pointer;
padding: 8px;
border-radius: 8px;
padding: 4px;
border-radius: 6px;
transition: background-color 0.3s;
color: inherit;
}
.footer-icons button:hover {
.header-icons button:hover {
background-color: rgba(0, 0, 0, 0.1);
}
[data-bs-theme="dark"] .footer-icons button:hover {
[data-bs-theme="dark"] .header-icons button:hover {
background-color: rgba(255, 255, 255, 0.1);
}
</style>
</head>
<body class="bg-light" data-bs-theme="light">
<!-- Theme & Hilfe Icons -->
<div class="footer-icons">
<button id="theme-toggle" aria-label="Theme wechseln" title="Theme wechseln">
<i data-lucide="sun" id="theme-toggle-sun" style="width: 24px; height: 24px;"></i>
<i data-lucide="moon" id="theme-toggle-moon" style="width: 24px; height: 24px; display: none;"></i>
</button>
<button onclick="window.open('/hilfe', '_blank')" aria-label="Hilfe öffnen" title="Hilfe öffnen">
<i data-lucide="help-circle" style="width: 24px; height: 24px;"></i>
</button>
</div>
<div class="container py-4">
<div class="row justify-content-center">
<div class="col-12">
<div class="card shadow-lg border-0">
<div class="card-body p-3 text-center">
<!-- Header mit Icons -->
<div class="d-flex justify-content-end mb-2">
<div class="header-icons">
<button onclick="window.open('/hilfe', '_blank')" aria-label="Hilfe öffnen" title="Hilfe öffnen">
<i data-lucide="help-circle" style="width: 20px; height: 20px;"></i>
</button>
<button id="theme-toggle" aria-label="Theme wechseln" title="Theme wechseln">
<i data-lucide="sun" id="theme-toggle-sun" style="width: 20px; height: 20px;"></i>
<i data-lucide="moon" id="theme-toggle-moon" style="width: 20px; height: 20px; display: none;"></i>
</button>
<button onclick="window.location.href='/settings'" aria-label="Einstellungen" title="Einstellungen">
<i data-lucide="settings" style="width: 20px; height: 20px;"></i>
</button>
</div>
</div>
<div class="welcome-container">
<div class="welcome-card text-center p-4">
<div class="card shadow-lg border-0">
<div class="card-body p-5">
<!-- Logo -->
<div class="logo-icon">
🎹
</div>
<!-- Logo -->
<div class="logo-icon">
🎹
</div>
<!-- Title -->
<h1 class="mb-2">Typewriter Trainer</h1>
<p class="version-badge mb-4">{{ APP_VERSION }}</p>
<!-- Title -->
<h1 class="mb-1">Typewriter Trainer</h1>
<p class="version-badge mb-3">{{ APP_VERSION }}</p>
<!-- Subtitle -->
<p class="lead mb-4">
Lernen Sie das Zehnfingersystem<br>
mit adaptivem Metronom
</p>
<!-- Subtitle -->
<p class="lead mb-3" style="font-size: 1rem;">
Lernen Sie das Zehnfingersystem<br>
mit adaptivem Metronom
</p>
<!-- Features -->
<div class="mb-4">
<ul class="feature-list list-unstyled">
<li class="feature-item">✓ 43 aufbauende Lektionen</li>
<li class="feature-item">✓ Echtzeit-Statistiken & Präzisions-Anzeige</li>
<li class="feature-item">✓ Adaptives Metronom mit max. Geschwindigkeit</li>
<li class="feature-item">✓ Dark/Light Mode & persistente Einstellungen</li>
</ul>
</div>
<!-- Features -->
<div class="mb-3">
<ul class="feature-list list-unstyled">
<li class="feature-item">✓ 43 aufbauende Lektionen</li>
<li class="feature-item">✓ Echtzeit-Statistiken & Präzisions-Anzeige</li>
<li class="feature-item">✓ Adaptives Metronom mit max. Geschwindigkeit</li>
<li class="feature-item">✓ Dark/Light Mode & persistente Einstellungen</li>
</ul>
</div>
<!-- CTA Buttons -->
<div class="d-grid gap-3">
<a href="/train" class="btn btn-success btn-start">
Training starten
</a>
<a href="/statistics" class="btn btn-outline-primary">
Statistiken ansehen
</a>
</div>
<!-- CTA Buttons -->
<div class="d-grid gap-2">
<a href="/train" class="btn btn-outline-primary btn-start">
Training starten
</a>
<a href="/statistics" class="btn btn-outline-primary">
Statistiken ansehen
</a>
</div>
<!-- Info Links -->
<div class="mt-4">
<small class="text-muted">
<a href="/hilfe" target="_blank" class="text-decoration-none">Hilfe & Anleitung</a>
|
<a href="/settings" class="text-decoration-none">Einstellungen</a>
</small>
<!-- Footer -->
<div class="mt-3 text-muted">
<small>
Entwickelt mit ❤️ für effektives Tipptraining<br>
<a href="https://github.com/jamulix/typewriter" target="_blank" class="text-decoration-none">
<i data-lucide="github" style="width: 16px; height: 16px; display: inline;"></i>
GitHub
</a>
| © 2025 <a href="mailto:dieter.schlueter@linix.de" class="text-decoration-none">Dieter Schlüter</a>
</small>
</div>
</div>
</div>
</div>
<!-- Footer -->
<div class="mt-3 text-muted">
<small>
Entwickelt mit ❤️ für effektives Tipptraining<br>
<a href="https://github.com/jamulix/typewriter" target="_blank" class="text-decoration-none">
<i data-lucide="github" style="width: 16px; height: 16px; display: inline;"></i>
GitHub
</a>
</small>
</div>
</div>
</div>