clocktopus 1.2.0 → 1.3.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.
package/README.md CHANGED
@@ -21,9 +21,11 @@ curl -fsSL https://bun.sh/install | bash
21
21
  ### Install
22
22
 
23
23
  ```bash
24
- bun i -g clocktopus
24
+ bun install -g clocktopus --trust
25
25
  ```
26
26
 
27
+ > **Note:** `--trust` allows the postinstall script to build native addons (required for idle monitor on macOS). Without it, addon compilation is skipped and the monitor may fail to load.
28
+
27
29
  ### Run
28
30
 
29
31
  ```bash
@@ -160,10 +162,16 @@ bun pm cache rm && bun i -g clocktopus@latest
160
162
 
161
163
  ### Native addons not built (untrusted postinstall)
162
164
 
163
- If `bun install -g` skips the postinstall script, the monitor will auto-build native addons on first run (requires Node.js for `npx`). Alternatively, trust the package and reinstall:
165
+ Bun skips postinstall scripts for untrusted packages. Install with `--trust` to fix this:
166
+
167
+ ```bash
168
+ bun install -g clocktopus --trust
169
+ ```
170
+
171
+ Or if already installed, rebuild manually:
164
172
 
165
173
  ```bash
166
- bun pm trust clocktopus && bun i -g clocktopus
174
+ cd ~/.bun/install/global/node_modules/macos-notification-state && npx node-gyp rebuild
167
175
  ```
168
176
 
169
177
  ### Linux
@@ -1,6 +1,12 @@
1
1
  import { Hono } from 'hono';
2
+ import { v4 as uuidv4 } from 'uuid';
2
3
  import { Clockify } from '../../clockify.js';
3
- import { completeLatestSession } from '../../lib/db.js';
4
+ import { completeLatestSession, getOpenSession, logCompletedSession, logSessionStart } from '../../lib/db.js';
5
+ import { stopJiraTimer } from '../../lib/jira.js';
6
+ function extractJiraTicket(description) {
7
+ const match = description.match(/\b([A-Z][A-Z0-9]+-\d+)\b/);
8
+ return match?.[1];
9
+ }
4
10
  const timerRoutes = new Hono();
5
11
  timerRoutes.get('/timer/active', async (c) => {
6
12
  try {
@@ -11,11 +17,20 @@ timerRoutes.get('/timer/active', async (c) => {
11
17
  const timer = await clockify.getActiveTimer(user.defaultWorkspace, user.id);
12
18
  if (!timer)
13
19
  return c.json({ active: false });
20
+ // Sync externally-started timers (e.g. from Clockify app or Jira plugin) to DB
21
+ const timerStart = timer.timeInterval.start;
22
+ const jiraTicket = extractJiraTicket(timer.description ?? '');
23
+ const openSession = getOpenSession();
24
+ const alreadyTracked = openSession && openSession.startedAt.slice(0, 19) === timerStart.slice(0, 19);
25
+ if (!alreadyTracked) {
26
+ logSessionStart(timer.id ?? uuidv4(), timer.projectId, timer.description ?? '', timerStart, jiraTicket);
27
+ }
14
28
  return c.json({
15
29
  active: true,
16
30
  description: timer.description,
17
31
  projectId: timer.projectId,
18
- start: timer.timeInterval.start,
32
+ start: timerStart,
33
+ ...(jiraTicket ? { jiraTicket } : {}),
19
34
  });
20
35
  }
21
36
  catch {
@@ -47,14 +62,79 @@ timerRoutes.post('/timer/stop', async (c) => {
47
62
  const user = await clockify.getUser();
48
63
  if (!user)
49
64
  return c.json({ ok: false, error: 'Could not connect to Clockify.' }, 500);
65
+ const openSession = getOpenSession();
50
66
  const result = await clockify.stopTimer(user.defaultWorkspace, user.id);
51
67
  if (!result)
52
68
  return c.json({ ok: false, error: 'Failed to stop timer.' }, 500);
53
- completeLatestSession(new Date().toISOString(), false);
69
+ const completedAt = new Date().toISOString();
70
+ completeLatestSession(completedAt, false);
71
+ if (openSession?.jiraTicket) {
72
+ const timeSpentSeconds = Math.round((new Date(completedAt).getTime() - new Date(openSession.startedAt).getTime()) / 1000);
73
+ if (timeSpentSeconds >= 60) {
74
+ try {
75
+ await stopJiraTimer(openSession.jiraTicket, timeSpentSeconds);
76
+ }
77
+ catch (err) {
78
+ console.error('Error stopping Jira timer:', err);
79
+ }
80
+ }
81
+ }
54
82
  return c.json({ ok: true });
55
83
  }
56
84
  catch {
57
85
  return c.json({ ok: false, error: 'Failed to stop timer.' }, 500);
58
86
  }
59
87
  });
88
+ timerRoutes.post('/timer/log', async (c) => {
89
+ const { projectId, description, start, end, jiraTicket } = await c.req.json();
90
+ if (!projectId) {
91
+ return c.json({ ok: false, error: 'Project is required.' }, 400);
92
+ }
93
+ if (!start || !end) {
94
+ return c.json({ ok: false, error: 'Start and end are required.' }, 400);
95
+ }
96
+ const startMs = new Date(start).getTime();
97
+ const endMs = new Date(end).getTime();
98
+ if (Number.isNaN(startMs) || Number.isNaN(endMs)) {
99
+ return c.json({ ok: false, error: 'Invalid start or end date.' }, 400);
100
+ }
101
+ if (endMs <= startMs) {
102
+ return c.json({ ok: false, error: 'End must be after start.' }, 400);
103
+ }
104
+ const cleanDescription = (description ?? '').trim();
105
+ const cleanJira = jiraTicket?.trim() || undefined;
106
+ if (!cleanDescription && !cleanJira) {
107
+ return c.json({ ok: false, error: 'Description or Jira ticket is required.' }, 400);
108
+ }
109
+ try {
110
+ const clockify = new Clockify();
111
+ const user = await clockify.getUser();
112
+ if (!user)
113
+ return c.json({ ok: false, error: 'Could not connect to Clockify.' }, 500);
114
+ const startIso = new Date(startMs).toISOString();
115
+ const endIso = new Date(endMs).toISOString();
116
+ const finalDescription = cleanDescription || cleanJira;
117
+ const entry = await clockify.logTime(user.defaultWorkspace, projectId, startIso, endIso, finalDescription);
118
+ if (!entry)
119
+ return c.json({ ok: false, error: 'Failed to log time in Clockify.' }, 500);
120
+ const entryId = entry.id ?? uuidv4();
121
+ logCompletedSession(entryId, projectId, finalDescription, startIso, endIso, cleanJira);
122
+ if (cleanJira) {
123
+ const timeSpentSeconds = Math.round((endMs - startMs) / 1000);
124
+ if (timeSpentSeconds >= 60) {
125
+ try {
126
+ await stopJiraTimer(cleanJira, timeSpentSeconds);
127
+ }
128
+ catch (err) {
129
+ console.error('Error posting Jira worklog for manual entry:', err);
130
+ }
131
+ }
132
+ }
133
+ return c.json({ ok: true });
134
+ }
135
+ catch (err) {
136
+ console.error('Error logging manual time:', err);
137
+ return c.json({ ok: false, error: 'Failed to log time.' }, 500);
138
+ }
139
+ });
60
140
  export default timerRoutes;
@@ -22,11 +22,18 @@ export function indexPage() {
22
22
 
23
23
  /* Cards */
24
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; }
25
+ .home-cards { grid-template-columns: minmax(0, 2fr) minmax(0, 1fr); }
26
+ .card { background: #1c1f26; border: 1px solid #2d3139; border-radius: 12px; padding: 1.5rem; min-width: 0; }
26
27
  .card-header { display: flex; align-items: center; gap: 0.75rem; margin-bottom: 1rem; }
27
28
  .card-header h2 { margin-bottom: 0; }
28
29
  .card-full { grid-column: 1 / -1; }
29
30
 
31
+ /* Track tabs */
32
+ .track-tabs { display: inline-flex; gap: 0.25rem; background: #0d1117; border: 1px solid #30363d; border-radius: 8px; padding: 0.25rem; margin-bottom: 1rem; }
33
+ .track-tab-btn { margin-top: 0; padding: 0.4rem 1rem; border: none; border-radius: 6px; background: transparent; color: #8b949e; font-size: 0.85rem; cursor: pointer; }
34
+ .track-tab-btn:hover { color: #e1e4e8; }
35
+ .track-tab-btn.active { background: #30363d; color: #fff; }
36
+
30
37
  /* Active timer */
31
38
  .active-timer { border-left: 3px solid #3fb950; display: flex; align-items: center; justify-content: space-between; gap: 1rem; flex-wrap: wrap; }
32
39
  .active-timer .timer-info { flex: 1; }
@@ -69,6 +76,7 @@ export function indexPage() {
69
76
 
70
77
  /* Inline form row */
71
78
  .form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }
79
+ .form-row > div { min-width: 0; }
72
80
  /* Calendar event cards */
73
81
  .cal-event-card { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 0.75rem; margin-bottom: 0.5rem; }
74
82
  .cal-event-card .cal-card-name { color: #e1e4e8; font-size: 0.85rem; font-weight: 500; margin-bottom: 0.5rem; }
@@ -80,6 +88,7 @@ export function indexPage() {
80
88
  @media (max-width: 600px) {
81
89
  body { padding: 0.75rem; }
82
90
  .form-row { grid-template-columns: 1fr; }
91
+ .home-cards { grid-template-columns: 1fr; }
83
92
  .header { flex-direction: column; gap: 0; align-items: stretch; margin-bottom: 1rem; }
84
93
  .header h1 { display: none; }
85
94
  .nav { justify-content: center; flex-wrap: wrap; }
@@ -120,29 +129,64 @@ export function indexPage() {
120
129
  <button class="stop-btn" onclick="stopTimer()">Stop Timer</button>
121
130
  </div>
122
131
 
123
- <div class="cards">
124
- <!-- Start Timer -->
132
+ <div class="cards home-cards">
133
+ <!-- Track Time -->
125
134
  <div class="card">
126
- <h2>Start Timer</h2>
127
- <div id="start-timer-form">
128
- <label for="project-select">Project</label>
129
- <select id="project-select">
135
+ <div class="track-tabs">
136
+ <button class="track-tab-btn active" data-mode="auto" onclick="switchTrackMode('auto')">Auto Track</button>
137
+ <button class="track-tab-btn" data-mode="manual" onclick="switchTrackMode('manual')">Manual Log</button>
138
+ </div>
139
+
140
+ <div id="track-auto">
141
+ <div id="start-timer-form">
142
+ <label for="project-select">Project</label>
143
+ <select id="project-select">
144
+ <option value="">Loading projects...</option>
145
+ </select>
146
+ <div class="form-row">
147
+ <div>
148
+ <label for="timer-description">Description</label>
149
+ <input type="text" id="timer-description" placeholder="What are you working on?" />
150
+ </div>
151
+ <div>
152
+ <label for="timer-jira">Jira Ticket (optional)</label>
153
+ <input type="text" id="timer-jira" placeholder="e.g. PROJ-123" />
154
+ </div>
155
+ </div>
156
+ <button id="start-btn" onclick="startTimer()">Start Timer</button>
157
+ </div>
158
+ <div id="last-tasks" style="display:none; margin-top:0.75rem;"></div>
159
+ <div class="msg" id="timer-msg"></div>
160
+ </div>
161
+
162
+ <div id="track-manual" style="display:none;">
163
+ <label for="manual-project">Project</label>
164
+ <select id="manual-project">
130
165
  <option value="">Loading projects...</option>
131
166
  </select>
132
167
  <div class="form-row">
133
168
  <div>
134
- <label for="timer-description">Description</label>
135
- <input type="text" id="timer-description" placeholder="What are you working on?" />
169
+ <label for="manual-start">Start</label>
170
+ <input type="datetime-local" id="manual-start" />
136
171
  </div>
137
172
  <div>
138
- <label for="timer-jira">Jira Ticket (optional)</label>
139
- <input type="text" id="timer-jira" placeholder="e.g. PROJ-123" />
173
+ <label for="manual-end">End</label>
174
+ <input type="datetime-local" id="manual-end" />
140
175
  </div>
141
176
  </div>
142
- <button id="start-btn" onclick="startTimer()">Start Timer</button>
177
+ <div class="form-row">
178
+ <div>
179
+ <label for="manual-description">Description</label>
180
+ <input type="text" id="manual-description" placeholder="What did you work on?" />
181
+ </div>
182
+ <div>
183
+ <label for="manual-jira">Jira Ticket (optional)</label>
184
+ <input type="text" id="manual-jira" placeholder="e.g. PROJ-123" />
185
+ </div>
186
+ </div>
187
+ <button id="manual-log-btn" onclick="logManualTime()">Log Time</button>
188
+ <div class="msg" id="manual-msg"></div>
143
189
  </div>
144
- <div id="last-tasks" style="display:none; margin-top:0.75rem;"></div>
145
- <div class="msg" id="timer-msg"></div>
146
190
  </div>
147
191
 
148
192
  <!-- Monitor Control -->
@@ -311,6 +355,14 @@ export function indexPage() {
311
355
  document.getElementById('nav-' + tab).classList.add('active');
312
356
  }
313
357
 
358
+ function switchTrackMode(mode) {
359
+ document.querySelectorAll('.track-tab-btn').forEach(function(b) {
360
+ b.classList.toggle('active', b.dataset.mode === mode);
361
+ });
362
+ document.getElementById('track-auto').style.display = mode === 'auto' ? 'block' : 'none';
363
+ document.getElementById('track-manual').style.display = mode === 'manual' ? 'block' : 'none';
364
+ }
365
+
314
366
  // --- Utilities ---
315
367
  function setMsg(id, text, ok) {
316
368
  const el = document.getElementById(id);
@@ -430,6 +482,70 @@ export function indexPage() {
430
482
  }
431
483
  }
432
484
 
485
+ function toLocalInputValue(date) {
486
+ const pad = function(n) { return String(n).padStart(2, '0'); };
487
+ return date.getFullYear() + '-' + pad(date.getMonth() + 1) + '-' + pad(date.getDate()) +
488
+ 'T' + pad(date.getHours()) + ':' + pad(date.getMinutes());
489
+ }
490
+
491
+ function setManualDefaults() {
492
+ const now = new Date();
493
+ const hourAgo = new Date(now.getTime() - 60 * 60 * 1000);
494
+ const startEl = document.getElementById('manual-start');
495
+ const endEl = document.getElementById('manual-end');
496
+ if (startEl) startEl.value = toLocalInputValue(hourAgo);
497
+ if (endEl) endEl.value = toLocalInputValue(now);
498
+ }
499
+
500
+ async function logManualTime() {
501
+ const projectId = document.getElementById('manual-project').value;
502
+ const startVal = document.getElementById('manual-start').value;
503
+ const endVal = document.getElementById('manual-end').value;
504
+ const description = document.getElementById('manual-description').value.trim();
505
+ const jiraTicket = document.getElementById('manual-jira').value.trim();
506
+
507
+ if (!projectId) return setMsg('manual-msg', 'Please select a project.', false);
508
+ if (!startVal || !endVal) return setMsg('manual-msg', 'Please set start and end.', false);
509
+
510
+ const startMs = new Date(startVal).getTime();
511
+ const endMs = new Date(endVal).getTime();
512
+ if (isNaN(startMs) || isNaN(endMs)) return setMsg('manual-msg', 'Invalid date.', false);
513
+ if (endMs <= startMs) return setMsg('manual-msg', 'End must be after start.', false);
514
+ if (!description && !jiraTicket) return setMsg('manual-msg', 'Please enter a description or Jira ticket.', false);
515
+
516
+ const btn = document.getElementById('manual-log-btn');
517
+ btn.disabled = true;
518
+ btn.textContent = 'Logging...';
519
+
520
+ try {
521
+ const res = await fetch('/api/timer/log', {
522
+ method: 'POST',
523
+ headers: { 'Content-Type': 'application/json' },
524
+ body: JSON.stringify({
525
+ projectId: projectId,
526
+ description: description,
527
+ start: new Date(startMs).toISOString(),
528
+ end: new Date(endMs).toISOString(),
529
+ jiraTicket: jiraTicket || undefined,
530
+ }),
531
+ });
532
+ const data = await res.json();
533
+ if (data.ok) {
534
+ setMsg('manual-msg', 'Time logged.', true);
535
+ document.getElementById('manual-description').value = '';
536
+ document.getElementById('manual-jira').value = '';
537
+ setManualDefaults();
538
+ loadSessions();
539
+ } else {
540
+ setMsg('manual-msg', data.error || 'Failed to log time.', false);
541
+ }
542
+ } catch {
543
+ setMsg('manual-msg', 'Request failed.', false);
544
+ }
545
+ btn.disabled = false;
546
+ btn.textContent = 'Log Time';
547
+ }
548
+
433
549
  // --- Last Tasks ---
434
550
  var lastTasks = [];
435
551
 
@@ -542,20 +658,26 @@ export function indexPage() {
542
658
  try {
543
659
  const res = await fetch('/api/projects');
544
660
  const projects = await res.json();
545
- const select = document.getElementById('project-select');
546
- select.innerHTML = '<option value="">Select a project</option>';
547
- if (projects.length === 0) {
548
- select.innerHTML = '<option value="">No active projects \u2014 pull from Clockify in Settings</option>';
549
- return;
550
- }
551
- projects.forEach(function(p) {
552
- const opt = document.createElement('option');
553
- opt.value = p.id;
554
- opt.textContent = p.name;
555
- select.appendChild(opt);
661
+ const selects = [document.getElementById('project-select'), document.getElementById('manual-project')];
662
+ selects.forEach(function(select) {
663
+ if (!select) return;
664
+ select.innerHTML = '<option value="">Select a project</option>';
665
+ if (projects.length === 0) {
666
+ select.innerHTML = '<option value="">No active projects \u2014 pull from Clockify in Settings</option>';
667
+ return;
668
+ }
669
+ projects.forEach(function(p) {
670
+ const opt = document.createElement('option');
671
+ opt.value = p.id;
672
+ opt.textContent = p.name;
673
+ select.appendChild(opt);
674
+ });
556
675
  });
557
676
  } catch {
558
- document.getElementById('project-select').innerHTML = '<option value="">Failed to load projects</option>';
677
+ const select = document.getElementById('project-select');
678
+ if (select) select.innerHTML = '<option value="">Failed to load projects</option>';
679
+ const manual = document.getElementById('manual-project');
680
+ if (manual) manual.innerHTML = '<option value="">Failed to load projects</option>';
559
681
  }
560
682
  }
561
683
 
@@ -1000,6 +1122,7 @@ export function indexPage() {
1000
1122
  checkActiveTimer();
1001
1123
  checkMonitorStatus();
1002
1124
  loadAllProjects();
1125
+ setManualDefaults();
1003
1126
 
1004
1127
  </script>
1005
1128
  </body>
package/dist/lib/db.js CHANGED
@@ -105,9 +105,14 @@ export function getLatestToken() {
105
105
  }
106
106
  export function logSessionStart(id, projectId, description, startedAt, jiraTicket) {
107
107
  const db = getDb();
108
- const stmt = db.prepare('INSERT INTO sessions (id, projectId, description, startedAt, isAutoCompleted, jiraTicket) VALUES (?, ?, ?, ?, ?, ?)');
108
+ const stmt = db.prepare('INSERT OR IGNORE INTO sessions (id, projectId, description, startedAt, isAutoCompleted, jiraTicket) VALUES (?, ?, ?, ?, ?, ?)');
109
109
  stmt.run(id, projectId, description, startedAt, 0, jiraTicket ?? null);
110
110
  }
111
+ export function logCompletedSession(id, projectId, description, startedAt, completedAt, jiraTicket) {
112
+ const db = getDb();
113
+ const stmt = db.prepare('INSERT OR IGNORE INTO sessions (id, projectId, description, startedAt, completedAt, isAutoCompleted, jiraTicket) VALUES (?, ?, ?, ?, ?, 0, ?)');
114
+ stmt.run(id, projectId, description, startedAt, completedAt, jiraTicket ?? null);
115
+ }
111
116
  export function completeLatestSession(completedAt, isAutoCompleted = false) {
112
117
  const db = getDb();
113
118
  const stmt = db.prepare(`
@@ -130,6 +135,14 @@ export function getSessionCount() {
130
135
  const row = stmt.get();
131
136
  return row.count;
132
137
  }
138
+ export function getOpenSession() {
139
+ const db = getDb();
140
+ const stmt = db.prepare('SELECT * FROM sessions WHERE completedAt IS NULL ORDER BY startedAt DESC LIMIT 1');
141
+ const row = stmt.get();
142
+ if (!row)
143
+ return null;
144
+ return SessionSchema.parse(row);
145
+ }
133
146
  export function getLatestSession() {
134
147
  const db = getDb();
135
148
  const stmt = db.prepare(`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clocktopus",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "bin": {