clocktopus 1.5.1 → 1.6.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.
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();
@@ -159,6 +159,10 @@ export function indexPage() {
159
159
  <input type="text" id="timer-jira" placeholder="e.g. PROJ-123" />
160
160
  </div>
161
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>
162
166
  <button id="start-btn" onclick="startTimer()">Start Timer</button>
163
167
  </div>
164
168
  <div id="last-tasks" style="display:none; margin-top:0.75rem;"></div>
@@ -190,6 +194,10 @@ export function indexPage() {
190
194
  <input type="text" id="manual-jira" placeholder="e.g. PROJ-123" />
191
195
  </div>
192
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>
193
201
  <button id="manual-log-btn" onclick="logManualTime()">Log Time</button>
194
202
  <div class="msg" id="manual-msg"></div>
195
203
  </div>
@@ -442,6 +450,7 @@ export function indexPage() {
442
450
  const projectId = document.getElementById('project-select').value;
443
451
  const description = document.getElementById('timer-description').value.trim();
444
452
  const jiraTicket = document.getElementById('timer-jira').value.trim();
453
+ const billable = document.getElementById('timer-billable').checked;
445
454
 
446
455
  if (!projectId) return setMsg('timer-msg', 'Please select a project.', false);
447
456
  if (!description && !jiraTicket) return setMsg('timer-msg', 'Please enter a description or Jira ticket.', false);
@@ -454,7 +463,7 @@ export function indexPage() {
454
463
  const res = await fetch('/api/timer/start', {
455
464
  method: 'POST',
456
465
  headers: { 'Content-Type': 'application/json' },
457
- 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 }),
458
467
  });
459
468
  const data = await res.json();
460
469
  if (data.ok) {
@@ -514,6 +523,7 @@ export function indexPage() {
514
523
  const endVal = document.getElementById('manual-end').value;
515
524
  const description = document.getElementById('manual-description').value.trim();
516
525
  const jiraTicket = document.getElementById('manual-jira').value.trim();
526
+ const billable = document.getElementById('manual-billable').checked;
517
527
 
518
528
  if (!projectId) return setMsg('manual-msg', 'Please select a project.', false);
519
529
  if (!startVal || !endVal) return setMsg('manual-msg', 'Please set start and end.', false);
@@ -538,6 +548,7 @@ export function indexPage() {
538
548
  start: new Date(startMs).toISOString(),
539
549
  end: new Date(endMs).toISOString(),
540
550
  jiraTicket: jiraTicket || undefined,
551
+ billable: billable,
541
552
  }),
542
553
  });
543
554
  const data = await res.json();
@@ -599,6 +610,7 @@ export function indexPage() {
599
610
  projectId: task.projectId,
600
611
  description: task.description || 'Working on a task...',
601
612
  jiraTicket: task.jiraTicket || undefined,
613
+ billable: document.getElementById('timer-billable').checked,
602
614
  }),
603
615
  });
604
616
  var data = await res.json();
@@ -649,18 +661,54 @@ export function indexPage() {
649
661
  }
650
662
 
651
663
  async function monitorAction(action) {
652
- 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
+
685
+ const MIN_DISPLAY_MS = 1500;
686
+ const started = Date.now();
687
+ const waitMin = function() {
688
+ const elapsed = Date.now() - started;
689
+ return new Promise(function(r) { setTimeout(r, Math.max(0, MIN_DISPLAY_MS - elapsed)); });
690
+ };
691
+
653
692
  try {
654
693
  const res = await fetch('/api/monitor/' + action, { method: 'POST' });
655
694
  const data = await res.json();
695
+ await waitMin();
656
696
  if (data.ok) {
657
697
  setMsg('monitor-msg', 'Monitor ' + action + (action === 'stop' ? 'ped' : 'ed') + '.', true);
658
698
  } else {
659
699
  setMsg('monitor-msg', data.output || 'Failed.', false);
700
+ desc.textContent = prevDesc;
701
+ desc.style.color = prevColor;
702
+ dot.className = prevDot;
660
703
  }
661
- setTimeout(checkMonitorStatus, 1000);
704
+ setTimeout(checkMonitorStatus, 500);
662
705
  } catch {
706
+ await waitMin();
663
707
  setMsg('monitor-msg', 'Request failed.', false);
708
+ desc.textContent = prevDesc;
709
+ desc.style.color = prevColor;
710
+ dot.className = prevDot;
711
+ checkMonitorStatus();
664
712
  }
665
713
  }
