/** * Timer App - Frontend * * Die Timer selbst leben auf dem Server. Der Browser zaehlt zwischen zwei * Server-Abgleichen nur lokal weiter, damit die Anzeige fluessig laeuft. * Gerechnet wird lokal ausschliesslich fuer die Anzeige - der verbindliche * Betrag entsteht beim Beenden auf dem Server. */ const state = { timers: [], // {id, customerName, isRunning, baseMs, syncedAt} rate: { rateAmount: 10, rateInterval: 10 }, tickHandle: null, syncHandle: null, history: { offset: 0, limit: 50, reachedEnd: false } }; const SYNC_INTERVAL_MS = 15000; // Abgleich mit dem Server const TICK_INTERVAL_MS = 250; // Anzeige-Aktualisierung let oidcConfigured = false; // ob Authentik erreichbar ist let loggedInUser = null; // {username, displayName, email} nach SSO-Login // --------------------------------------------------------------------------- // Hilfsfunktionen // --------------------------------------------------------------------------- function formatTime(ms) { const total = Math.floor(Math.max(0, ms) / 1000); const p = n => String(n).padStart(2, '0'); return `${p(Math.floor(total / 3600))}:${p(Math.floor((total % 3600) / 60))}:${p(total % 60)}`; } function formatEuro(value) { return '€' + Number(value || 0).toLocaleString('de-DE', { minimumFractionDigits: Number.isInteger(Number(value)) ? 0 : 2, maximumFractionDigits: 2 }); } function calcAmount(ms) { if (!ms || ms <= 0) return 0; const minutes = Math.ceil(ms / 60000); const units = Math.ceil(minutes / state.rate.rateInterval); return Math.round(units * state.rate.rateAmount * 100) / 100; } /** Aktuelle Zeit eines Timers - lokal fortgeschrieben seit dem letzten Sync. */ function currentMs(timer) { if (!timer.isRunning) return timer.baseMs; return timer.baseMs + (Date.now() - timer.syncedAt); } function escapeHtml(str) { const div = document.createElement('div'); div.textContent = str == null ? '' : String(str); return div.innerHTML; } let toastTimeout = null; function toast(message, kind = 'info') { const el = document.getElementById('toast'); el.textContent = message; el.className = 'toast show ' + kind; clearTimeout(toastTimeout); toastTimeout = setTimeout(() => { el.className = 'toast'; }, 4000); } async function api(url, options = {}) { const response = await fetch(url, { headers: { 'Content-Type': 'application/json' }, ...options }); if (response.status === 401) { stopLoops(); showLogin(); throw new Error('Nicht angemeldet'); } if (!response.ok) { let msg = 'Serverfehler'; try { msg = (await response.json()).error || msg; } catch (e) { /* egal */ } throw new Error(msg); } return response.status === 204 ? null : response.json(); } // --------------------------------------------------------------------------- // Ansichten // --------------------------------------------------------------------------- function switchView(id) { document.querySelectorAll('.view').forEach(v => v.classList.remove('active')); document.getElementById(id).classList.add('active'); } function showLogin() { document.getElementById('header').style.display = 'none'; switchView('loginView'); applyLoginMode(); } // Anmeldung ausschliesslich ueber Authentik. Ist Authentik nicht erreichbar, // wird der Hinweis unter dem Button eingeblendet. function applyLoginMode() { const err = document.getElementById('loginError'); if (err) err.classList.toggle('show', !oidcConfigured); } function showTimer() { switchView('timerView'); } async function showHistory() { switchView('historyView'); state.history.offset = 0; state.history.reachedEnd = false; await loadHistory(true); } async function showSettings() { switchView('settingsView'); await loadSettings(); } // --------------------------------------------------------------------------- // Anmeldung // --------------------------------------------------------------------------- async function checkAuth() { try { const data = await api('/api/auth/status'); oidcConfigured = !!data.oidcConfigured; loggedInUser = data.user || null; if (data.authenticated) { await enterApp(); } else { showLogin(); } } catch (e) { showLogin(); } } async function enterApp() { document.getElementById('header').style.display = 'flex'; renderCurrentUser(); showTimer(); await syncTimers(); startLoops(); } // Zeigt den angemeldeten Authentik-Nutzer oben rechts im Header. function renderCurrentUser() { const el = document.getElementById('currentUser'); if (!el) return; const u = loggedInUser; el.textContent = u ? (u.displayName || u.username || '') : ''; el.title = u && u.email ? u.email : 'Angemeldet'; } async function logout() { const running = state.timers.filter(t => t.isRunning).length; if (running > 0 && !confirm( `${running} Timer ${running === 1 ? 'läuft' : 'laufen'} noch.\n\n` + `Sie laufen serverseitig weiter und sind nach dem nächsten Anmelden wieder da. Trotzdem abmelden?` )) return; stopLoops(); state.timers = []; // GET-Navigation: beendet lokale Sitzung und – bei Authentik-Login – auch die SSO-Sitzung window.location.href = '/auth/logout'; } // --------------------------------------------------------------------------- // Timer-Synchronisation // --------------------------------------------------------------------------- async function syncTimers() { const data = await api('/api/timers'); state.rate = data.rate; const now = Date.now(); state.timers = data.timers.map(t => ({ id: t.id, customerName: t.customerName, isRunning: t.isRunning, baseMs: t.elapsedMs, syncedAt: now, createdAt: t.createdAt })); renderTimers(); } function startLoops() { stopLoops(); state.tickHandle = setInterval(tick, TICK_INTERVAL_MS); state.syncHandle = setInterval(() => { syncTimers().catch(() => {}); }, SYNC_INTERVAL_MS); } function stopLoops() { clearInterval(state.tickHandle); clearInterval(state.syncHandle); state.tickHandle = null; state.syncHandle = null; } // Nach Ruhezustand/Tabwechsel sofort abgleichen statt bis zum naechsten Sync warten document.addEventListener('visibilitychange', () => { if (!document.hidden && state.syncHandle) syncTimers().catch(() => {}); }); /** Aktualisiert nur die Zahlen - baut das DOM nicht neu (sonst springt der Fokus). */ function tick() { for (const timer of state.timers) { const ms = currentMs(timer); const timeEl = document.getElementById(`time-${timer.id}`); const amountEl = document.getElementById(`amount-${timer.id}`); if (timeEl) timeEl.textContent = formatTime(ms); if (amountEl) amountEl.textContent = formatEuro(calcAmount(ms)); } } // --------------------------------------------------------------------------- // Timer-Darstellung // --------------------------------------------------------------------------- function renderTimers() { const list = document.getElementById('timerList'); const empty = document.getElementById('timerEmpty'); document.getElementById('rateHint').textContent = `Tarif: ${formatEuro(state.rate.rateAmount)} pro angefangene ${state.rate.rateInterval} Minuten`; if (state.timers.length === 0) { list.innerHTML = ''; empty.style.display = 'block'; return; } empty.style.display = 'none'; list.innerHTML = state.timers.map(timer => { const ms = currentMs(timer); const since = new Date(timer.createdAt).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' }); return `
${timer.isRunning ? '● läuft' : '❚❚ pausiert'}
${formatTime(ms)}
${formatEuro(calcAmount(ms))}
gestartet um ${since} Uhr
${timer.isRunning ? `` : ``}
`; }).join(''); } // --------------------------------------------------------------------------- // Timer-Aktionen // --------------------------------------------------------------------------- async function addTimer() { const field = document.getElementById('newTimerName'); const name = field.value.trim(); try { await api('/api/timers', { method: 'POST', body: JSON.stringify({ customerName: name }) }); field.value = ''; await syncTimers(); toast(name ? `Timer für „${name}" gestartet` : 'Timer gestartet', 'success'); } catch (e) { toast('Timer konnte nicht gestartet werden: ' + e.message, 'error'); } } async function renameTimer(id, value) { try { await api(`/api/timers/${id}`, { method: 'PATCH', body: JSON.stringify({ customerName: value.trim() }) }); const timer = state.timers.find(t => t.id === id); if (timer) timer.customerName = value.trim(); } catch (e) { toast('Name konnte nicht gespeichert werden: ' + e.message, 'error'); } } async function pauseTimer(id) { try { await api(`/api/timers/${id}/pause`, { method: 'POST' }); await syncTimers(); } catch (e) { toast('Pause fehlgeschlagen: ' + e.message, 'error'); } } async function resumeTimer(id) { try { await api(`/api/timers/${id}/resume`, { method: 'POST' }); await syncTimers(); } catch (e) { toast('Fortsetzen fehlgeschlagen: ' + e.message, 'error'); } } async function stopTimer(id) { const timer = state.timers.find(t => t.id === id); if (!timer) return; const ms = currentMs(timer); const label = timer.customerName ? `„${timer.customerName}"` : 'Timer'; if (!confirm(`${label} beenden?\n\nZeit: ${formatTime(ms)}\nBetrag: ${formatEuro(calcAmount(ms))}\n\nDer Eintrag wird in der Historie gespeichert.`)) return; try { const result = await api(`/api/timers/${id}/stop`, { method: 'POST' }); await syncTimers(); if (result.saved) { toast(`Gespeichert: ${formatTime(result.duration)} – ${formatEuro(result.amount)}`, 'success'); } else { toast(result.reason || 'Nichts gespeichert.', 'info'); } } catch (e) { toast('Beenden fehlgeschlagen: ' + e.message, 'error'); } } async function discardTimer(id) { const timer = state.timers.find(t => t.id === id); if (!timer) return; const label = timer.customerName ? `„${timer.customerName}"` : 'Timer'; if (!confirm(`${label} verwerfen?\n\nDie erfasste Zeit (${formatTime(currentMs(timer))}) wird NICHT gespeichert.`)) return; try { await api(`/api/timers/${id}`, { method: 'DELETE' }); await syncTimers(); toast('Timer verworfen - nichts gespeichert.', 'info'); } catch (e) { toast('Verwerfen fehlgeschlagen: ' + e.message, 'error'); } } document.getElementById('newTimerForm').addEventListener('submit', (e) => { e.preventDefault(); addTimer(); }); // --------------------------------------------------------------------------- // Historie // --------------------------------------------------------------------------- function historyQuery() { const params = new URLSearchParams(); const from = document.getElementById('filterFrom').value; const to = document.getElementById('filterTo').value; const q = document.getElementById('filterName').value.trim(); if (from) params.set('from', from); if (to) params.set('to', to); if (q) params.set('q', q); return params; } async function loadHistory(reset = false) { if (reset) { state.history.offset = 0; state.history.reachedEnd = false; } const params = historyQuery(); params.set('limit', state.history.limit); params.set('offset', state.history.offset); try { const data = await api('/api/sessions?' + params.toString()); renderHistory(data, reset); } catch (e) { document.getElementById('historyList').innerHTML = `
⚠️

Fehler beim Laden: ${escapeHtml(e.message)}

`; } } function renderHistory(data, reset) { const listEl = document.getElementById('historyList'); const statsEl = document.getElementById('historyStats'); const moreBtn = document.getElementById('historyMore'); statsEl.innerHTML = `

Einträge

${data.totals.count}

Gesamtzeit

${formatTime(data.totals.duration)}

Gesamtbetrag

${formatEuro(data.totals.amount)}

`; if (data.totals.count === 0) { listEl.innerHTML = '
📭

Keine Einträge für diesen Zeitraum

'; moreBtn.style.display = 'none'; return; } const rows = data.sessions.map(s => { const date = new Date(s.created_at * 1000).toLocaleString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' }); const name = s.customer_name ? `${escapeHtml(s.customer_name)}` : 'ohne Namen'; return `
${name}
${date} Uhr
${formatTime(s.duration)}
${formatEuro(s.amount)}
`; }).join(''); if (reset) { listEl.innerHTML = rows; } else { listEl.insertAdjacentHTML('beforeend', rows); } const shown = state.history.offset + data.sessions.length; state.history.reachedEnd = shown >= data.totals.count; moreBtn.style.display = state.history.reachedEnd ? 'none' : 'inline-block'; moreBtn.textContent = `Weitere laden (${shown} von ${data.totals.count})`; } async function loadMoreHistory() { state.history.offset += state.history.limit; await loadHistory(false); } async function deleteSession(id) { if (!confirm('Diesen Eintrag endgültig aus der Historie löschen?')) return; try { await api(`/api/sessions/${id}`, { method: 'DELETE' }); await loadHistory(true); toast('Eintrag gelöscht', 'info'); } catch (e) { toast('Löschen fehlgeschlagen: ' + e.message, 'error'); } } function exportCsv() { const params = historyQuery(); window.location.href = '/api/sessions/export.csv?' + params.toString(); } function resetFilter() { document.getElementById('filterFrom').value = ''; document.getElementById('filterTo').value = ''; document.getElementById('filterName').value = ''; loadHistory(true); } function filterToday() { const today = new Date().toISOString().slice(0, 10); document.getElementById('filterFrom').value = today; document.getElementById('filterTo').value = today; loadHistory(true); } function filterThisMonth() { const now = new Date(); const first = new Date(now.getFullYear(), now.getMonth(), 1); const pad = n => String(n).padStart(2, '0'); document.getElementById('filterFrom').value = `${first.getFullYear()}-${pad(first.getMonth() + 1)}-01`; document.getElementById('filterTo').value = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`; loadHistory(true); } ['filterFrom', 'filterTo'].forEach(id => document.getElementById(id).addEventListener('change', () => loadHistory(true))); document.getElementById('filterName').addEventListener('input', (() => { let handle = null; return () => { clearTimeout(handle); handle = setTimeout(() => loadHistory(true), 350); }; })()); // --------------------------------------------------------------------------- // Einstellungen (serverseitig - gelten fuer alle Geraete) // --------------------------------------------------------------------------- async function loadSettings() { try { const rate = await api('/api/settings'); state.rate = rate; document.getElementById('rateAmount').value = rate.rateAmount; document.getElementById('rateInterval').value = rate.rateInterval; updateSettingsPreview(); } catch (e) { toast('Einstellungen konnten nicht geladen werden: ' + e.message, 'error'); } } async function saveSettings() { const rateAmount = parseFloat(document.getElementById('rateAmount').value); const rateInterval = parseInt(document.getElementById('rateInterval').value); try { const rate = await api('/api/settings', { method: 'PUT', body: JSON.stringify({ rateAmount, rateInterval }) }); state.rate = rate; renderTimers(); toast('Einstellungen gespeichert - gilt für alle Geräte', 'success'); showTimer(); } catch (e) { toast('Speichern fehlgeschlagen: ' + e.message, 'error'); } } async function resetSettings() { if (!confirm('Tarif auf Standard zurücksetzen (€10 pro 10 Minuten)?')) return; document.getElementById('rateAmount').value = 10; document.getElementById('rateInterval').value = 10; await saveSettings(); } function updateSettingsPreview() { const amount = parseFloat(document.getElementById('rateAmount').value) || 0; const interval = parseInt(document.getElementById('rateInterval').value) || 1; document.getElementById('settingsPreview').textContent = `${formatEuro(amount)} pro angefangene ${interval} Minuten`; const examples = [Math.max(1, interval - 5), interval + 5, interval * 2, 60] .filter((v, i, a) => a.indexOf(v) === i) .map(mins => `${mins} Min = ${formatEuro(Math.ceil(mins / interval) * amount)}`); document.getElementById('previewExamples').innerHTML = `Beispiele:
${examples.join(' | ')}`; } document.getElementById('settingsForm').addEventListener('submit', (e) => { e.preventDefault(); saveSettings(); }); document.getElementById('rateAmount').addEventListener('input', updateSettingsPreview); document.getElementById('rateInterval').addEventListener('input', updateSettingsPreview); // --------------------------------------------------------------------------- checkAuth();