Nelze vybrat více než 25 témat Téma musí začínat písmenem nebo číslem, může obsahovat pomlčky („-“) a může být dlouhé až 35 znaků.
 
 
 
 
 

555 řádky
18 KiB

  1. // assets/scripts/stopwatch.js
  2. import { esc, createTranslator } from './utils.js';
  3. import { SearchableSelect } from './searchable-select.js';
  4. const LS_KEY = 'tt_timer_state';
  5. const LAST_PROJECT_KEY = 'tt_last_project_id';
  6. const LAST_SERVICE_KEY = 'tt_last_service_id';
  7. const t = createTranslator('STOPWATCH');
  8. 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>`;
  9. function buildEntryLabel(entry) {
  10. if (!entry) return '';
  11. let label = `${entry.projectName} (${entry.clientName})`;
  12. if (entry.serviceName) label += ` – ${entry.serviceName}`;
  13. return label;
  14. }
  15. async function apiCall(url, options = {}) {
  16. const res = await fetch(url, options);
  17. const ct = res.headers.get('content-type') || '';
  18. if (!ct.includes('application/json')) {
  19. console.error(`[Stopwatch] ${url} returned non-JSON:`, res.status, ct);
  20. return { ok: false, status: res.status, data: null };
  21. }
  22. const data = await res.json();
  23. return { ok: res.ok, status: res.status, data };
  24. }
  25. // SearchableSelect ist jetzt in searchable-select.js extrahiert und wird oben importiert
  26. // ── StopwatchManager ──────────────────────────────────────────────────────────
  27. class StopwatchManager {
  28. constructor() {
  29. this.contexts = [];
  30. const desktopCtx = this.buildContext(
  31. 'stopwatch-toggle', 'stopwatch-popover', 'stopwatch-display',
  32. 'stopwatch-start', 'stopwatch-note', 'stopwatch-header-time',
  33. 'stopwatch-project', 'stopwatch-service'
  34. );
  35. if (desktopCtx) this.contexts.push(desktopCtx);
  36. const hamburgerCtx = this.buildContext(
  37. 'hamburger-stopwatch', 'hamburger-stopwatch-popover', 'hamburger-stopwatch-display',
  38. 'hamburger-sw-start', 'hamburger-sw-note', 'hamburger-stopwatch-time',
  39. 'hamburger-sw-project', 'hamburger-sw-service'
  40. );
  41. if (hamburgerCtx) this.contexts.push(hamburgerCtx);
  42. this.running = false;
  43. this.entryId = null;
  44. this.startedAt = null;
  45. this.baseDuration = 0;
  46. this.entryLabel = '';
  47. this.tickInterval = null;
  48. this.originalTitle = document.title;
  49. this.cachedOptions = null;
  50. this.busy = false;
  51. this.activePopoverCtx = null;
  52. if (!this.contexts.length) return;
  53. this.init();
  54. }
  55. buildContext(toggleId, popoverId, displayId, startBtnId, noteId, timeId, projectId, serviceId) {
  56. const toggle = document.getElementById(toggleId);
  57. if (!toggle) return null;
  58. const ctx = {
  59. toggle,
  60. popover: document.getElementById(popoverId),
  61. display: document.getElementById(displayId),
  62. startBtn: document.getElementById(startBtnId),
  63. noteField: document.getElementById(noteId),
  64. headerTime: document.getElementById(timeId),
  65. projectSelect: null,
  66. serviceSelect: null,
  67. runningClass: toggleId === 'hamburger-stopwatch'
  68. ? 'hamburger-nav__stopwatch--running'
  69. : 'main-nav__stopwatch--running',
  70. };
  71. const projEl = document.getElementById(projectId);
  72. const svcEl = document.getElementById(serviceId);
  73. const ssOpts = { searchPlaceholder: t('search') };
  74. if (projEl) ctx.projectSelect = new SearchableSelect(projEl, ssOpts);
  75. if (svcEl) ctx.serviceSelect = new SearchableSelect(svcEl, ssOpts);
  76. return ctx;
  77. }
  78. async init() {
  79. for (const ctx of this.contexts) {
  80. ctx.toggle.addEventListener('click', (e) => {
  81. e.stopPropagation();
  82. this.handleToggleClick(ctx);
  83. });
  84. ctx.startBtn?.addEventListener('click', () => this.startNew(ctx));
  85. }
  86. document.addEventListener('click', (e) => {
  87. if (!this.activePopoverCtx) return;
  88. const ctx = this.activePopoverCtx;
  89. if (ctx.popover && !ctx.popover.hidden
  90. && !ctx.popover.contains(e.target)
  91. && !ctx.toggle.contains(e.target)) {
  92. this.closePopover(ctx);
  93. }
  94. });
  95. this.loadFromLocalStorage();
  96. await this.loadStatus();
  97. }
  98. // ── Toggle ──────────────────────────────────────────────────────────────────
  99. handleToggleClick(ctx) {
  100. if (this.running) {
  101. this.stop();
  102. } else {
  103. this.togglePopover(ctx);
  104. }
  105. }
  106. async togglePopover(ctx) {
  107. if (!ctx.popover) return;
  108. if (!ctx.popover.hidden) {
  109. this.closePopover(ctx);
  110. return;
  111. }
  112. if (this.activePopoverCtx && this.activePopoverCtx !== ctx) {
  113. this.closePopover(this.activePopoverCtx);
  114. }
  115. await this.loadOptions();
  116. this.populateSelects(ctx);
  117. ctx.popover.hidden = false;
  118. ctx.toggle.setAttribute('aria-expanded', 'true');
  119. this.activePopoverCtx = ctx;
  120. ctx.projectSelect?.focus();
  121. }
  122. closePopover(ctx) {
  123. if (!ctx?.popover) return;
  124. ctx.popover.hidden = true;
  125. ctx.toggle.setAttribute('aria-expanded', 'false');
  126. ctx.projectSelect?.close();
  127. ctx.serviceSelect?.close();
  128. if (this.activePopoverCtx === ctx) this.activePopoverCtx = null;
  129. }
  130. // ── Select-Builder ──────────────────────────────────────────────────────────
  131. populateSelects(ctx) {
  132. if (!this.cachedOptions) return;
  133. const lastProject = localStorage.getItem(LAST_PROJECT_KEY);
  134. const lastService = localStorage.getItem(LAST_SERVICE_KEY);
  135. if (ctx.projectSelect) {
  136. const groups = {};
  137. (this.cachedOptions.projects ?? []).forEach(p => {
  138. if (!groups[p.clientName]) groups[p.clientName] = [];
  139. groups[p.clientName].push(p);
  140. });
  141. ctx.projectSelect.setGroups(
  142. Object.entries(groups).map(([label, items]) => ({ label, items }))
  143. );
  144. if (lastProject) ctx.projectSelect.setValue(lastProject);
  145. }
  146. if (ctx.serviceSelect) {
  147. const billable = (this.cachedOptions.services ?? []).filter(s => s.billable);
  148. const notBillable = (this.cachedOptions.services ?? []).filter(s => !s.billable);
  149. const groups = [];
  150. if (billable.length) groups.push({ label: t('billable'), items: billable });
  151. if (notBillable.length) groups.push({ label: t('notBillable'), items: notBillable });
  152. ctx.serviceSelect.setGroups(groups);
  153. if (lastService) ctx.serviceSelect.setValue(lastService);
  154. }
  155. }
  156. // ── API ─────────────────────────────────────────────────────────────────────
  157. async loadStatus() {
  158. try {
  159. const { ok, data } = await apiCall('/api/timer/status');
  160. if (!ok || !data) {
  161. if (this.running) {
  162. this.running = false;
  163. this.clearLocalStorage();
  164. this.applyStoppedState();
  165. }
  166. return;
  167. }
  168. if (data.running && data.startedAt) {
  169. this.running = true;
  170. this.entryId = data.entry?.id ?? null;
  171. this.startedAt = new Date(data.startedAt).getTime();
  172. this.baseDuration = data.entry?.duration ?? 0;
  173. this.entryLabel = buildEntryLabel(data.entry);
  174. this.saveToLocalStorage();
  175. this.applyRunningState();
  176. } else if (this.running) {
  177. this.running = false;
  178. this.clearLocalStorage();
  179. this.applyStoppedState();
  180. }
  181. } catch {
  182. if (this.running) {
  183. this.running = false;
  184. this.clearLocalStorage();
  185. this.applyStoppedState();
  186. }
  187. }
  188. }
  189. async loadOptions() {
  190. if (this.cachedOptions) return;
  191. try {
  192. const { ok, data } = await apiCall('/api/timer/options');
  193. if (ok && data) this.cachedOptions = data;
  194. } catch { /* silent */ }
  195. }
  196. async startNew(ctx) {
  197. if (this.busy) return;
  198. const projectId = ctx.projectSelect?.getValue();
  199. if (!projectId) {
  200. ctx.projectSelect?.focus();
  201. return;
  202. }
  203. this.busy = true;
  204. if (ctx.startBtn) ctx.startBtn.disabled = true;
  205. try {
  206. const serviceId = ctx.serviceSelect?.getValue();
  207. const { ok, status, data } = await apiCall('/api/timer/start', {
  208. method: 'POST',
  209. headers: { 'Content-Type': 'application/json' },
  210. body: JSON.stringify({
  211. projectId: parseInt(projectId, 10),
  212. serviceId: serviceId ? parseInt(serviceId, 10) : null,
  213. note: ctx.noteField?.value || null,
  214. }),
  215. });
  216. if (status === 409 && data) {
  217. const name = data.runningEntry
  218. ? `${data.runningEntry.clientName} / ${data.runningEntry.projectName}`
  219. : '';
  220. const msg = t('confirmReplace').replace('%project%', name);
  221. if (!confirm(msg)) return;
  222. await this.forceStop();
  223. this.busy = false;
  224. return this.startNew(ctx);
  225. }
  226. if (!ok) {
  227. alert(t('errorStart') + (data?.error ? `\n${data.error}` : ` (HTTP ${status})`));
  228. return;
  229. }
  230. this.running = true;
  231. this.entryId = data.entry.id;
  232. this.startedAt = new Date(data.entry.timerStartedAt).getTime();
  233. this.baseDuration = data.entry.duration;
  234. this.entryLabel = buildEntryLabel(data.entry);
  235. this.saveToLocalStorage();
  236. this.applyRunningState();
  237. this.closePopover(ctx);
  238. if (projectId) localStorage.setItem(LAST_PROJECT_KEY, projectId);
  239. if (serviceId) localStorage.setItem(LAST_SERVICE_KEY, serviceId);
  240. try {
  241. this.addEntryToDOM(data.entry, data.totalDuration);
  242. } catch (domEx) {
  243. console.error('[Stopwatch] addEntryToDOM error (non-fatal):', domEx);
  244. }
  245. } catch (ex) {
  246. console.error('[Stopwatch] startNew error:', ex);
  247. alert(t('errorStart') + `\n${ex.message}`);
  248. } finally {
  249. this.busy = false;
  250. if (ctx.startBtn) ctx.startBtn.disabled = false;
  251. }
  252. }
  253. async resumeEntry(entryId) {
  254. if (this.busy) return;
  255. if (this.running && this.entryId === entryId) {
  256. this.stop();
  257. return;
  258. }
  259. this.busy = true;
  260. try {
  261. const { ok, status, data } = await apiCall(`/api/timer/start/${entryId}`, {
  262. method: 'POST',
  263. });
  264. if (status === 409 && data) {
  265. const name = data.runningEntry
  266. ? `${data.runningEntry.clientName} / ${data.runningEntry.projectName}`
  267. : '';
  268. const msg = t('confirmReplace').replace('%project%', name);
  269. if (!confirm(msg)) return;
  270. await this.forceStop();
  271. this.busy = false;
  272. return this.resumeEntry(entryId);
  273. }
  274. if (!ok) {
  275. alert(t('errorStart') + (data?.error ? `\n${data.error}` : ` (HTTP ${status})`));
  276. return;
  277. }
  278. this.running = true;
  279. this.entryId = data.entry.id;
  280. this.startedAt = new Date(data.entry.timerStartedAt).getTime();
  281. this.baseDuration = data.entry.duration;
  282. this.entryLabel = buildEntryLabel(data.entry);
  283. this.saveToLocalStorage();
  284. this.applyRunningState();
  285. } catch (ex) {
  286. console.error('[Stopwatch] resumeEntry error:', ex);
  287. alert(t('errorStart') + `\n${ex.message}`);
  288. } finally {
  289. this.busy = false;
  290. }
  291. }
  292. async stop() {
  293. if (this.busy) return;
  294. this.busy = true;
  295. try {
  296. const { ok, status, data } = await apiCall('/api/timer/stop', { method: 'POST' });
  297. if (!ok) {
  298. alert(t('errorStop') + (data?.error ? `\n${data.error}` : ` (HTTP ${status})`));
  299. if (status === 404) {
  300. this.running = false;
  301. this.clearLocalStorage();
  302. this.applyStoppedState();
  303. }
  304. return;
  305. }
  306. const stoppedEntryId = this.entryId;
  307. this.running = false;
  308. this.clearLocalStorage();
  309. this.applyStoppedState();
  310. try {
  311. this.updateEntryInDOM(stoppedEntryId, data.entry, data.totalDuration);
  312. } catch (domEx) {
  313. console.error('[Stopwatch] updateEntryInDOM error (non-fatal):', domEx);
  314. }
  315. } catch (ex) {
  316. console.error('[Stopwatch] stop error:', ex);
  317. alert(t('errorStop') + `\n${ex.message}`);
  318. } finally {
  319. this.busy = false;
  320. }
  321. }
  322. async forceStop() {
  323. try {
  324. const stoppedId = this.entryId;
  325. const { ok, data } = await apiCall('/api/timer/stop', { method: 'POST' });
  326. if (!ok) return;
  327. this.running = false;
  328. this.clearLocalStorage();
  329. this.applyStoppedState();
  330. try {
  331. this.updateEntryInDOM(stoppedId, data.entry, data.totalDuration);
  332. } catch { /* silent */ }
  333. } catch { /* silent */ }
  334. }
  335. // ── Timer-Tick ──────────────────────────────────────────────────────────────
  336. startTicking() {
  337. if (this.tickInterval) return;
  338. this.tick();
  339. this.tickInterval = setInterval(() => this.tick(), 1000);
  340. }
  341. stopTicking() {
  342. if (this.tickInterval) {
  343. clearInterval(this.tickInterval);
  344. this.tickInterval = null;
  345. }
  346. document.title = this.originalTitle;
  347. for (const ctx of this.contexts) {
  348. if (ctx.display) ctx.display.textContent = '0:00';
  349. if (ctx.headerTime) {
  350. ctx.headerTime.textContent = '';
  351. ctx.headerTime.hidden = true;
  352. }
  353. }
  354. }
  355. tick() {
  356. if (!this.startedAt) return;
  357. const elapsedSec = Math.floor((Date.now() - this.startedAt) / 1000);
  358. const totalSec = (this.baseDuration * 60) + elapsedSec;
  359. const h = Math.floor(totalSec / 3600);
  360. const m = Math.floor((totalSec % 3600) / 60);
  361. const s = totalSec % 60;
  362. const long = h > 0
  363. ? `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`
  364. : `${m}:${String(s).padStart(2, '0')}`;
  365. const short = `${h}:${String(m).padStart(2, '0')}`;
  366. document.title = `${short} - ${this.originalTitle}`;
  367. for (const ctx of this.contexts) {
  368. if (ctx.display) ctx.display.textContent = long;
  369. if (ctx.headerTime) {
  370. ctx.headerTime.textContent = short;
  371. ctx.headerTime.hidden = false;
  372. }
  373. }
  374. if (this.entryId) {
  375. const badge = document.querySelector(`#entry-${this.entryId} .entry-row__badge`);
  376. if (badge) badge.textContent = short;
  377. }
  378. }
  379. // ── Visual State ────────────────────────────────────────────────────────────
  380. applyRunningState() {
  381. for (const ctx of this.contexts) {
  382. ctx.toggle.classList.add(ctx.runningClass);
  383. if (this.entryLabel) ctx.toggle.title = this.entryLabel;
  384. }
  385. this.startTicking();
  386. this.markActiveEntryRow();
  387. }
  388. applyStoppedState() {
  389. for (const ctx of this.contexts) {
  390. ctx.toggle.classList.remove(ctx.runningClass);
  391. ctx.toggle.title = t('title');
  392. }
  393. this.stopTicking();
  394. this.entryId = null;
  395. this.startedAt = null;
  396. this.baseDuration = 0;
  397. this.entryLabel = '';
  398. this.clearActiveEntryRows();
  399. }
  400. markActiveEntryRow() {
  401. this.clearActiveEntryRows();
  402. if (!this.entryId) return;
  403. const row = document.getElementById(`entry-${this.entryId}`);
  404. if (row) row.classList.add('entry-row--timer-active');
  405. }
  406. clearActiveEntryRows() {
  407. document.querySelectorAll('.entry-row--timer-active')
  408. .forEach(el => el.classList.remove('entry-row--timer-active'));
  409. }
  410. // ── DOM Integration ─────────────────────────────────────────────────────────
  411. addEntryToDOM(entry, totalDuration) {
  412. if (!window.entryManager) return;
  413. window.entryManager.addEntryToDOM(entry);
  414. if (totalDuration) window.entryManager.updateTotal(totalDuration);
  415. this.markActiveEntryRow();
  416. }
  417. updateEntryInDOM(entryId, entry, totalDuration) {
  418. if (!window.entryManager || !entryId) return;
  419. const row = document.getElementById(`entry-${entryId}`);
  420. if (row) {
  421. window.entryManager.updateRowDisplay(row, entry);
  422. row.dataset.duration = entry.duration;
  423. }
  424. if (totalDuration) window.entryManager.updateTotal(totalDuration);
  425. }
  426. // ── LocalStorage ────────────────────────────────────────────────────────────
  427. saveToLocalStorage() {
  428. try {
  429. localStorage.setItem(LS_KEY, JSON.stringify({
  430. entryId: this.entryId,
  431. startedAt: this.startedAt,
  432. baseDuration: this.baseDuration,
  433. entryLabel: this.entryLabel,
  434. }));
  435. } catch { /* silent */ }
  436. }
  437. loadFromLocalStorage() {
  438. try {
  439. const raw = localStorage.getItem(LS_KEY);
  440. if (!raw) return;
  441. const data = JSON.parse(raw);
  442. if (data?.startedAt) {
  443. this.running = true;
  444. this.entryId = data.entryId;
  445. this.startedAt = data.startedAt;
  446. this.baseDuration = data.baseDuration ?? 0;
  447. this.entryLabel = data.entryLabel ?? '';
  448. this.applyRunningState();
  449. }
  450. } catch { /* silent */ }
  451. }
  452. clearLocalStorage() {
  453. try { localStorage.removeItem(LS_KEY); } catch { /* silent */ }
  454. }
  455. // ── Public ─────────────────────────────────────────────────────────────────
  456. static get SVG() { return STOPWATCH_SVG; }
  457. isRunningForEntry(entryId) {
  458. return this.running && this.entryId === entryId;
  459. }
  460. }
  461. window.stopwatchManager = null;
  462. document.addEventListener('DOMContentLoaded', () => {
  463. window.stopwatchManager = new StopwatchManager();
  464. });
  465. export { STOPWATCH_SVG };