// 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.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);
if (projEl) ctx.projectSelect = new SearchableSelect(projEl);
if (svcEl) ctx.serviceSelect = new SearchableSelect(svcEl);
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 };