clocktopus 1.2.1 → 1.4.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
@@ -150,6 +152,15 @@ Go to **System Settings > Notifications** and ensure **terminal-notifier** has n
150
152
 
151
153
  Enable **Require password immediately** in System Settings > Lock Screen.
152
154
 
155
+ ### Uninstall
156
+
157
+ ```bash
158
+ bun remove -g clocktopus
159
+
160
+ # If the above doesn't work, delete the binary directly:
161
+ rm ~/.bun/bin/clocktopus
162
+ ```
163
+
153
164
  ### Bun installs an old version
154
165
 
155
166
  Bun caches registry data aggressively. Clear the cache and reinstall:
@@ -160,10 +171,16 @@ bun pm cache rm && bun i -g clocktopus@latest
160
171
 
161
172
  ### Native addons not built (untrusted postinstall)
162
173
 
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:
174
+ Bun skips postinstall scripts for untrusted packages. Install with `--trust` to fix this:
175
+
176
+ ```bash
177
+ bun install -g clocktopus --trust
178
+ ```
179
+
180
+ Or if already installed, rebuild manually:
164
181
 
165
182
  ```bash
166
- bun pm trust clocktopus && bun i -g clocktopus
183
+ cd ~/.bun/install/global/node_modules/macos-notification-state && npx node-gyp rebuild
167
184
  ```
168
185
 
169
186
  ### Linux
@@ -1,7 +1,7 @@
1
1
  import { Hono } from 'hono';
2
2
  import { v4 as uuidv4 } from 'uuid';
3
3
  import { Clockify } from '../../clockify.js';
4
- import { completeLatestSession, getOpenSession, logSessionStart } from '../../lib/db.js';
4
+ import { completeLatestSession, getOpenSession, logCompletedSession, logSessionStart } from '../../lib/db.js';
5
5
  import { stopJiraTimer } from '../../lib/jira.js';
