選択できるのは25トピックまでです。 トピックは、先頭が英数字で、英数字とダッシュ('-')を使用した35文字以内のものにしてください。
 
 
 
 
 

659 行
21 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.toggle = document.getElementById('stopwatch-toggle');
  158. this.popover = document.getElementById('stopwatch-popover');
  159. this.display = document.getElementById('stopwatch-display');
  160. this.startBtn = document.getElementById('stopwatch-start');
  161. this.noteField = document.getElementById('stopwatch-note');
  162. this.hamburgerBtn = document.getElementById('hamburger-stopwatch');
  163. this.headerTime = document.getElementById('stopwatch-header-time');
  164. this.projectSelect = null;
  165. this.serviceSelect = null;
  166. const projEl = document.getElementById('stopwatch-project');
  167. const svcEl = document.getElementById('stopwatch-service');
  168. if (projEl) this.projectSelect = new SearchableSelect(projEl);
  169. if (svcEl) this.serviceSelect = new SearchableSelect(svcEl);
  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. if (!this.toggle) return;
  180. this.init();
  181. }
  182. async init() {
  183. this.toggle.addEventListener('click', (e) => {
  184. e.stopPropagation();
  185. this.handleToggleClick();
  186. });
  187. this.startBtn?.addEventListener('click', () => this.startNew());
  188. document.addEventListener('click', (e) => {
  189. if (this.popover && !this.popover.hidden
  190. && !this.popover.contains(e.target)
  191. && !this.toggle.contains(e.target)) {
  192. this.closePopover();
  193. }
  194. });
  195. this.hamburgerBtn?.addEventListener('click', () => this.handleToggleClick());
  196. this.loadFromLocalStorage();
  197. await this.loadStatus();
  198. }
  199. // ── Toggle ──────────────────────────────────────────────────────────────────
  200. handleToggleClick() {
  201. if (this.running) {
  202. this.stop();
  203. } else {
  204. this.togglePopover();
  205. }
  206. }
  207. async togglePopover() {
  208. if (!this.popover) return;
  209. if (!this.popover.hidden) {
  210. this.closePopover();
  211. return;
  212. }
  213. await this.loadOptions();
  214. this.populateSelects();
  215. this.popover.hidden = false;
  216. this.toggle.setAttribute('aria-expanded', 'true');
  217. this.projectSelect?.focus();
  218. }
  219. closePopover() {
  220. if (!this.popover) return;
  221. this.popover.hidden = true;
  222. this.toggle.setAttribute('aria-expanded', 'false');
  223. this.projectSelect?.close();
  224. this.serviceSelect?.close();
  225. }
  226. // ── Select-Builder ──────────────────────────────────────────────────────────
  227. populateSelects() {
  228. if (!this.cachedOptions) return;
  229. const lastProject = localStorage.getItem(LAST_PROJECT_KEY);
  230. const lastService = localStorage.getItem(LAST_SERVICE_KEY);
  231. if (this.projectSelect) {
  232. const groups = {};
  233. (this.cachedOptions.projects ?? []).forEach(p => {
  234. if (!groups[p.clientName]) groups[p.clientName] = [];
  235. groups[p.clientName].push(p);
  236. });
  237. this.projectSelect.setGroups(
  238. Object.entries(groups).map(([label, items]) => ({ label, items }))
  239. );
  240. if (lastProject) this.projectSelect.setValue(lastProject);
  241. }
  242. if (this.serviceSelect) {
  243. const billable = (this.cachedOptions.services ?? []).filter(s => s.billable);
  244. const notBillable = (this.cachedOptions.services ?? []).filter(s => !s.billable);
  245. const groups = [];
  246. if (billable.length) groups.push({ label: t('billable'), items: billable });
  247. if (notBillable.length) groups.push({ label: t('notBillable'), items: notBillable });
  248. this.serviceSelect.setGroups(groups);
  249. if (lastService) this.serviceSelect.setValue(lastService);
  250. }
  251. }
  252. // ── API ─────────────────────────────────────────────────────────────────────
  253. async loadStatus() {
  254. try {
  255. const { ok, data } = await apiCall('/api/timer/status');
  256. if (!ok || !data) {
  257. if (this.running) {
  258. this.running = false;
  259. this.clearLocalStorage();
  260. this.applyStoppedState();
  261. }
  262. return;
  263. }
  264. if (data.running && data.startedAt) {
  265. this.running = true;
  266. this.entryId = data.entry?.id ?? null;
  267. this.startedAt = new Date(data.startedAt).getTime();
  268. this.baseDuration = data.entry?.duration ?? 0;
  269. this.entryLabel = buildEntryLabel(data.entry);
  270. this.saveToLocalStorage();
  271. this.applyRunningState();
  272. } else if (this.running) {
  273. this.running = false;
  274. this.clearLocalStorage();
  275. this.applyStoppedState();
  276. }
  277. } catch {
  278. if (this.running) {
  279. this.running = false;
  280. this.clearLocalStorage();
  281. this.applyStoppedState();
  282. }
  283. }
  284. }
  285. async loadOptions() {
  286. if (this.cachedOptions) return;
  287. try {
  288. const { ok, data } = await apiCall('/api/timer/options');
  289. if (ok && data) this.cachedOptions = data;
  290. } catch { /* silent */ }
  291. }
  292. async startNew() {
  293. if (this.busy) return;
  294. const projectId = this.projectSelect?.getValue();
  295. if (!projectId) {
  296. this.projectSelect?.focus();
  297. return;
  298. }
  299. this.busy = true;
  300. this.startBtn.disabled = true;
  301. try {
  302. const serviceId = this.serviceSelect?.getValue();
  303. const { ok, status, data } = await apiCall('/api/timer/start', {
  304. method: 'POST',
  305. headers: { 'Content-Type': 'application/json' },
  306. body: JSON.stringify({
  307. projectId: parseInt(projectId, 10),
  308. serviceId: serviceId ? parseInt(serviceId, 10) : null,
  309. note: this.noteField?.value || null,
  310. }),
  311. });
  312. if (status === 409 && data) {
  313. const name = data.runningEntry
  314. ? `${data.runningEntry.clientName} / ${data.runningEntry.projectName}`
  315. : '';
  316. const msg = t('confirmReplace').replace('%project%', name);
  317. if (!confirm(msg)) return;
  318. await this.forceStop();
  319. this.busy = false;
  320. return this.startNew();
  321. }
  322. if (!ok) {
  323. alert(t('errorStart') + (data?.error ? `\n${data.error}` : ` (HTTP ${status})`));
  324. return;
  325. }
  326. this.running = true;
  327. this.entryId = data.entry.id;
  328. this.startedAt = new Date(data.entry.timerStartedAt).getTime();
  329. this.baseDuration = data.entry.duration;
  330. this.entryLabel = buildEntryLabel(data.entry);
  331. this.saveToLocalStorage();
  332. this.applyRunningState();
  333. this.closePopover();
  334. if (projectId) localStorage.setItem(LAST_PROJECT_KEY, projectId);
  335. if (serviceId) localStorage.setItem(LAST_SERVICE_KEY, serviceId);
  336. try {
  337. this.addEntryToDOM(data.entry, data.totalDuration);
  338. } catch (domEx) {
  339. console.error('[Stopwatch] addEntryToDOM error (non-fatal):', domEx);
  340. }
  341. } catch (ex) {
  342. console.error('[Stopwatch] startNew error:', ex);
  343. alert(t('errorStart') + `\n${ex.message}`);
  344. } finally {
  345. this.busy = false;
  346. if (this.startBtn) this.startBtn.disabled = false;
  347. }
  348. }
  349. async resumeEntry(entryId) {
  350. if (this.busy) return;
  351. if (this.running && this.entryId === entryId) {
  352. this.stop();
  353. return;
  354. }
  355. this.busy = true;
  356. try {
  357. const { ok, status, data } = await apiCall(`/api/timer/start/${entryId}`, {
  358. method: 'POST',
  359. });
  360. if (status === 409 && data) {
  361. const name = data.runningEntry
  362. ? `${data.runningEntry.clientName} / ${data.runningEntry.projectName}`
  363. : '';
  364. const msg = t('confirmReplace').replace('%project%', name);
  365. if (!confirm(msg)) return;
  366. await this.forceStop();
  367. this.busy = false;
  368. return this.resumeEntry(entryId);
  369. }
  370. if (!ok) {
  371. alert(t('errorStart') + (data?.error ? `\n${data.error}` : ` (HTTP ${status})`));
  372. return;
  373. }
  374. this.running = true;
  375. this.entryId = data.entry.id;
  376. this.startedAt = new Date(data.entry.timerStartedAt).getTime();
  377. this.baseDuration = data.entry.duration;
  378. this.entryLabel = buildEntryLabel(data.entry);
  379. this.saveToLocalStorage();
  380. this.applyRunningState();
  381. } catch (ex) {
  382. console.error('[Stopwatch] resumeEntry error:', ex);
  383. alert(t('errorStart') + `\n${ex.message}`);
  384. } finally {
  385. this.busy = false;
  386. }
  387. }
  388. async stop() {
  389. if (this.busy) return;
  390. this.busy = true;
  391. try {
  392. const { ok, status, data } = await apiCall('/api/timer/stop', { method: 'POST' });
  393. if (!ok) {
  394. alert(t('errorStop') + (data?.error ? `\n${data.error}` : ` (HTTP ${status})`));
  395. if (status === 404) {
  396. this.running = false;
  397. this.clearLocalStorage();
  398. this.applyStoppedState();
  399. }
  400. return;
  401. }
  402. const stoppedEntryId = this.entryId;
  403. this.running = false;
  404. this.clearLocalStorage();
  405. this.applyStoppedState();
  406. try {
  407. this.updateEntryInDOM(stoppedEntryId, data.entry, data.totalDuration);
  408. } catch (domEx) {
  409. console.error('[Stopwatch] updateEntryInDOM error (non-fatal):', domEx);
  410. }
  411. } catch (ex) {
  412. console.error('[Stopwatch] stop error:', ex);
  413. alert(t('errorStop') + `\n${ex.message}`);
  414. } finally {
  415. this.busy = false;
  416. }
  417. }
  418. async forceStop() {
  419. try {
  420. const stoppedId = this.entryId;
  421. const { ok, data } = await apiCall('/api/timer/stop', { method: 'POST' });
  422. if (!ok) return;
  423. this.running = false;
  424. this.clearLocalStorage();
  425. this.applyStoppedState();
  426. try {
  427. this.updateEntryInDOM(stoppedId, data.entry, data.totalDuration);
  428. } catch { /* silent */ }
  429. } catch { /* silent */ }
  430. }
  431. // ── Timer-Tick ──────────────────────────────────────────────────────────────
  432. startTicking() {
  433. if (this.tickInterval) return;
  434. this.tick();
  435. this.tickInterval = setInterval(() => this.tick(), 1000);
  436. }
  437. stopTicking() {
  438. if (this.tickInterval) {
  439. clearInterval(this.tickInterval);
  440. this.tickInterval = null;
  441. }
  442. document.title = this.originalTitle;
  443. if (this.display) this.display.textContent = '0:00';
  444. if (this.headerTime) {
  445. this.headerTime.textContent = '';
  446. this.headerTime.hidden = true;
  447. }
  448. }
  449. tick() {
  450. if (!this.startedAt) return;
  451. const elapsedSec = Math.floor((Date.now() - this.startedAt) / 1000);
  452. const totalSec = (this.baseDuration * 60) + elapsedSec;
  453. const h = Math.floor(totalSec / 3600);
  454. const m = Math.floor((totalSec % 3600) / 60);
  455. const s = totalSec % 60;
  456. const long = h > 0
  457. ? `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`
  458. : `${m}:${String(s).padStart(2, '0')}`;
  459. const short = `${h}:${String(m).padStart(2, '0')}`;
  460. document.title = `${short} - ${this.originalTitle}`;
  461. if (this.display) this.display.textContent = long;
  462. if (this.headerTime) {
  463. this.headerTime.textContent = short;
  464. this.headerTime.hidden = false;
  465. }
  466. if (this.entryId) {
  467. const badge = document.querySelector(`#entry-${this.entryId} .entry-row__badge`);
  468. if (badge) badge.textContent = short;
  469. }
  470. }
  471. // ── Visual State ────────────────────────────────────────────────────────────
  472. applyRunningState() {
  473. this.toggle?.classList.add('main-nav__stopwatch--running');
  474. this.hamburgerBtn?.classList.add('hamburger-nav__stopwatch--running');
  475. if (this.toggle && this.entryLabel) this.toggle.title = this.entryLabel;
  476. this.startTicking();
  477. this.markActiveEntryRow();
  478. }
  479. applyStoppedState() {
  480. this.toggle?.classList.remove('main-nav__stopwatch--running');
  481. this.hamburgerBtn?.classList.remove('hamburger-nav__stopwatch--running');
  482. if (this.toggle) this.toggle.title = t('title');
  483. this.stopTicking();
  484. this.entryId = null;
  485. this.startedAt = null;
  486. this.baseDuration = 0;
  487. this.entryLabel = '';
  488. this.clearActiveEntryRows();
  489. }
  490. markActiveEntryRow() {
  491. this.clearActiveEntryRows();
  492. if (!this.entryId) return;
  493. const row = document.getElementById(`entry-${this.entryId}`);
  494. if (row) row.classList.add('entry-row--timer-active');
  495. }
  496. clearActiveEntryRows() {
  497. document.querySelectorAll('.entry-row--timer-active')
  498. .forEach(el => el.classList.remove('entry-row--timer-active'));
  499. }
  500. // ── DOM Integration ─────────────────────────────────────────────────────────
  501. addEntryToDOM(entry, totalDuration) {
  502. if (!window.entryManager) return;
  503. window.entryManager.addEntryToDOM(entry);
  504. if (totalDuration) window.entryManager.updateTotal(totalDuration);
  505. this.markActiveEntryRow();
  506. }
  507. updateEntryInDOM(entryId, entry, totalDuration) {
  508. if (!window.entryManager || !entryId) return;
  509. const row = document.getElementById(`entry-${entryId}`);
  510. if (row) {
  511. window.entryManager.updateRowDisplay(row, entry);
  512. row.dataset.duration = entry.duration;
  513. }
  514. if (totalDuration) window.entryManager.updateTotal(totalDuration);
  515. }
  516. // ── LocalStorage ────────────────────────────────────────────────────────────
  517. saveToLocalStorage() {
  518. try {
  519. localStorage.setItem(LS_KEY, JSON.stringify({
  520. entryId: this.entryId,
  521. startedAt: this.startedAt,
  522. baseDuration: this.baseDuration,
  523. entryLabel: this.entryLabel,
  524. }));
  525. } catch { /* silent */ }
  526. }
  527. loadFromLocalStorage() {
  528. try {
  529. const raw = localStorage.getItem(LS_KEY);
  530. if (!raw) return;
  531. const data = JSON.parse(raw);
  532. if (data?.startedAt) {
  533. this.running = true;
  534. this.entryId = data.entryId;
  535. this.startedAt = data.startedAt;
  536. this.baseDuration = data.baseDuration ?? 0;
  537. this.entryLabel = data.entryLabel ?? '';
  538. this.applyRunningState();
  539. }
  540. } catch { /* silent */ }
  541. }
  542. clearLocalStorage() {
  543. try { localStorage.removeItem(LS_KEY); } catch { /* silent */ }
  544. }
  545. // ── Public ─────────────────────────────────────────────────────────────────
  546. static get SVG() { return STOPWATCH_SVG; }
  547. isRunningForEntry(entryId) {
  548. return this.running && this.entryId === entryId;
  549. }
  550. }
  551. window.stopwatchManager = null;
  552. document.addEventListener('DOMContentLoaded', () => {
  553. window.stopwatchManager = new StopwatchManager();
  554. });
  555. export { STOPWATCH_SVG };