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:
parent
f900aff90a
commit
aab3830e7f
8 changed files with 538 additions and 29 deletions
|
|
@ -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
180
app.py
|
|
@ -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")
|
||||
|
|
|
|||
25
models.py
25
models.py
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
237
templates/session_end.html
Normal 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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue