// assets/scripts/stopwatch.js import { esc, createTranslator } from './utils.js'; import { SearchableSelect } from './searchable-select.js'; const LS_KEY = 'tt_timer_state'; const LAST_PROJECT_KEY = 'tt_last_project_id'; const LAST_SERVICE_KEY = 'tt_last_service_id'; const t = createTranslator('STOPWATCH'); const STOPWATCH_SVG = ``; function buildEntryLabel(entry) { if (!entry) return ''; let label = `${entry.projectName} (${entry.clientName})`; if (entry.serviceName) label += ` – ${entry.serviceName}`; return label; } async function apiCall(url, options = {}) { const res = await fetch(url, options); const ct = res.headers.get('content-type') || ''; if (!ct.includes('application/json')) { console.error(`[Stopwatch] ${url} returned non-JSON:`, res.status, ct); return { ok: false, status: res.status, data: null }; } const data = await res.json(); return { ok: res.ok, status: res.status, data }; } // SearchableSelect ist jetzt in searchable-select.js extrahiert und wird oben importiert // ── StopwatchManager ────────────────────────────────────────────────────────── class StopwatchManager { constructor() { this.contexts = []; const desktopCtx = this.buildContext( 'stopwatch-toggle', 'stopwatch-popover', 'stopwatch-display', 'stopwatch-start', 'stopwatch-note', 'stopwatch-header-time', 'stopwatch-project', 'stopwatch-service' ); if (desktopCtx) this.contexts.push(desktopCtx); const hamburgerCtx = this.buildContext( 'hamburger-stopwatch', 'hamburger-stopwatch-popover', 'hamburger-stopwatch-display', 'hamburger-sw-start', 'hamburger-sw-note', 'hamburger-stopwatch-time', 'hamburger-sw-project', 'hamburger-sw-service' ); if (hamburgerCtx) this.contexts.push(hamburgerCtx); this.running = false; this.entryId = null; this.startedAt = null; this.baseDuration = 0; this.entryLabel = ''; this.tickInterval = null; this.originalTitle = document.title; this.cachedOptions = null; this.busy = false; this.activePopoverCtx = null; if (!this.contexts.length) return; this.init(); } buildContext(toggleId, popoverId, displayId, startBtnId, noteId, timeId, projectId, serviceId) { const toggle = document.getElementById(toggleId); if (!toggle) return null; const ctx = { toggle, popover: document.getElementById(popoverId), display: document.getElementById(displayId), startBtn: document.getElementById(startBtnId), noteField: document.getElementById(noteId), headerTime: document.getElementById(timeId), projectSelect: null, serviceSelect: null, runningClass: toggleId === 'hamburger-stopwatch' ? 'hamburger-nav__stopwatch--running' : 'main-nav__stopwatch--running', }; const projEl = document.getElementById(projectId); const svcEl = document.getElementById(serviceId); const ssOpts = { searchPlaceholder: t('search') }; if (projEl) ctx.projectSelect = new SearchableSelect(projEl, ssOpts); if (svcEl) ctx.serviceSelect = new SearchableSelect(svcEl, ssOpts); return ctx; } async init() { for (const ctx of this.contexts) { ctx.toggle.addEventListener('click', (e) => { e.stopPropagation(); this.handleToggleClick(ctx); }); ctx.startBtn?.addEventListener('click', () => this.startNew(ctx)); } document.addEventListener('click', (e) => { if (!this.activePopoverCtx) return; const ctx = this.activePopoverCtx; if (ctx.popover && !ctx.popover.hidden && !ctx.popover.contains(e.target) && !ctx.toggle.contains(e.target)) { this.closePopover(ctx); } }); this.loadFromLocalStorage(); await this.loadStatus(); } // ── Toggle ────────────────────────────────────────────────────────────────── handleToggleClick(ctx) { if (this.running) { this.stop(); } else { this.togglePopover(ctx); } } async togglePopover(ctx) { if (!ctx.popover) return; if (!ctx.popover.hidden) { this.closePopover(ctx); return; } if (this.activePopoverCtx && this.activePopoverCtx !== ctx) { this.closePopover(this.activePopoverCtx); } await this.loadOptions(); this.populateSelects(ctx); ctx.popover.hidden = false; ctx.toggle.setAttribute('aria-expanded', 'true'); this.activePopoverCtx = ctx; ctx.projectSelect?.focus(); } closePopover(ctx) { if (!ctx?.popover) return; ctx.popover.hidden = true; ctx.toggle.setAttribute('aria-expanded', 'false'); ctx.projectSelect?.close(); ctx.serviceSelect?.close(); if (this.activePopoverCtx === ctx) this.activePopoverCtx = null; } // ── Select-Builder ────────────────────────────────────────────────────────── populateSelects(ctx) { if (!this.cachedOptions) return; const lastProject = localStorage.getItem(LAST_PROJECT_KEY); const lastService = localStorage.getItem(LAST_SERVICE_KEY); if (ctx.projectSelect) { const groups = {}; (this.cachedOptions.projects ?? []).forEach(p => { if (!groups[p.clientName]) groups[p.clientName] = []; groups[p.clientName].push(p); }); ctx.projectSelect.setGroups( Object.entries(groups).map(([label, items]) => ({ label, items })) ); if (lastProject) ctx.projectSelect.setValue(lastProject); } if (ctx.serviceSelect) { const billable = (this.cachedOptions.services ?? []).filter(s => s.billable); const notBillable = (this.cachedOptions.services ?? []).filter(s => !s.billable); const groups = []; if (billable.length) groups.push({ label: t('billable'), items: billable }); if (notBillable.length) groups.push({ label: t('notBillable'), items: notBillable }); ctx.serviceSelect.setGroups(groups); if (lastService) ctx.serviceSelect.setValue(lastService); } } // ── API ───────────────────────────────────────────────────────────────────── async loadStatus() { try { const { ok, data } = await apiCall('/api/timer/status'); if (!ok || !data) { if (this.running) { this.running = false; this.clearLocalStorage(); this.applyStoppedState(); } return; } if (data.running && data.startedAt) { this.running = true; this.entryId = data.entry?.id ?? null; this.startedAt = new Date(data.startedAt).getTime(); this.baseDuration = data.entry?.duration ?? 0; this.entryLabel = buildEntryLabel(data.entry); this.saveToLocalStorage(); this.applyRunningState(); } else if (this.running) { this.running = false; this.clearLocalStorage(); this.applyStoppedState(); } } catch { if (this.running) { this.running = false; this.clearLocalStorage(); this.applyStoppedState(); } } } async loadOptions() { if (this.cachedOptions) return; try { const { ok, data } = await apiCall('/api/timer/options'); if (ok && data) this.cachedOptions = data; } catch { /* silent */ } } async startNew(ctx) { if (this.busy) return; const projectId = ctx.projectSelect?.getValue(); if (!projectId) { ctx.projectSelect?.focus(); return; } this.busy = true; if (ctx.startBtn) ctx.startBtn.disabled = true; try { const serviceId = ctx.serviceSelect?.getValue(); const { ok, status, data } = await apiCall('/api/timer/start', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ projectId: parseInt(projectId, 10), serviceId: serviceId ? parseInt(serviceId, 10) : null, note: ctx.noteField?.value || null, }), }); if (status === 409 && data) { const name = data.runningEntry ? `${data.runningEntry.clientName} / ${data.runningEntry.projectName}` : ''; const msg = t('confirmReplace').replace('%project%', name); if (!confirm(msg)) return; await this.forceStop(); this.busy = false; return this.startNew(ctx); } if (!ok) { alert(t('errorStart') + (data?.error ? `\n${data.error}` : ` (HTTP ${status})`)); return; } this.running = true; this.entryId = data.entry.id; this.startedAt = new Date(data.entry.timerStartedAt).getTime(); this.baseDuration = data.entry.duration; this.entryLabel = buildEntryLabel(data.entry); this.saveToLocalStorage(); this.applyRunningState(); this.closePopover(ctx); if (projectId) localStorage.setItem(LAST_PROJECT_KEY, projectId); if (serviceId) localStorage.setItem(LAST_SERVICE_KEY, serviceId); try { this.addEntryToDOM(data.entry, data.totalDuration); } catch (domEx) { console.error('[Stopwatch] addEntryToDOM error (non-fatal):', domEx); } } catch (ex) { console.error('[Stopwatch] startNew error:', ex); alert(t('errorStart') + `\n${ex.message}`); } finally { this.busy = false; if (ctx.startBtn) ctx.startBtn.disabled = false; } } async resumeEntry(entryId) { if (this.busy) return; if (this.running && this.entryId === entryId) { this.stop(); return; } this.busy = true; try { const { ok, status, data } = await apiCall(`/api/timer/start/${entryId}`, { method: 'POST', }); if (status === 409 && data) { const name = data.runningEntry ? `${data.runningEntry.clientName} / ${data.runningEntry.projectName}` : ''; const msg = t('confirmReplace').replace('%project%', name); if (!confirm(msg)) return; await this.forceStop(); this.busy = false; return this.resumeEntry(entryId); } if (!ok) { alert(t('errorStart') + (data?.error ? `\n${data.error}` : ` (HTTP ${status})`)); return; } this.running = true; this.entryId = data.entry.id; this.startedAt = new Date(data.entry.timerStartedAt).getTime(); this.baseDuration = data.entry.duration; this.entryLabel = buildEntryLabel(data.entry); this.saveToLocalStorage(); this.applyRunningState(); } catch (ex) { console.error('[Stopwatch] resumeEntry error:', ex); alert(t('errorStart') + `\n${ex.message}`); } finally { this.busy = false; } } async stop() { if (this.busy) return; this.busy = true; try { const { ok, status, data } = await apiCall('/api/timer/stop', { method: 'POST' }); if (!ok) { alert(t('errorStop') + (data?.error ? `\n${data.error}` : ` (HTTP ${status})`)); if (status === 404) { this.running = false; this.clearLocalStorage(); this.applyStoppedState(); } return; } const stoppedEntryId = this.entryId; this.running = false; this.clearLocalStorage(); this.applyStoppedState(); try { this.updateEntryInDOM(stoppedEntryId, data.entry, data.totalDuration); } catch (domEx) { console.error('[Stopwatch] updateEntryInDOM error (non-fatal):', domEx); } } catch (ex) { console.error('[Stopwatch] stop error:', ex); alert(t('errorStop') + `\n${ex.message}`); } finally { this.busy = false; } } async forceStop() { try { const stoppedId = this.entryId; const { ok, data } = await apiCall('/api/timer/stop', { method: 'POST' }); if (!ok) return; this.running = false; this.clearLocalStorage(); this.applyStoppedState(); try { this.updateEntryInDOM(stoppedId, data.entry, data.totalDuration); } catch { /* silent */ } } catch { /* silent */ } } // ── Timer-Tick ────────────────────────────────────────────────────────────── startTicking() { if (this.tickInterval) return; this.tick(); this.tickInterval = setInterval(() => this.tick(), 1000); } stopTicking() { if (this.tickInterval) { clearInterval(this.tickInterval); this.tickInterval = null; } document.title = this.originalTitle; for (const ctx of this.contexts) { if (ctx.display) ctx.display.textContent = '0:00'; if (ctx.headerTime) { ctx.headerTime.textContent = ''; ctx.headerTime.hidden = true; } } } tick() { if (!this.startedAt) return; const elapsedSec = Math.floor((Date.now() - this.startedAt) / 1000); const totalSec = (this.baseDuration * 60) + elapsedSec; const h = Math.floor(totalSec / 3600); const m = Math.floor((totalSec % 3600) / 60); const s = totalSec % 60; const long = h > 0 ? `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}` : `${m}:${String(s).padStart(2, '0')}`; const short = `${h}:${String(m).padStart(2, '0')}`; document.title = `${short} - ${this.originalTitle}`; for (const ctx of this.contexts) { if (ctx.display) ctx.display.textContent = long; if (ctx.headerTime) { ctx.headerTime.textContent = short; ctx.headerTime.hidden = false; } } if (this.entryId) { const badge = document.querySelector(`#entry-${this.entryId} .entry-row__badge`); if (badge) badge.textContent = short; } } // ── Visual State ──────────────────────────────────────────────────────────── applyRunningState() { for (const ctx of this.contexts) { ctx.toggle.classList.add(ctx.runningClass); if (this.entryLabel) ctx.toggle.title = this.entryLabel; } this.startTicking(); this.markActiveEntryRow(); } applyStoppedState() { for (const ctx of this.contexts) { ctx.toggle.classList.remove(ctx.runningClass); ctx.toggle.title = t('title'); } this.stopTicking(); this.entryId = null; this.startedAt = null; this.baseDuration = 0; this.entryLabel = ''; this.clearActiveEntryRows(); } markActiveEntryRow() { this.clearActiveEntryRows(); if (!this.entryId) return; const row = document.getElementById(`entry-${this.entryId}`); if (row) row.classList.add('entry-row--timer-active'); } clearActiveEntryRows() { document.querySelectorAll('.entry-row--timer-active') .forEach(el => el.classList.remove('entry-row--timer-active')); } // ── DOM Integration ───────────────────────────────────────────────────────── addEntryToDOM(entry, totalDuration) { if (!window.entryManager) return; window.entryManager.addEntryToDOM(entry); if (totalDuration) window.entryManager.updateTotal(totalDuration); this.markActiveEntryRow(); } updateEntryInDOM(entryId, entry, totalDuration) { if (!window.entryManager || !entryId) return; const row = document.getElementById(`entry-${entryId}`); if (row) { window.entryManager.updateRowDisplay(row, entry); row.dataset.duration = entry.duration; } if (totalDuration) window.entryManager.updateTotal(totalDuration); } // ── LocalStorage ──────────────────────────────────────────────────────────── saveToLocalStorage() { try { localStorage.setItem(LS_KEY, JSON.stringify({ entryId: this.entryId, startedAt: this.startedAt, baseDuration: this.baseDuration, entryLabel: this.entryLabel, })); } catch { /* silent */ } } loadFromLocalStorage() { try { const raw = localStorage.getItem(LS_KEY); if (!raw) return; const data = JSON.parse(raw); if (data?.startedAt) { this.running = true; this.entryId = data.entryId; this.startedAt = data.startedAt; this.baseDuration = data.baseDuration ?? 0; this.entryLabel = data.entryLabel ?? ''; this.applyRunningState(); } } catch { /* silent */ } } clearLocalStorage() { try { localStorage.removeItem(LS_KEY); } catch { /* silent */ } } // ── Public ───────────────────────────────────────────────────────────────── static get SVG() { return STOPWATCH_SVG; } isRunningForEntry(entryId) { return this.running && this.entryId === entryId; } } window.stopwatchManager = null; document.addEventListener('DOMContentLoaded', () => { window.stopwatchManager = new StopwatchManager(); }); export { STOPWATCH_SVG };