clocktopus 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,843 @@
1
+ export function indexPage() {
2
+ return `<!DOCTYPE html>
3
+ <html lang="en">
4
+ <head>
5
+ <meta charset="UTF-8">
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
7
+ <title>Clocktopus Dashboard</title>
8
+ <style>
9
+ * { box-sizing: border-box; margin: 0; padding: 0; }
10
+ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #0f1117; color: #e1e4e8; padding: 2rem; }
11
+ h1 { font-size: 1.8rem; margin-bottom: 0; color: #fff; }
12
+ h2 { font-size: 1.1rem; color: #fff; margin-bottom: 1rem; }
13
+
14
+ /* Nav */
15
+ .header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 1.5rem; }
16
+ .nav { display: flex; gap: 0.25rem; background: #1c1f26; border-radius: 10px; padding: 0.3rem; }
17
+ .nav-btn { margin-top: 0; padding: 0.5rem 1.25rem; border: none; border-radius: 6px; background: transparent; color: #8b949e; font-size: 0.9rem; cursor: pointer; }
18
+ .nav-btn:hover { color: #e1e4e8; }
19
+ .nav-btn.active { background: #30363d; color: #fff; }
20
+ .tab-content { display: none; }
21
+ .tab-content.active { display: block; }
22
+
23
+ /* Cards */
24
+ .cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(380px, 1fr)); gap: 1.5rem; }
25
+ .card { background: #1c1f26; border: 1px solid #2d3139; border-radius: 12px; padding: 1.5rem; }
26
+ .card-header { display: flex; align-items: center; gap: 0.75rem; margin-bottom: 1rem; }
27
+ .card-header h2 { margin-bottom: 0; }
28
+ .card-full { grid-column: 1 / -1; }
29
+
30
+ /* Active timer */
31
+ .active-timer { border-left: 3px solid #3fb950; display: flex; align-items: center; justify-content: space-between; gap: 1rem; flex-wrap: wrap; }
32
+ .active-timer .timer-info { flex: 1; }
33
+ .active-timer .timer-desc { font-size: 1rem; color: #fff; font-weight: 500; }
34
+ .active-timer .timer-elapsed { font-size: 0.9rem; color: #3fb950; margin-top: 0.25rem; }
35
+ .stop-btn { background: #da3633; margin-top: 0; }
36
+ .stop-btn:hover { background: #f85149; }
37
+
38
+ /* Form elements */
39
+ .dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
40
+ .dot.green { background: #3fb950; }
41
+ .dot.red { background: #f85149; }
42
+ .dot.gray { background: #484f58; }
43
+ label { display: block; font-size: 0.85rem; color: #8b949e; margin-bottom: 0.25rem; margin-top: 0.75rem; }
44
+ input, select { width: 100%; padding: 0.5rem 0.75rem; background: #0d1117; border: 1px solid #30363d; border-radius: 6px; color: #e1e4e8; font-size: 0.9rem; }
45
+ input:focus, select:focus { outline: none; border-color: #58a6ff; }
46
+ select { appearance: none; -webkit-appearance: none; cursor: pointer; }
47
+ button { margin-top: 1rem; padding: 0.5rem 1.25rem; background: #238636; border: none; border-radius: 6px; color: #fff; font-size: 0.9rem; cursor: pointer; }
48
+ button:hover { background: #2ea043; }
49
+ button:disabled { opacity: 0.5; cursor: not-allowed; }
50
+ button.connect { background: #1f6feb; }
51
+ button.connect:hover { background: #388bfd; }
52
+ .msg { font-size: 0.85rem; margin-top: 0.5rem; }
53
+ .msg.ok { color: #3fb950; }
54
+ .msg.err { color: #f85149; }
55
+ .guide { font-size: 0.8rem; color: #8b949e; margin-bottom: 0.25rem; }
56
+ .guide a { color: #58a6ff; text-decoration: none; }
57
+ .guide a:hover { text-decoration: underline; }
58
+ .guide ol { margin: 0.25rem 0 0 1.25rem; }
59
+ .guide li { margin-bottom: 0.15rem; }
60
+
61
+ /* Sessions table */
62
+ .table-wrap { overflow-x: auto; -webkit-overflow-scrolling: touch; }
63
+ .sessions-table { width: 100%; border-collapse: collapse; font-size: 0.85rem; min-width: 540px; }
64
+ .sessions-table th { text-align: left; color: #8b949e; padding: 0.5rem 0.75rem; border-bottom: 1px solid #30363d; font-weight: 500; white-space: nowrap; }
65
+ .sessions-table td { padding: 0.5rem 0.75rem; border-bottom: 1px solid #21262d; }
66
+ .sessions-table tr:hover { background: #161b22; }
67
+ .sessions-table .in-progress { color: #3fb950; font-style: italic; }
68
+ .empty-state { color: #8b949e; font-size: 0.9rem; padding: 2rem; text-align: center; }
69
+
70
+ /* Inline form row */
71
+ .form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }
72
+ @media (max-width: 600px) {
73
+ body { padding: 0.75rem; }
74
+ .form-row { grid-template-columns: 1fr; }
75
+ .header { flex-direction: column; gap: 0; align-items: stretch; margin-bottom: 1rem; }
76
+ .header h1 { display: none; }
77
+ .nav { justify-content: center; flex-wrap: wrap; }
78
+ .nav-btn { padding: 0.5rem 1rem; font-size: 0.8rem; }
79
+ }
80
+ /* Project toggles */
81
+ .project-item { display: flex; align-items: center; gap: 0.75rem; padding: 0.4rem 0; border-bottom: 1px solid #21262d; }
82
+ .project-item:last-child { border-bottom: none; }
83
+ .project-item label { margin: 0; color: #e1e4e8; font-size: 0.9rem; cursor: pointer; flex: 1; }
84
+ .toggle { position: relative; width: 36px; height: 20px; flex-shrink: 0; }
85
+ .toggle input { opacity: 0; width: 100%; height: 100%; position: absolute; inset: 0; z-index: 1; cursor: pointer; margin: 0; }
86
+ .toggle .slider { position: absolute; inset: 0; background: #30363d; border-radius: 10px; cursor: pointer; transition: background 0.2s; pointer-events: none; }
87
+ .toggle .slider::before { content: ''; position: absolute; width: 16px; height: 16px; left: 2px; top: 2px; background: #8b949e; border-radius: 50%; transition: transform 0.2s, background 0.2s; }
88
+ .toggle input:checked + .slider { background: #238636; }
89
+ .toggle input:checked + .slider::before { transform: translateX(16px); background: #fff; }
90
+ </style>
91
+ </head>
92
+ <body>
93
+ <div class="header">
94
+ <h1>Clocktopus</h1>
95
+ <div class="nav">
96
+ <button class="nav-btn active" onclick="switchTab('home')" id="nav-home">Home</button>
97
+ <button class="nav-btn" onclick="switchTab('projects')" id="nav-projects">Projects</button>
98
+ <button class="nav-btn" onclick="switchTab('settings')" id="nav-settings">Settings</button>
99
+ </div>
100
+ </div>
101
+
102
+ <!-- HOME TAB -->
103
+ <div id="tab-home" class="tab-content active">
104
+
105
+ <!-- Active Timer Banner -->
106
+ <div id="active-timer" class="card active-timer" style="display:none; margin-bottom:1.5rem;">
107
+ <div class="timer-info">
108
+ <div class="timer-desc" id="active-timer-desc"></div>
109
+ <div class="timer-elapsed" id="active-timer-elapsed"></div>
110
+ </div>
111
+ <button class="stop-btn" onclick="stopTimer()">Stop Timer</button>
112
+ </div>
113
+
114
+ <div class="cards">
115
+ <!-- Start Timer -->
116
+ <div class="card">
117
+ <h2>Start Timer</h2>
118
+ <div id="start-timer-form">
119
+ <label for="project-select">Project</label>
120
+ <select id="project-select">
121
+ <option value="">Loading projects...</option>
122
+ </select>
123
+ <div class="form-row">
124
+ <div>
125
+ <label for="timer-description">Description</label>
126
+ <input type="text" id="timer-description" placeholder="What are you working on?" />
127
+ </div>
128
+ <div>
129
+ <label for="timer-jira">Jira Ticket (optional)</label>
130
+ <input type="text" id="timer-jira" placeholder="e.g. PROJ-123" />
131
+ </div>
132
+ </div>
133
+ <button id="start-btn" onclick="startTimer()">Start Timer</button>
134
+ </div>
135
+ <div id="last-tasks" style="display:none; margin-top:0.75rem;"></div>
136
+ <div class="msg" id="timer-msg"></div>
137
+ </div>
138
+
139
+ <!-- Monitor Control -->
140
+ <div class="card">
141
+ <div class="card-header">
142
+ <div class="dot gray" id="monitor-dot"></div>
143
+ <h2>Idle Monitor</h2>
144
+ </div>
145
+ <p id="monitor-desc" style="font-size:0.85rem;color:#8b949e;margin-bottom:0.5rem;">Auto-stops timers when idle, resumes when active.</p>
146
+ <div style="display:flex;gap:0.5rem;flex-wrap:wrap;">
147
+ <button id="monitor-start-btn" onclick="monitorAction('start')" style="margin-top:0;">Start</button>
148
+ <button id="monitor-stop-btn" onclick="monitorAction('stop')" class="stop-btn" style="margin-top:0;" disabled>Stop</button>
149
+ <button id="monitor-restart-btn" onclick="monitorAction('restart')" style="margin-top:0;background:#30363d;" disabled>Restart</button>
150
+ </div>
151
+ <div class="msg" id="monitor-msg"></div>
152
+ </div>
153
+
154
+ <!-- Session History -->
155
+ <div class="card card-full">
156
+ <h2>Recent Sessions</h2>
157
+ <div id="sessions-container" class="table-wrap">
158
+ <table class="sessions-table">
159
+ <thead>
160
+ <tr>
161
+ <th>Description</th>
162
+ <th>Project</th>
163
+ <th>Started</th>
164
+ <th>Duration</th>
165
+ <th>Jira</th>
166
+ </tr>
167
+ </thead>
168
+ <tbody id="sessions-body">
169
+ <tr><td colspan="5" class="empty-state">Loading...</td></tr>
170
+ </tbody>
171
+ </table>
172
+ <div id="pagination" style="display:none; margin-top:1rem; align-items:center; justify-content:center; gap:0.75rem; flex-wrap:wrap;">
173
+ <button id="prev-btn" onclick="changePage(-1)" style="background:#30363d; margin-top:0; padding:0.3rem 0.75rem;" disabled>&lt;</button>
174
+ <span id="page-info" style="font-size:0.85rem; color:#8b949e;"></span>
175
+ <button id="next-btn" onclick="changePage(1)" style="background:#30363d; margin-top:0; padding:0.3rem 0.75rem;">&gt;</button>
176
+ </div>
177
+ </div>
178
+ </div>
179
+ </div>
180
+ </div>
181
+
182
+ <!-- SETTINGS TAB -->
183
+ <div id="tab-settings" class="tab-content">
184
+ <div class="cards">
185
+
186
+ <!-- Clockify -->
187
+ <div class="card">
188
+ <div class="card-header">
189
+ <div class="dot gray" id="clockify-dot"></div>
190
+ <h2>Clockify</h2>
191
+ </div>
192
+ <div class="guide">
193
+ <ol>
194
+ <li>Go to <a href="https://app.clockify.me/manage-api-keys" target="_blank">Manage API Keys</a></li>
195
+ <li>Click <strong>Generate</strong>, enter a name, and confirm</li>
196
+ <li>Copy the key and paste it below</li>
197
+ </ol>
198
+ </div>
199
+ <label for="clockify-key">API Key</label>
200
+ <input type="password" id="clockify-key" placeholder="Enter your Clockify API key" />
201
+ <button onclick="saveClockify()">Save &amp; Validate</button>
202
+ <div class="msg" id="clockify-msg"></div>
203
+ </div>
204
+
205
+ <!-- Google Calendar -->
206
+ <div class="card">
207
+ <div class="card-header">
208
+ <div class="dot gray" id="google-dot"></div>
209
+ <h2>Google Calendar</h2>
210
+ </div>
211
+ <p id="google-desc" style="font-size:0.85rem;color:#8b949e;margin-bottom:0.5rem;">Authorize access to your Google Calendar.</p>
212
+ <button class="connect" id="google-connect-btn" onclick="connectGoogle()">Connect Google Account</button>
213
+ <div class="msg" id="google-msg"></div>
214
+ </div>
215
+
216
+ <!-- Jira -->
217
+ <div class="card">
218
+ <div class="card-header">
219
+ <div class="dot gray" id="jira-dot"></div>
220
+ <h2>Jira</h2>
221
+ </div>
222
+ <p id="jira-desc" style="font-size:0.85rem;color:#8b949e;margin-bottom:0.5rem;">Connect your Atlassian account to log time on Jira tickets.</p>
223
+ <button class="connect" id="jira-connect-btn" onclick="connectJira()">Connect Atlassian</button>
224
+ <div class="msg" id="jira-msg"></div>
225
+ <div style="margin-top:1rem;">
226
+ <a href="#" id="jira-toggle" onclick="toggleJiraForm(event)" style="font-size:0.8rem;color:#8b949e;text-decoration:none;">or use API token &darr;</a>
227
+ <div id="jira-form" style="display:none;margin-top:0.5rem;">
228
+ <div class="guide">
229
+ <ol>
230
+ <li>Go to <a href="https://id.atlassian.com/manage-profile/security/api-tokens" target="_blank">Atlassian API Tokens</a></li>
231
+ <li>Click <strong>Create API token</strong> and copy it</li>
232
+ <li>Your URL is <code>https://&lt;your-org&gt;.atlassian.net/rest/api/3</code></li>
233
+ </ol>
234
+ </div>
235
+ <label for="jira-url">Atlassian URL</label>
236
+ <input type="text" id="jira-url" placeholder="https://your-org.atlassian.net/rest/api/3" />
237
+ <label for="jira-email">Email</label>
238
+ <input type="email" id="jira-email" placeholder="you@example.com" />
239
+ <label for="jira-token">API Token</label>
240
+ <input type="password" id="jira-token" placeholder="Atlassian API token" />
241
+ <button onclick="saveJira()">Save &amp; Validate</button>
242
+ </div>
243
+ </div>
244
+ </div>
245
+
246
+ </div>
247
+ </div>
248
+
249
+ <!-- PROJECTS TAB -->
250
+ <div id="tab-projects" class="tab-content">
251
+ <div class="card">
252
+ <div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:1rem;">
253
+ <h2 style="margin-bottom:0;">Projects</h2>
254
+ <button id="fetch-projects-btn" onclick="fetchProjects()" style="margin-top:0; background:#1f6feb;">Pull from Clockify</button>
255
+ </div>
256
+ <div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:0.75rem;">
257
+ <p style="font-size:0.8rem; color:#8b949e; margin:0;">Toggle projects on/off to control which appear in the timer dropdown.</p>
258
+ <a href="#" id="toggle-all-link" onclick="toggleAllProjects(event)" style="font-size:0.8rem; color:#58a6ff; text-decoration:none; white-space:nowrap; margin-left:1rem;">Deselect all</a>
259
+ </div>
260
+ <div class="msg" id="projects-msg"></div>
261
+ <div id="projects-list" style="margin-top:0.5rem;"></div>
262
+ </div>
263
+ </div>
264
+
265
+ <script>
266
+ let elapsedInterval = null;
267
+ let currentPage = 1;
268
+ let totalPages = 1;
269
+
270
+ // --- Tab switching ---
271
+ function switchTab(tab) {
272
+ document.querySelectorAll('.tab-content').forEach(el => el.classList.remove('active'));
273
+ document.querySelectorAll('.nav-btn').forEach(el => el.classList.remove('active'));
274
+ document.getElementById('tab-' + tab).classList.add('active');
275
+ document.getElementById('nav-' + tab).classList.add('active');
276
+ }
277
+
278
+ // --- Utilities ---
279
+ function setMsg(id, text, ok) {
280
+ const el = document.getElementById(id);
281
+ el.textContent = text;
282
+ el.className = 'msg ' + (ok ? 'ok' : 'err');
283
+ }
284
+
285
+ function setDot(id, color) {
286
+ document.getElementById(id).className = 'dot ' + color;
287
+ }
288
+
289
+ function formatDuration(ms) {
290
+ const totalSec = Math.floor(ms / 1000);
291
+ const h = Math.floor(totalSec / 3600);
292
+ const m = Math.floor((totalSec % 3600) / 60);
293
+ const s = totalSec % 60;
294
+ if (h > 0) return h + 'h ' + m + 'm ' + s + 's';
295
+ if (m > 0) return m + 'm ' + s + 's';
296
+ return s + 's';
297
+ }
298
+
299
+ function formatDate(iso) {
300
+ if (!iso) return '-';
301
+ const d = new Date(iso);
302
+ return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) + ' ' +
303
+ d.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' });
304
+ }
305
+
306
+ // --- Timer ---
307
+ async function checkActiveTimer() {
308
+ try {
309
+ const res = await fetch('/api/timer/active');
310
+ const data = await res.json();
311
+ const banner = document.getElementById('active-timer');
312
+ const startBtn = document.getElementById('start-btn');
313
+
314
+ if (data.active) {
315
+ document.getElementById('active-timer-desc').textContent = data.description || 'Timer running';
316
+ banner.style.display = 'flex';
317
+ startBtn.disabled = true;
318
+ startBtn.textContent = 'Timer Running...';
319
+ document.getElementById('last-tasks').style.display = 'none';
320
+
321
+ if (elapsedInterval) clearInterval(elapsedInterval);
322
+ const startTime = new Date(data.start).getTime();
323
+ function updateElapsed() {
324
+ const elapsed = Date.now() - startTime;
325
+ document.getElementById('active-timer-elapsed').textContent = formatDuration(elapsed);
326
+ }
327
+ updateElapsed();
328
+ elapsedInterval = setInterval(updateElapsed, 1000);
329
+ } else {
330
+ banner.style.display = 'none';
331
+ startBtn.disabled = false;
332
+ startBtn.textContent = 'Start Timer';
333
+ if (elapsedInterval) { clearInterval(elapsedInterval); elapsedInterval = null; }
334
+ loadLastTask();
335
+ }
336
+ } catch {}
337
+ }
338
+
339
+ async function startTimer() {
340
+ const projectId = document.getElementById('project-select').value;
341
+ const description = document.getElementById('timer-description').value.trim();
342
+ const jiraTicket = document.getElementById('timer-jira').value.trim();
343
+
344
+ if (!projectId) return setMsg('timer-msg', 'Please select a project.', false);
345
+ if (!description && !jiraTicket) return setMsg('timer-msg', 'Please enter a description or Jira ticket.', false);
346
+
347
+ const btn = document.getElementById('start-btn');
348
+ btn.disabled = true;
349
+ btn.textContent = 'Starting...';
350
+
351
+ try {
352
+ const res = await fetch('/api/timer/start', {
353
+ method: 'POST',
354
+ headers: { 'Content-Type': 'application/json' },
355
+ body: JSON.stringify({ projectId, description: description || 'Working on a task...', jiraTicket: jiraTicket || undefined }),
356
+ });
357
+ const data = await res.json();
358
+ if (data.ok) {
359
+ setMsg('timer-msg', 'Timer started!', true);
360
+ document.getElementById('timer-description').value = '';
361
+ document.getElementById('timer-jira').value = '';
362
+ checkActiveTimer();
363
+ loadSessions();
364
+ } else {
365
+ setMsg('timer-msg', data.error || 'Failed to start timer.', false);
366
+ btn.disabled = false;
367
+ btn.textContent = 'Start Timer';
368
+ }
369
+ } catch {
370
+ setMsg('timer-msg', 'Request failed.', false);
371
+ btn.disabled = false;
372
+ btn.textContent = 'Start Timer';
373
+ }
374
+ }
375
+
376
+ async function stopTimer() {
377
+ try {
378
+ const res = await fetch('/api/timer/stop', {
379
+ method: 'POST',
380
+ });
381
+ const data = await res.json();
382
+ if (data.ok) {
383
+ setMsg('timer-msg', 'Timer stopped.', true);
384
+ checkActiveTimer();
385
+ loadSessions();
386
+ } else {
387
+ setMsg('timer-msg', data.error || 'Failed to stop timer.', false);
388
+ }
389
+ } catch {
390
+ setMsg('timer-msg', 'Request failed.', false);
391
+ }
392
+ }
393
+
394
+ // --- Last Tasks ---
395
+ var lastTasks = [];
396
+
397
+ async function loadLastTask() {
398
+ try {
399
+ var res = await fetch('/api/sessions?page=1&limit=2');
400
+ var result = await res.json();
401
+ var container = document.getElementById('last-tasks');
402
+ if (result.data && result.data.length > 0) {
403
+ lastTasks = result.data;
404
+ container.innerHTML = lastTasks.map(function(t, i) {
405
+ var label = t.description || 'Untitled';
406
+ if (t.projectName) label = t.projectName + ' — ' + label;
407
+ return '<div style="display:flex; align-items:center; padding:0.5rem 0.75rem; background:#161b22; border:1px solid #30363d; font-size:0.85rem;' +
408
+ (i === 0 ? ' border-radius:8px 8px 0 0; border-bottom:none;' : ' border-radius:0 0 8px 8px;') + '">' +
409
+ '<span style="color:#e1e4e8; flex:1; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;">' + escapeHtml(label) + '</span>' +
410
+ '<button onclick="restartTask(' + i + ')" style="margin-top:0; margin-left:0.5rem; padding:0.2rem 0.6rem; font-size:0.8rem; background:#30363d; flex-shrink:0;">Restart</button>' +
411
+ '</div>';
412
+ }).join('');
413
+ container.style.display = 'block';
414
+ } else {
415
+ container.style.display = 'none';
416
+ }
417
+ } catch {
418
+ document.getElementById('last-tasks').style.display = 'none';
419
+ }
420
+ }
421
+
422
+ async function restartTask(index) {
423
+ var task = lastTasks[index];
424
+ if (!task) return;
425
+ var btns = document.getElementById('last-tasks').querySelectorAll('button');
426
+ btns[index].disabled = true;
427
+ btns[index].textContent = 'Starting...';
428
+ try {
429
+ var res = await fetch('/api/timer/start', {
430
+ method: 'POST',
431
+ headers: { 'Content-Type': 'application/json' },
432
+ body: JSON.stringify({
433
+ projectId: task.projectId,
434
+ description: task.description || 'Working on a task...',
435
+ jiraTicket: task.jiraTicket || undefined,
436
+ }),
437
+ });
438
+ var data = await res.json();
439
+ if (data.ok) {
440
+ setMsg('timer-msg', 'Restarted task!', true);
441
+ checkActiveTimer();
442
+ loadSessions();
443
+ } else {
444
+ setMsg('timer-msg', data.error || 'Failed.', false);
445
+ }
446
+ } catch {
447
+ setMsg('timer-msg', 'Request failed.', false);
448
+ }
449
+ btns[index].disabled = false;
450
+ btns[index].textContent = 'Restart';
451
+ }
452
+
453
+ // --- Monitor ---
454
+ async function checkMonitorStatus() {
455
+ try {
456
+ const res = await fetch('/api/monitor/status');
457
+ const data = await res.json();
458
+ const dot = document.getElementById('monitor-dot');
459
+ const desc = document.getElementById('monitor-desc');
460
+ const startBtn = document.getElementById('monitor-start-btn');
461
+ const stopBtn = document.getElementById('monitor-stop-btn');
462
+ const restartBtn = document.getElementById('monitor-restart-btn');
463
+
464
+ if (data.running) {
465
+ dot.className = 'dot green';
466
+ const uptime = data.uptime ? formatDuration(Date.now() - data.uptime) : '';
467
+ desc.textContent = 'Running' + (uptime ? ' for ' + uptime : '') + (data.restarts > 0 ? ' (' + data.restarts + ' restarts)' : '');
468
+ desc.style.color = '#3fb950';
469
+ startBtn.disabled = true;
470
+ stopBtn.disabled = false;
471
+ restartBtn.disabled = false;
472
+ } else {
473
+ dot.className = 'dot red';
474
+ desc.textContent = data.status === 'stopped' ? 'Stopped' : 'Not running';
475
+ desc.style.color = '#8b949e';
476
+ startBtn.disabled = false;
477
+ stopBtn.disabled = true;
478
+ restartBtn.disabled = true;
479
+ }
480
+ } catch {
481
+ document.getElementById('monitor-dot').className = 'dot gray';
482
+ }
483
+ }
484
+
485
+ async function monitorAction(action) {
486
+ setMsg('monitor-msg', '', true);
487
+ try {
488
+ const res = await fetch('/api/monitor/' + action, { method: 'POST' });
489
+ const data = await res.json();
490
+ if (data.ok) {
491
+ setMsg('monitor-msg', 'Monitor ' + action + (action === 'stop' ? 'ped' : 'ed') + '.', true);
492
+ } else {
493
+ setMsg('monitor-msg', data.output || 'Failed.', false);
494
+ }
495
+ setTimeout(checkMonitorStatus, 1000);
496
+ } catch {
497
+ setMsg('monitor-msg', 'Request failed.', false);
498
+ }
499
+ }
500
+
501
+ // --- Projects ---
502
+ async function loadProjects() {
503
+ try {
504
+ const res = await fetch('/api/projects');
505
+ const projects = await res.json();
506
+ const select = document.getElementById('project-select');
507
+ select.innerHTML = '<option value="">Select a project</option>';
508
+ if (projects.length === 0) {
509
+ select.innerHTML = '<option value="">No active projects \u2014 pull from Clockify in Settings</option>';
510
+ return;
511
+ }
512
+ projects.forEach(function(p) {
513
+ const opt = document.createElement('option');
514
+ opt.value = p.id;
515
+ opt.textContent = p.name;
516
+ select.appendChild(opt);
517
+ });
518
+ } catch {
519
+ document.getElementById('project-select').innerHTML = '<option value="">Failed to load projects</option>';
520
+ }
521
+ }
522
+
523
+ async function fetchProjects() {
524
+ const btn = document.getElementById('fetch-projects-btn');
525
+ btn.disabled = true;
526
+ btn.textContent = 'Fetching...';
527
+ try {
528
+ const res = await fetch('/api/projects/fetch', { method: 'POST' });
529
+ const data = await res.json();
530
+ if (data.ok) {
531
+ setMsg('projects-msg', 'Pulled ' + data.count + ' projects from Clockify.', true);
532
+ loadAllProjects();
533
+ loadProjects();
534
+ } else {
535
+ setMsg('projects-msg', data.error || 'Failed.', false);
536
+ }
537
+ } catch {
538
+ setMsg('projects-msg', 'Request failed.', false);
539
+ }
540
+ btn.disabled = false;
541
+ btn.textContent = 'Pull from Clockify';
542
+ }
543
+
544
+ async function loadAllProjects() {
545
+ try {
546
+ const res = await fetch('/api/projects/all');
547
+ const projects = await res.json();
548
+ const container = document.getElementById('projects-list');
549
+
550
+ if (projects.length === 0) {
551
+ container.innerHTML = '<p style="color:#8b949e;font-size:0.85rem;">No projects yet. Click "Pull from Clockify" to import them.</p>';
552
+ return;
553
+ }
554
+
555
+ container.innerHTML = projects.map(function(p) {
556
+ return '<div class="project-item">' +
557
+ '<div class="toggle">' +
558
+ '<input type="checkbox" data-project-id="' + p.id + '" id="proj-' + p.id + '" ' + (p.active ? 'checked' : '') + ' />' +
559
+ '<span class="slider"></span>' +
560
+ '</div>' +
561
+ '<label for="proj-' + p.id + '">' + escapeHtml(p.name) + '</label>' +
562
+ '</div>';
563
+ }).join('');
564
+ container.querySelectorAll('input[type="checkbox"]').forEach(function(cb) {
565
+ cb.addEventListener('change', function() {
566
+ toggleProject(cb.dataset.projectId, cb.checked);
567
+ });
568
+ });
569
+ } catch {
570
+ document.getElementById('projects-list').innerHTML = '<p style="color:#f85149;font-size:0.85rem;">Failed to load projects.</p>';
571
+ }
572
+ }
573
+
574
+ async function toggleProject(id, active) {
575
+ try {
576
+ const res = await fetch('/api/projects/toggle', {
577
+ method: 'POST',
578
+ headers: { 'Content-Type': 'application/json' },
579
+ body: JSON.stringify({ id, active }),
580
+ });
581
+ if (!res.ok) console.error('Toggle failed:', await res.text());
582
+ loadProjects();
583
+ loadAllProjects();
584
+ updateToggleAllLink();
585
+ } catch (e) { console.error('Toggle error:', e); }
586
+ }
587
+
588
+ async function toggleAllProjects(e) {
589
+ e.preventDefault();
590
+ const checkboxes = document.querySelectorAll('#projects-list input[type="checkbox"]');
591
+ if (checkboxes.length === 0) return;
592
+ const allChecked = Array.from(checkboxes).every(function(cb) { return cb.checked; });
593
+ const newState = !allChecked;
594
+ const promises = Array.from(checkboxes).map(function(cb) {
595
+ cb.checked = newState;
596
+ const id = cb.id.replace('proj-', '');
597
+ return fetch('/api/projects/toggle', {
598
+ method: 'POST',
599
+ headers: { 'Content-Type': 'application/json' },
600
+ body: JSON.stringify({ id, active: newState }),
601
+ });
602
+ });
603
+ await Promise.all(promises);
604
+ loadProjects();
605
+ updateToggleAllLink();
606
+ }
607
+
608
+ function updateToggleAllLink() {
609
+ const checkboxes = document.querySelectorAll('#projects-list input[type="checkbox"]');
610
+ const link = document.getElementById('toggle-all-link');
611
+ if (!link || checkboxes.length === 0) return;
612
+ const allChecked = Array.from(checkboxes).every(function(cb) { return cb.checked; });
613
+ link.textContent = allChecked ? 'Deselect all' : 'Select all';
614
+ }
615
+
616
+ // --- Sessions ---
617
+ async function loadSessions() {
618
+ try {
619
+ const res = await fetch('/api/sessions?page=' + currentPage + '&limit=10');
620
+ const result = await res.json();
621
+ const sessions = result.data;
622
+ totalPages = result.totalPages;
623
+ const tbody = document.getElementById('sessions-body');
624
+ const pagination = document.getElementById('pagination');
625
+
626
+ if (sessions.length === 0 && currentPage === 1) {
627
+ tbody.innerHTML = '<tr><td colspan="5" class="empty-state">No sessions yet. Start a timer to get going!</td></tr>';
628
+ pagination.style.display = 'none';
629
+ return;
630
+ }
631
+
632
+ tbody.innerHTML = sessions.map(function(s) {
633
+ const started = formatDate(s.startedAt);
634
+ let duration;
635
+ if (s.completedAt) {
636
+ const ms = new Date(s.completedAt).getTime() - new Date(s.startedAt).getTime();
637
+ duration = formatDuration(ms);
638
+ } else {
639
+ duration = '<span class="in-progress">In progress</span>';
640
+ }
641
+ const jira = s.jiraTicket || '-';
642
+ return '<tr>' +
643
+ '<td>' + escapeHtml(s.description) + '</td>' +
644
+ '<td>' + escapeHtml(s.projectName) + '</td>' +
645
+ '<td>' + started + '</td>' +
646
+ '<td>' + duration + '</td>' +
647
+ '<td>' + escapeHtml(jira) + '</td>' +
648
+ '</tr>';
649
+ }).join('');
650
+
651
+ // Update pagination controls
652
+ pagination.style.display = totalPages > 1 ? 'flex' : 'none';
653
+ document.getElementById('prev-btn').disabled = currentPage <= 1;
654
+ document.getElementById('next-btn').disabled = currentPage >= totalPages;
655
+ document.getElementById('page-info').textContent = 'Page ' + currentPage + ' of ' + totalPages + ' (' + result.total + ' sessions)';
656
+ } catch {
657
+ document.getElementById('sessions-body').innerHTML = '<tr><td colspan="5" class="empty-state">Failed to load sessions.</td></tr>';
658
+ }
659
+ }
660
+
661
+ function changePage(delta) {
662
+ const newPage = currentPage + delta;
663
+ if (newPage < 1 || newPage > totalPages) return;
664
+ currentPage = newPage;
665
+ loadSessions();
666
+ }
667
+
668
+ function escapeHtml(str) {
669
+ const div = document.createElement('div');
670
+ div.textContent = str;
671
+ return div.innerHTML;
672
+ }
673
+
674
+ // --- Settings: Clockify ---
675
+ async function saveClockify() {
676
+ const apiKey = document.getElementById('clockify-key').value.trim();
677
+ if (!apiKey) return setMsg('clockify-msg', 'API key is required.', false);
678
+ try {
679
+ const res = await fetch('/api/clockify', {
680
+ method: 'POST',
681
+ headers: { 'Content-Type': 'application/json' },
682
+ body: JSON.stringify({ apiKey }),
683
+ });
684
+ const data = await res.json();
685
+ if (data.ok) {
686
+ setMsg('clockify-msg', 'Saved and validated successfully.', true);
687
+ setDot('clockify-dot', 'green');
688
+ } else {
689
+ setMsg('clockify-msg', data.error || 'Validation failed.', false);
690
+ setDot('clockify-dot', 'red');
691
+ }
692
+ } catch {
693
+ setMsg('clockify-msg', 'Request failed.', false);
694
+ }
695
+ }
696
+
697
+ // --- Settings: Google ---
698
+ function setGoogleConnected(connected, email) {
699
+ const btn = document.getElementById('google-connect-btn');
700
+ const desc = document.getElementById('google-desc');
701
+ if (connected) {
702
+ btn.textContent = 'Reconnect';
703
+ desc.textContent = 'Connected' + (email ? ' as ' + email : '');
704
+ desc.style.color = '#3fb950';
705
+ } else {
706
+ btn.textContent = 'Connect Google Account';
707
+ desc.textContent = 'Authorize access to your Google Calendar.';
708
+ desc.style.color = '#8b949e';
709
+ }
710
+ }
711
+
712
+ async function connectGoogle() {
713
+ try {
714
+ const res = await fetch('/api/google/auth-url');
715
+ const data = await res.json();
716
+ if (data.url) {
717
+ if (window.__TAURI__) {
718
+ window.__TAURI__.opener.openUrl(data.url);
719
+ } else {
720
+ window.location.href = '/api/google/connect';
721
+ }
722
+ }
723
+ } catch (e) { console.error('Connect Google error:', e); }
724
+ }
725
+
726
+ // --- Settings: Jira ---
727
+ function setJiraConnected(connected, isOAuth, siteUrl) {
728
+ const btn = document.getElementById('jira-connect-btn');
729
+ const desc = document.getElementById('jira-desc');
730
+ if (connected && isOAuth) {
731
+ btn.textContent = 'Reconnect';
732
+ desc.textContent = 'Connected via OAuth' + (siteUrl ? ' (' + siteUrl.replace('https://', '') + ')' : '');
733
+ desc.style.color = '#3fb950';
734
+ } else if (connected) {
735
+ btn.textContent = 'Reconnect';
736
+ desc.textContent = 'Connected via API token';
737
+ desc.style.color = '#3fb950';
738
+ } else {
739
+ btn.textContent = 'Connect Atlassian';
740
+ desc.textContent = 'Connect your Atlassian account to log time on Jira tickets.';
741
+ desc.style.color = '#8b949e';
742
+ }
743
+ }
744
+
745
+ async function connectJira() {
746
+ try {
747
+ const res = await fetch('/api/jira/auth-url');
748
+ const data = await res.json();
749
+ if (data.url) {
750
+ if (window.__TAURI__) {
751
+ window.__TAURI__.opener.openUrl(data.url);
752
+ } else {
753
+ window.location.href = '/api/jira/connect';
754
+ }
755
+ }
756
+ } catch (e) { console.error('Connect Jira error:', e); }
757
+ }
758
+
759
+ function toggleJiraForm(e) {
760
+ e.preventDefault();
761
+ const form = document.getElementById('jira-form');
762
+ const toggle = document.getElementById('jira-toggle');
763
+ if (form.style.display === 'none') {
764
+ form.style.display = 'block';
765
+ toggle.innerHTML = 'hide API token form &uarr;';
766
+ } else {
767
+ form.style.display = 'none';
768
+ toggle.innerHTML = 'or use API token &darr;';
769
+ }
770
+ }
771
+
772
+ async function saveJira() {
773
+ const url = document.getElementById('jira-url').value.trim();
774
+ const email = document.getElementById('jira-email').value.trim();
775
+ const token = document.getElementById('jira-token').value.trim();
776
+ if (!url || !email || !token) return setMsg('jira-msg', 'All fields are required.', false);
777
+ try {
778
+ const res = await fetch('/api/jira', {
779
+ method: 'POST',
780
+ headers: { 'Content-Type': 'application/json' },
781
+ body: JSON.stringify({ url, email, token }),
782
+ });
783
+ const data = await res.json();
784
+ if (data.ok) {
785
+ setMsg('jira-msg', 'Saved and validated successfully.', true);
786
+ setDot('jira-dot', 'green');
787
+ } else {
788
+ setMsg('jira-msg', data.error || 'Validation failed.', false);
789
+ setDot('jira-dot', 'red');
790
+ }
791
+ } catch {
792
+ setMsg('jira-msg', 'Request failed.', false);
793
+ }
794
+ }
795
+
796
+ // --- Status ---
797
+ async function fetchStatus() {
798
+ try {
799
+ const res = await fetch('/api/status');
800
+ const data = await res.json();
801
+ setDot('clockify-dot', data.clockify ? 'green' : 'red');
802
+ setDot('google-dot', data.google ? 'green' : 'red');
803
+ setDot('jira-dot', data.jira ? 'green' : 'red');
804
+ if (data.clockifyKeyHint) {
805
+ document.getElementById('clockify-key').placeholder = data.clockifyKeyHint;
806
+ }
807
+ setGoogleConnected(data.google, data.googleEmail);
808
+ setJiraConnected(data.jira, data.jiraOAuth, data.jiraSiteUrl);
809
+ } catch {}
810
+ }
811
+
812
+ // --- OAuth callback params ---
813
+ (function checkUrlParams() {
814
+ const params = new URLSearchParams(window.location.search);
815
+ if (params.get('jira') === 'connected') {
816
+ switchTab('settings');
817
+ setTimeout(function() {
818
+ setMsg('jira-msg', 'Connected to ' + (params.get('site') || 'Atlassian') + ' successfully!', true);
819
+ setDot('jira-dot', 'green');
820
+ }, 100);
821
+ window.history.replaceState({}, '', '/');
822
+ } else if (params.get('jira') === 'error') {
823
+ switchTab('settings');
824
+ setTimeout(function() {
825
+ setMsg('jira-msg', 'Connection failed: ' + (params.get('reason') || 'unknown error'), false);
826
+ }, 100);
827
+ window.history.replaceState({}, '', '/');
828
+ }
829
+ })();
830
+
831
+ // --- Init ---
832
+ fetchStatus();
833
+ loadProjects();
834
+ loadSessions();
835
+ loadLastTask();
836
+ checkActiveTimer();
837
+ checkMonitorStatus();
838
+ loadAllProjects();
839
+
840
+ </script>
841
+ </body>
842
+ </html>`;
843
+ }