clocktopus 1.5.0 → 1.6.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
@@ -72,7 +72,13 @@ After installing, remove the quarantine flag (app is not code-signed):
72
72
  xattr -cr /Applications/Clocktopus.app
73
73
  ```
74
74
 
75
- The dashboard server must be running (`clocktopus serve`). See [desktop/README.md](desktop/README.md) for details.
75
+ The app manages the dashboard server for you:
76
+
77
+ - **Install Clocktopus** — if the CLI is not installed, the popup offers a one-click installer that runs `bun install -g clocktopus` for you.
78
+ - **Start Server** — when the dashboard is not running, the popup shows a "Start Server" button. Click it and the app spawns `clocktopus dash` in the background, then loads the dashboard once it's up.
79
+ - **Stop Server** / **Restart Server** — available from the tray menu when the server is reachable. Stop also kills any pre-existing process on port 4001 (terminal, PM2, prior session).
80
+
81
+ See [desktop/README.md](desktop/README.md) for details.
76
82
 
77
83
  ---
78
84
 
package/dist/clockify.js CHANGED
@@ -104,7 +104,7 @@ export class Clockify {
104
104
  return null;
105
105
  }
106
106
  }
107
- async startTimer(workspaceId, projectId, description = 'Working on a task...', jiraTicket) {
107
+ async startTimer(workspaceId, projectId, description = 'Working on a task...', jiraTicket, billable = true) {
108
108
  try {
109
109
  const user = await this.getUser();
110
110
  if (!user) {
@@ -123,6 +123,7 @@ export class Clockify {
123
123
  projectId: projectId,
124
124
  description: finalDescription,
125
125
  start: startedAt,
126
+ billable,
126
127
  });
127
128
  // Log session to SQLite
128
129
  logSessionStart(sessionId, projectId, finalDescription, startedAt, jiraTicket);
@@ -195,7 +196,7 @@ export class Clockify {
195
196
  return [];
196
197
  }
197
198
  }
198
- async logTime(workspaceId, projectId, start, end, description) {
199
+ async logTime(workspaceId, projectId, start, end, description, billable = true) {
199
200
  if (!projectId) {
200
201
  return null;
201
202
  }
@@ -205,6 +206,7 @@ export class Clockify {
205
206
  start: start,
206
207
  end: end,
207
208
  description: description,
209
+ billable,
208
210
  });
209
211
  return response.data;
210
212
  }
@@ -31,13 +31,33 @@ calendarRoutes.get('/calendar/events', async (c) => {
31
31
  const endOfDay = new Date(end);
32
32
  endOfDay.setDate(endOfDay.getDate() + 1);
33
33
  const timeMax = endOfDay.toISOString();
34
- const res = await calendar.events.list({
34
+ const listEvents = () => calendar.events.list({
35
35
  calendarId: 'primary',
36
36
  timeMin,
37
37
  timeMax,
38
38
  singleEvents: true,
39
39
  orderBy: 'startTime',
40
40
  });
41
+ let res;
42
+ try {
43
+ res = await listEvents();
44
+ }
45
+ catch (err) {
46
+ const status = err?.code ?? err?.status;
47
+ if (status === 401 && token.refresh_token) {
48
+ console.log('[calendar] Access token rejected (401). Refreshing and retrying...');
49
+ token = await getRefreshedToken(token);
50
+ storeToken(token);
51
+ oAuth2Client.setCredentials(token);
52
+ res = await listEvents();
53
+ }
54
+ else if (status === 401) {
55
+ return c.json({ ok: false, error: 'Google auth expired. Please reconnect your Google account in Settings.' }, 401);
56
+ }
57
+ else {
58
+ throw err;
59
+ }
60
+ }
41
61
  // Fetch existing Clockify entries for the same range to detect duplicates
42
62
  const clockify = new Clockify();
43
63
  const user = await clockify.getUser();
@@ -86,7 +106,7 @@ calendarRoutes.post('/calendar/log', async (c) => {
86
106
  continue;
87
107
  }
88
108
  try {
89
- await clockify.logTime(user.defaultWorkspace, entry.projectId, entry.start, entry.end, entry.summary);
109
+ await clockify.logTime(user.defaultWorkspace, entry.projectId, entry.start, entry.end, entry.summary, entry.billable ?? true);
90
110
  setEventProject(entry.summary, entry.projectId);
91
111
  logged.push(entry.summary);
92
112
  }
@@ -56,7 +56,7 @@ timerRoutes.get('/timer/active', async (c) => {
56
56
  }
57
57
  });
58
58
  timerRoutes.post('/timer/start', async (c) => {
59
- const { projectId, description, jiraTicket } = await c.req.json();
59
+ const { projectId, description, jiraTicket, billable } = await c.req.json();
60
60
  if (!projectId || !description) {
61
61
  return c.json({ ok: false, error: 'Project and description are required.' }, 400);
62
62
  }
@@ -65,7 +65,7 @@ timerRoutes.post('/timer/start', async (c) => {
65
65
  const user = await clockify.getUser();
66
66
  if (!user)
67
67
  return c.json({ ok: false, error: 'Could not connect to Clockify.' }, 500);
68
- const result = await clockify.startTimer(user.defaultWorkspace, projectId, description, jiraTicket);
68
+ const result = await clockify.startTimer(user.defaultWorkspace, projectId, description, jiraTicket, billable ?? true);
69
69
  if (!result)
70
70
  return c.json({ ok: false, error: 'Failed to start timer.' }, 500);
71
71
  return c.json({ ok: true });
@@ -104,7 +104,7 @@ timerRoutes.post('/timer/stop', async (c) => {
104
104
  }
105
105
  });
106
106
  timerRoutes.post('/timer/log', async (c) => {
107
- const { projectId, description, start, end, jiraTicket } = await c.req.json();
107
+ const { projectId, description, start, end, jiraTicket, billable } = await c.req.json();
108
108
  if (!projectId) {
109
109
  return c.json({ ok: false, error: 'Project is required.' }, 400);
110
110
  }
@@ -132,7 +132,7 @@ timerRoutes.post('/timer/log', async (c) => {
132
132
  const startIso = new Date(startMs).toISOString();
133
133
  const endIso = new Date(endMs).toISOString();
134
134
  const finalDescription = cleanDescription || cleanJira;
135
- const entry = await clockify.logTime(user.defaultWorkspace, projectId, startIso, endIso, finalDescription);
135
+ const entry = await clockify.logTime(user.defaultWorkspace, projectId, startIso, endIso, finalDescription, billable ?? true);
136
136
  if (!entry)
137
137
  return c.json({ ok: false, error: 'Failed to log time in Clockify.' }, 500);
138
138
  const entryId = entry.id ?? uuidv4();
@@ -5,8 +5,14 @@ export function indexPage() {
5
5
  <meta charset="UTF-8">
6
6
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
7
7
  <title>Clocktopus Dashboard</title>
8
+ <script>
9
+ if (!window.__TAURI_INTERNALS__ && !window.__TAURI__) {
10
+ document.documentElement.classList.add('browser');
11
+ }
12
+ </script>
8
13
  <style>
9
14
  * { box-sizing: border-box; margin: 0; padding: 0; }
15
+ html.browser { background: #0d1117; }
10
16
  body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: transparent; color: #e1e4e8; padding: 2rem; }
11
17
  h1 { font-size: 1.8rem; margin-bottom: 0; color: #fff; }
12
18
  h2 { font-size: 1.1rem; color: #fff; margin-bottom: 1rem; }
@@ -153,6 +159,10 @@ export function indexPage() {
153
159
  <input type="text" id="timer-jira" placeholder="e.g. PROJ-123" />
154
160
  </div>
155
161
  </div>
162
+ <label style="display:flex; align-items:center; gap:0.5rem; font-weight:normal; cursor:pointer;">
163
+ <input type="checkbox" id="timer-billable" checked style="width:auto; margin:0;" />
164
+ Billable
165
+ </label>
156
166
  <button id="start-btn" onclick="startTimer()">Start Timer</button>
157
167
  </div>
158
168
  <div id="last-tasks" style="display:none; margin-top:0.75rem;"></div>
@@ -184,6 +194,10 @@ export function indexPage() {
184
194
  <input type="text" id="manual-jira" placeholder="e.g. PROJ-123" />
185
195
  </div>
186
196
  </div>
197
+ <label style="display:flex; align-items:center; gap:0.5rem; font-weight:normal; cursor:pointer;">
198
+ <input type="checkbox" id="manual-billable" checked style="width:auto; margin:0;" />
199
+ Billable
200
+ </label>
187
201
  <button id="manual-log-btn" onclick="logManualTime()">Log Time</button>
188
202
  <div class="msg" id="manual-msg"></div>
189
203
  </div>
@@ -436,6 +450,7 @@ export function indexPage() {
436
450
  const projectId = document.getElementById('project-select').value;
437
451
  const description = document.getElementById('timer-description').value.trim();
438
452
  const jiraTicket = document.getElementById('timer-jira').value.trim();
453
+ const billable = document.getElementById('timer-billable').checked;
439
454
 
440
455
  if (!projectId) return setMsg('timer-msg', 'Please select a project.', false);
441
456
  if (!description && !jiraTicket) return setMsg('timer-msg', 'Please enter a description or Jira ticket.', false);
@@ -448,7 +463,7 @@ export function indexPage() {
448
463
  const res = await fetch('/api/timer/start', {
449
464
  method: 'POST',
450
465
  headers: { 'Content-Type': 'application/json' },
451
- body: JSON.stringify({ projectId, description: description || 'Working on a task...', jiraTicket: jiraTicket || undefined }),
466
+ body: JSON.stringify({ projectId, description: description || 'Working on a task...', jiraTicket: jiraTicket || undefined, billable }),
452
467
  });
453
468
  const data = await res.json();
454
469
  if (data.ok) {
@@ -508,6 +523,7 @@ export function indexPage() {
508
523
  const endVal = document.getElementById('manual-end').value;
509
524
  const description = document.getElementById('manual-description').value.trim();
510
525
  const jiraTicket = document.getElementById('manual-jira').value.trim();
526
+ const billable = document.getElementById('manual-billable').checked;
511
527
 
512
528
  if (!projectId) return setMsg('manual-msg', 'Please select a project.', false);
513
529
  if (!startVal || !endVal) return setMsg('manual-msg', 'Please set start and end.', false);
@@ -532,6 +548,7 @@ export function indexPage() {
532
548
  start: new Date(startMs).toISOString(),
533
549
  end: new Date(endMs).toISOString(),
534
550
  jiraTicket: jiraTicket || undefined,
551
+ billable: billable,
535
552
  }),
536
553
  });
537
554
  const data = await res.json();
@@ -593,6 +610,7 @@ export function indexPage() {
593
610
  projectId: task.projectId,
594
611
  description: task.description || 'Working on a task...',
595
612
  jiraTicket: task.jiraTicket || undefined,
613
+ billable: document.getElementById('timer-billable').checked,
596
614
  }),
597
615
  });
598
616
  var data = await res.json();
@@ -643,7 +661,27 @@ export function indexPage() {
643
661
  }
644
662
 
645
663
  async function monitorAction(action) {
646
- setMsg('monitor-msg', '', true);
664
+ const startBtn = document.getElementById('monitor-start-btn');
665
+ const stopBtn = document.getElementById('monitor-stop-btn');
666
+ const restartBtn = document.getElementById('monitor-restart-btn');
667
+ const desc = document.getElementById('monitor-desc');
668
+ const dot = document.getElementById('monitor-dot');
669
+
670
+ const labels = { start: 'Starting', stop: 'Stopping', restart: 'Restarting' };
671
+ const pending = labels[action] || 'Working';
672
+
673
+ const prevDesc = desc.textContent;
674
+ const prevColor = desc.style.color;
675
+ const prevDot = dot.className;
676
+
677
+ startBtn.disabled = true;
678
+ stopBtn.disabled = true;
679
+ restartBtn.disabled = true;
680
+ dot.className = 'dot gray';
681
+ desc.textContent = pending + ' server...';
682
+ desc.style.color = '#d29922';
683
+ setMsg('monitor-msg', pending + ' server...', true);
684
+
647
685
  try {
648
686
  const res = await fetch('/api/monitor/' + action, { method: 'POST' });
649
687
  const data = await res.json();
@@ -651,10 +689,17 @@ export function indexPage() {
651
689
  setMsg('monitor-msg', 'Monitor ' + action + (action === 'stop' ? 'ped' : 'ed') + '.', true);
652
690
  } else {
653
691
  setMsg('monitor-msg', data.output || 'Failed.', false);
692
+ desc.textContent = prevDesc;
693
+ desc.style.color = prevColor;
694
+ dot.className = prevDot;
654
695
  }
655
696
  setTimeout(checkMonitorStatus, 1000);
656
697
  } catch {
657
698
  setMsg('monitor-msg', 'Request failed.', false);
699
+ desc.textContent = prevDesc;
700
+ desc.style.color = prevColor;
701
+ dot.className = prevDot;
702
+ checkMonitorStatus();
658
703
  }
659
704
  }
660
705
 
@@ -1030,9 +1075,10 @@ export function indexPage() {
1030
1075
  document.getElementById('cal-table-wrap').style.display = 'block';
1031
1076
  } catch {
1032
1077
  setMsg('cal-msg', 'Request failed.', false);
1078
+ } finally {
1079
+ fetchBtn.disabled = false;
1080
+ fetchBtn.textContent = 'Fetch Events';
1033
1081
  }
1034
- fetchBtn.disabled = false;
1035
- fetchBtn.textContent = 'Fetch Events';
1036
1082
  }
1037
1083
 
1038
1084
  function renderCalendarTable() {
@@ -1054,10 +1100,15 @@ export function indexPage() {
1054
1100
  return '<option value="' + p.id + '"' + selected + '>' + escapeHtml(p.name) + '</option>';
1055
1101
  }).join('');
1056
1102
 
1103
+ const billableChecked = ev.billable === false ? '' : ' checked';
1057
1104
  return '<div class="cal-event-card' + (logged ? ' logged' : '') + '">' +
1058
1105
  '<div class="cal-card-name">' + escapeHtml(ev.summary || 'Untitled') + badge + '</div>' +
1059
1106
  '<div class="cal-card-time">' + startDay + ' ' + startTime + ' — ' + endTime + ' (' + formatDuration(durationMs) + ')</div>' +
1060
1107
  '<select class="cal-project-sel" data-index="' + i + '"' + selDisabled + '>' + projectOptions + '</select>' +
1108
+ '<label style="display:flex; align-items:center; gap:0.4rem; font-size:0.75rem; color:#8b949e; margin-top:0.5rem; cursor:pointer;">' +
1109
+ '<input type="checkbox" class="cal-billable-sel" data-index="' + i + '"' + billableChecked + (logged ? ' disabled' : '') + ' style="width:auto; margin:0;" />' +
1110
+ 'Billable' +
1111
+ '</label>' +
1061
1112
  '</div>';
1062
1113
  }).join('');
1063
1114
 
@@ -1067,6 +1118,13 @@ export function indexPage() {
1067
1118
  calEvents[idx].savedProjectId = sel.value || null;
1068
1119
  });
1069
1120
  });
1121
+
1122
+ document.querySelectorAll('.cal-billable-sel').forEach(function(cb) {
1123
+ cb.addEventListener('change', function() {
1124
+ const idx = parseInt(cb.dataset.index);
1125
+ calEvents[idx].billable = cb.checked;
1126
+ });
1127
+ });
1070
1128
  }
1071
1129
 
1072
1130
  async function logCalendarEvents() {
@@ -1076,7 +1134,9 @@ export function indexPage() {
1076
1134
  if (sel.value === 'skip' || !sel.value) return;
1077
1135
  const idx = parseInt(sel.dataset.index);
1078
1136
  const ev = calEvents[idx];
1079
- entries.push({ projectId: sel.value, summary: ev.summary, start: ev.start, end: ev.end });
1137
+ const cb = document.querySelector('.cal-billable-sel[data-index="' + idx + '"]');
1138
+ const billable = cb ? cb.checked : true;
1139
+ entries.push({ projectId: sel.value, summary: ev.summary, start: ev.start, end: ev.end, billable: billable });
1080
1140
  });
1081
1141
 
1082
1142
  if (entries.length === 0) {
package/dist/index.js CHANGED
@@ -56,6 +56,7 @@ program
56
56
  .description('Start a new time entry. Select a project interactively.')
57
57
  .argument('[message]', 'Description for the time entry')
58
58
  .option('-j, --jira <ticket>', 'Jira ticket number')
59
+ .option('--no-billable', 'Mark the time entry as non-billable')
59
60
  .action(async (message, options) => {
60
61
  const { workspaceId } = await getWorkspaceAndUser();
61
62
  let projects = await clockify.getProjects(workspaceId);
@@ -84,7 +85,7 @@ program
84
85
  choices: projects.map((p) => ({ name: p.name, value: p.id })),
85
86
  },
86
87
  ]);
87
- const entry = await clockify.startTimer(workspaceId, selectedProjectId, message, options.jira);
88
+ const entry = await clockify.startTimer(workspaceId, selectedProjectId, message, options.jira, options.billable);
88
89
  if (entry) {
89
90
  const projectName = projects.find((p) => p.id === selectedProjectId)?.name;
90
91
  console.log(chalk.green(`Timer started for project: ${chalk.bold(projectName)}`));
@@ -35,9 +35,10 @@ program
35
35
  .option('-e, --end-date <endDate>', 'End date for fetching calendar events')
36
36
  .option('-t, --today', 'Log time for today')
37
37
  .option('-p, --project-id <projectId>', 'Clockify project ID')
38
+ .option('--no-billable', 'Mark logged entries as non-billable')
38
39
  .parse(process.argv);
39
40
  const opts = program.opts();
40
- const { projectId, today } = opts;
41
+ const { projectId, today, billable } = opts;
41
42
  let { startDate, endDate } = opts;
42
43
  if (today) {
43
44
  const todayDate = new Date();
@@ -124,7 +125,7 @@ async function main() {
124
125
  }
125
126
  if (eventProjectId) {
126
127
  console.log(`Logging "${event.summary}" to Clockify...`);
127
- await clockify.logTime(user.activeWorkspace, eventProjectId, event.start.dateTime, event.end.dateTime, event.summary);
128
+ await clockify.logTime(user.activeWorkspace, eventProjectId, event.start.dateTime, event.end.dateTime, event.summary, billable);
128
129
  }
129
130
  }
130
131
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clocktopus",
3
- "version": "1.5.0",
3
+ "version": "1.6.0",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "bin": {