Streak Statistik am Ende

- Neue Tabellen: DailyPractice und UserSettings für persistente Speicherung
- Session-Ende zeigt 30-Tage Balkendiagramm mit Übungsstatistiken
- Einstellungen und aktuelle Lektion werden beim Beenden gespeichert
- Beim Start werden gespeicherte Einstellungen wiederhergestellt
- Metronom läuft jetzt rhythmisch präzise ohne Unterbrechungen
- Programm kann sich selbst beenden mit ENDE-Button
- Layout session_end angepasst an Hauptseite (Farben, Größen)
- Hilfe-Seite: Linker Rand korrigiert

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
jamulix 2025-10-27 23:05:03 +01:00
commit aab3830e7f
8 changed files with 538 additions and 29 deletions

View file

@ -0,0 +1,55 @@
"""Add DailyPractice and UserSettings tables
Revision ID: 8f83d622eb4b
Revises: 09de7cfb8b74
Create Date: 2025-10-27 22:27:26.580559
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '8f83d622eb4b'
down_revision: Union[str, Sequence[str], None] = '09de7cfb8b74'
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.create_table('daily_practice',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('date', sa.Date(), nullable=False),
sa.Column('minutes_practiced', sa.Float(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.Column('updated_at', sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_daily_practice_date'), 'daily_practice', ['date'], unique=True)
op.create_table('user_settings',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('current_lesson_index', sa.Integer(), nullable=True),
sa.Column('metronome_enabled', sa.Boolean(), nullable=True),
sa.Column('metronome_bpm', sa.Integer(), nullable=True),
sa.Column('metronome_sound', sa.String(length=20), nullable=True),
sa.Column('metronome_mode', sa.String(length=20), nullable=True),
sa.Column('metronome_speed', sa.Integer(), nullable=True),
sa.Column('target_error_rate', sa.Integer(), nullable=True),
sa.Column('speed_display', sa.String(length=10), nullable=True),
sa.Column('updated_at', sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint('id')
)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('user_settings')
op.drop_index(op.f('ix_daily_practice_date'), table_name='daily_practice')
op.drop_table('daily_practice')
# ### end Alembic commands ###

180
app.py
View file

@ -8,7 +8,8 @@ from io import StringIO
from typing import Dict, List, Any, Optional
from flask import Flask, render_template, request, jsonify, session, Response, make_response
from flask_wtf.csrf import CSRFProtect
from models import db, Progress, Statistic, LessonStatistic
from models import db, Progress, Statistic, LessonStatistic, DailyPractice, UserSettings
from datetime import datetime, date, timedelta
# Logging konfigurieren
logging.basicConfig(
@ -191,6 +192,59 @@ def create_sample_lessons() -> List[Dict[str, Any]]:
logger.error(f"Fehler beim Erstellen der Beispiel-Lektionen: {e}")
return FALLBACK_LESSONS
def get_or_create_user_settings() -> UserSettings:
"""Holt oder erstellt die Benutzereinstellungen"""
settings = UserSettings.query.first()
if not settings:
settings = UserSettings()
db.session.add(settings)
db.session.commit()
logger.info("Neue Benutzereinstellungen erstellt")
return settings
def save_user_settings(
current_lesson_index: int,
metronome_enabled: bool,
metronome_bpm: int,
metronome_sound: str,
metronome_mode: str,
metronome_speed: int,
target_error_rate: int,
speed_display: str
) -> None:
"""Speichert Benutzereinstellungen in der Datenbank"""
settings = get_or_create_user_settings()
settings.current_lesson_index = current_lesson_index
settings.metronome_enabled = metronome_enabled
settings.metronome_bpm = metronome_bpm
settings.metronome_sound = metronome_sound
settings.metronome_mode = metronome_mode
settings.metronome_speed = metronome_speed
settings.target_error_rate = target_error_rate
settings.speed_display = speed_display
db.session.commit()
logger.info("Benutzereinstellungen gespeichert")
def update_daily_practice(minutes: float) -> None:
"""Aktualisiert die tägliche Übungszeit"""
today = date.today()
practice = DailyPractice.query.filter_by(date=today).first()
if practice:
practice.minutes_practiced += minutes
else:
practice = DailyPractice(date=today, minutes_practiced=minutes)
db.session.add(practice)
db.session.commit()
logger.info(f"Tägliche Übungszeit aktualisiert: {minutes:.2f} Minuten")
def get_last_30_days_practice() -> List[Dict[str, Any]]:
"""Holt die Übungszeiten der letzten 30 Tage"""
thirty_days_ago = date.today() - timedelta(days=29)
practices = DailyPractice.query.filter(DailyPractice.date >= thirty_days_ago).order_by(DailyPractice.date.asc()).all()
return [{'date': p.date.strftime('%Y-%m-%d'), 'minutes': p.minutes_practiced} for p in practices]
# Prüfe Datenbank-Status
def check_database_status():
"""
@ -302,20 +356,32 @@ def index():
logger.debug(f"Rendering Index - Current Text Länge: {len(current_text)} Zeichen")
# Lade gespeicherte Benutzereinstellungen aus der Datenbank
user_settings = get_or_create_user_settings()
# Wenn eine gespeicherte Lektion existiert und der Progress noch initial ist,
# lade die gespeicherte Lektion
if progress.current_text_index == 0 and user_settings.current_lesson_index > 0:
progress.current_text_index = user_settings.current_lesson_index
if lessons and progress.current_text_index < len(lessons):
current_text = lessons[progress.current_text_index]['text']
progress.last_text = current_text
db.session.commit()
# Bestimme den Lektionstitel
current_lesson_title = "Unbekannte Lektion"
if lessons and progress.current_text_index < len(lessons):
lesson = lessons[progress.current_text_index]
current_lesson_title = lesson.get('title', f"Lektion {progress.current_text_index + 1}")
# Metronom-Einstellungen aus Session laden oder Standardwerte setzen
metronome_enabled = session.get('metronome_enabled', False)
metronome_bpm = session.get('metronome_bpm', 60)
metronome_sound = session.get('metronome_sound', 'beep')
metronome_mode = session.get('metronome_mode', 'automatic')
metronome_speed = session.get('metronome_speed', 60)
target_error_rate = session.get('target_error_rate', 5)
speed_display = session.get('speed_display', 'zpm')
# Metronom-Einstellungen aus Datenbank laden
metronome_enabled = user_settings.metronome_enabled
metronome_bpm = user_settings.metronome_bpm
metronome_sound = user_settings.metronome_sound
metronome_mode = user_settings.metronome_mode
metronome_speed = user_settings.metronome_speed
target_error_rate = user_settings.target_error_rate
speed_display = user_settings.speed_display
# Lade die gespeicherte Benutzereingabe und Cursor-Position aus der Datenbank
user_input = progress.last_text
@ -944,12 +1010,102 @@ def set_metronome():
def end_session():
"""Beendet eine Tipp-Session und speichert Statistiken"""
try:
# Hier könnten erweiterte Statistiken gespeichert werden
return jsonify({'status': 'success', 'message': 'Session beendet'})
data = request.get_json()
if not data:
return jsonify({'error': 'Keine Daten empfangen'}), 400
# Hole die aktuelle Session-Zeit (in Sekunden)
total_elapsed_time = float(data.get('total_elapsed_time', 0))
minutes_practiced = total_elapsed_time / 60.0
# Aktualisiere die tägliche Übungszeit
if minutes_practiced > 0:
update_daily_practice(minutes_practiced)
# Speichere alle aktuellen Einstellungen
progress = Progress.query.first()
current_lesson = progress.current_text_index if progress else 0
save_user_settings(
current_lesson_index=current_lesson,
metronome_enabled=data.get('metronome_enabled', False),
metronome_bpm=data.get('metronome_bpm', 60),
metronome_sound=data.get('metronome_sound', 'beep'),
metronome_mode=data.get('metronome_mode', 'automatic'),
metronome_speed=data.get('metronome_speed', 60),
target_error_rate=data.get('target_error_rate', 5),
speed_display=data.get('speed_display', 'zpm')
)
logger.info(f"Session beendet: {minutes_practiced:.2f} Minuten geübt")
return jsonify({
'status': 'success',
'message': 'Session beendet',
'redirect': '/session_end'
})
except Exception as e:
logger.error(f"Fehler in end_session: {e}", exc_info=True)
return jsonify({'error': 'Interner Serverfehler'}), 500
@app.route('/session_end')
def session_end():
"""Zeigt die Endseite mit Übungsstatistiken der letzten 30 Tage"""
try:
# Hole die letzten 30 Tage
practice_data = get_last_30_days_practice()
# Erstelle vollständige Daten für alle 30 Tage (auch Tage ohne Übung)
today = date.today()
full_data = []
for i in range(29, -1, -1): # Von 29 Tagen zurück bis heute
current_date = today - timedelta(days=i)
date_str = current_date.strftime('%Y-%m-%d')
# Suche nach Daten für diesen Tag
matching = next((p for p in practice_data if p['date'] == date_str), None)
full_data.append({
'date': date_str,
'display_date': current_date.strftime('%d.%m'),
'minutes': matching['minutes'] if matching else 0
})
# Berechne Gesamtstatistiken
total_minutes = sum(d['minutes'] for d in full_data)
days_practiced = sum(1 for d in full_data if d['minutes'] > 0)
return render_template('session_end.html',
practice_data=full_data,
total_minutes=total_minutes,
days_practiced=days_practiced)
except Exception as e:
logger.error(f"Fehler in session_end: {e}", exc_info=True)
return render_template('session_end.html', practice_data=[], total_minutes=0, days_practiced=0)
@app.route('/shutdown', methods=['POST'])
@csrf.exempt
def shutdown():
"""Beendet den Flask-Server (nur für lokale Desktop-Nutzung)"""
try:
logger.info("Server-Shutdown angefordert")
# Nur im Debug-Mode erlauben
if not app.debug:
return jsonify({'error': 'Shutdown nur im Debug-Mode erlaubt'}), 403
# Verzögert beenden, damit die Response noch gesendet werden kann
import threading
def shutdown_server():
import time
time.sleep(0.5)
os._exit(0)
threading.Thread(target=shutdown_server).start()
return jsonify({'status': 'success', 'message': 'Server wird beendet...'})
except Exception as e:
logger.error(f"Fehler beim Shutdown: {e}", exc_info=True)
return jsonify({'error': str(e)}), 500
if __name__ == '__main__':
logger.info("=" * 50)
logger.info("Tipptrainer gestartet")