666
714
 
@@ -1036,9 +1084,10 @@ export function indexPage() {
1036
1084
  document.getElementById('cal-table-wrap').style.display = 'block';
1037
1085
  } catch {
1038
1086
  setMsg('cal-msg', 'Request failed.', false);
1087
+ } finally {
1088
+ fetchBtn.disabled = false;
1089
+ fetchBtn.textContent = 'Fetch Events';
1039
1090
  }
1040
- fetchBtn.disabled = false;
1041
- fetchBtn.textContent = 'Fetch Events';
1042
1091
  }
1043
1092
 
1044
1093
  function renderCalendarTable() {
@@ -1060,10 +1109,15 @@ export function indexPage() {
1060
1109
  return '<option value="' + p.id + '"' + selected + '>' + escapeHtml(p.name) + '</option>';
1061
1110
  }).join('');
1062
1111
 
1112
+ const billableChecked = ev.billable === false ? '' : ' checked';
1063
1113
  return '<div class="cal-event-card' + (logged ? ' logged' : '') + '">' +
1064
1114
  '<div class="cal-card-name">' + escapeHtml(ev.summary || 'Untitled') + badge + '</div>' +
1065
1115
  '<div class="cal-card-time">' + startDay + ' ' + startTime + ' — ' + endTime + ' (' + formatDuration(durationMs) + ')</div>' +
1066
1116
  '<select class="cal-project-sel" data-index="' + i + '"' + selDisabled + '>' + projectOptions + '</select>' +
1117
+ '<label style="display:flex; align-items:center; gap:0.4rem; font-size:0.75rem; color:#8b949e; margin-top:0.5rem; cursor:pointer;">' +
1118
+ '<input type="checkbox" class="cal-billable-sel" data-index="' + i + '"' + billableChecked + (logged ? ' disabled' : '') + ' style="width:auto; margin:0;" />' +
1119
+ 'Billable' +
1120
+ '</label>' +
1067
1121
  '</div>';
1068
1122
  }).join('');
1069
1123
 
@@ -1073,6 +1127,13 @@ export function indexPage() {
1073
1127
  calEvents[idx].savedProjectId = sel.value || null;
1074
1128
  });
1075
1129
  });
1130
+
1131
+ document.querySelectorAll('.cal-billable-sel').forEach(function(cb) {
1132
+ cb.addEventListener('change', function() {
1133
+ const idx = parseInt(cb.dataset.index);
1134
+ calEvents[idx].billable = cb.checked;
1135
+ });
1136
+ });
1076
1137
  }
1077
1138
 
1078
1139
  async function logCalendarEvents() {
@@ -1082,7 +1143,9 @@ export function indexPage() {
1082
1143
  if (sel.value === 'skip' || !sel.value) return;
1083
1144
  const idx = parseInt(sel.dataset.index);
1084
1145
  const ev = calEvents[idx];
1085
- entries.push({ projectId: sel.value, summary: ev.summary, start: ev.start, end: ev.end });
1146
+ const cb = document.querySelector('.cal-billable-sel[data-index="' + idx + '"]');
1147
+ const billable = cb ? cb.checked : true;
1148
+ entries.push({ projectId: sel.value, summary: ev.summary, start: ev.start, end: ev.end, billable: billable });
1086
1149
  });
1087
1150
 
1088
1151
  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.1",
3
+ "version": "1.6.1",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "bin": {