|
- // assets/scripts/stopwatch.js
-
- import { esc, createTranslator } from './utils.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 };
- }
-
- // ── Searchable Select ─────────────────────────────────────────────────────────
-
- class SearchableSelect {
- constructor(container) {
- this.container = container;
- this.value = '';
- this.label = '';
- this.groups = [];
- this.open = false;
- this.highlightIdx = -1;
- this.placeholder = container.dataset.placeholder || '...';
-
- this.container.innerHTML = `
- <button type="button" class="ss__trigger">
- <span class="ss__value">${esc(this.placeholder)}</span>
- <svg class="ss__arrow" viewBox="0 0 10 6"><path d="M1 1l4 4 4-4" stroke="currentColor" stroke-width="1.3" fill="none" stroke-linecap="round"/></svg>
- </button>
- <div class="ss__dropdown" hidden>
- <input type="text" class="ss__search" placeholder="${esc(t('search'))}">
- <div class="ss__list"></div>
- </div>`;
-
- this.trigger = container.querySelector('.ss__trigger');
- this.valueEl = container.querySelector('.ss__value');
- this.dropdown = container.querySelector('.ss__dropdown');
- this.search = container.querySelector('.ss__search');
- this.list = container.querySelector('.ss__list');
-
- this.trigger.addEventListener('click', (e) => {
- e.stopPropagation();
- this.toggle();
- });
- this.search.addEventListener('input', () => this.render());
- this.search.addEventListener('keydown', (e) => this.onKeydown(e));
- this.list.addEventListener('click', (e) => {
- const item = e.target.closest('[data-value]');
- if (item) this.select(item.dataset.value, item.dataset.label);
- });
- document.addEventListener('click', (e) => {
- if (this.open && !this.container.contains(e.target)) this.close();
- });
- }
-
- setGroups(groups) {
- this.groups = groups;
- }
-
- setValue(val) {
- for (const g of this.groups) {
- for (const item of g.items) {
- if (String(item.id) === String(val)) {
- this.value = String(item.id);
- this.label = item.name;
- this.valueEl.textContent = item.name;
- this.valueEl.classList.add('ss__value--selected');
- return;
- }
- }
- }
- this.value = '';
- this.label = '';
- this.valueEl.textContent = this.placeholder;
- this.valueEl.classList.remove('ss__value--selected');
- }
-
- getValue() { return this.value; }
-
- toggle() {
- this.open ? this.close() : this.openDropdown();
- }
-
- openDropdown() {
- this.open = true;
- this.dropdown.hidden = false;
- this.search.value = '';
- this.highlightIdx = -1;
- this.render();
- this.search.focus();
- }
-
- close() {
- this.open = false;
- this.dropdown.hidden = true;
- }
-
- select(val, label) {
- this.value = val;
- this.label = label;
- this.valueEl.textContent = label;
- this.valueEl.classList.add('ss__value--selected');
- this.close();
- }
-
- focus() {
- this.openDropdown();
- }
-
- render() {
- const q = this.search.value.toLowerCase().trim();
- let html = '';
- let idx = 0;
- const visibleItems = [];
-
- for (const g of this.groups) {
- const filtered = g.items.filter(item =>
- !q || item.name.toLowerCase().includes(q) || g.label.toLowerCase().includes(q)
- );
- if (!filtered.length) continue;
-
- html += `<div class="ss__group">${esc(g.label)}</div>`;
- for (const item of filtered) {
- const active = String(item.id) === this.value ? ' ss__item--active' : '';
- const hl = idx === this.highlightIdx ? ' ss__item--highlight' : '';
- html += `<div class="ss__item${active}${hl}" data-value="${item.id}" data-label="${esc(item.name)}" data-idx="${idx}">${esc(item.name)}</div>`;
- visibleItems.push(item);
- idx++;
- }
- }
-
- this.list.innerHTML = html || `<div class="ss__empty">–</div>`;
- this.visibleCount = visibleItems.length;
- }
-
- onKeydown(e) {
- if (e.key === 'ArrowDown') {
- e.preventDefault();
- this.highlightIdx = Math.min(this.highlightIdx + 1, this.visibleCount - 1);
- this.render();
- this.scrollToHighlight();
- } else if (e.key === 'ArrowUp') {
- e.preventDefault();
- this.highlightIdx = Math.max(this.highlightIdx - 1, 0);
- this.render();
- this.scrollToHighlight();
- } else if (e.key === 'Enter') {
- e.preventDefault();
- const el = this.list.querySelector(`[data-idx="${this.highlightIdx}"]`);
- if (el) this.select(el.dataset.value, el.dataset.label);
- } else if (e.key === 'Escape') {
- this.close();
- }
- }
-
- scrollToHighlight() {
- const el = this.list.querySelector('.ss__item--highlight');
- if (el) el.scrollIntoView({ block: 'nearest' });
- }
- }
-
- // ── StopwatchManager ──────────────────────────────────────────────────────────
-
- class StopwatchManager {
- constructor() {
- this.toggle = document.getElementById('stopwatch-toggle');
- this.popover = document.getElementById('stopwatch-popover');
- this.display = document.getElementById('stopwatch-display');
- this.startBtn = document.getElementById('stopwatch-start');
- this.noteField = document.getElementById('stopwatch-note');
- this.hamburgerBtn = document.getElementById('hamburger-stopwatch');
- this.headerTime = document.getElementById('stopwatch-header-time');
-
- this.projectSelect = null;
- this.serviceSelect = null;
- const projEl = document.getElementById('stopwatch-project');
- const svcEl = document.getElementById('stopwatch-service');
- if (projEl) this.projectSelect = new SearchableSelect(projEl);
- if (svcEl) this.serviceSelect = new SearchableSelect(svcEl);
-
- 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;
-
- if (!this.toggle) return;
- this.init();
- }
-
- async init() {
- this.toggle.addEventListener('click', (e) => {
- e.stopPropagation();
- this.handleToggleClick();
- });
-
- this.startBtn?.addEventListener('click', () => this.startNew());
-
- document.addEventListener('click', (e) => {
- if (this.popover && !this.popover.hidden
- && !this.popover.contains(e.target)
- && !this.toggle.contains(e.target)) {
- this.closePopover();
- }
- });
-
- this.hamburgerBtn?.addEventListener('click', () => this.handleToggleClick());
-
- this.loadFromLocalStorage();
- await this.loadStatus();
- }
-
- // ── Toggle ──────────────────────────────────────────────────────────────────
-
- handleToggleClick() {
- if (this.running) {
- this.stop();
- } else {
- this.togglePopover();
- }
- }
-
- async togglePopover() {
- if (!this.popover) return;
-
- if (!this.popover.hidden) {
- this.closePopover();
- return;
- }
-
- await this.loadOptions();
- this.populateSelects();
- this.popover.hidden = false;
- this.toggle.setAttribute('aria-expanded', 'true');
- this.projectSelect?.focus();
- }
-
- closePopover() {
- if (!this.popover) return;
- this.popover.hidden = true;
- this.toggle.setAttribute('aria-expanded', 'false');
- this.projectSelect?.close();
- this.serviceSelect?.close();
- }
-
- // ── Select-Builder ──────────────────────────────────────────────────────────
-
- populateSelects() {
- if (!this.cachedOptions) return;
-
- const lastProject = localStorage.getItem(LAST_PROJECT_KEY);
- const lastService = localStorage.getItem(LAST_SERVICE_KEY);
-
- if (this.projectSelect) {
- const groups = {};
- (this.cachedOptions.projects ?? []).forEach(p => {
- if (!groups[p.clientName]) groups[p.clientName] = [];
- groups[p.clientName].push(p);
- });
- this.projectSelect.setGroups(
- Object.entries(groups).map(([label, items]) => ({ label, items }))
- );
- if (lastProject) this.projectSelect.setValue(lastProject);
- }
-
- if (this.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 });
- this.serviceSelect.setGroups(groups);
- if (lastService) this.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() {
- if (this.busy) return;
-
- const projectId = this.projectSelect?.getValue();
- if (!projectId) {
- this.projectSelect?.focus();
- return;
- }
-
- this.busy = true;
- this.startBtn.disabled = true;
-
- try {
- const serviceId = this.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: this.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();
- }
-
- 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();
-
- 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 (this.startBtn) this.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;
- if (this.display) this.display.textContent = '0:00';
- if (this.headerTime) {
- this.headerTime.textContent = '';
- this.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}`;
- if (this.display) this.display.textContent = long;
-
- if (this.headerTime) {
- this.headerTime.textContent = short;
- this.headerTime.hidden = false;
- }
-
- if (this.entryId) {
- const badge = document.querySelector(`#entry-${this.entryId} .entry-row__badge`);
- if (badge) badge.textContent = short;
- }
- }
-
- // ── Visual State ────────────────────────────────────────────────────────────
-
- applyRunningState() {
- this.toggle?.classList.add('main-nav__stopwatch--running');
- this.hamburgerBtn?.classList.add('hamburger-nav__stopwatch--running');
- if (this.toggle && this.entryLabel) this.toggle.title = this.entryLabel;
- this.startTicking();
- this.markActiveEntryRow();
- }
-
- applyStoppedState() {
- this.toggle?.classList.remove('main-nav__stopwatch--running');
- this.hamburgerBtn?.classList.remove('hamburger-nav__stopwatch--running');
- if (this.toggle) this.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 };
|