View file

@ -1,4 +1,5 @@
from flask_sqlalchemy import SQLAlchemy
from datetime import datetime
db = SQLAlchemy()
@ -30,3 +31,27 @@ class LessonStatistic(db.Model):
__table_args__ = (
db.Index('idx_lesson_created', 'lesson_index', 'created_at'), # Composite Index
)
class DailyPractice(db.Model):
"""Speichert die tägliche Übungszeit"""
id = db.Column(db.Integer, primary_key=True)
date = db.Column(db.Date, nullable=False, unique=True, index=True) # Ein Eintrag pro Tag
minutes_practiced = db.Column(db.Float, default=0.0) # Minuten geübt
created_at = db.Column(db.DateTime, default=db.func.now())
updated_at = db.Column(db.DateTime, default=db.func.now(), onupdate=db.func.now())
class UserSettings(db.Model):
"""Speichert Benutzereinstellungen persistent"""
id = db.Column(db.Integer, primary_key=True)
# Aktuelle Lektion
current_lesson_index = db.Column(db.Integer, default=0)
# Metronom-Einstellungen
metronome_enabled = db.Column(db.Boolean, default=False)
metronome_bpm = db.Column(db.Integer, default=60)
metronome_sound = db.Column(db.String(20), default='beep')
metronome_mode = db.Column(db.String(20), default='automatic')
metronome_speed = db.Column(db.Integer, default=60)
target_error_rate = db.Column(db.Integer, default=5)
speed_display = db.Column(db.String(10), default='zpm')
# Zeitstempel
updated_at = db.Column(db.DateTime, default=db.func.now(), onupdate=db.func.now())