6
6
  function extractJiraTicket(description) {
7
7
  const match = description.match(/\b([A-Z][A-Z0-9]+-\d+)\b/);
@@ -15,14 +15,32 @@ timerRoutes.get('/timer/active', async (c) => {
15
15
  if (!user)
16
16
  return c.json({ active: false });
17
17
  const timer = await clockify.getActiveTimer(user.defaultWorkspace, user.id);
18
- if (!timer)
18
+ if (!timer) {
19
+ // Timer stopped externally (e.g. in Clockify app) — close any lingering open session
20
+ const openSession = getOpenSession();
21
+ if (openSession) {
22
+ const completedAt = new Date().toISOString();
23
+ completeLatestSession(completedAt, false);
24
+ if (openSession.jiraTicket) {
25
+ const timeSpentSeconds = Math.round((new Date(completedAt).getTime() - new Date(openSession.startedAt).getTime()) / 1000);
26
+ if (timeSpentSeconds >= 60) {
27
+ try {
28
+ await stopJiraTimer(openSession.jiraTicket, timeSpentSeconds);
29
+ }
30
+ catch (err) {
31
+ console.error('Error stopping Jira timer on external stop:', err);
32
+ }
33
+ }
34
+ }
35
+ }
19
36
  return c.json({ active: false });
37
+ }
20
38
  // Sync externally-started timers (e.g. from Clockify app or Jira plugin) to DB
21
39
  const timerStart = timer.timeInterval.start;
40
+ const jiraTicket = extractJiraTicket(timer.description ?? '');
22
41
  const openSession = getOpenSession();
23
42
  const alreadyTracked = openSession && openSession.startedAt.slice(0, 19) === timerStart.slice(0, 19);
24
43
  if (!alreadyTracked) {
25
- const jiraTicket = extractJiraTicket(timer.description ?? '');
26
44
  logSessionStart(timer.id ?? uuidv4(), timer.projectId, timer.description ?? '', timerStart, jiraTicket);
27
45
  }
28
46
  return c.json({
@@ -30,6 +48,7 @@ timerRoutes.get('/timer/active', async (c) => {
30
48
  description: timer.description,
31
49
  projectId: timer.projectId,
32
50
  start: timerStart,
51
+ ...(jiraTicket ? { jiraTicket } : {}),
33
52
  });
34
53
  }
35
54
  catch {
@@ -84,4 +103,56 @@ timerRoutes.post('/timer/stop', async (c) => {
84
103
  return c.json({ ok: false, error: 'Failed to stop timer.' }, 500);
85
104
  }
86
105
  });
106
+ timerRoutes.post('/timer/log', async (c) => {
107
+ const { projectId, description, start, end, jiraTicket } = await c.req.json();
108
+ if (!projectId) {
109
+ return c.json({ ok: false, error: 'Project is required.' }, 400);
110
+ }
111
+ if (!start || !end) {
112
+ return c.json({ ok: false, error: 'Start and end are required.' }, 400);
113
+ }
114
+ const startMs = new Date(start).getTime();
115
+ const endMs = new Date(end).getTime();
116
+ if (Number.isNaN(startMs) || Number.isNaN(endMs)) {
117
+ return c.json({ ok: false, error: 'Invalid start or end date.' }, 400);
118
+ }
119
+ if (endMs <= startMs) {
120
+ return c.json({ ok: false, error: 'End must be after start.' }, 400);
121
+ }
122
+ const cleanDescription = (description ?? '').trim();
123
+ const cleanJira = jiraTicket?.trim() || undefined;
124
+ if (!cleanDescription && !cleanJira) {
125
+ return c.json({ ok: false, error: 'Description or Jira ticket is required.' }, 400);
126
+ }
127
+ try {
128
+ const clockify = new Clockify();
129
+ const user = await clockify.getUser();
130
+ if (!user)
131
+ return c.json({ ok: false, error: 'Could not connect to Clockify.' }, 500);
132
+ const startIso = new Date(startMs).toISOString();
133
+ const endIso = new Date(endMs).toISOString();
134
+ const finalDescription = cleanDescription || cleanJira;
135
+ const entry = await clockify.logTime(user.defaultWorkspace, projectId, startIso, endIso, finalDescription);
136
+ if (!entry)
137
+ return c.json({ ok: false, error: 'Failed to log time in Clockify.' }, 500);
138
+ const entryId = entry.id ?? uuidv4();
139
+ logCompletedSession(entryId, projectId, finalDescription, startIso, endIso, cleanJira);
140
+ if (cleanJira) {
141
+ const timeSpentSeconds = Math.round((endMs - startMs) / 1000);
142
+ if (timeSpentSeconds >= 60) {
143
+ try {
144
+ await stopJiraTimer(cleanJira, timeSpentSeconds);
145
+ }
146
+ catch (err) {
147
+ console.error('Error posting Jira worklog for manual entry:', err);
148
+ }
149
+ }
150
+ }
151
+ return c.json({ ok: true });
152
+ }
153
+ catch (err) {
154
+ console.error('Error logging manual time:', err);
155
+ return c.json({ ok: false, error: 'Failed to log time.' }, 500);
156
+ }
157
+ });
87
158
  export default timerRoutes;
@@ -1,4 +1,5 @@
1
1
  import { Hono } from 'hono';
2
+ import { cors } from 'hono/cors';
2
3
  import { serve } from '@hono/node-server';
3
4
  import { indexPage } from './views.js';
4
5
  import statusRoutes from './routes/status.js';
@@ -10,6 +11,7 @@ import dataRoutes from './routes/data.js';
10
11
  import monitorRoutes from './routes/monitor.js';
11
12
  import calendarRoutes from './routes/calendar.js';
12
13
  const app = new Hono();
14
+ app.use('*', cors());
13
15
  app.get('/', (c) => c.html(indexPage()));
14
16
  app.route('/api', statusRoutes);
15
17
  app.route('/api', clockifyRoutes);
@@ -7,7 +7,7 @@ export function indexPage() {
7
7
  <title>Clocktopus Dashboard</title>
8
8
  <style>
9
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; }
10
+ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: transparent; color: #e1e4e8; padding: 2rem; }
11
11
  h1 { font-size: 1.8rem; margin-bottom: 0; color: #fff; }
12
12
  h2 { font-size: 1.1rem; color: #fff; margin-bottom: 1rem; }
13
13
 
@@ -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; }
@@ -97,7 +106,7 @@ export function indexPage() {
97
106
  .toggle input:checked + .slider::before { transform: translateX(16px); background: #fff; }
98
107
  </style>
99
108
  </head>
100
- <body>
109
+ <body oncontextmenu="return false;">
101
110
  <div class="header">
102
111
  <h1>Clocktopus</h1>
103
112
  <div class="nav">
@@ -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" />
171
+ </div>
172
+ <div>
173
+ <label for="manual-end">End</label>
174
+ <input type="datetime-local" id="manual-end" />
175
+ </div>
176
+ </div>
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?" />
136
181
  </div>
137
182
  <div>
138
- <label for="timer-jira">Jira Ticket (optional)</label>
139
- <input type="text" id="timer-jira" placeholder="e.g. PROJ-123" />
183
+ <label for="manual-jira">Jira Ticket (optional)</label>
184
+ <input type="text" id="manual-jira" placeholder="e.g. PROJ-123" />
140
185
  </div>
141
186
  </div>
142
- <button id="start-btn" onclick="startTimer()">Start Timer</button>
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 -->
@@ -299,6 +343,11 @@ export function indexPage() {
299
343
  </div>
300
344
 
301
345
  <script>
346
+ // Disable WebKit's Back/Forward/Reload context menu. Capture-phase and
347
+ // attached on window so it runs before any app handler and wins the race
348
+ // with the native WKWebView menu.
349
+ window.addEventListener('contextmenu', e => e.preventDefault(), { capture: true });
350
+
302
351
  let elapsedInterval = null;
303
352
  let currentPage = 1;
304
353
  let totalPages = 1;
@@ -311,6 +360,14 @@ export function indexPage() {
311
360
  document.getElementById('nav-' + tab).classList.add('active');
312
361
  }
313
362
 
363
+ function switchTrackMode(mode) {
364
+ document.querySelectorAll('.track-tab-btn').forEach(function(b) {
365
+ b.classList.toggle('active', b.dataset.mode === mode);
366
+ });
367
+ document.getElementById('track-auto').style.display = mode === 'auto' ? 'block' : 'none';
368
+ document.getElementById('track-manual').style.display = mode === 'manual' ? 'block' : 'none';
369
+ }
370
+
314
371
  // --- Utilities ---
315
372
  function setMsg(id, text, ok) {
316
373
  const el = document.getElementById(id);
@@ -430,6 +487,70 @@ export function indexPage() {
430
487
  }
431
488
  }
432
489
 
490
+ function toLocalInputValue(date) {
491
+ const pad = function(n) { return String(n).padStart(2, '0'); };
492
+ return date.getFullYear() + '-' + pad(date.getMonth() + 1) + '-' + pad(date.getDate()) +
493
+ 'T' + pad(date.getHours()) + ':' + pad(date.getMinutes());
494
+ }
495
+
496
+ function setManualDefaults() {
497
+ const now = new Date();
498
+ const hourAgo = new Date(now.getTime() - 60 * 60 * 1000);
499
+ const startEl = document.getElementById('manual-start');
500
+ const endEl = document.getElementById('manual-end');
501
+ if (startEl) startEl.value = toLocalInputValue(hourAgo);
502
+ if (endEl) endEl.value = toLocalInputValue(now);
503
+ }
504
+
505
+ async function logManualTime() {
506
+ const projectId = document.getElementById('manual-project').value;
507
+ const startVal = document.getElementById('manual-start').value;
508
+ const endVal = document.getElementById('manual-end').value;
509
+ const description = document.getElementById('manual-description').value.trim();
510
+ const jiraTicket = document.getElementById('manual-jira').value.trim();
511
+
512
+ if (!projectId) return setMsg('manual-msg', 'Please select a project.', false);
513
+ if (!startVal || !endVal) return setMsg('manual-msg', 'Please set start and end.', false);
514
+
515
+ const startMs = new Date(startVal).getTime();
516
+ const endMs = new Date(endVal).getTime();
517
+ if (isNaN(startMs) || isNaN(endMs)) return setMsg('manual-msg', 'Invalid date.', false);
518
+ if (endMs <= startMs) return setMsg('manual-msg', 'End must be after start.', false);
519
+ if (!description && !jiraTicket) return setMsg('manual-msg', 'Please enter a description or Jira ticket.', false);
520
+
521
+ const btn = document.getElementById('manual-log-btn');
522
+ btn.disabled = true;
523
+ btn.textContent = 'Logging...';
524
+
525
+ try {
526
+ const res = await fetch('/api/timer/log', {
527
+ method: 'POST',
528
+ headers: { 'Content-Type': 'application/json' },
529
+ body: JSON.stringify({
530
+ projectId: projectId,
531
+ description: description,
532
+ start: new Date(startMs).toISOString(),
533
+ end: new Date(endMs).toISOString(),
534
+ jiraTicket: jiraTicket || undefined,
535
+ }),
536
+ });
537
+ const data = await res.json();
538
+ if (data.ok) {
539
+ setMsg('manual-msg', 'Time logged.', true);
540
+ document.getElementById('manual-description').value = '';
541
+ document.getElementById('manual-jira').value = '';
542
+ setManualDefaults();
543
+ loadSessions();
544
+ } else {
545
+ setMsg('manual-msg', data.error || 'Failed to log time.', false);
546
+ }
547
+ } catch {
548
+ setMsg('manual-msg', 'Request failed.', false);
549
+ }
550
+ btn.disabled = false;
551
+ btn.textContent = 'Log Time';
552
+ }
553
+
433
554
  // --- Last Tasks ---
434
555
  var lastTasks = [];
435
556
 
@@ -542,20 +663,26 @@ export function indexPage() {
542
663
  try {
543
664
  const res = await fetch('/api/projects');
544
665
  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);
666
+ const selects = [document.getElementById('project-select'), document.getElementById('manual-project')];
667
+ selects.forEach(function(select) {
668
+ if (!select) return;
669
+ select.innerHTML = '<option value="">Select a project</option>';
670
+ if (projects.length === 0) {
671
+ select.innerHTML = '<option value="">No active projects \u2014 pull from Clockify in Settings</option>';
672
+ return;
673
+ }
674
+ projects.forEach(function(p) {
675
+ const opt = document.createElement('option');
676
+ opt.value = p.id;
677
+ opt.textContent = p.name;
678
+ select.appendChild(opt);
679
+ });
556
680
  });
557
681
  } catch {
558
- document.getElementById('project-select').innerHTML = '<option value="">Failed to load projects</option>';
682
+ const select = document.getElementById('project-select');
683
+ if (select) select.innerHTML = '<option value="">Failed to load projects</option>';
684
+ const manual = document.getElementById('manual-project');
685
+ if (manual) manual.innerHTML = '<option value="">Failed to load projects</option>';
559
686
  }
560
687
  }
561
688
 
@@ -1000,6 +1127,7 @@ export function indexPage() {
1000
1127
  checkActiveTimer();
1001
1128
  checkMonitorStatus();
1002
1129
  loadAllProjects();
1130
+ setManualDefaults();
1003
1131
 
1004
1132
  </script>
1005
1133
  </body>
package/dist/lib/db.js CHANGED
@@ -108,6 +108,11 @@ export function logSessionStart(id, projectId, description, startedAt, jiraTicke
108
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(`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clocktopus",
3
- "version": "1.2.1",
3
+ "version": "1.4.0",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "bin": {