Update auf Version 0.4.0
This commit is contained in:
parent
423b978384
commit
c8e1451c0f
17 changed files with 344 additions and 1308 deletions
2
HILFE.md
2
HILFE.md
|
|
@ -2,6 +2,8 @@
|
|||
|
||||
Willkommen beim Typewriter Trainer! Diese Hilfe erklärt alle Funktionen und gibt Tipps für effektives Tipptraining.
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
## 🎯 Was ist Typewriter Trainer?
|
||||
|
|
|
|||
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
|||
v0.3.0
|
||||
v0.4.0
|
||||
|
|
|
|||
|
|
@ -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
43
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="<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']:
|
||||
|
|
|
|||
BIN
images/Quertz_10_Finger_Layout.jpg
Normal file
BIN
images/Quertz_10_Finger_Layout.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 61 KiB |
|
|
@ -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__ = (
|
||||
|
|
|
|||
|
|
@ -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.');
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue