diff --git a/alembic/versions/8f83d622eb4b_add_dailypractice_and_usersettings_.py b/alembic/versions/8f83d622eb4b_add_dailypractice_and_usersettings_.py new file mode 100644 index 0000000..c635c29 --- /dev/null +++ b/alembic/versions/8f83d622eb4b_add_dailypractice_and_usersettings_.py @@ -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 ### diff --git a/app.py b/app.py index 4c20675..54aa5fb 100644 --- a/app.py +++ b/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") diff --git a/models.py b/models.py index 5ce92f8..d03019c 100644 --- a/models.py +++ b/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()) diff --git a/static/js/main.js b/static/js/main.js index 2697f7d..00bffea 100644 --- a/static/js/main.js +++ b/static/js/main.js @@ -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); } diff --git a/static/js/metronome.js b/static/js/metronome.js index 0980b90..6278ec0 100644 --- a/static/js/metronome.js +++ b/static/js/metronome.js @@ -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 } /** diff --git a/static/js/session.js b/static/js/session.js index fb18298..1aa7950 100644 --- a/static/js/session.js +++ b/static/js/session.js @@ -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) { diff --git a/templates/hilfe.html b/templates/hilfe.html index c19d114..96932e5 100644 --- a/templates/hilfe.html +++ b/templates/hilfe.html @@ -9,7 +9,7 @@ + +
+Großartige Arbeit! Hier ist deine Übungsübersicht der letzten 30 Tage.
+