// 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 = ``;
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 = `
`;
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 += `${esc(g.label)}
`;
for (const item of filtered) {
const active = String(item.id) === this.value ? ' ss__item--active' : '';
const hl = idx === this.highlightIdx ? ' ss__item--highlight' : '';
html += `${esc(item.name)}
`;
visibleItems.push(item);
idx++;
}
}
this.list.innerHTML = html || `–
`;
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 };