|
- // 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 = `<svg viewBox="0 0 16 16" fill="none"><circle cx="8" cy="9" r="5.5" stroke="currentColor" stroke-width="1.3"/><path d="M8 6v3.5l2 1.5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/><path d="M6.5 1.5h3" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/><path d="M8 1.5v2" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/><path d="M12.5 4.5l1-1" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/></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 };
|