/**
* Countdown-Timer mit Force-Quit-Funktionalität
*
* Dieses Modul stellt eine vollständige Timer-UI bereit mit:
* - Visueller Countdown-Anzeige
* - Fortschrittsbalken
* - Warnungen vor Ablauf
* - Force-Quit-Aktionen
* - Responsive Design
*
* Autor: System
* Erstellt: 2025
*/
class CountdownTimer {
/**
* Erstellt einen neuen Countdown-Timer
* @param {Object} options - Konfigurationsoptionen
*/
constructor(options = {}) {
// Standard-Konfiguration
this.config = {
// Timer-Grundeinstellungen
name: options.name || 'default_timer',
duration: options.duration || 1800, // 30 Minuten in Sekunden
autoStart: options.autoStart || false,
// UI-Einstellungen
container: options.container || 'countdown-timer',
size: options.size || 'large', // small, medium, large
theme: options.theme || 'primary', // primary, warning, danger, success
showProgress: options.showProgress !== false,
showControls: options.showControls !== false,
// Warnungs-Einstellungen
warningThreshold: options.warningThreshold || 30, // Warnung 30 Sekunden vor Ablauf
showWarning: options.showWarning !== false,
warningMessage: options.warningMessage || 'Timer läuft ab!',
// Force-Quit-Einstellungen
forceQuitEnabled: options.forceQuitEnabled !== false,
forceQuitAction: options.forceQuitAction || 'logout',
customEndpoint: options.customEndpoint || null,
// Callback-Funktionen
onTick: options.onTick || null,
onWarning: options.onWarning || null,
onExpired: options.onExpired || null,
onForceQuit: options.onForceQuit || null,
// API-Einstellungen
apiBase: options.apiBase || '/api/timers',
updateInterval: options.updateInterval || 1000,
syncWithServer: options.syncWithServer !== false
};
// Timer-Zustand
this.state = {
remaining: this.config.duration,
total: this.config.duration,
status: 'stopped', // stopped, running, paused, expired
warningShown: false,
lastServerSync: null
};
// DOM-Elemente
this.elements = {};
// Timer-Intervalle
this.intervals = {
countdown: null,
serverSync: null
};
// Event-Listener
this.listeners = new Map();
this.init();
}
/**
* Initialisiert den Timer
*/
init() {
this.createUI();
this.attachEventListeners();
if (this.config.syncWithServer) {
this.syncWithServer();
this.startServerSync();
}
if (this.config.autoStart) {
this.start();
}
console.log(`Timer '${this.config.name}' initialisiert`);
}
/**
* Erstellt die Timer-UI
*/
createUI() {
const container = document.getElementById(this.config.container);
if (!container) {
console.error(`Container '${this.config.container}' nicht gefunden`);
return;
}
// Haupt-Container erstellen
const timerWrapper = document.createElement('div');
timerWrapper.className = `countdown-timer-wrapper size-${this.config.size} theme-${this.config.theme}`;
timerWrapper.innerHTML = this.getTimerHTML();
container.appendChild(timerWrapper);
// DOM-Referenzen speichern
this.elements = {
wrapper: timerWrapper,
display: timerWrapper.querySelector('.timer-display'),
timeText: timerWrapper.querySelector('.time-text'),
progressBar: timerWrapper.querySelector('.progress-fill'),
progressText: timerWrapper.querySelector('.progress-text'),
statusIndicator: timerWrapper.querySelector('.status-indicator'),
warningBox: timerWrapper.querySelector('.warning-box'),
warningText: timerWrapper.querySelector('.warning-text'),
controls: timerWrapper.querySelector('.timer-controls'),
startBtn: timerWrapper.querySelector('.btn-start'),
pauseBtn: timerWrapper.querySelector('.btn-pause'),
stopBtn: timerWrapper.querySelector('.btn-stop'),
resetBtn: timerWrapper.querySelector('.btn-reset'),
extendBtn: timerWrapper.querySelector('.btn-extend')
};
this.updateDisplay();
}
/**
* Generiert das HTML für den Timer
*/
getTimerHTML() {
return `
${this.formatTime(this.state.remaining)}
verbleibend
Gestoppt
${this.config.showProgress ? `
` : ''}
${this.config.warningMessage}
${this.config.showControls ? `
` : ''}
`;
}
/**
* Registriert Event-Listener
*/
attachEventListeners() {
if (this.elements.startBtn) {
this.elements.startBtn.addEventListener('click', () => this.start());
}
if (this.elements.pauseBtn) {
this.elements.pauseBtn.addEventListener('click', () => this.pause());
}
if (this.elements.stopBtn) {
this.elements.stopBtn.addEventListener('click', () => this.stop());
}
if (this.elements.resetBtn) {
this.elements.resetBtn.addEventListener('click', () => this.reset());
}
if (this.elements.extendBtn) {
this.elements.extendBtn.addEventListener('click', () => this.extend(300)); // 5 Minuten
}
// Globale Events für Tastatur-Shortcuts
document.addEventListener('keydown', (e) => this.handleKeyboardShortcuts(e));
// Page Visibility API für Pause bei Tab-Wechsel
document.addEventListener('visibilitychange', () => this.handleVisibilityChange());
// Before Unload Event für Warnung
window.addEventListener('beforeunload', (e) => this.handleBeforeUnload(e));
}
/**
* Startet den Timer
*/
async start() {
try {
if (this.state.status === 'running') {
return true;
}
// Server-API aufrufen wenn aktiviert
if (this.config.syncWithServer) {
const response = await this.apiCall('start', 'POST');
if (!response.success) {
this.showError('Fehler beim Starten des Timers');
return false;
}
}
this.state.status = 'running';
this.startCountdown();
this.updateControls();
this.updateStatusIndicator();
console.log(`Timer '${this.config.name}' gestartet`);
return true;
} catch (error) {
console.error('Fehler beim Starten des Timers:', error);
this.showError('Timer konnte nicht gestartet werden');
return false;
}
}
/**
* Pausiert den Timer
*/
async pause() {
try {
if (this.state.status !== 'running') {
return true;
}
// Server-API aufrufen wenn aktiviert
if (this.config.syncWithServer) {
const response = await this.apiCall('pause', 'POST');
if (!response.success) {
this.showError('Fehler beim Pausieren des Timers');
return false;
}
}
this.state.status = 'paused';
this.stopCountdown();
this.updateControls();
this.updateStatusIndicator();
console.log(`Timer '${this.config.name}' pausiert`);
return true;
} catch (error) {
console.error('Fehler beim Pausieren des Timers:', error);
this.showError('Timer konnte nicht pausiert werden');
return false;
}
}
/**
* Stoppt den Timer
*/
async stop() {
try {
// Server-API aufrufen wenn aktiviert
if (this.config.syncWithServer) {
const response = await this.apiCall('stop', 'POST');
if (!response.success) {
this.showError('Fehler beim Stoppen des Timers');
return false;
}
}
this.state.status = 'stopped';
this.state.remaining = this.state.total;
this.state.warningShown = false;
this.stopCountdown();
this.hideWarning();
this.updateDisplay();
this.updateControls();
this.updateStatusIndicator();
console.log(`Timer '${this.config.name}' gestoppt`);
return true;
} catch (error) {
console.error('Fehler beim Stoppen des Timers:', error);
this.showError('Timer konnte nicht gestoppt werden');
return false;
}
}
/**
* Setzt den Timer zurück
*/
async reset() {
try {
// Server-API aufrufen wenn aktiviert
if (this.config.syncWithServer) {
const response = await this.apiCall('reset', 'POST');
if (!response.success) {
this.showError('Fehler beim Zurücksetzen des Timers');
return false;
}
}
this.stop();
this.state.remaining = this.state.total;
this.updateDisplay();
console.log(`Timer '${this.config.name}' zurückgesetzt`);
return true;
} catch (error) {
console.error('Fehler beim Zurücksetzen des Timers:', error);
this.showError('Timer konnte nicht zurückgesetzt werden');
return false;
}
}
/**
* Verlängert den Timer
*/
async extend(seconds) {
try {
// Server-API aufrufen wenn aktiviert
if (this.config.syncWithServer) {
const response = await this.apiCall('extend', 'POST', { seconds });
if (!response.success) {
this.showError('Fehler beim Verlängern des Timers');
return false;
}
}
this.state.remaining += seconds;
this.state.total += seconds;
this.state.warningShown = false;
this.hideWarning();
this.updateDisplay();
// Toast-Benachrichtigung
this.showToast(`Timer um ${Math.floor(seconds / 60)} Minuten verlängert`, 'success');
console.log(`Timer '${this.config.name}' um ${seconds} Sekunden verlängert`);
return true;
} catch (error) {
console.error('Fehler beim Verlängern des Timers:', error);
this.showError('Timer konnte nicht verlängert werden');
return false;
}
}
/**
* Startet den Countdown-Mechanismus
*/
startCountdown() {
this.stopCountdown(); // Sicherstellen, dass kein anderer Countdown läuft
this.intervals.countdown = setInterval(() => {
this.tick();
}, this.config.updateInterval);
}
/**
* Stoppt den Countdown-Mechanismus
*/
stopCountdown() {
if (this.intervals.countdown) {
clearInterval(this.intervals.countdown);
this.intervals.countdown = null;
}
}
/**
* Timer-Tick (wird jede Sekunde aufgerufen)
*/
tick() {
if (this.state.status !== 'running') {
return;
}
this.state.remaining = Math.max(0, this.state.remaining - 1);
this.updateDisplay();
// Callback aufrufen
if (this.config.onTick) {
this.config.onTick(this.state.remaining, this.state.total);
}
// Warnung prüfen
if (!this.state.warningShown && this.state.remaining <= this.config.warningThreshold && this.state.remaining > 0) {
this.showWarning();
}
// Timer abgelaufen prüfen
if (this.state.remaining <= 0) {
this.handleExpired();
}
}
/**
* Behandelt Timer-Ablauf
*/
async handleExpired() {
console.warn(`Timer '${this.config.name}' ist abgelaufen`);
this.state.status = 'expired';
this.stopCountdown();
this.updateDisplay();
this.updateStatusIndicator();
// Callback aufrufen
if (this.config.onExpired) {
this.config.onExpired();
}
// Force-Quit ausführen wenn aktiviert
if (this.config.forceQuitEnabled) {
await this.executeForceQuit();
}
}
/**
* Führt Force-Quit-Aktion aus
*/
async executeForceQuit() {
try {
console.warn(`Force-Quit für Timer '${this.config.name}' wird ausgeführt...`);
// Callback aufrufen
if (this.config.onForceQuit) {
const shouldContinue = this.config.onForceQuit(this.config.forceQuitAction);
if (!shouldContinue) {
return;
}
}
// Server-API aufrufen
if (this.config.syncWithServer) {
const response = await this.apiCall('force-quit', 'POST');
if (!response.success) {
console.error('Force-Quit-API-Aufruf fehlgeschlagen');
}
}
// Lokale Force-Quit-Aktionen
switch (this.config.forceQuitAction) {
case 'logout':
this.performLogout();
break;
case 'redirect':
this.performRedirect();
break;
case 'refresh':
this.performRefresh();
break;
case 'custom':
this.performCustomAction();
break;
default:
console.warn(`Unbekannte Force-Quit-Aktion: ${this.config.forceQuitAction}`);
}
} catch (error) {
console.error('Fehler bei Force-Quit-Ausführung:', error);
}
}
/**
* Führt Logout-Aktion aus
*/
performLogout() {
this.showModal('Session abgelaufen', 'Sie werden automatisch abgemeldet...', 'warning');
setTimeout(() => {
window.location.href = '/auth/logout';
}, 2000);
}
/**
* Führt Redirect-Aktion aus
*/
performRedirect() {
const redirectUrl = this.config.redirectUrl || '/';
this.showModal('Umleitung', 'Sie werden weitergeleitet...', 'info');
setTimeout(() => {
window.location.href = redirectUrl;
}, 2000);
}
/**
* Führt Refresh-Aktion aus
*/
performRefresh() {
this.showModal('Seite wird aktualisiert', 'Die Seite wird automatisch neu geladen...', 'info');
setTimeout(() => {
window.location.reload();
}, 2000);
}
/**
* Führt benutzerdefinierte Aktion aus
*/
performCustomAction() {
if (this.config.customEndpoint) {
fetch(this.config.customEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': this.getCSRFToken()
},
body: JSON.stringify({
timer_name: this.config.name,
action: 'force_quit'
})
}).catch(error => {
console.error('Custom-Action-Request fehlgeschlagen:', error);
});
}
}
/**
* Zeigt Warnung an
*/
showWarning() {
if (!this.config.showWarning || this.state.warningShown) {
return;
}
this.state.warningShown = true;
if (this.elements.warningBox) {
this.elements.warningBox.style.display = 'block';
this.elements.warningBox.classList.add('pulse');
}
// Callback aufrufen
if (this.config.onWarning) {
this.config.onWarning(this.state.remaining);
}
// Browser-Benachrichtigung
this.showNotification('Timer-Warnung', this.config.warningMessage);
console.warn(`Timer-Warnung für '${this.config.name}': ${this.state.remaining} Sekunden verbleiben`);
}
/**
* Versteckt Warnung
*/
hideWarning() {
this.state.warningShown = false;
if (this.elements.warningBox) {
this.elements.warningBox.style.display = 'none';
this.elements.warningBox.classList.remove('pulse');
}
}
/**
* Aktualisiert die Anzeige
*/
updateDisplay() {
// Zeit-Text aktualisieren
if (this.elements.timeText) {
this.elements.timeText.textContent = this.formatTime(this.state.remaining);
}
// Fortschrittsbalken aktualisieren
if (this.config.showProgress && this.elements.progressBar) {
const progress = ((this.state.total - this.state.remaining) / this.state.total) * 100;
this.elements.progressBar.style.width = `${progress}%`;
if (this.elements.progressText) {
this.elements.progressText.textContent = `${Math.round(progress)}% abgelaufen`;
}
}
// Theme basierend auf verbleibender Zeit anpassen
this.updateTheme();
}
/**
* Aktualisiert das Farbthema basierend auf verbleibender Zeit
*/
updateTheme() {
if (!this.elements.wrapper) return;
const progress = (this.state.total - this.state.remaining) / this.state.total;
// Theme-Klassen entfernen
this.elements.wrapper.classList.remove('theme-primary', 'theme-warning', 'theme-danger');
// Neues Theme basierend auf Fortschritt
if (progress < 0.7) {
this.elements.wrapper.classList.add('theme-primary');
} else if (progress < 0.9) {
this.elements.wrapper.classList.add('theme-warning');
} else {
this.elements.wrapper.classList.add('theme-danger');
}
}
/**
* Aktualisiert die Steuerungsbuttons
*/
updateControls() {
if (!this.config.showControls) return;
const isRunning = this.state.status === 'running';
const isPaused = this.state.status === 'paused';
const isStopped = this.state.status === 'stopped';
if (this.elements.startBtn) {
this.elements.startBtn.style.display = (isStopped || isPaused) ? 'inline-flex' : 'none';
}
if (this.elements.pauseBtn) {
this.elements.pauseBtn.style.display = isRunning ? 'inline-flex' : 'none';
}
if (this.elements.stopBtn) {
this.elements.stopBtn.disabled = isStopped;
}
if (this.elements.resetBtn) {
this.elements.resetBtn.disabled = isRunning;
}
if (this.elements.extendBtn) {
this.elements.extendBtn.disabled = this.state.status === 'expired';
}
}
/**
* Aktualisiert den Status-Indikator
*/
updateStatusIndicator() {
if (!this.elements.statusIndicator) return;
const statusText = this.elements.statusIndicator.querySelector('.status-text');
const statusIcon = this.elements.statusIndicator.querySelector('i');
if (statusText && statusIcon) {
switch (this.state.status) {
case 'running':
statusText.textContent = 'Läuft';
statusIcon.className = 'fas fa-circle text-success';
break;
case 'paused':
statusText.textContent = 'Pausiert';
statusIcon.className = 'fas fa-circle text-warning';
break;
case 'expired':
statusText.textContent = 'Abgelaufen';
statusIcon.className = 'fas fa-circle text-danger';
break;
default:
statusText.textContent = 'Gestoppt';
statusIcon.className = 'fas fa-circle text-secondary';
}
}
}
/**
* Formatiert Zeit in MM:SS Format
*/
formatTime(seconds) {
const minutes = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
}
/**
* Synchronisiert mit Server
*/
async syncWithServer() {
try {
const response = await this.apiCall('status', 'GET');
if (response.success && response.data) {
const serverState = response.data;
// Lokalen Zustand mit Server-Zustand synchronisieren
this.state.remaining = serverState.remaining_seconds || this.state.remaining;
this.state.total = serverState.duration_seconds || this.state.total;
this.state.status = serverState.status || this.state.status;
this.updateDisplay();
this.updateControls();
this.updateStatusIndicator();
this.state.lastServerSync = new Date();
}
} catch (error) {
console.error('Server-Synchronisation fehlgeschlagen:', error);
}
}
/**
* Startet regelmäßige Server-Synchronisation
*/
startServerSync() {
if (this.intervals.serverSync) {
clearInterval(this.intervals.serverSync);
}
this.intervals.serverSync = setInterval(() => {
this.syncWithServer();
}, 30000); // Alle 30 Sekunden synchronisieren
}
/**
* API-Aufruf an Server
*/
async apiCall(action, method = 'GET', data = null) {
const url = `${this.config.apiBase}/${this.config.name}/${action}`;
const options = {
method,
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': this.getCSRFToken()
}
};
if (data && (method === 'POST' || method === 'PUT')) {
options.body = JSON.stringify(data);
}
const response = await fetch(url, options);
return await response.json();
}
/**
* Holt CSRF-Token
*/
getCSRFToken() {
const token = document.querySelector('meta[name="csrf-token"]');
return token ? token.getAttribute('content') : '';
}
/**
* Zeigt Browser-Benachrichtigung
*/
showNotification(title, message) {
if ('Notification' in window && Notification.permission === 'granted') {
new Notification(title, {
body: message,
icon: '/static/icons/timer-icon.png'
});
}
}
/**
* Zeigt Toast-Nachricht
*/
showToast(message, type = 'info') {
// Implementation für Toast-Nachrichten
console.log(`Toast [${type}]: ${message}`);
}
/**
* Zeigt Fehler-Modal
*/
showError(message) {
this.showModal('Fehler', message, 'danger');
}
/**
* Zeigt Modal-Dialog
*/
showModal(title, message, type = 'info') {
// Implementation für Modal-Dialoge
console.log(`Modal [${type}] ${title}: ${message}`);
}
/**
* Behandelt Tastatur-Shortcuts
*/
handleKeyboardShortcuts(e) {
if (e.ctrlKey || e.metaKey) {
switch (e.key) {
case ' ': // Strg + Leertaste
e.preventDefault();
if (this.state.status === 'running') {
this.pause();
} else {
this.start();
}
break;
case 'r': // Strg + R
e.preventDefault();
this.reset();
break;
case 's': // Strg + S
e.preventDefault();
this.stop();
break;
}
}
}
/**
* Behandelt Visibility-Änderungen
*/
handleVisibilityChange() {
if (document.hidden) {
// Tab wurde versteckt
this.config._wasRunning = this.state.status === 'running';
} else {
// Tab wurde wieder sichtbar
if (this.config.syncWithServer) {
this.syncWithServer();
}
}
}
/**
* Behandelt Before-Unload-Event
*/
handleBeforeUnload(e) {
if (this.state.status === 'running' && this.state.remaining > 0) {
e.preventDefault();
e.returnValue = 'Timer läuft noch. Möchten Sie die Seite wirklich verlassen?';
return e.returnValue;
}
}
/**
* Zerstört den Timer und räumt auf
*/
destroy() {
this.stopCountdown();
if (this.intervals.serverSync) {
clearInterval(this.intervals.serverSync);
}
// DOM-Elemente entfernen
if (this.elements.wrapper) {
this.elements.wrapper.remove();
}
// Event-Listener entfernen
this.listeners.forEach((listener, element) => {
element.removeEventListener(listener.event, listener.handler);
});
console.log(`Timer '${this.config.name}' zerstört`);
}
}
// CSS-Styles für den Timer (wird dynamisch eingefügt)
const timerStyles = `
`;
// Styles automatisch einfügen wenn noch nicht vorhanden
if (!document.getElementById('countdown-timer-styles')) {
document.head.insertAdjacentHTML('beforeend', timerStyles);
}
// Globale Funktionen für einfache Nutzung
window.CountdownTimer = CountdownTimer;
// Timer-Manager für mehrere Timer-Instanzen
window.TimerManager = {
timers: new Map(),
create(name, options) {
if (this.timers.has(name)) {
console.warn(`Timer '${name}' existiert bereits`);
return this.timers.get(name);
}
const timer = new CountdownTimer({
...options,
name: name
});
this.timers.set(name, timer);
return timer;
},
get(name) {
return this.timers.get(name);
},
destroy(name) {
const timer = this.timers.get(name);
if (timer) {
timer.destroy();
this.timers.delete(name);
}
},
destroyAll() {
this.timers.forEach(timer => timer.destroy());
this.timers.clear();
}
};