Release v0.3.0: UI-Verbesserungen und Metronom-Optimierung

Features:
- Maximale Metronom-Geschwindigkeit konfigurierbar (Standard: 100 BPM)
- Metronom läuft präzise unabhängig von Tastatureingaben
- Präzisions-Anzeige statt Fehlerquote (positiver formuliert)
- Versionsnummer im Header anzeigen

UI-Verbesserungen:
- Dezente Statistik-Anzeige ohne bunte Badges
- Einheitliches Button-Design (Blau: Weiter/Leistung/Statistik, Grün: Weiter)
- Rechtsbündige Werte mit konsistentem Abstand
- Weiße Beschriftung bei primären Buttons

Technisch:
- Settings werden persistent in Datenbank gespeichert
- Alembic-Migration für max_bpm_speed
- Audio-Timing mit Web Audio API statt setTimeout
- Metronom-Code aufgeräumt und optimiert

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
jamulix 2025-10-28 12:29:06 +01:00
commit 94b6dd91f0
11 changed files with 204 additions and 114 deletions

View file

@ -1 +1 @@
v0.2.0
v0.3.0

View file

@ -0,0 +1,32 @@
"""Add max_bpm_speed to UserSettings
Revision ID: 5fcbe91de50b
Revises: 8f83d622eb4b
Create Date: 2025-10-28 11:34:47.704389
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '5fcbe91de50b'
down_revision: Union[str, Sequence[str], None] = '8f83d622eb4b'
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('user_settings', sa.Column('max_bpm_speed', sa.Integer(), nullable=True))
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('user_settings', 'max_bpm_speed')
# ### end Alembic commands ###

45
app.py
View file

@ -228,6 +228,7 @@ def save_user_settings(
metronome_mode: str,
metronome_speed: int,
target_error_rate: int,
max_bpm_speed: int,
speed_display: str
) -> None:
"""Speichert Benutzereinstellungen in der Datenbank"""
@ -239,6 +240,7 @@ def save_user_settings(
settings.metronome_mode = metronome_mode
settings.metronome_speed = metronome_speed
settings.target_error_rate = target_error_rate
settings.max_bpm_speed = max_bpm_speed
settings.speed_display = speed_display
db.session.commit()
logger.info("Benutzereinstellungen gespeichert")
@ -399,6 +401,7 @@ def index():
metronome_mode = user_settings.metronome_mode
metronome_speed = user_settings.metronome_speed
target_error_rate = user_settings.target_error_rate
max_bpm_speed = getattr(user_settings, 'max_bpm_speed', 100) # Fallback für Kompatibilität
speed_display = user_settings.speed_display
# Lade die gespeicherte Benutzereingabe und Cursor-Position aus der Datenbank
@ -410,7 +413,7 @@ def index():
key_stroke_count = session.get('key_stroke_count', 0)
is_paused = session.get('is_paused', False)
return render_template('index.html',
return render_template('index.html',
current_text=current_text,
current_position=cursor_position,
last_text=user_input,
@ -426,6 +429,7 @@ def index():
metronome_mode=metronome_mode,
metronome_speed=metronome_speed,
target_error_rate=target_error_rate,
max_bpm_speed=max_bpm_speed,
speed_display=speed_display)
@ -950,11 +954,20 @@ def hilfe():
@app.route('/settings')
def settings():
"""Einstellungsseite"""
return render_template('settings.html')
# Lade Einstellungen aus Datenbank mit Defaults
user_settings = get_or_create_user_settings()
current_settings = {
'metronome_mode': user_settings.metronome_mode,
'metronome_speed': user_settings.metronome_speed,
'target_error_rate': user_settings.target_error_rate,
'max_bpm_speed': getattr(user_settings, 'max_bpm_speed', 100),
'speed_display': user_settings.speed_display
}
return render_template('settings.html', settings=current_settings)
@app.route('/save_settings', methods=['POST'])
def save_settings():
"""Speichert die Einstellungen in der Session"""
"""Speichert die Einstellungen persistent in der Datenbank"""
try:
data = request.get_json()
if not data:
@ -969,6 +982,7 @@ def save_settings():
try:
metronome_speed = int(data.get('metronome_speed', 60))
target_error_rate = int(data.get('target_error_rate', 5))
max_bpm_speed = int(data.get('max_bpm_speed', 100))
except (ValueError, TypeError):
return jsonify({'error': 'Ungültige numerische Werte'}), 400
@ -979,15 +993,31 @@ def save_settings():
if not (0 <= target_error_rate <= 100):
return jsonify({'error': 'Fehlerrate muss zwischen 0 und 100 liegen'}), 400
if not (40 <= max_bpm_speed <= 200):
return jsonify({'error': 'Maximale BPM-Geschwindigkeit muss zwischen 40 und 200 liegen'}), 400
# Validiere speed_display
speed_display = data.get('speed_display', 'zpm')
if speed_display not in ['zpm', 'wpm']:
return jsonify({'error': 'Ungültige Geschwindigkeitsanzeige'}), 400
session['metronome_mode'] = metronome_mode
session['metronome_speed'] = metronome_speed
session['target_error_rate'] = target_error_rate
session['speed_display'] = speed_display
# Speichere in Datenbank
progress = Progress.query.first()
current_lesson = progress.current_text_index if progress else 0
save_user_settings(
current_lesson_index=current_lesson,
metronome_enabled=True, # Metronome bleibt aktiviert
metronome_bpm=metronome_speed,
metronome_sound='beep',
metronome_mode=metronome_mode,
metronome_speed=metronome_speed,
target_error_rate=target_error_rate,
max_bpm_speed=max_bpm_speed,
speed_display=speed_display
)
logger.info(f"Einstellungen gespeichert: mode={metronome_mode}, speed={metronome_speed}, max_bpm={max_bpm_speed}")
return jsonify({'status': 'success'})
except Exception as e:
logger.error(f"Fehler in save_settings: {e}", exc_info=True)
@ -1052,6 +1082,7 @@ def end_session():
metronome_mode=data.get('metronome_mode', 'automatic'),
metronome_speed=data.get('metronome_speed', 60),
target_error_rate=data.get('target_error_rate', 5),
max_bpm_speed=data.get('max_bpm_speed', 100),
speed_display=data.get('speed_display', 'zpm')
)

View file

@ -52,6 +52,7 @@ class UserSettings(db.Model):
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)
max_bpm_speed = db.Column(db.Integer, default=100)
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

@ -157,11 +157,9 @@ body {
.input-toggle {
cursor: pointer;
transition: all 0.3s ease;
color: inherit;
}
.progress-toggle:hover,
.statistics-link:hover,
.input-toggle:hover {
color: #ffc107 !important;
text-shadow: 0 0 10px #ffc107, 0 0 20px #ffc107;
@ -401,3 +399,25 @@ body {
background-color: #90EE90 !important;
color: #006400 !important;
}
/* Statistik Button behält weiße Schrift */
.statistics-link.btn-primary {
color: #ffffff !important;
}
.statistics-link.btn-primary:hover {
color: #ffffff !important;
}
/* Weiter Button in Grün (wie korrekte Zeichen) */
#next-btn {
background-color: #28a745 !important;
border-color: #28a745 !important;
color: #ffffff !important;
}
[data-bs-theme="dark"] #next-btn {
background-color: #4ade80 !important;
border-color: #4ade80 !important;
color: #000000 !important;
}

View file

@ -83,7 +83,10 @@ class TypewriterApp {
this.themeManager.setupEventListeners();
// Metronome
this.adaptiveMetronome = new AdaptiveMetronome();
this.adaptiveMetronome = new AdaptiveMetronome({
initialSpeed: window.metronomeBPM || 60,
maxSpeed: window.maxBpmSpeed || 100
});
this.metronomePlayer = new MetronomePlayer();
this.metronomePlayer.enabled = window.metronomeEnabled || true;
this.metronomePlayer.bpm = window.metronomeBPM || 60;

View file

@ -1,78 +1,57 @@
/**
* Adaptive Metronome Module
* Implements an intelligent metronome that adjusts speed based on typing accuracy
*/
// Adaptive Metronome Module
export class AdaptiveMetronome {
constructor() {
this.speed = 60.0; // Starting speed in BPM
this.accuracy_history = []; // Last 50 results (1=correct, 0=incorrect)
this.consecutive_correct = 0; // Consecutive correct characters
this.target_accuracy = 0.96; // Target accuracy (96%)
constructor(options = {}) {
this.speed = options.initialSpeed || 60.0; // Starting speed in BPM
this.max_speed = options.maxSpeed || 100.0; // Maximum speed in BPM (default: 100)
this.accuracy_history = []; // Last 50 results (1=correct, 0=incorrect)
this.consecutive_correct = 0; // Consecutive correct characters
this.target_accuracy = 0.96; // Target accuracy (96%)
}
/**
* Process a keystroke and adjust speed
* @param {boolean} is_correct - Whether the typed character was correct
* @param {number} current_difficulty - Difficulty factor (1.0 = normal)
* @returns {number} New BPM speed
*/
// Process a keystroke and adjust speed
process_keystroke(is_correct, current_difficulty = 1.0) {
// 1. Update history
this.accuracy_history.push(is_correct ? 1.0 : 0.0);
if (this.accuracy_history.length > 50) {
this.accuracy_history.shift();
}
// 2. Count consecutive correct characters
if (is_correct) {
this.consecutive_correct += 1;
} else {
this.consecutive_correct = 0;
}
// 3. Adjust speed
return this._adjust_speed(current_difficulty);
}
/**
* Adjust speed based on current accuracy
* @param {number} difficulty - Difficulty factor
* @returns {number} New BPM speed
* @private
*/
// Adjust speed based on current accuracy
_adjust_speed(difficulty) {
if (this.accuracy_history.length === 0) {
return this.speed;
}
let current_accuracy = this.accuracy_history.reduce((a, b) => a + b, 0) / this.accuracy_history.length;
let adjusted_target = this.target_accuracy - (difficulty * 0.05);
// Multiple adjustment strategies:
if (this.consecutive_correct >= 15) {
// Reward for sustained accuracy
this.speed *= 1.03;
this.speed *= 1.03; // Reward for sustained accuracy
} else if (current_accuracy > adjusted_target + 0.04) {
// Too easy - accelerate more
this.speed *= 1.02;
this.speed *= 1.02; // Too easy - accelerate more
} else if (current_accuracy < adjusted_target - 0.06) {
// Too difficult - brake harder
this.speed *= 0.92;
this.speed *= 0.92; // Too difficult - brake harder
} else if (current_accuracy < adjusted_target) {
// Slightly below target - gentle braking
this.speed *= 0.97;
this.speed *= 0.97; // Slightly below target - gentle braking
} else {
// In target range - gentle acceleration
this.speed *= 1.01;
this.speed *= 1.01; // In target range - gentle acceleration
}
// Speed limits
this.speed = Math.max(40, Math.min(200, this.speed));
// 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));
return this.speed;
}
}
// MetronomePlayer: encapsulates the timed metronome logic
export class MetronomePlayer {
constructor() {
this.audioContext = null;
@ -83,35 +62,21 @@ export class MetronomePlayer {
this.sound = 'beep';
}
/**
* Create a beep sound
* @param {number} vol - Volume (0-1)
* @param {number} freq - Frequency in Hz
* @param {number} duration - Duration in ms
*/
createBeepSound(vol, freq, duration) {
if (!this.audioContext) {
this.audioContext = new (window.AudioContext || window.webkitAudioContext)();
}
const oscillator = this.audioContext.createOscillator();
const gainNode = this.audioContext.createGain();
oscillator.connect(gainNode);
gainNode.connect(this.audioContext.destination);
gainNode.gain.value = vol;
oscillator.frequency.value = freq;
oscillator.type = 'sine';
oscillator.start();
setTimeout(() => {
oscillator.stop();
}, duration);
oscillator.start(this.audioContext.currentTime);
oscillator.stop(this.audioContext.currentTime + duration / 1000);
}
/**
* Create a click sound
*/
createClickSound() {
if (!this.audioContext) {
this.audioContext = new (window.AudioContext || window.webkitAudioContext)();
@ -119,28 +84,22 @@ export class MetronomePlayer {
const bufferSize = this.audioContext.sampleRate * 0.01;
const buffer = this.audioContext.createBuffer(1, bufferSize, this.audioContext.sampleRate);
const data = buffer.getChannelData(0);
for (let i = 0; i < bufferSize; i++) {
data[i] = Math.random() * 2 - 1;
}
const source = this.audioContext.createBufferSource();
source.buffer = buffer;
const gainNode = this.audioContext.createGain();
gainNode.gain.setValueAtTime(0.1, this.audioContext.currentTime);
gainNode.gain.exponentialRampToValueAtTime(0.001, this.audioContext.currentTime + 0.1);
source.connect(gainNode);
gainNode.connect(this.audioContext.destination);
source.start();
}
/**
* Play metronome sound and provide visual feedback
*/
// Play metronome sound and provide visual feedback; called ONLY by scheduler
play() {
// Visual feedback
// Visual feedback (optional)
const cursorElement = document.querySelector('.cursor-char');
if (cursorElement) {
cursorElement.style.animation = 'pulse 0.3s';
@ -148,12 +107,10 @@ export class MetronomePlayer {
cursorElement.style.animation = '';
}, 300);
}
// Audio feedback (only if enabled)
if (!this.enabled) {
return;
}
if (this.sound === 'beep') {
this.createBeepSound(0.1, 800, 50);
} else {
@ -161,22 +118,16 @@ export class MetronomePlayer {
}
}
/**
* Start the metronome
*/
// Start the metronome timer sequence
start() {
this.stop();
this.isRunning = true;
this.scheduleNextBeat();
}
/**
* Schedule the next metronome beat
* @private
*/
// Private: schedules next beat - this is the ONLY place play() is called
scheduleNextBeat() {
if (!this.isRunning) return;
const interval = (60 / this.bpm) * 1000;
this.timeout = setTimeout(() => {
this.play();
@ -184,9 +135,6 @@ export class MetronomePlayer {
}, interval);
}
/**
* Stop the metronome
*/
stop() {
this.isRunning = false;
if (this.timeout) {
@ -195,28 +143,34 @@ export class MetronomePlayer {
}
}
/**
* Set BPM (takes effect at next beat)
* @param {number} bpm - Beats per minute
*/
// Set BPM - takes effect at next scheduled beat without interrupting current beat
setBPM(bpm) {
this.bpm = bpm;
// No restart needed - change takes effect at next scheduled beat
// No restart - allows metronome to run continuously during typing
// New BPM will be applied when next beat is scheduled
}
/**
* Toggle metronome on/off
*/
// Toggle metronome enabled/disabled
toggle() {
this.enabled = !this.enabled;
return this.enabled;
}
/**
* Set sound type
* @param {string} sound - 'beep' or 'click'
*/
// Set sound type ('beep' or 'click')
setSound(sound) {
this.sound = sound;
}
}
/* --- Integration Example (not actual code) ---
* Event Handler does NOT trigger metronome sound directly!
*
* document.addEventListener('keydown', (event) => {
* // Update typing statistics and BPM, but NO direct sound trigger!
* let isCorrect = ...; // your logic here
* let currentDifficulty = ...; // your logic here
* let newBPM = adaptiveMetronome.process_keystroke(isCorrect, currentDifficulty);
* metronomePlayer.setBPM(newBPM); // Sound plays on next scheduled beat
* });
*/

View file

@ -65,7 +65,7 @@ export class StatisticsManager {
if (speedLabel) speedLabel.textContent = 'ZPM:';
}
if (errorRateElement) errorRateElement.textContent = stats.errorRate.toFixed(1);
if (errorRateElement) errorRateElement.textContent = (100.0 - stats.errorRate).toFixed(1);
if (correctElement) correctElement.textContent = stats.correctKeyStrokes;
if (incorrectElement) incorrectElement.textContent = stats.incorrectKeyStrokes;
if (totalElement) totalElement.textContent = stats.totalKeyStrokes;

View file

@ -225,13 +225,13 @@ export class UIManager {
if (visible) {
progressContainer.classList.add('visible');
performanceBtn.classList.remove('btn-light-custom');
performanceBtn.classList.add('btn-primary');
} else {
progressContainer.classList.remove('visible');
performanceBtn.classList.remove('btn-primary');
performanceBtn.classList.add('btn-light-custom');
}
// Button bleibt immer blau (btn-primary)
performanceBtn.classList.remove('btn-light-custom');
performanceBtn.classList.add('btn-primary');
}
/**

View file

@ -18,7 +18,7 @@
<div class="card shadow">
<div class="card-header bg-primary text-white">
<div class="d-flex justify-content-between align-items-center">
<h2 class="mb-0">Typewriter Trainer</h2>
<h2 class="mb-0">Typewriter Trainer <small class="text-white-50">{{ APP_VERSION }}</small></h2>
<div class="d-flex flex-row-reverse align-items-center gap-2">
<button id="settings-toggle"
style="width: 24px; height: 24px; cursor: pointer; border: none; background: none; padding: 0; color: white;"
@ -98,6 +98,7 @@
window.metronomeMode = {{ metronome_mode|tojson }};
window.metronomeSpeed = {{ metronome_speed|tojson }};
window.targetErrorRate = {{ target_error_rate|tojson }};
window.maxBpmSpeed = {{ max_bpm_speed|tojson }};
</script>
@ -132,15 +133,15 @@
<ul class="list-group list-group-flush">
<li class="list-group-item d-flex justify-content-between align-items-center">
Korrekte Zeichen
<span id="correct" class="badge bg-success rounded-pill" role="status" aria-live="polite">0</span>
<span id="correct" class="text-end fw-semibold" style="min-width: 60px;" role="status" aria-live="polite">0</span>
</li>
<li class="list-group-item d-flex justify-content-between align-items-center">
Falsche Zeichen
<span id="incorrect" class="badge bg-danger rounded-pill" role="status" aria-live="polite">0</span>
<span id="incorrect" class="text-end fw-semibold" style="min-width: 60px;" role="status" aria-live="polite">0</span>
</li>
<li class="list-group-item d-flex justify-content-between align-items-center">
Tastaturanschläge
<span id="total" class="badge bg-secondary rounded-pill" role="status" aria-live="polite">0</span>
<span id="total" class="text-end fw-semibold" style="min-width: 60px;" role="status" aria-live="polite">0</span>
</li>
</ul>
</div>
@ -153,15 +154,15 @@
<ul class="list-group list-group-flush">
<li class="list-group-item d-flex justify-content-between align-items-center">
Zeichen pro Minute (ZPM)
<span id="speed" class="badge bg-primary rounded-pill">0</span>
<span id="speed" class="text-end fw-semibold" style="min-width: 60px;">0</span>
</li>
<li class="list-group-item d-flex justify-content-between align-items-center">
Fehler in %
<span id="error-rate" class="badge bg-danger rounded-pill">0</span>
Präzision in %
<span id="error-rate" class="text-end fw-semibold" style="min-width: 60px;">100.0</span>
</li>
<li class="list-group-item d-flex justify-content-between align-items-center">
Wörter pro Minute (WPM)
<span id="wpm" class="badge bg-info rounded-pill">0</span>
<span id="wpm" class="text-end fw-semibold" style="min-width: 60px;">0</span>
</li>
</ul>
</div>
@ -172,13 +173,13 @@
<!-- Buttons -->
<div class="d-flex justify-content-between flex-wrap gap-2">
<button id="next-btn" class="btn btn-success flex-fill">
<button id="next-btn" class="btn btn-primary flex-fill">
<i class="fas fa-arrow-right"></i> Weiter
</button>
<button id="performance-btn" class="btn btn-light-custom flex-fill">
<button id="performance-btn" class="btn btn-primary flex-fill">
<i class="fas fa-chart-line"></i> Leistung
</button>
<button class="btn btn-light-custom flex-fill statistics-link">
<button class="btn btn-primary flex-fill statistics-link">
<i class="fas fa-chart-bar"></i> Statistik
</button>
<button id="end-btn" class="btn btn-danger flex-fill">

View file

@ -64,6 +64,12 @@
<label for="targetErrorRate" class="form-label">Ziel-Fehlerrate (%): <span id="errorRateValue">5</span></label>
<input type="range" class="form-range" id="targetErrorRate" min="1" max="20" step="1" value="5">
<small class="form-text text-muted">Die Metronom-Geschwindigkeit wird automatisch angepasst, um diese Fehlerrate nicht zu überschreiten</small>
<div class="mt-3">
<label for="maxBpmSpeed" class="form-label">Maximale Metronom-Geschwindigkeit (ZPM): <span id="maxBpmValue">100</span></label>
<input type="range" class="form-range" id="maxBpmSpeed" min="40" max="200" step="5" value="100">
<small class="form-text text-muted">Bei fehlerfreier Eingabe wird diese Geschwindigkeit nicht überschritten (Default: 100 ZPM)</small>
</div>
</div>
</div>
@ -153,11 +159,17 @@
document.getElementById('errorRateValue').textContent = this.value;
});
// Schieberegler für maximale BPM-Geschwindigkeit
document.getElementById('maxBpmSpeed').addEventListener('input', function() {
document.getElementById('maxBpmValue').textContent = this.value;
});
// Einstellungen speichern
document.getElementById('saveSettings').addEventListener('click', function() {
const metronomeMode = document.querySelector('input[name="metronomeMode"]:checked').value;
const metronomeSpeed = document.getElementById('metronomeSpeed').value;
const targetErrorRate = document.getElementById('targetErrorRate').value;
const maxBpmSpeed = document.getElementById('maxBpmSpeed').value;
const speedDisplay = document.querySelector('input[name="speedDisplay"]:checked').value;
const csrfToken = document.querySelector('meta[name="csrf-token"]').getAttribute('content');
@ -171,6 +183,7 @@
metronome_mode: metronomeMode,
metronome_speed: parseInt(metronomeSpeed),
target_error_rate: parseInt(targetErrorRate),
max_bpm_speed: parseInt(maxBpmSpeed),
speed_display: speedDisplay
})
})
@ -189,10 +202,45 @@
});
});
// Einstellungen aus Backend laden
function loadSettings() {
const settings = {{ settings | tojson }};
// Metronom-Modus setzen
document.querySelector(`input[name="metronomeMode"][value="${settings.metronome_mode}"]`).checked = true;
// Metronom-Geschwindigkeit setzen
document.getElementById('metronomeSpeed').value = settings.metronome_speed;
document.getElementById('speedValue').textContent = settings.metronome_speed;
// Fehlerrate setzen
document.getElementById('targetErrorRate').value = settings.target_error_rate;
document.getElementById('errorRateValue').textContent = settings.target_error_rate;
// Maximale BPM-Geschwindigkeit setzen
document.getElementById('maxBpmSpeed').value = settings.max_bpm_speed;
document.getElementById('maxBpmValue').textContent = settings.max_bpm_speed;
// Geschwindigkeitsanzeige setzen
document.querySelector(`input[name="speedDisplay"][value="${settings.speed_display}"]`).checked = true;
// Sichtbarkeit der Einstellungen aktualisieren
const explicitSettings = document.getElementById('explicitSettings');
const automaticSettings = document.getElementById('automaticSettings');
if (settings.metronome_mode === 'explicit') {
explicitSettings.style.display = 'block';
automaticSettings.style.display = 'none';
} else {
explicitSettings.style.display = 'none';
automaticSettings.style.display = 'block';
}
}
// Theme initialisieren
document.addEventListener('DOMContentLoaded', function() {
lucide.createIcons();
initializeTheme();
loadSettings();
document.getElementById('theme-toggle').addEventListener('click', toggleTheme);
});
</script>