25개 이상의 토픽을 선택하실 수 없습니다. Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

700 lines
22 KiB

  1. // assets/scripts/stopwatch.js
  2. import { esc, createTranslator } from './utils.js';
  3. const LS_KEY = 'tt_timer_state';
  4. const LAST_PROJECT_KEY = 'tt_last_project_id';
  5. const LAST_SERVICE_KEY = 'tt_last_service_id';
  6. const t = createTranslator('STOPWATCH');
  7. 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>`;
  8. function buildEntryLabel(entry) {
  9. if (!entry) return '';
  10. let label = `${entry.projectName} (${entry.clientName})`;
  11. if (entry.serviceName) label += ` – ${entry.serviceName}`;
  12. return label;
  13. }
  14. async function apiCall(url, options = {}) {
  15. const res = await fetch(url, options);
  16. const ct = res.headers.get('content-type') || '';
  17. if (!ct.includes('application/json')) {
  18. console.error(`[Stopwatch] ${url} returned non-JSON:`, res.status, ct);
  19. return { ok: false, status: res.status, data: null };
  20. }
  21. const data = await res.json();
  22. return { ok: res.ok, status: res.status, data };
  23. }
  24. // ── Searchable Select ─────────────────────────────────────────────────────────
  25. class SearchableSelect {
  26. constructor(container) {
  27. this.container = container;
  28. this.value = '';
  29. this.label = '';
  30. this.groups = [];
  31. this.open = false;
  32. this.highlightIdx = -1;
  33. this.placeholder = container.dataset.placeholder || '...';
  34. this.container.innerHTML = `
  35. <button type="button" class="ss__trigger">
  36. <span class="ss__value">${esc(this.placeholder)}</span>
  37. <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>
  38. </button>
  39. <div class="ss__dropdown" hidden>
  40. <input type="text" class="ss__search" placeholder="${esc(t('search'))}">
  41. <div class="ss__list"></div>
  42. </div>`;
  43. this.trigger = container.querySelector('.ss__trigger');
  44. this.valueEl = container.querySelector('.ss__value');
  45. this.dropdown = container.querySelector('.ss__dropdown');
  46. this.search = container.querySelector('.ss__search');
  47. this.list = container.querySelector('.ss__list');
  48. this.trigger.addEventListener('click', (e) => {
  49. e.stopPropagation();
  50. this.toggle();
  51. });
  52. this.search.addEventListener('input', () => this.render());
  53. this.search.addEventListener('keydown', (e) => this.onKeydown(e));
  54. this.list.addEventListener('click', (e) => {
  55. const item = e.target.closest('[data-value]');
  56. if (item) this.select(item.dataset.value, item.dataset.label);
  57. });
  58. document.addEventListener('click', (e) => {
  59. if (this.open && !this.container.contains(e.target)) this.close();
  60. });
  61. }
  62. setGroups(groups) {
  63. this.groups = groups;
  64. }
  65. setValue(val) {
  66. for (const g of this.groups) {
  67. for (const item of g.items) {
  68. if (String(item.id) === String(val)) {
  69. this.value = String(item.id);
  70. this.label = item.name;
  71. this.valueEl.textContent = item.name;
  72. this.valueEl.classList.add('ss__value--selected');
  73. return;
  74. }
  75. }
  76. }
  77. this.value = '';
  78. this.label = '';
  79. this.valueEl.textContent = this.placeholder;
  80. this.valueEl.classList.remove('ss__value--selected');
  81. }
  82. getValue() { return this.value; }
  83. toggle() {
  84. this.open ? this.close() : this.openDropdown();
  85. }
  86. openDropdown() {
  87. this.open = true;
  88. this.dropdown.hidden = false;
  89. this.search.value = '';
  90. this.highlightIdx = -1;
  91. this.render();
  92. this.search.focus();
  93. }
  94. close() {
  95. this.open = false;
  96. this.dropdown.hidden = true;
  97. }
  98. select(val, label) {
  99. this.value = val;
  100. this.label = label;
  101. this.valueEl.textContent = label;
  102. this.valueEl.classList.add('ss__value--selected');
  103. this.close();
  104. }
  105. focus() {
  106. this.openDropdown();
  107. }
  108. render() {
  109. const q = this.search.value.toLowerCase().trim();
  110. let html = '';
  111. let idx = 0;
  112. const visibleItems = [];
  113. for (const g of this.groups) {
  114. const filtered = g.items.filter(item =>
  115. !q || item.name.toLowerCase().includes(q) || g.label.toLowerCase().includes(q)
  116. );
  117. if (!filtered.length) continue;
  118. html += `<div class="ss__group">${esc(g.label)}</div>`;
  119. for (const item of filtered) {
  120. const active = String(item.id) === this.value ? ' ss__item--active' : '';
  121. const hl = idx === this.highlightIdx ? ' ss__item--highlight' : '';
  122. html += `<div class="ss__item${active}${hl}" data-value="${item.id}" data-label="${esc(item.name)}" data-idx="${idx}">${esc(item.name)}</div>`;
  123. visibleItems.push(item);
  124. idx++;
  125. }
  126. }
  127. this.list.innerHTML = html || `<div class="ss__empty">–</div>`;
  128. this.visibleCount = visibleItems.length;
  129. }
  130. onKeydown(e) {
  131. if (e.key === 'ArrowDown') {
  132. e.preventDefault();
  133. this.highlightIdx = Math.min(this.highlightIdx + 1, this.visibleCount - 1);
  134. this.render();
  135. this.scrollToHighlight();
  136. } else if (e.key === 'ArrowUp') {
  137. e.preventDefault();
  138. this.highlightIdx = Math.max(this.highlightIdx - 1, 0);
  139. this.render();
  140. this.scrollToHighlight();
  141. } else if (e.key === 'Enter') {
  142. e.preventDefault();
  143. const el = this.list.querySelector(`[data-idx="${this.highlightIdx}"]`);
  144. if (el) this.select(el.dataset.value, el.dataset.label);
  145. } else if (e.key === 'Escape') {
  146. this.close();
  147. }
  148. }
  149. scrollToHighlight() {
  150. const el = this.list.querySelector('.ss__item--highlight');
  151. if (el) el.scrollIntoView({ block: 'nearest' });
  152. }
  153. }
  154. // ── StopwatchManager ──────────────────────────────────────────────────────────
  155. class StopwatchManager {
  156. constructor() {
  157. this.contexts = [];
  158. const desktopCtx = this.buildContext(
  159. 'stopwatch-toggle', 'stopwatch-popover', 'stopwatch-display',
  160. 'stopwatch-start', 'stopwatch-note', 'stopwatch-header-time',
  161. 'stopwatch-project', 'stopwatch-service'
  162. );
  163. if (desktopCtx) this.contexts.push(desktopCtx);
  164. const hamburgerCtx = this.buildContext(
  165. 'hamburger-stopwatch', 'hamburger-stopwatch-popover', 'hamburger-stopwatch-display',
  166. 'hamburger-sw-start', 'hamburger-sw-note', 'hamburger-stopwatch-time',
  167. 'hamburger-sw-project', 'hamburger-sw-service'
  168. );
  169. if (hamburgerCtx) this.contexts.push(hamburgerCtx);
  170. this.running = false;
  171. this.entryId = null;
  172. this.startedAt = null;
  173. this.baseDuration = 0;
  174. this.entryLabel = '';
  175. this.tickInterval = null;
  176. this.originalTitle = document.title;
  177. this.cachedOptions = null;
  178. this.busy = false;
  179. this.activePopoverCtx = null;
  180. if (!this.contexts.length) return;
  181. this.init();
  182. }
  183. buildContext(toggleId, popoverId, displayId, startBtnId, noteId, timeId, projectId, serviceId) {
  184. const toggle = document.getElementById(toggleId);
  185. if (!toggle) return null;
  186. const ctx = {
  187. toggle,
  188. popover: document.getElementById(popoverId),
  189. display: document.getElementById(displayId),
  190. startBtn: document.getElementById(startBtnId),
  191. noteField: document.getElementById(noteId),
  192. headerTime: document.getElementById(timeId),
  193. projectSelect: null,
  194. serviceSelect: null,
  195. runningClass: toggleId === 'hamburger-stopwatch'
  196. ? 'hamburger-nav__stopwatch--running'
  197. : 'main-nav__stopwatch--running',
  198. };
  199. const projEl = document.getElementById(projectId);
  200. const svcEl = document.getElementById(serviceId);
  201. if (projEl) ctx.projectSelect = new SearchableSelect(projEl);
  202. if (svcEl) ctx.serviceSelect = new SearchableSelect(svcEl);
  203. return ctx;
  204. }
  205. async init() {
  206. for (const ctx of this.contexts) {
  207. ctx.toggle.addEventListener('click', (e) => {
  208. e.stopPropagation();
  209. this.handleToggleClick(ctx);
  210. });
  211. ctx.startBtn?.addEventListener('click', () => this.startNew(ctx));
  212. }
  213. document.addEventListener('click', (e) => {
  214. if (!this.activePopoverCtx) return;
  215. const ctx = this.activePopoverCtx;
  216. if (ctx.popover && !ctx.popover.hidden
  217. && !ctx.popover.contains(e.target)
  218. && !ctx.toggle.contains(e.target)) {
  219. this.closePopover(ctx);
  220. }
  221. });
  222. this.loadFromLocalStorage();
  223. await this.loadStatus();
  224. }
  225. // ── Toggle ──────────────────────────────────────────────────────────────────
  226. handleToggleClick(ctx) {
  227. if (this.running) {
  228. this.stop();
  229. } else {
  230. this.togglePopover(ctx);
  231. }
  232. }
  233. async togglePopover(ctx) {
  234. if (!ctx.popover) return;
  235. if (!ctx.popover.hidden) {
  236. this.closePopover(ctx);
  237. return;
  238. }
  239. if (this.activePopoverCtx && this.activePopoverCtx !== ctx) {
  240. this.closePopover(this.activePopoverCtx);
  241. }
  242. await this.loadOptions();
  243. this.populateSelects(ctx);
  244. ctx.popover.hidden = false;
  245. ctx.toggle.setAttribute('aria-expanded', 'true');
  246. this.activePopoverCtx = ctx;
  247. ctx.projectSelect?.focus();
  248. }
  249. closePopover(ctx) {
  250. if (!ctx?.popover) return;
  251. ctx.popover.hidden = true;
  252. ctx.toggle.setAttribute('aria-expanded', 'false');
  253. ctx.projectSelect?.close();
  254. ctx.serviceSelect?.close();
  255. if (this.activePopoverCtx === ctx) this.activePopoverCtx = null;
  256. }
  257. // ── Select-Builder ──────────────────────────────────────────────────────────
  258. populateSelects(ctx) {
  259. if (!this.cachedOptions) return;
  260. const lastProject = localStorage.getItem(LAST_PROJECT_KEY);
  261. const lastService = localStorage.getItem(LAST_SERVICE_KEY);
  262. if (ctx.projectSelect) {
  263. const groups = {};
  264. (this.cachedOptions.projects ?? []).forEach(p => {
  265. if (!groups[p.clientName]) groups[p.clientName] = [];
  266. groups[p.clientName].push(p);
  267. });
  268. ctx.projectSelect.setGroups(
  269. Object.entries(groups).map(([label, items]) => ({ label, items }))
  270. );
  271. if (lastProject) ctx.projectSelect.setValue(lastProject);
  272. }
  273. if (ctx.serviceSelect) {
  274. const billable = (this.cachedOptions.services ?? []).filter(s => s.billable);
  275. const notBillable = (this.cachedOptions.services ?? []).filter(s => !s.billable);
  276. const groups = [];
  277. if (billable.length) groups.push({ label: t('billable'), items: billable });
  278. if (notBillable.length) groups.push({ label: t('notBillable'), items: notBillable });
  279. ctx.serviceSelect.setGroups(groups);
  280. if (lastService) ctx.serviceSelect.setValue(lastService);
  281. }
  282. }
  283. // ── API ─────────────────────────────────────────────────────────────────────
  284. async loadStatus() {
  285. try {
  286. const { ok, data } = await apiCall('/api/timer/status');
  287. if (!ok || !data) {
  288. if (this.running) {
  289. this.running = false;
  290. this.clearLocalStorage();
  291. this.applyStoppedState();
  292. }
  293. return;
  294. }
  295. if (data.running && data.startedAt) {
  296. this.running = true;
  297. this.entryId = data.entry?.id ?? null;
  298. this.startedAt = new Date(data.startedAt).getTime();
  299. this.baseDuration = data.entry?.duration ?? 0;
  300. this.entryLabel = buildEntryLabel(data.entry);
  301. this.saveToLocalStorage();
  302. this.applyRunningState();
  303. } else if (this.running) {
  304. this.running = false;
  305. this.clearLocalStorage();
  306. this.applyStoppedState();
  307. }
  308. } catch {
  309. if (this.running) {
  310. this.running = false;
  311. this.clearLocalStorage();
  312. this.applyStoppedState();
  313. }
  314. }
  315. }
  316. async loadOptions() {
  317. if (this.cachedOptions) return;
  318. try {
  319. const { ok, data } = await apiCall('/api/timer/options');
  320. if (ok && data) this.cachedOptions = data;
  321. } catch { /* silent */ }
  322. }
  323. async startNew(ctx) {
  324. if (this.busy) return;
  325. const projectId = ctx.projectSelect?.getValue();
  326. if (!projectId) {
  327. ctx.projectSelect?.focus();
  328. return;
  329. }
  330. this.busy = true;
  331. if (ctx.startBtn) ctx.startBtn.disabled = true;
  332. try {
  333. const serviceId = ctx.serviceSelect?.getValue();
  334. const { ok, status, data } = await apiCall('/api/timer/start', {
  335. method: 'POST',
  336. headers: { 'Content-Type': 'application/json' },
  337. body: JSON.stringify({
  338. projectId: parseInt(projectId, 10),
  339. serviceId: serviceId ? parseInt(serviceId, 10) : null,
  340. note: ctx.noteField?.value || null,
  341. }),
  342. });
  343. if (status === 409 && data) {
  344. const name = data.runningEntry
  345. ? `${data.runningEntry.clientName} / ${data.runningEntry.projectName}`
  346. : '';
  347. const msg = t('confirmReplace').replace('%project%', name);
  348. if (!confirm(msg)) return;
  349. await this.forceStop();
  350. this.busy = false;
  351. return this.startNew(ctx);
  352. }
  353. if (!ok) {
  354. alert(t('errorStart') + (data?.error ? `\n${data.error}` : ` (HTTP ${status})`));
  355. return;
  356. }
  357. this.running = true;
  358. this.entryId = data.entry.id;
  359. this.startedAt = new Date(data.entry.timerStartedAt).getTime();
  360. this.baseDuration = data.entry.duration;
  361. this.entryLabel = buildEntryLabel(data.entry);
  362. this.saveToLocalStorage();
  363. this.applyRunningState();
  364. this.closePopover(ctx);
  365. if (projectId) localStorage.setItem(LAST_PROJECT_KEY, projectId);
  366. if (serviceId) localStorage.setItem(LAST_SERVICE_KEY, serviceId);
  367. try {
  368. this.addEntryToDOM(data.entry, data.totalDuration);
  369. } catch (domEx) {
  370. console.error('[Stopwatch] addEntryToDOM error (non-fatal):', domEx);
  371. }
  372. } catch (ex) {
  373. console.error('[Stopwatch] startNew error:', ex);
  374. alert(t('errorStart') + `\n${ex.message}`);
  375. } finally {
  376. this.busy = false;
  377. if (ctx.startBtn) ctx.startBtn.disabled = false;
  378. }
  379. }
  380. async resumeEntry(entryId) {
  381. if (this.busy) return;
  382. if (this.running && this.entryId === entryId) {
  383. this.stop();
  384. return;
  385. }
  386. this.busy = true;
  387. try {
  388. const { ok, status, data } = await apiCall(`/api/timer/start/${entryId}`, {
  389. method: 'POST',
  390. });
  391. if (status === 409 && data) {
  392. const name = data.runningEntry
  393. ? `${data.runningEntry.clientName} / ${data.runningEntry.projectName}`
  394. : '';
  395. const msg = t('confirmReplace').replace('%project%', name);
  396. if (!confirm(msg)) return;
  397. await this.forceStop();
  398. this.busy = false;
  399. return this.resumeEntry(entryId);
  400. }
  401. if (!ok) {
  402. alert(t('errorStart') + (data?.error ? `\n${data.error}` : ` (HTTP ${status})`));
  403. return;
  404. }
  405. this.running = true;
  406. this.entryId = data.entry.id;
  407. this.startedAt = new Date(data.entry.timerStartedAt).getTime();
  408. this.baseDuration = data.entry.duration;
  409. this.entryLabel = buildEntryLabel(data.entry);
  410. this.saveToLocalStorage();
  411. this.applyRunningState();
  412. } catch (ex) {
  413. console.error('[Stopwatch] resumeEntry error:', ex);
  414. alert(t('errorStart') + `\n${ex.message}`);
  415. } finally {
  416. this.busy = false;
  417. }
  418. }
  419. async stop() {
  420. if (this.busy) return;
  421. this.busy = true;
  422. try {
  423. const { ok, status, data } = await apiCall('/api/timer/stop', { method: 'POST' });
  424. if (!ok) {
  425. alert(t('errorStop') + (data?.error ? `\n${data.error}` : ` (HTTP ${status})`));
  426. if (status === 404) {
  427. this.running = false;
  428. this.clearLocalStorage();
  429. this.applyStoppedState();
  430. }
  431. return;
  432. }
  433. const stoppedEntryId = this.entryId;
  434. this.running = false;
  435. this.clearLocalStorage();
  436. this.applyStoppedState();
  437. try {
  438. this.updateEntryInDOM(stoppedEntryId, data.entry, data.totalDuration);
  439. } catch (domEx) {
  440. console.error('[Stopwatch] updateEntryInDOM error (non-fatal):', domEx);
  441. }
  442. } catch (ex) {
  443. console.error('[Stopwatch] stop error:', ex);
  444. alert(t('errorStop') + `\n${ex.message}`);
  445. } finally {
  446. this.busy = false;
  447. }
  448. }
  449. async forceStop() {
  450. try {
  451. const stoppedId = this.entryId;
  452. const { ok, data } = await apiCall('/api/timer/stop', { method: 'POST' });
  453. if (!ok) return;
  454. this.running = false;
  455. this.clearLocalStorage();
  456. this.applyStoppedState();
  457. try {
  458. this.updateEntryInDOM(stoppedId, data.entry, data.totalDuration);
  459. } catch { /* silent */ }
  460. } catch { /* silent */ }
  461. }
  462. // ── Timer-Tick ──────────────────────────────────────────────────────────────
  463. startTicking() {
  464. if (this.tickInterval) return;
  465. this.tick();
  466. this.tickInterval = setInterval(() => this.tick(), 1000);
  467. }
  468. stopTicking() {
  469. if (this.tickInterval) {
  470. clearInterval(this.tickInterval);
  471. this.tickInterval = null;
  472. }
  473. document.title = this.originalTitle;
  474. for (const ctx of this.contexts) {
  475. if (ctx.display) ctx.display.textContent = '0:00';
  476. if (ctx.headerTime) {
  477. ctx.headerTime.textContent = '';
  478. ctx.headerTime.hidden = true;
  479. }
  480. }
  481. }
  482. tick() {
  483. if (!this.startedAt) return;
  484. const elapsedSec = Math.floor((Date.now() - this.startedAt) / 1000);
  485. const totalSec = (this.baseDuration * 60) + elapsedSec;
  486. const h = Math.floor(totalSec / 3600);
  487. const m = Math.floor((totalSec % 3600) / 60);
  488. const s = totalSec % 60;
  489. const long = h > 0
  490. ? `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`
  491. : `${m}:${String(s).padStart(2, '0')}`;
  492. const short = `${h}:${String(m).padStart(2, '0')}`;
  493. document.title = `${short} - ${this.originalTitle}`;
  494. for (const ctx of this.contexts) {
  495. if (ctx.display) ctx.display.textContent = long;
  496. if (ctx.headerTime) {
  497. ctx.headerTime.textContent = short;
  498. ctx.headerTime.hidden = false;
  499. }
  500. }
  501. if (this.entryId) {
  502. const badge = document.querySelector(`#entry-${this.entryId} .entry-row__badge`);
  503. if (badge) badge.textContent = short;
  504. }
  505. }
  506. // ── Visual State ────────────────────────────────────────────────────────────
  507. applyRunningState() {
  508. for (const ctx of this.contexts) {
  509. ctx.toggle.classList.add(ctx.runningClass);
  510. if (this.entryLabel) ctx.toggle.title = this.entryLabel;
  511. }
  512. this.startTicking();
  513. this.markActiveEntryRow();
  514. }
  515. applyStoppedState() {
  516. for (const ctx of this.contexts) {
  517. ctx.toggle.classList.remove(ctx.runningClass);
  518. ctx.toggle.title = t('title');
  519. }
  520. this.stopTicking();
  521. this.entryId = null;
  522. this.startedAt = null;
  523. this.baseDuration = 0;
  524. this.entryLabel = '';
  525. this.clearActiveEntryRows();
  526. }
  527. markActiveEntryRow() {
  528. this.clearActiveEntryRows();
  529. if (!this.entryId) return;
  530. const row = document.getElementById(`entry-${this.entryId}`);
  531. if (row) row.classList.add('entry-row--timer-active');
  532. }
  533. clearActiveEntryRows() {
  534. document.querySelectorAll('.entry-row--timer-active')
  535. .forEach(el => el.classList.remove('entry-row--timer-active'));
  536. }
  537. // ── DOM Integration ─────────────────────────────────────────────────────────
  538. addEntryToDOM(entry, totalDuration) {
  539. if (!window.entryManager) return;
  540. window.entryManager.addEntryToDOM(entry);
  541. if (totalDuration) window.entryManager.updateTotal(totalDuration);
  542. this.markActiveEntryRow();
  543. }
  544. updateEntryInDOM(entryId, entry, totalDuration) {
  545. if (!window.entryManager || !entryId) return;
  546. const row = document.getElementById(`entry-${entryId}`);
  547. if (row) {
  548. window.entryManager.updateRowDisplay(row, entry);
  549. row.dataset.duration = entry.duration;
  550. }
  551. if (totalDuration) window.entryManager.updateTotal(totalDuration);
  552. }
  553. // ── LocalStorage ────────────────────────────────────────────────────────────
  554. saveToLocalStorage() {
  555. try {
  556. localStorage.setItem(LS_KEY, JSON.stringify({
  557. entryId: this.entryId,
  558. startedAt: this.startedAt,
  559. baseDuration: this.baseDuration,
  560. entryLabel: this.entryLabel,
  561. }));
  562. } catch { /* silent */ }
  563. }
  564. loadFromLocalStorage() {
  565. try {
  566. const raw = localStorage.getItem(LS_KEY);
  567. if (!raw) return;
  568. const data = JSON.parse(raw);
  569. if (data?.startedAt) {
  570. this.running = true;
  571. this.entryId = data.entryId;
  572. this.startedAt = data.startedAt;
  573. this.baseDuration = data.baseDuration ?? 0;
  574. this.entryLabel = data.entryLabel ?? '';
  575. this.applyRunningState();
  576. }
  577. } catch { /* silent */ }
  578. }
  579. clearLocalStorage() {
  580. try { localStorage.removeItem(LS_KEY); } catch { /* silent */ }
  581. }
  582. // ── Public ─────────────────────────────────────────────────────────────────
  583. static get SVG() { return STOPWATCH_SVG; }
  584. isRunningForEntry(entryId) {
  585. return this.running && this.entryId === entryId;
  586. }
  587. }
  588. window.stopwatchManager = null;
  589. document.addEventListener('DOMContentLoaded', () => {
  590. window.stopwatchManager = new StopwatchManager();
  591. });
  592. export { STOPWATCH_SVG };