View file

@ -464,9 +464,23 @@ class TypewriterApp {
*/
async endSession() {
try {
await this.sessionManager.endSession();
this.uiManager.alert('Sitzung beendet. Fortschritt gespeichert.');
window.location.href = '/';
const settings = {
metronome_enabled: this.metronomePlayer.enabled,
metronome_bpm: this.metronomePlayer.bpm,
metronome_sound: this.metronomePlayer.sound,
metronome_mode: this.metronomeMode,
metronome_speed: this.metronomePlayer.bpm,
target_error_rate: this.targetErrorRate,
speed_display: this.speedDisplay
};
const response = await this.sessionManager.endSession(settings);
if (response.redirect) {
window.location.href = response.redirect;
} else {
window.location.href = '/';
}
} catch (error) {
this.uiManager.alert('Fehler beim Beenden der Sitzung: ' + error.message);
}

View file

@ -76,7 +76,8 @@ export class AdaptiveMetronome {
export class MetronomePlayer {
constructor() {
this.audioContext = null;
this.interval = null;
this.timeout = null;
this.isRunning = false;
this.enabled = true;
this.bpm = 60;
this.sound = 'beep';
@ -165,31 +166,42 @@ export class MetronomePlayer {
*/
start() {
this.stop();
this.isRunning = true;
this.scheduleNextBeat();
}
/**
* Schedule the next metronome beat
* @private
*/
scheduleNextBeat() {
if (!this.isRunning) return;
const interval = (60 / this.bpm) * 1000;
this.play(); // Play immediately
this.interval = setInterval(() => this.play(), interval);
this.timeout = setTimeout(() => {
this.play();
this.scheduleNextBeat();
}, interval);
}
/**
* Stop the metronome
*/
stop() {
if (this.interval) {
clearInterval(this.interval);
this.interval = null;
this.isRunning = false;
if (this.timeout) {
clearTimeout(this.timeout);
this.timeout = null;
}
}
/**
* Set BPM and restart if playing
* Set BPM (takes effect at next beat)
* @param {number} bpm - Beats per minute
*/
setBPM(bpm) {
const wasPlaying = this.interval !== null;
this.bpm = bpm;
if (wasPlaying) {
this.start();
}
// No restart needed - change takes effect at next scheduled beat
}
/**

View file

@ -214,16 +214,26 @@ export class SessionManager {
/**
* End session
* @param {Object} settings - Current application settings
* @returns {Promise} Fetch promise
*/
async endSession() {
async endSession(settings = {}) {
try {
// Pause to capture final time
if (!this.isPaused) {
this.pause();
}
const response = await fetch('/end_session', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': this.csrfToken
}
},
body: JSON.stringify({
total_elapsed_time: this.totalElapsedTime / 1000,
...settings
})
});
if (!response.ok) {

View file

@ -9,7 +9,7 @@
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
<style>
.hilfe-content {
padding: 2rem 0;
padding: 2rem 1rem;
max-width: 900px;
margin: 0 auto;
}

237
templates/session_end.html Normal file
View file

@ -0,0 +1,237 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Session beendet - Typewriter Trainer</title>
<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://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;
border-radius: 10px;
margin-bottom: 1.5rem;
}
.header-section h2,
.header-section p {
color: white !important;
margin: 0;
}
.header-section p {
margin-top: 0.5rem;
}
.stats-card {
background: #0d6efd;
color: white;
padding: 1rem;
border-radius: 10px;
margin-bottom: 1.5rem;
box-shadow: 0 10px 30px rgba(0,0,0,0.2);
}
.stats-card h2 {
font-size: 1.3rem;
margin-bottom: 1rem;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
}
.stat-box {
background: rgba(255,255,255,0.1);
padding: 0.8rem;
border-radius: 8px;
text-align: center;
backdrop-filter: blur(10px);
}
.stat-number {
font-size: 1.8rem;
font-weight: bold;
margin-bottom: 0.5rem;
}
.stat-label {
font-size: 0.9rem;
opacity: 0.9;
}
.chart-container {
background: white;
padding: 1.5rem;
border-radius: 10px;
box-shadow: 0 5px 15px rgba(0,0,0,0.1);
margin-bottom: 1.5rem;
}
.chart-container h3 {
font-size: 1.2rem;
margin-bottom: 1rem;
}
.action-buttons {
display: flex;
gap: 1rem;
justify-content: center;
margin-top: 1.5rem;
}
/* Dark Mode */
[data-bs-theme="dark"] .chart-container {
background: #2d2d2d;
color: #e0e0e0;
}
[data-bs-theme="dark"] .stat-box {
background: rgba(255,255,255,0.05);
}
[data-bs-theme="dark"] .stats-card {
background: #0d47a1;
}
[data-bs-theme="dark"] .text-center.mb-3 h2,
[data-bs-theme="dark"] .text-center.mb-3 p {
color: #e0e0e0 !important;
}
[data-bs-theme="dark"] body {
background-color: #1a1a1a !important;
}
</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>
<!-- 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>
<!-- 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="/" 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>
<script>
// Theme Management
const savedTheme = localStorage.getItem('theme') || 'light';
document.body.setAttribute('data-bs-theme', savedTheme);
// Chart data
const practiceData = {{ practice_data|tojson }};
// Prepare chart data
const labels = practiceData.map(d => d.display_date);
const data = practiceData.map(d => d.minutes);
// Create chart
const ctx = document.getElementById('practiceChart').getContext('2d');
const chart = new Chart(ctx, {
type: 'bar',
data: {
labels: labels,
datasets: [{
label: 'Minuten geübt',
data: data,
backgroundColor: 'rgba(102, 126, 234, 0.8)',
borderColor: 'rgba(102, 126, 234, 1)',
borderWidth: 2,
borderRadius: 5,
hoverBackgroundColor: 'rgba(118, 75, 162, 0.9)'
}]
},
options: {
responsive: true,
maintainAspectRatio: true,
scales: {
y: {
beginAtZero: true,
ticks: {
callback: function(value) {
return value + ' min';
}
},
grid: {
color: savedTheme === 'dark' ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.1)'
}
},
x: {
grid: {
display: false
}
}
},
plugins: {
legend: {
display: false
},
tooltip: {
callbacks: {
label: function(context) {
return context.parsed.y.toFixed(1) + ' Minuten';
}
}
}
}
}
});
// Update chart colors for dark mode
if (savedTheme === 'dark') {
chart.options.scales.y.ticks.color = '#e0e0e0';
chart.options.scales.x.ticks.color = '#e0e0e0';
chart.update();
}
// Shutdown function
async function shutdownServer() {
if (!confirm('Möchten Sie das Programm wirklich beenden?')) {
return;
}
try {
await fetch('/shutdown', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
});
// 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>';
} catch (error) {
alert('Fehler beim Beenden: ' + error.message);
}
}
</script>
</body>
</html>