clocktopus 1.7.0 → 1.8.1

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.
@@ -125,9 +125,24 @@ export function indexPage() {
125
125
  .toggle input:checked + .slider { background: #238636; }
126
126
  .toggle input:checked + .slider::before { transform: translateX(16px); background: #fff; }
127
127
 
128
+ .empty-state-card { padding: 1.5rem; text-align: center; color: #8b949e; font-size: 0.9rem; }
129
+ .ticket-preview { margin-top: 0.5rem; padding: 0.6rem 0.8rem; border: 1px solid #30363d; border-radius: 6px; background: #161b22; color: #e1e4e8; font-size: 0.85rem; min-height: 2rem; }
130
+ .ticket-preview .ticket-id { color: #58a6ff; font-weight: 600; margin-right: 0.4rem; }
131
+ .ticket-preview .ticket-hint { color: #8b949e; font-style: italic; }
132
+ .ticket-preview .ticket-error { color: #f85149; }
133
+ .local-hint { margin-top: 0.4rem; color: #8b949e; font-size: 0.8rem; font-style: italic; }
134
+ .local-hint a { color: #58a6ff; }
135
+
136
+ #ctx-menu { position: fixed; display: none; z-index: 9999; background: #161b22; border: 1px solid #30363d; border-radius: 6px; padding: 0.25rem; min-width: 140px; box-shadow: 0 4px 12px rgba(0,0,0,0.4); }
137
+ #ctx-menu button { display: block; width: 100%; margin: 0; text-align: left; background: transparent; border: none; color: #e1e4e8; padding: 0.4rem 0.7rem; border-radius: 4px; font-size: 0.85rem; cursor: pointer; }
138
+ #ctx-menu button:hover { background: #21262d; }
139
+
128
140
  </style>
129
141
  </head>
130
- <body oncontextmenu="return false;">
142
+ <body>
143
+ <div id="ctx-menu">
144
+ <button type="button" onclick="location.reload()">Reload</button>
145
+ </div>
131
146
  <div class="header">
132
147
  <h1>Clocktopus</h1>
133
148
  <div class="nav">
@@ -152,29 +167,33 @@ export function indexPage() {
152
167
 
153
168
  <div class="cards home-cards">
154
169
  <!-- Track Time -->
155
- <div class="card">
156
- <div class="track-tabs">
170
+ <div class="card" id="track-card">
171
+ <div class="track-tabs" id="track-tabs">
157
172
  <button class="track-tab-btn active" data-mode="auto" onclick="switchTrackMode('auto')">Auto Track</button>
158
173
  <button class="track-tab-btn" data-mode="manual" onclick="switchTrackMode('manual')">Manual Log</button>
159
174
  </div>
160
175
 
161
176
  <div id="track-auto">
162
177
  <div id="start-timer-form">
163
- <label for="project-select">Project</label>
164
- <select id="project-select">
165
- <option value="">Loading projects...</option>
166
- </select>
178
+ <div id="timer-project-wrap">
179
+ <label for="project-select">Project</label>
180
+ <select id="project-select">
181
+ <option value="">Loading projects...</option>
182
+ </select>
183
+ </div>
167
184
  <div class="form-row">
168
- <div>
185
+ <div id="timer-description-wrap">
169
186
  <label for="timer-description">Description</label>
170
187
  <input type="text" id="timer-description" placeholder="What are you working on?" />
188
+ <div id="timer-local-hint" class="local-hint" style="display:none;">Time will be logged locally only. Configure Clockify or Jira in <a href="#" onclick="switchTab('settings');return false;">Settings</a> to sync.</div>
171
189
  </div>
172
190
  <div>
173
- <label for="timer-jira">Jira Ticket (optional)</label>
191
+ <label for="timer-jira" id="timer-jira-label">Jira Ticket (optional)</label>
174
192
  <input type="text" id="timer-jira" placeholder="e.g. PROJ-123" />
175
193
  </div>
176
194
  </div>
177
- <label style="display:flex; align-items:center; gap:0.5rem; font-weight:normal; cursor:pointer;">
195
+ <div id="timer-description-preview" class="ticket-preview" style="display:none;"></div>
196
+ <label id="timer-billable-wrap" style="display:flex; align-items:center; gap:0.5rem; font-weight:normal; cursor:pointer;">
178
197
  <input type="checkbox" id="timer-billable" checked style="width:auto; margin:0;" />
179
198
  Billable
180
199
  </label>
@@ -185,31 +204,41 @@ export function indexPage() {
185
204
  </div>
186
205
 
187
206
  <div id="track-manual" style="display:none;">
188
- <label for="manual-project">Project</label>
189
- <select id="manual-project">
190
- <option value="">Loading projects...</option>
191
- </select>
207
+ <div id="manual-project-wrap">
208
+ <label for="manual-project">Project</label>
209
+ <select id="manual-project">
210
+ <option value="">Loading projects...</option>
211
+ </select>
212
+ </div>
192
213
  <div class="form-row">
193
214
  <div>
194
- <label for="manual-start">Start</label>
195
- <input type="datetime-local" id="manual-start" />
215
+ <label for="manual-start-date">Start</label>
216
+ <div style="display:flex; gap:0.4rem;">
217
+ <input type="date" id="manual-start-date" style="flex:1;" />
218
+ <input type="time" id="manual-start-time" style="flex:1;" />
219
+ </div>
196
220
  </div>
197
221
  <div>
198
- <label for="manual-end">End</label>
199
- <input type="datetime-local" id="manual-end" />
222
+ <label for="manual-end-date">End</label>
223
+ <div style="display:flex; gap:0.4rem;">
224
+ <input type="date" id="manual-end-date" style="flex:1;" />
225
+ <input type="time" id="manual-end-time" style="flex:1;" />
226
+ </div>
200
227
  </div>
201
228
  </div>
202
229
  <div class="form-row">
203
- <div>
230
+ <div id="manual-description-wrap">
204
231
  <label for="manual-description">Description</label>
205
232
  <input type="text" id="manual-description" placeholder="What did you work on?" />
233
+ <div id="manual-local-hint" class="local-hint" style="display:none;">Time will be logged locally only. Configure Clockify or Jira in <a href="#" onclick="switchTab('settings');return false;">Settings</a> to sync.</div>
206
234
  </div>
207
235
  <div>
208
- <label for="manual-jira">Jira Ticket (optional)</label>
236
+ <label for="manual-jira" id="manual-jira-label">Jira Ticket (optional)</label>
209
237
  <input type="text" id="manual-jira" placeholder="e.g. PROJ-123" />
210
238
  </div>
211
239
  </div>
212
- <label style="display:flex; align-items:center; gap:0.5rem; font-weight:normal; cursor:pointer;">
240
+ <div id="manual-description-preview" class="ticket-preview" style="display:none;"></div>
241
+ <label id="manual-billable-wrap" style="display:flex; align-items:center; gap:0.5rem; font-weight:normal; cursor:pointer;">
213
242
  <input type="checkbox" id="manual-billable" checked style="width:auto; margin:0;" />
214
243
  Billable
215
244
  </label>
@@ -218,6 +247,7 @@ export function indexPage() {
218
247
  </div>
219
248
  </div>
220
249
 
250
+
221
251
  <!-- Monitor Control -->
222
252
  <div class="card">
223
253
  <div class="card-header">
@@ -293,6 +323,13 @@ export function indexPage() {
293
323
  <label for="clockify-key">API Key</label>
294
324
  <input type="password" id="clockify-key" placeholder="Enter your Clockify API key" />
295
325
  <button onclick="saveClockify()">Save &amp; Validate</button>
326
+ <div style="display:flex; align-items:center; gap:0.6rem; margin-top:0.75rem;">
327
+ <label class="toggle">
328
+ <input type="checkbox" id="clockify-enabled-toggle" onchange="toggleClockifyEnabled()" />
329
+ <span class="slider"></span>
330
+ </label>
331
+ <span id="clockify-enabled-label" style="font-size:0.9rem; color:#8b949e;">Enabled</span>
332
+ </div>
296
333
  <div class="msg" id="clockify-msg"></div>
297
334
  </div>
298
335
 
@@ -304,6 +341,7 @@ export function indexPage() {
304
341
  </div>
305
342
  <p id="google-desc" style="font-size:0.85rem;color:#8b949e;margin-bottom:0.5rem;">Authorize access to your Google Calendar.</p>
306
343
  <button class="connect" id="google-connect-btn" onclick="connectGoogle()">Connect Google Account</button>
344
+ <p id="google-connect-note" style="font-size:0.75rem;color:#8b949e;margin-top:0.5rem;display:none;"></p>
307
345
  <div class="msg" id="google-msg"></div>
308
346
  </div>
309
347
 
@@ -313,28 +351,28 @@ export function indexPage() {
313
351
  <div class="dot gray" id="jira-dot"></div>
314
352
  <h2>Jira</h2>
315
353
  </div>
316
- <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>
317
- <button class="connect" id="jira-connect-btn" onclick="connectJira()">Connect Atlassian</button>
318
- <div class="msg" id="jira-msg"></div>
319
- <div style="margin-top:1rem;">
320
- <a href="#" id="jira-toggle" onclick="toggleJiraForm(event)" style="font-size:0.8rem;color:#8b949e;text-decoration:none;">or use API token &darr;</a>
321
- <div id="jira-form" style="display:none;margin-top:0.5rem;">
322
- <div class="guide">
323
- <ol>
324
- <li>Go to <a href="https://id.atlassian.com/manage-profile/security/api-tokens" target="_blank">Atlassian API Tokens</a></li>
325
- <li>Click <strong>Create API token</strong> and copy it</li>
326
- <li>Your URL is <code>https://&lt;your-org&gt;.atlassian.net/rest/api/3</code></li>
327
- </ol>
328
- </div>
329
- <label for="jira-url">Atlassian URL</label>
330
- <input type="text" id="jira-url" placeholder="https://your-org.atlassian.net/rest/api/3" />
331
- <label for="jira-email">Email</label>
332
- <input type="email" id="jira-email" placeholder="you@example.com" />
333
- <label for="jira-token">API Token</label>
334
- <input type="password" id="jira-token" placeholder="Atlassian API token" />
335
- <button onclick="saveJira()">Save &amp; Validate</button>
336
- </div>
354
+ <p id="jira-desc" style="font-size:0.85rem;color:#8b949e;margin-bottom:0.5rem;">Connect Jira with an API token to log time on tickets.</p>
355
+ <div class="guide">
356
+ <ol>
357
+ <li>Go to <a href="https://id.atlassian.com/manage-profile/security/api-tokens" target="_blank">Atlassian API Tokens</a></li>
358
+ <li>Click <strong>Create API token</strong> and copy it</li>
359
+ </ol>
337
360
  </div>
361
+ <label for="jira-url">Atlassian URL</label>
362
+ <input type="text" id="jira-url" value="https://outsidetech.atlassian.net/rest/api/3" placeholder="https://your-org.atlassian.net/rest/api/3" />
363
+ <label for="jira-email">Email</label>
364
+ <input type="email" id="jira-email" placeholder="you@example.com" />
365
+ <label for="jira-token">API Token</label>
366
+ <input type="password" id="jira-token" placeholder="Atlassian API token" />
367
+ <button onclick="saveJira()">Save &amp; Validate</button>
368
+ <div style="display:flex; align-items:center; gap:0.6rem; margin-top:0.75rem;">
369
+ <label class="toggle">
370
+ <input type="checkbox" id="jira-enabled-toggle" onchange="toggleJiraEnabled()" />
371
+ <span class="slider"></span>
372
+ </label>
373
+ <span id="jira-enabled-label" style="font-size:0.9rem; color:#8b949e;">Enabled</span>
374
+ </div>
375
+ <div class="msg" id="jira-msg"></div>
338
376
  </div>
339
377
 
340
378
  </div>
@@ -384,10 +422,26 @@ export function indexPage() {
384
422
  </div>
385
423
 
386
424
  <script>
387
- // Disable WebKit's Back/Forward/Reload context menu. Capture-phase and
388
- // attached on window so it runs before any app handler and wins the race
389
- // with the native WKWebView menu.
390
- window.addEventListener('contextmenu', e => e.preventDefault(), { capture: true });
425
+ // Suppress WebKit's Back/Forward menu. Capture-phase wins the race with
426
+ // the native WKWebView menu. Our custom menu offers Reload only.
427
+ (function wireContextMenu() {
428
+ const menu = document.getElementById('ctx-menu');
429
+ if (!menu) return;
430
+ const hide = () => { menu.style.display = 'none'; };
431
+ window.addEventListener('contextmenu', (e) => {
432
+ e.preventDefault();
433
+ const pad = 4;
434
+ const x = Math.min(e.clientX, window.innerWidth - menu.offsetWidth - pad);
435
+ const y = Math.min(e.clientY, window.innerHeight - menu.offsetHeight - pad);
436
+ menu.style.left = x + 'px';
437
+ menu.style.top = y + 'px';
438
+ menu.style.display = 'block';
439
+ }, { capture: true });
440
+ window.addEventListener('click', hide);
441
+ window.addEventListener('scroll', hide, { capture: true });
442
+ window.addEventListener('keydown', (e) => { if (e.key === 'Escape') hide(); });
443
+ window.addEventListener('blur', hide);
444
+ })();
391
445
 
392
446
  let elapsedInterval = null;
393
447
  let currentPage = 1;
@@ -395,10 +449,12 @@ export function indexPage() {
395
449
 
396
450
  // --- Tab switching ---
397
451
  function switchTab(tab) {
452
+ const nav = document.getElementById('nav-' + tab);
453
+ if (nav && nav.getAttribute('aria-disabled') === 'true') return;
398
454
  document.querySelectorAll('.tab-content').forEach(el => el.classList.remove('active'));
399
455
  document.querySelectorAll('.nav-btn').forEach(el => el.classList.remove('active'));
400
456
  document.getElementById('tab-' + tab).classList.add('active');
401
- document.getElementById('nav-' + tab).classList.add('active');
457
+ if (nav) nav.classList.add('active');
402
458
  }
403
459
 
404
460
  function switchTrackMode(mode) {
@@ -475,12 +531,19 @@ export function indexPage() {
475
531
 
476
532
  async function startTimer() {
477
533
  const projectId = document.getElementById('project-select').value;
478
- const description = document.getElementById('timer-description').value.trim();
479
534
  const jiraTicket = document.getElementById('timer-jira').value.trim();
480
535
  const billable = document.getElementById('timer-billable').checked;
481
-
482
- if (!projectId) return setMsg('timer-msg', 'Please select a project.', false);
483
- if (!description && !jiraTicket) return setMsg('timer-msg', 'Please enter a description or Jira ticket.', false);
536
+ const typedDescription = document.getElementById('timer-description').value.trim();
537
+ const description = typedDescription;
538
+
539
+ if (currentMode.clockifyOn) {
540
+ if (!projectId) return setMsg('timer-msg', 'Please select a project.', false);
541
+ if (!typedDescription && !jiraTicket) return setMsg('timer-msg', 'Please enter a description or Jira ticket.', false);
542
+ } else if (currentMode.jiraOn) {
543
+ if (!jiraTicket) return setMsg('timer-msg', 'Please enter a Jira ticket.', false);
544
+ } else {
545
+ if (!typedDescription) return setMsg('timer-msg', 'Please enter a description.', false);
546
+ }
484
547
 
485
548
  const btn = document.getElementById('start-btn');
486
549
  btn.disabled = true;
@@ -490,13 +553,20 @@ export function indexPage() {
490
553
  const res = await fetch('/api/timer/start', {
491
554
  method: 'POST',
492
555
  headers: { 'Content-Type': 'application/json' },
493
- body: JSON.stringify({ projectId, description: description || 'Working on a task...', jiraTicket: jiraTicket || undefined, billable }),
556
+ body: JSON.stringify({
557
+ projectId: projectId || undefined,
558
+ description: currentMode.clockifyOn ? (description || 'Working on a task...') : description,
559
+ jiraTicket: jiraTicket || undefined,
560
+ billable,
561
+ }),
494
562
  });
495
563
  const data = await res.json();
496
564
  if (data.ok) {
497
565
  setMsg('timer-msg', 'Timer started!', true);
498
566
  document.getElementById('timer-description').value = '';
499
567
  document.getElementById('timer-jira').value = '';
568
+ timerJiraSummary = '';
569
+ renderTicketPreview('timer-description-preview', '', '');
500
570
  checkActiveTimer();
501
571
  loadSessions();
502
572
  } else {
@@ -529,37 +599,55 @@ export function indexPage() {
529
599
  }
530
600
  }
531
601
 
532
- function toLocalInputValue(date) {
602
+ function toDateInputValue(date) {
603
+ const pad = function(n) { return String(n).padStart(2, '0'); };
604
+ return date.getFullYear() + '-' + pad(date.getMonth() + 1) + '-' + pad(date.getDate());
605
+ }
606
+
607
+ function toTimeInputValue(date) {
533
608
  const pad = function(n) { return String(n).padStart(2, '0'); };
534
- return date.getFullYear() + '-' + pad(date.getMonth() + 1) + '-' + pad(date.getDate()) +
535
- 'T' + pad(date.getHours()) + ':' + pad(date.getMinutes());
609
+ return pad(date.getHours()) + ':' + pad(date.getMinutes());
536
610
  }
537
611
 
538
612
  function setManualDefaults() {
539
613
  const now = new Date();
540
614
  const hourAgo = new Date(now.getTime() - 60 * 60 * 1000);
541
- const startEl = document.getElementById('manual-start');
542
- const endEl = document.getElementById('manual-end');
543
- if (startEl) startEl.value = toLocalInputValue(hourAgo);
544
- if (endEl) endEl.value = toLocalInputValue(now);
615
+ const sd = document.getElementById('manual-start-date');
616
+ const st = document.getElementById('manual-start-time');
617
+ const ed = document.getElementById('manual-end-date');
618
+ const et = document.getElementById('manual-end-time');
619
+ if (sd) sd.value = toDateInputValue(hourAgo);
620
+ if (st) st.value = toTimeInputValue(hourAgo);
621
+ if (ed) ed.value = toDateInputValue(now);
622
+ if (et) et.value = toTimeInputValue(now);
545
623
  }
546
624
 
547
625
  async function logManualTime() {
548
626
  const projectId = document.getElementById('manual-project').value;
549
- const startVal = document.getElementById('manual-start').value;
550
- const endVal = document.getElementById('manual-end').value;
551
- const description = document.getElementById('manual-description').value.trim();
627
+ const startDate = document.getElementById('manual-start-date').value;
628
+ const startTime = document.getElementById('manual-start-time').value;
629
+ const endDate = document.getElementById('manual-end-date').value;
630
+ const endTime = document.getElementById('manual-end-time').value;
631
+ const typedDescription = document.getElementById('manual-description').value.trim();
552
632
  const jiraTicket = document.getElementById('manual-jira').value.trim();
553
633
  const billable = document.getElementById('manual-billable').checked;
634
+ const description = typedDescription;
554
635
 
555
- if (!projectId) return setMsg('manual-msg', 'Please select a project.', false);
556
- if (!startVal || !endVal) return setMsg('manual-msg', 'Please set start and end.', false);
636
+ if (!startDate || !startTime || !endDate || !endTime) return setMsg('manual-msg', 'Please set start and end.', false);
557
637
 
558
- const startMs = new Date(startVal).getTime();
559
- const endMs = new Date(endVal).getTime();
638
+ const startMs = new Date(startDate + 'T' + startTime).getTime();
639
+ const endMs = new Date(endDate + 'T' + endTime).getTime();
560
640
  if (isNaN(startMs) || isNaN(endMs)) return setMsg('manual-msg', 'Invalid date.', false);
561
641
  if (endMs <= startMs) return setMsg('manual-msg', 'End must be after start.', false);
562
- if (!description && !jiraTicket) return setMsg('manual-msg', 'Please enter a description or Jira ticket.', false);
642
+
643
+ if (currentMode.clockifyOn) {
644
+ if (!projectId) return setMsg('manual-msg', 'Please select a project.', false);
645
+ if (!typedDescription && !jiraTicket) return setMsg('manual-msg', 'Please enter a description or Jira ticket.', false);
646
+ } else if (currentMode.jiraOn) {
647
+ if (!jiraTicket) return setMsg('manual-msg', 'Please enter a Jira ticket.', false);
648
+ } else {
649
+ if (!typedDescription) return setMsg('manual-msg', 'Please enter a description.', false);
650
+ }
563
651
 
564
652
  const btn = document.getElementById('manual-log-btn');
565
653
  btn.disabled = true;
@@ -570,7 +658,7 @@ export function indexPage() {
570
658
  method: 'POST',
571
659
  headers: { 'Content-Type': 'application/json' },
572
660
  body: JSON.stringify({
573
- projectId: projectId,
661
+ projectId: projectId || undefined,
574
662
  description: description,
575
663
  start: new Date(startMs).toISOString(),
576
664
  end: new Date(endMs).toISOString(),
@@ -583,6 +671,8 @@ export function indexPage() {
583
671
  setMsg('manual-msg', 'Time logged.', true);
584
672
  document.getElementById('manual-description').value = '';
585
673
  document.getElementById('manual-jira').value = '';
674
+ manualJiraSummary = '';
675
+ renderTicketPreview('manual-description-preview', '', '');
586
676
  setManualDefaults();
587
677
  loadSessions();
588
678
  } else {
@@ -893,7 +983,7 @@ export function indexPage() {
893
983
  : '';
894
984
  return '<tr>' +
895
985
  '<td>' + escapeHtml(s.description) + '</td>' +
896
- '<td>' + escapeHtml(s.projectName) + '</td>' +
986
+ '<td>' + (s.projectName ? escapeHtml(s.projectName) : '—') + '</td>' +
897
987
  '<td>' + started + '</td>' +
898
988
  '<td>' + duration + '</td>' +
899
989
  '<td>' + escapeHtml(jira) + '</td>' +
@@ -992,6 +1082,7 @@ export function indexPage() {
992
1082
  if (data.ok) {
993
1083
  setMsg('clockify-msg', 'Saved and validated successfully.', true);
994
1084
  setDot('clockify-dot', 'green');
1085
+ fetchStatus();
995
1086
  } else {
996
1087
  setMsg('clockify-msg', data.error || 'Validation failed.', false);
997
1088
  setDot('clockify-dot', 'red');
@@ -1001,6 +1092,27 @@ export function indexPage() {
1001
1092
  }
1002
1093
  }
1003
1094
 
1095
+ async function toggleClockifyEnabled() {
1096
+ const enabled = document.getElementById('clockify-enabled-toggle').checked;
1097
+ document.getElementById('clockify-enabled-label').textContent = enabled ? 'Enabled' : 'Disabled';
1098
+ try {
1099
+ const res = await fetch('/api/clockify/enabled', {
1100
+ method: 'POST',
1101
+ headers: { 'Content-Type': 'application/json' },
1102
+ body: JSON.stringify({ enabled }),
1103
+ });
1104
+ const data = await res.json();
1105
+ if (data.ok) {
1106
+ setMsg('clockify-msg', enabled ? 'Clockify enabled.' : 'Clockify disabled.', true);
1107
+ fetchStatus();
1108
+ } else {
1109
+ setMsg('clockify-msg', data.error || 'Failed to update.', false);
1110
+ }
1111
+ } catch {
1112
+ setMsg('clockify-msg', 'Request failed.', false);
1113
+ }
1114
+ }
1115
+
1004
1116
  // --- Settings: Google ---
1005
1117
  function setGoogleConnected(connected, email) {
1006
1118
  const btn = document.getElementById('google-connect-btn');
@@ -1031,48 +1143,35 @@ export function indexPage() {
1031
1143
  }
1032
1144
 
1033
1145
  // --- Settings: Jira ---
1034
- function setJiraConnected(connected, isOAuth, siteUrl) {
1035
- const btn = document.getElementById('jira-connect-btn');
1146
+ function setJiraConnected(connected) {
1036
1147
  const desc = document.getElementById('jira-desc');
1037
- if (connected && isOAuth) {
1038
- btn.textContent = 'Reconnect';
1039
- desc.textContent = 'Connected via OAuth' + (siteUrl ? ' (' + siteUrl.replace('https://', '') + ')' : '');
1040
- desc.style.color = '#3fb950';
1041
- } else if (connected) {
1042
- btn.textContent = 'Reconnect';
1148
+ if (connected) {
1043
1149
  desc.textContent = 'Connected via API token';
1044
1150
  desc.style.color = '#3fb950';
1045
1151
  } else {
1046
- btn.textContent = 'Connect Atlassian';
1047
- desc.textContent = 'Connect your Atlassian account to log time on Jira tickets.';
1152
+ desc.textContent = 'Connect Jira with an API token to log time on tickets.';
1048
1153
  desc.style.color = '#8b949e';
1049
1154
  }
1050
1155
  }
1051
1156
 
1052
- async function connectJira() {
1157
+ async function toggleJiraEnabled() {
1158
+ const enabled = document.getElementById('jira-enabled-toggle').checked;
1159
+ document.getElementById('jira-enabled-label').textContent = enabled ? 'Enabled' : 'Disabled';
1053
1160
  try {
1054
- const res = await fetch('/api/jira/auth-url');
1161
+ const res = await fetch('/api/jira/enabled', {
1162
+ method: 'POST',
1163
+ headers: { 'Content-Type': 'application/json' },
1164
+ body: JSON.stringify({ enabled }),
1165
+ });
1055
1166
  const data = await res.json();
1056
- if (data.url) {
1057
- if (window.__TAURI__) {
1058
- window.__TAURI__.opener.openUrl(data.url);
1059
- } else {
1060
- window.location.href = '/api/jira/connect';
1061
- }
1167
+ if (data.ok) {
1168
+ setMsg('jira-msg', enabled ? 'Jira enabled.' : 'Jira disabled.', true);
1169
+ fetchStatus();
1170
+ } else {
1171
+ setMsg('jira-msg', data.error || 'Failed to update.', false);
1062
1172
  }
1063
- } catch (e) { console.error('Connect Jira error:', e); }
1064
- }
1065
-
1066
- function toggleJiraForm(e) {
1067
- e.preventDefault();
1068
- const form = document.getElementById('jira-form');
1069
- const toggle = document.getElementById('jira-toggle');
1070
- if (form.style.display === 'none') {
1071
- form.style.display = 'block';
1072
- toggle.innerHTML = 'hide API token form &uarr;';
1073
- } else {
1074
- form.style.display = 'none';
1075
- toggle.innerHTML = 'or use API token &darr;';
1173
+ } catch {
1174
+ setMsg('jira-msg', 'Request failed.', false);
1076
1175
  }
1077
1176
  }
1078
1177
 
@@ -1091,6 +1190,7 @@ export function indexPage() {
1091
1190
  if (data.ok) {
1092
1191
  setMsg('jira-msg', 'Saved and validated successfully.', true);
1093
1192
  setDot('jira-dot', 'green');
1193
+ fetchStatus();
1094
1194
  } else {
1095
1195
  setMsg('jira-msg', data.error || 'Validation failed.', false);
1096
1196
  setDot('jira-dot', 'red');
@@ -1101,6 +1201,169 @@ export function indexPage() {
1101
1201
  }
1102
1202
 
1103
1203
  // --- Status ---
1204
+ var currentMode = { clockifyOn: true, jiraOn: false };
1205
+ var timerJiraSummary = '';
1206
+ var manualJiraSummary = '';
1207
+
1208
+ function renderTicketPreview(previewId, ticket, description) {
1209
+ var el = document.getElementById(previewId);
1210
+ if (!el) return;
1211
+ if (!ticket || !description) {
1212
+ el.style.display = 'none';
1213
+ el.innerHTML = '';
1214
+ return;
1215
+ }
1216
+ el.style.display = '';
1217
+ el.innerHTML = '<span class="ticket-id">' + escapeHtml(ticket) + '</span>' + escapeHtml(description);
1218
+ }
1219
+
1220
+ async function fetchTicketSummary(ticket) {
1221
+ if (!/^[A-Z][A-Z0-9]+-\d+$/.test(ticket)) return null;
1222
+ try {
1223
+ const res = await fetch('/api/jira/ticket-summary?jira=' + encodeURIComponent(ticket));
1224
+ const data = await res.json();
1225
+ if (data.ok) return data.description || '';
1226
+ } catch {}
1227
+ return null;
1228
+ }
1229
+
1230
+ function debounce(fn, ms) {
1231
+ var t;
1232
+ return function() {
1233
+ var ctx = this, args = arguments;
1234
+ clearTimeout(t);
1235
+ t = setTimeout(function(){ fn.apply(ctx, args); }, ms);
1236
+ };
1237
+ }
1238
+
1239
+ async function onTimerJiraInput() {
1240
+ var ticket = document.getElementById('timer-jira').value.trim().toUpperCase();
1241
+ if (!currentMode.clockifyOn) {
1242
+ var desc = await fetchTicketSummary(ticket);
1243
+ timerJiraSummary = desc || '';
1244
+ renderTicketPreview('timer-description-preview', ticket, timerJiraSummary);
1245
+ }
1246
+ }
1247
+ async function onManualJiraInput() {
1248
+ var ticket = document.getElementById('manual-jira').value.trim().toUpperCase();
1249
+ if (!currentMode.clockifyOn) {
1250
+ var desc = await fetchTicketSummary(ticket);
1251
+ manualJiraSummary = desc || '';
1252
+ renderTicketPreview('manual-description-preview', ticket, manualJiraSummary);
1253
+ }
1254
+ }
1255
+
1256
+ (function wireTicketPreviewInputs() {
1257
+ var t = document.getElementById('timer-jira');
1258
+ var m = document.getElementById('manual-jira');
1259
+ if (t) t.addEventListener('input', debounce(onTimerJiraInput, 300));
1260
+ if (m) m.addEventListener('input', debounce(onManualJiraInput, 300));
1261
+ })();
1262
+
1263
+ function applyMode(data) {
1264
+ const clockifyOn = !!data.clockify;
1265
+ const jiraOn = !!data.jira;
1266
+ const localOnly = !clockifyOn && !jiraOn;
1267
+ currentMode.clockifyOn = clockifyOn;
1268
+ currentMode.jiraOn = jiraOn;
1269
+
1270
+ const trackCard = document.getElementById('track-card');
1271
+ const timerLocalHint = document.getElementById('timer-local-hint');
1272
+ const manualLocalHint = document.getElementById('manual-local-hint');
1273
+ const projWrap = document.getElementById('timer-project-wrap');
1274
+ const manualProjWrap = document.getElementById('manual-project-wrap');
1275
+ const descWrap = document.getElementById('timer-description-wrap');
1276
+ const manualDescWrap = document.getElementById('manual-description-wrap');
1277
+ const descPreview = document.getElementById('timer-description-preview');
1278
+ const manualDescPreview = document.getElementById('manual-description-preview');
1279
+ const jiraInput = document.getElementById('timer-jira');
1280
+ const manualJiraInput = document.getElementById('manual-jira');
1281
+ const jiraLabel = document.getElementById('timer-jira-label');
1282
+ const manualJiraLabel = document.getElementById('manual-jira-label');
1283
+ const jiraWrap = jiraInput ? jiraInput.closest('.row') || jiraInput.parentElement : null;
1284
+ const manualJiraWrap = manualJiraInput ? manualJiraInput.closest('.row') || manualJiraInput.parentElement : null;
1285
+ const billableWrap = document.getElementById('timer-billable-wrap');
1286
+ const manualBillableWrap = document.getElementById('manual-billable-wrap');
1287
+
1288
+ if (trackCard) trackCard.style.display = '';
1289
+ if (timerLocalHint) timerLocalHint.style.display = localOnly ? '' : 'none';
1290
+ if (manualLocalHint) manualLocalHint.style.display = localOnly ? '' : 'none';
1291
+
1292
+ if (clockifyOn) {
1293
+ if (projWrap) projWrap.style.display = '';
1294
+ if (manualProjWrap) manualProjWrap.style.display = '';
1295
+ if (descWrap) descWrap.style.display = '';
1296
+ if (manualDescWrap) manualDescWrap.style.display = '';
1297
+ if (descPreview) descPreview.style.display = 'none';
1298
+ if (manualDescPreview) manualDescPreview.style.display = 'none';
1299
+ if (jiraWrap) jiraWrap.style.display = jiraOn ? '' : 'none';
1300
+ if (manualJiraWrap) manualJiraWrap.style.display = jiraOn ? '' : 'none';
1301
+ if (jiraInput) jiraInput.required = false;
1302
+ if (manualJiraInput) manualJiraInput.required = false;
1303
+ if (jiraLabel) jiraLabel.textContent = 'Jira Ticket (optional)';
1304
+ if (manualJiraLabel) manualJiraLabel.textContent = 'Jira Ticket (optional)';
1305
+ if (billableWrap) billableWrap.style.display = '';
1306
+ if (manualBillableWrap) manualBillableWrap.style.display = '';
1307
+ } else if (jiraOn) {
1308
+ if (projWrap) projWrap.style.display = 'none';
1309
+ if (manualProjWrap) manualProjWrap.style.display = 'none';
1310
+ if (descWrap) descWrap.style.display = 'none';
1311
+ if (manualDescWrap) manualDescWrap.style.display = 'none';
1312
+ if (descPreview) { descPreview.style.display = ''; renderTicketPreview('timer-description-preview', (jiraInput ? jiraInput.value.trim().toUpperCase() : ''), timerJiraSummary); }
1313
+ if (manualDescPreview) { manualDescPreview.style.display = ''; renderTicketPreview('manual-description-preview', (manualJiraInput ? manualJiraInput.value.trim().toUpperCase() : ''), manualJiraSummary); }
1314
+ if (jiraWrap) jiraWrap.style.display = '';
1315
+ if (manualJiraWrap) manualJiraWrap.style.display = '';
1316
+ if (jiraInput) jiraInput.required = true;
1317
+ if (manualJiraInput) manualJiraInput.required = true;
1318
+ if (jiraLabel) jiraLabel.textContent = 'Jira Ticket';
1319
+ if (manualJiraLabel) manualJiraLabel.textContent = 'Jira Ticket';
1320
+ if (billableWrap) billableWrap.style.display = 'none';
1321
+ if (manualBillableWrap) manualBillableWrap.style.display = 'none';
1322
+ onTimerJiraInput();
1323
+ onManualJiraInput();
1324
+ } else {
1325
+ // Local-only: description only
1326
+ if (projWrap) projWrap.style.display = 'none';
1327
+ if (manualProjWrap) manualProjWrap.style.display = 'none';
1328
+ if (descWrap) descWrap.style.display = '';
1329
+ if (manualDescWrap) manualDescWrap.style.display = '';
1330
+ if (descPreview) descPreview.style.display = 'none';
1331
+ if (manualDescPreview) manualDescPreview.style.display = 'none';
1332
+ if (jiraWrap) jiraWrap.style.display = 'none';
1333
+ if (manualJiraWrap) manualJiraWrap.style.display = 'none';
1334
+ if (jiraInput) jiraInput.required = false;
1335
+ if (manualJiraInput) manualJiraInput.required = false;
1336
+ if (billableWrap) billableWrap.style.display = 'none';
1337
+ if (manualBillableWrap) manualBillableWrap.style.display = 'none';
1338
+ }
1339
+
1340
+ // Projects tab: hide "Pull from Clockify" button
1341
+ const pullBtn = document.getElementById('fetch-projects-btn');
1342
+ if (pullBtn) pullBtn.style.display = clockifyOn ? '' : 'none';
1343
+
1344
+ // Projects nav: hide entire tab when Clockify off
1345
+ const projectsNav = document.getElementById('nav-projects');
1346
+ if (projectsNav) projectsNav.style.display = clockifyOn ? '' : 'none';
1347
+
1348
+ // Calendar nav: hide entire tab when Clockify off
1349
+ const calNav = document.getElementById('nav-calendar');
1350
+ if (calNav) calNav.style.display = clockifyOn ? '' : 'none';
1351
+
1352
+ // Settings: Google Connect button — disable when Clockify off
1353
+ const gBtn = document.getElementById('google-connect-btn');
1354
+ const gNote = document.getElementById('google-connect-note');
1355
+ if (gBtn) gBtn.disabled = !clockifyOn;
1356
+ if (gNote) {
1357
+ if (clockifyOn) {
1358
+ gNote.textContent = '';
1359
+ gNote.style.display = 'none';
1360
+ } else {
1361
+ gNote.textContent = 'Calendar sync requires Clockify. Connect Clockify first.';
1362
+ gNote.style.display = '';
1363
+ }
1364
+ }
1365
+ }
1366
+
1104
1367
  async function fetchStatus() {
1105
1368
  try {
1106
1369
  const res = await fetch('/api/status');
@@ -1111,8 +1374,34 @@ export function indexPage() {
1111
1374
  if (data.clockifyKeyHint) {
1112
1375
  document.getElementById('clockify-key').placeholder = data.clockifyKeyHint;
1113
1376
  }
1377
+ const toggle = document.getElementById('clockify-enabled-toggle');
1378
+ const toggleLabel = document.getElementById('clockify-enabled-label');
1379
+ const keyInput = document.getElementById('clockify-key');
1380
+ const saveBtn = document.querySelector('button[onclick="saveClockify()"]');
1381
+ const enabled = !data.clockifyDisabled;
1382
+ if (toggle) toggle.checked = enabled;
1383
+ if (toggleLabel) toggleLabel.textContent = enabled ? 'Enabled' : 'Disabled';
1384
+ if (keyInput) keyInput.disabled = !enabled;
1385
+ if (saveBtn) saveBtn.disabled = !enabled;
1386
+
1387
+ const jToggle = document.getElementById('jira-enabled-toggle');
1388
+ const jToggleLabel = document.getElementById('jira-enabled-label');
1389
+ const jToggleWrap = jToggle ? jToggle.closest('label').parentElement : null;
1390
+ const jUrl = document.getElementById('jira-url');
1391
+ const jEmail = document.getElementById('jira-email');
1392
+ const jTokenInput = document.getElementById('jira-token');
1393
+ const jSaveBtn = document.querySelector('button[onclick="saveJira()"]');
1394
+ const jiraEnabled = !data.jiraDisabled;
1395
+ if (jToggle) jToggle.checked = jiraEnabled;
1396
+ if (jToggleLabel) jToggleLabel.textContent = jiraEnabled ? 'Enabled' : 'Disabled';
1397
+ if (jToggleWrap) jToggleWrap.style.display = data.jiraConfigured ? '' : 'none';
1398
+ if (jUrl) jUrl.disabled = data.jiraConfigured && !jiraEnabled;
1399
+ if (jEmail) jEmail.disabled = data.jiraConfigured && !jiraEnabled;
1400
+ if (jTokenInput) jTokenInput.disabled = data.jiraConfigured && !jiraEnabled;
1401
+ if (jSaveBtn) jSaveBtn.disabled = data.jiraConfigured && !jiraEnabled;
1114
1402
  setGoogleConnected(data.google, data.googleEmail);
1115
- setJiraConnected(data.jira, data.jiraOAuth, data.jiraSiteUrl);
1403
+ setJiraConnected(data.jira);
1404
+ applyMode(data);
1116
1405
  } catch {}
1117
1406
  }
1118
1407