Files
timer-app/frontend/app.js
T
adminandClaude Opus 5 a7e77793a8 Timer-App unter Versionsverwaltung stellen
Stand der laufenden Anwendung von 192.168.1.5:/opt/timer-app (systemd-Dienst
timer-app, Port 3003). Zeiterfassung mit Kostenberechnung: Node/Express,
SQLite, Anmeldung ausschliesslich ueber Authentik (OIDC, PKCE, RP-Logout) -
der frueher vorhandene Passwort-Login ist entfernt.

Nicht im Repo, bewusst:
- backend/.env und die .env-Sicherung: enthalten Session-Secret und die
  Authentik-Zugangsdaten. Vorlage ist backend/.env.example.
- data/: die Datenbank mit den echten Zeiterfassungen.
- node_modules/.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-04 17:47:07 +02:00

565 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 `
<div class="timer-card ${timer.isRunning ? 'running' : 'paused'}" data-id="${timer.id}">
<div class="timer-card-head">
<input class="timer-name" type="text" value="${escapeHtml(timer.customerName)}"
placeholder="Name / Platz (optional)" maxlength="80"
onchange="renameTimer(${timer.id}, this.value)">
<span class="timer-badge">${timer.isRunning ? '● läuft' : '❚❚ pausiert'}</span>
</div>
<div class="timer-card-time" id="time-${timer.id}">${formatTime(ms)}</div>
<div class="timer-card-amount" id="amount-${timer.id}">${formatEuro(calcAmount(ms))}</div>
<div class="timer-card-since">gestartet um ${since} Uhr</div>
<div class="timer-card-actions">
${timer.isRunning
? `<button class="btn btn-stop btn-sm" onclick="pauseTimer(${timer.id})">❚❚ Pause</button>`
: `<button class="btn btn-start btn-sm" onclick="resumeTimer(${timer.id})">▶ Weiter</button>`}
<button class="btn btn-sm" onclick="stopTimer(${timer.id})">✓ Beenden</button>
<button class="btn btn-danger btn-sm" onclick="discardTimer(${timer.id})">✕ Verwerfen</button>
</div>
</div>`;
}).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 =
`<div class="empty-state"><div class="empty-state-icon">⚠️</div><p>Fehler beim Laden: ${escapeHtml(e.message)}</p></div>`;
}
}
function renderHistory(data, reset) {
const listEl = document.getElementById('historyList');
const statsEl = document.getElementById('historyStats');
const moreBtn = document.getElementById('historyMore');
statsEl.innerHTML = `
<div class="stat-card"><h3>Einträge</h3><p>${data.totals.count}</p></div>
<div class="stat-card"><h3>Gesamtzeit</h3><p>${formatTime(data.totals.duration)}</p></div>
<div class="stat-card"><h3>Gesamtbetrag</h3><p>${formatEuro(data.totals.amount)}</p></div>
`;
if (data.totals.count === 0) {
listEl.innerHTML = '<div class="empty-state"><div class="empty-state-icon">📭</div><p>Keine Einträge für diesen Zeitraum</p></div>';
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
? `<span class="history-item-name">${escapeHtml(s.customer_name)}</span>`
: '<span class="history-item-name muted">ohne Namen</span>';
return `
<div class="history-item">
<div class="history-item-info">
${name}
<div class="history-item-date">${date} Uhr</div>
<div class="history-item-duration">${formatTime(s.duration)}</div>
</div>
<div class="history-item-right">
<div class="history-item-amount">${formatEuro(s.amount)}</div>
<button class="btn btn-danger btn-sm" onclick="deleteSession(${s.id})" title="Eintrag löschen">🗑</button>
</div>
</div>`;
}).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 =
`<strong>Beispiele:</strong><br>${examples.join(' | ')}`;
}
document.getElementById('settingsForm').addEventListener('submit', (e) => {
e.preventDefault();
saveSettings();
});
document.getElementById('rateAmount').addEventListener('input', updateSettingsPreview);
document.getElementById('rateInterval').addEventListener('input', updateSettingsPreview);
// ---------------------------------------------------------------------------
checkAuth();