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

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