clocktopus 1.1.6 → 1.2.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
@@ -162,6 +162,20 @@ export class Clockify {
162
162
  return null;
163
163
  }
164
164
  }
165
+ async getTimeEntries(workspaceId, userId, start, end) {
166
+ try {
167
+ const response = await this.httpClient.get(`/workspaces/${workspaceId}/user/${userId}/time-entries`, {
168
+ params: { start, end, 'page-size': 200 },
169
+ });
170
+ return response.data;
171
+ }
172
+ catch (error) {
173
+ if (error instanceof Error) {
174
+ console.error('Error fetching time entries:', error.message);
175
+ }
176
+ return [];
177
+ }
178
+ }
165
179
  async logTime(workspaceId, projectId, start, end, description) {
166
180
  if (!projectId) {
167
181
  return null;
@@ -0,0 +1,101 @@
1
+ import { Hono } from 'hono';
2
+ import { google } from 'googleapis';
3
+ import { getAuthenticatedClient, getRefreshedToken } from '../../lib/google.js';
4
+ import { getLatestToken, storeToken, getEventProject, setEventProject, getActiveProjects } from '../../lib/db.js';
5
+ import { Clockify } from '../../clockify.js';
6
+ const DASHBOARD_REDIRECT_URI = 'http://localhost:4001/api/google/callback';
7
+ const calendarRoutes = new Hono();
8
+ calendarRoutes.get('/calendar/events', async (c) => {
9
+ const start = c.req.query('start');
10
+ const end = c.req.query('end');
11
+ if (!start || !end) {
12
+ return c.json({ ok: false, error: 'Both start and end query parameters are required.' }, 400);
13
+ }
14
+ try {
15
+ let token = await getLatestToken();
16
+ if (!token) {
17
+ return c.json({ ok: false, error: 'Google account not connected. Please authenticate first.' }, 401);
18
+ }
19
+ const oAuth2Client = getAuthenticatedClient(DASHBOARD_REDIRECT_URI);
20
+ // Refresh if expired or if expiry_date is unknown (proxy tokens only have expires_in)
21
+ const isExpired = token.expiry_date ? new Date(token.expiry_date) < new Date() : true;
22
+ if (isExpired && token.refresh_token) {
23
+ token = await getRefreshedToken(token);
24
+ storeToken(token);
25
+ }
26
+ oAuth2Client.setCredentials(token);
27
+ const calendar = google.calendar({ version: 'v3', auth: oAuth2Client });
28
+ const timeMin = new Date(start).toISOString();
29
+ const endOfDay = new Date(end);
30
+ endOfDay.setDate(endOfDay.getDate() + 1);
31
+ const timeMax = endOfDay.toISOString();
32
+ const res = await calendar.events.list({
33
+ calendarId: 'primary',
34
+ timeMin,
35
+ timeMax,
36
+ singleEvents: true,
37
+ orderBy: 'startTime',
38
+ });
39
+ // Fetch existing Clockify entries for the same range to detect duplicates
40
+ const clockify = new Clockify();
41
+ const user = await clockify.getUser();
42
+ const existingEntries = user ? await clockify.getTimeEntries(user.defaultWorkspace, user.id, timeMin, timeMax) : [];
43
+ // Build a set of "description|startEpoch" for quick lookup (normalize timezones)
44
+ const loggedSet = new Set(existingEntries.map((e) => `${e.description}|${new Date(e.timeInterval.start).getTime()}`));
45
+ const events = (res.data.items || [])
46
+ .filter((event) => !event.start?.date) // Filter out all-day events
47
+ .filter((event) => event.summary && event.start?.dateTime && event.end?.dateTime)
48
+ .map((event) => {
49
+ const savedProjectId = getEventProject(event.summary);
50
+ const alreadyLogged = loggedSet.has(`${event.summary}|${new Date(event.start.dateTime).getTime()}`);
51
+ return {
52
+ summary: event.summary,
53
+ start: event.start.dateTime,
54
+ end: event.end.dateTime,
55
+ savedProjectId: savedProjectId ?? undefined,
56
+ skipped: savedProjectId === null,
57
+ alreadyLogged,
58
+ };
59
+ });
60
+ const projects = getActiveProjects();
61
+ return c.json({ ok: true, events, projects });
62
+ }
63
+ catch (error) {
64
+ console.error('Calendar events error:', error instanceof Error ? error.message : error);
65
+ return c.json({ ok: false, error: 'Failed to fetch calendar events.' }, 500);
66
+ }
67
+ });
68
+ calendarRoutes.post('/calendar/log', async (c) => {
69
+ const { entries } = await c.req.json();
70
+ if (!entries || !Array.isArray(entries) || entries.length === 0) {
71
+ return c.json({ ok: false, error: 'Entries array is required.' }, 400);
72
+ }
73
+ try {
74
+ const clockify = new Clockify();
75
+ const user = await clockify.getUser();
76
+ if (!user) {
77
+ return c.json({ ok: false, error: 'Could not connect to Clockify.' }, 500);
78
+ }
79
+ const logged = [];
80
+ const failed = [];
81
+ for (const entry of entries) {
82
+ if (!entry.summary || !entry.start || !entry.end || !entry.projectId) {
83
+ failed.push(entry.summary ?? '(unknown)');
84
+ continue;
85
+ }
86
+ try {
87
+ await clockify.logTime(user.defaultWorkspace, entry.projectId, entry.start, entry.end, entry.summary);
88
+ setEventProject(entry.summary, entry.projectId);
89
+ logged.push(entry.summary);
90
+ }
91
+ catch {
92
+ failed.push(entry.summary);
93
+ }
94
+ }
95
+ return c.json({ ok: true, logged, failed });
96
+ }
97
+ catch {
98
+ return c.json({ ok: false, error: 'Failed to log calendar events.' }, 500);
99
+ }
100
+ });
101
+ export default calendarRoutes;
@@ -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, 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,19 @@ 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 openSession = getOpenSession();
23
+ const alreadyTracked = openSession && openSession.startedAt.slice(0, 19) === timerStart.slice(0, 19);
24
+ if (!alreadyTracked) {
25
+ const jiraTicket = extractJiraTicket(timer.description ?? '');
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,
19
33
  });
20
34
  }
21
35
  catch {
@@ -47,10 +61,23 @@ timerRoutes.post('/timer/stop', async (c) => {
47
61
  const user = await clockify.getUser();
48
62
  if (!user)
49
63
  return c.json({ ok: false, error: 'Could not connect to Clockify.' }, 500);
64
+ const openSession = getOpenSession();
50
65
  const result = await clockify.stopTimer(user.defaultWorkspace, user.id);
51
66
  if (!result)
52
67
  return c.json({ ok: false, error: 'Failed to stop timer.' }, 500);
53
- completeLatestSession(new Date().toISOString(), false);
68
+ const completedAt = new Date().toISOString();
69
+ completeLatestSession(completedAt, false);
70
+ if (openSession?.jiraTicket) {
71
+ const timeSpentSeconds = Math.round((new Date(completedAt).getTime() - new Date(openSession.startedAt).getTime()) / 1000);
72
+ if (timeSpentSeconds >= 60) {
73
+ try {
74
+ await stopJiraTimer(openSession.jiraTicket, timeSpentSeconds);
75
+ }
76
+ catch (err) {
77
+ console.error('Error stopping Jira timer:', err);
78
+ }
79
+ }
80
+ }
54
81
  return c.json({ ok: true });
55
82
  }
56
83
  catch {
@@ -8,6 +8,7 @@ import googleRoutes from './routes/google.js';
8
8
  import timerRoutes from './routes/timer.js';
9
9
  import dataRoutes from './routes/data.js';
10
10
  import monitorRoutes from './routes/monitor.js';
11
+ import calendarRoutes from './routes/calendar.js';
11
12
  const app = new Hono();
12
13
  app.get('/', (c) => c.html(indexPage()));
13
14
  app.route('/api', statusRoutes);
@@ -17,6 +18,7 @@ app.route('/api', googleRoutes);
17
18
  app.route('/api', timerRoutes);
18
19
  app.route('/api', dataRoutes);
19
20
  app.route('/api', monitorRoutes);
21
+ app.route('/api', calendarRoutes);
20
22
  export function startDashboard() {
21
23
  const port = 4001;
22
24
  console.log(`Clocktopus dashboard running at http://localhost:${port}`);
@@ -41,7 +41,7 @@ export function indexPage() {
41
41
  .dot.red { background: #f85149; }
42
42
  .dot.gray { background: #484f58; }
43
43
  label { display: block; font-size: 0.85rem; color: #8b949e; margin-bottom: 0.25rem; margin-top: 0.75rem; }
44
- input, select { width: 100%; padding: 0.5rem 0.75rem; background: #0d1117; border: 1px solid #30363d; border-radius: 6px; color: #e1e4e8; font-size: 0.9rem; }
44
+ input, select { width: 100%; padding: 0.5rem 0.75rem; background: #0d1117; border: 1px solid #30363d; border-radius: 6px; color: #e1e4e8; font-size: 0.9rem; color-scheme: dark; }
45
45
  input:focus, select:focus { outline: none; border-color: #58a6ff; }
46
46
  select { appearance: none; -webkit-appearance: none; cursor: pointer; }
47
47
  button { margin-top: 1rem; padding: 0.5rem 1.25rem; background: #238636; border: none; border-radius: 6px; color: #fff; font-size: 0.9rem; cursor: pointer; }
@@ -69,6 +69,14 @@ export function indexPage() {
69
69
 
70
70
  /* Inline form row */
71
71
  .form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }
72
+ /* Calendar event cards */
73
+ .cal-event-card { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 0.75rem; margin-bottom: 0.5rem; }
74
+ .cal-event-card .cal-card-name { color: #e1e4e8; font-size: 0.85rem; font-weight: 500; margin-bottom: 0.5rem; }
75
+ .cal-event-card .cal-card-time { font-size: 0.75rem; color: #8b949e; margin-bottom: 0.4rem; }
76
+ .cal-event-card .cal-card-badge { color: #3fb950; font-size: 0.7rem; }
77
+ .cal-event-card select { margin-top: 0.25rem; }
78
+ .cal-event-card.logged { opacity: 0.5; }
79
+
72
80
  @media (max-width: 600px) {
73
81
  body { padding: 0.75rem; }
74
82
  .form-row { grid-template-columns: 1fr; }
@@ -95,6 +103,7 @@ export function indexPage() {
95
103
  <div class="nav">
96
104
  <button class="nav-btn active" onclick="switchTab('home')" id="nav-home">Home</button>
97
105
  <button class="nav-btn" onclick="switchTab('projects')" id="nav-projects">Projects</button>
106
+ <button class="nav-btn" onclick="switchTab('calendar')" id="nav-calendar">Calendar</button>
98
107
  <button class="nav-btn" onclick="switchTab('settings')" id="nav-settings">Settings</button>
99
108
  </div>
100
109
  </div>
@@ -262,6 +271,33 @@ export function indexPage() {
262
271
  </div>
263
272
  </div>
264
273
 
274
+ <!-- CALENDAR TAB -->
275
+ <div id="tab-calendar" class="tab-content">
276
+ <div class="cards">
277
+ <div class="card card-full">
278
+ <h2>Log Calendar Events</h2>
279
+ <div class="form-row" style="margin-top:1rem;">
280
+ <div>
281
+ <label for="cal-from">From</label>
282
+ <input type="date" id="cal-from" />
283
+ </div>
284
+ <div>
285
+ <label for="cal-to">To</label>
286
+ <input type="date" id="cal-to" />
287
+ </div>
288
+ </div>
289
+ <button onclick="fetchCalendarEvents()">Fetch Events</button>
290
+ <div class="msg" id="cal-msg"></div>
291
+
292
+ <div id="cal-table-wrap" style="display:none; margin-top:1rem;">
293
+ <div id="cal-cards"></div>
294
+ <button id="cal-log-btn" onclick="logCalendarEvents()" style="margin-top:1rem;">Log to Clockify</button>
295
+ <div class="msg" id="cal-log-msg"></div>
296
+ </div>
297
+ </div>
298
+ </div>
299
+ </div>
300
+
265
301
  <script>
266
302
  let elapsedInterval = null;
267
303
  let currentPage = 1;
@@ -299,8 +335,11 @@ export function indexPage() {
299
335
  function formatDate(iso) {
300
336
  if (!iso) return '-';
301
337
  const d = new Date(iso);
302
- return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) + ' ' +
303
- d.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' });
338
+ const y = d.getFullYear();
339
+ const m = String(d.getMonth() + 1).padStart(2, '0');
340
+ const day = String(d.getDate()).padStart(2, '0');
341
+ const time = d.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' });
342
+ return y + '/' + m + '/' + day + ' ' + time;
304
343
  }
305
344
 
306
345
  // --- Timer ---
@@ -828,6 +867,131 @@ export function indexPage() {
828
867
  }
829
868
  })();
830
869
 
870
+ // --- Calendar ---
871
+ let calEvents = [];
872
+ let calProjects = [];
873
+
874
+ async function fetchCalendarEvents() {
875
+ const from = document.getElementById('cal-from').value;
876
+ const to = document.getElementById('cal-to').value;
877
+ if (!from || !to) return setMsg('cal-msg', 'Please select both dates.', false);
878
+
879
+ const fetchBtn = document.querySelector('#tab-calendar button[onclick="fetchCalendarEvents()"]');
880
+ fetchBtn.disabled = true;
881
+ fetchBtn.textContent = 'Fetching...';
882
+ setMsg('cal-msg', '', true);
883
+ setMsg('cal-log-msg', '', true);
884
+ document.getElementById('cal-table-wrap').style.display = 'none';
885
+
886
+ try {
887
+ const res = await fetch('/api/calendar/events?start=' + encodeURIComponent(from) + '&end=' + encodeURIComponent(to));
888
+ const data = await res.json();
889
+ if (!res.ok) {
890
+ setMsg('cal-msg', data.error || 'Failed to fetch events.', false);
891
+ return;
892
+ }
893
+ calEvents = data.events || [];
894
+ calProjects = data.projects || [];
895
+
896
+ if (calEvents.length === 0) {
897
+ setMsg('cal-msg', 'No calendar events found for this date range.', false);
898
+ return;
899
+ }
900
+
901
+ setMsg('cal-msg', 'Found ' + calEvents.length + ' event(s).', true);
902
+ renderCalendarTable();
903
+ document.getElementById('cal-table-wrap').style.display = 'block';
904
+ } catch {
905
+ setMsg('cal-msg', 'Request failed.', false);
906
+ }
907
+ fetchBtn.disabled = false;
908
+ fetchBtn.textContent = 'Fetch Events';
909
+ }
910
+
911
+ function renderCalendarTable() {
912
+ const cards = document.getElementById('cal-cards');
913
+ cards.innerHTML = calEvents.map(function(ev, i) {
914
+ const startTime = new Date(ev.start).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' });
915
+ const endTime = new Date(ev.end).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' });
916
+ const sd = new Date(ev.start);
917
+ const startDay = sd.getFullYear() + '/' + String(sd.getMonth() + 1).padStart(2, '0') + '/' + String(sd.getDate()).padStart(2, '0');
918
+ const durationMs = new Date(ev.end).getTime() - new Date(ev.start).getTime();
919
+
920
+ const logged = ev.alreadyLogged;
921
+ const selDisabled = logged ? ' disabled' : '';
922
+ const badge = logged ? ' <span class="cal-card-badge">(logged)</span>' : '';
923
+
924
+ const projectOptions = '<option value="skip">Skip</option>' +
925
+ calProjects.map(function(p) {
926
+ const selected = (ev.savedProjectId && ev.savedProjectId === p.id) ? ' selected' : '';
927
+ return '<option value="' + p.id + '"' + selected + '>' + escapeHtml(p.name) + '</option>';
928
+ }).join('');
929
+
930
+ return '<div class="cal-event-card' + (logged ? ' logged' : '') + '">' +
931
+ '<div class="cal-card-name">' + escapeHtml(ev.summary || 'Untitled') + badge + '</div>' +
932
+ '<div class="cal-card-time">' + startDay + ' ' + startTime + ' — ' + endTime + ' (' + formatDuration(durationMs) + ')</div>' +
933
+ '<select class="cal-project-sel" data-index="' + i + '"' + selDisabled + '>' + projectOptions + '</select>' +
934
+ '</div>';
935
+ }).join('');
936
+
937
+ document.querySelectorAll('.cal-project-sel').forEach(function(sel) {
938
+ sel.addEventListener('change', function() {
939
+ const idx = parseInt(sel.dataset.index);
940
+ calEvents[idx].savedProjectId = sel.value || null;
941
+ });
942
+ });
943
+ }
944
+
945
+ async function logCalendarEvents() {
946
+ const entries = [];
947
+ document.querySelectorAll('.cal-project-sel').forEach(function(sel) {
948
+ if (sel.disabled) return;
949
+ if (sel.value === 'skip' || !sel.value) return;
950
+ const idx = parseInt(sel.dataset.index);
951
+ const ev = calEvents[idx];
952
+ entries.push({ projectId: sel.value, summary: ev.summary, start: ev.start, end: ev.end });
953
+ });
954
+
955
+ if (entries.length === 0) {
956
+ return setMsg('cal-log-msg', 'No events to log. Assign projects to events you want to log.', false);
957
+ }
958
+
959
+ const btn = document.getElementById('cal-log-btn');
960
+ btn.disabled = true;
961
+ btn.textContent = 'Logging...';
962
+ setMsg('cal-log-msg', '', true);
963
+
964
+ try {
965
+ const res = await fetch('/api/calendar/log', {
966
+ method: 'POST',
967
+ headers: { 'Content-Type': 'application/json' },
968
+ body: JSON.stringify({ entries: entries }),
969
+ });
970
+ const data = await res.json();
971
+ if (data.ok) {
972
+ const loggedCount = Array.isArray(data.logged) ? data.logged.length : data.logged;
973
+ const failedCount = Array.isArray(data.failed) ? data.failed.length : data.failed;
974
+ let msg = loggedCount + ' event(s) logged to Clockify.';
975
+ if (failedCount > 0) msg += ' ' + failedCount + ' failed.';
976
+ setMsg('cal-log-msg', msg, failedCount === 0);
977
+ fetchCalendarEvents();
978
+ } else {
979
+ setMsg('cal-log-msg', data.error || 'Failed to log events.', false);
980
+ }
981
+ } catch {
982
+ setMsg('cal-log-msg', 'Request failed.', false);
983
+ }
984
+ btn.disabled = false;
985
+ btn.textContent = 'Log to Clockify';
986
+ }
987
+
988
+ // Set default calendar dates to today
989
+ (function setCalendarDefaults() {
990
+ const today = new Date().toISOString().split('T')[0];
991
+ document.getElementById('cal-from').value = today;
992
+ document.getElementById('cal-to').value = today;
993
+ })();
994
+
831
995
  // --- Init ---
832
996
  fetchStatus();
833
997
  loadProjects();
package/dist/lib/db.js CHANGED
@@ -105,7 +105,7 @@ 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
111
  export function completeLatestSession(completedAt, isAutoCompleted = false) {
@@ -130,6 +130,14 @@ export function getSessionCount() {
130
130
  const row = stmt.get();
131
131
  return row.count;
132
132
  }
133
+ export function getOpenSession() {
134
+ const db = getDb();
135
+ const stmt = db.prepare('SELECT * FROM sessions WHERE completedAt IS NULL ORDER BY startedAt DESC LIMIT 1');
136
+ const row = stmt.get();
137
+ if (!row)
138
+ return null;
139
+ return SessionSchema.parse(row);
140
+ }
133
141
  export function getLatestSession() {
134
142
  const db = getDb();
135
143
  const stmt = db.prepare(`
@@ -44,7 +44,8 @@ export async function getRefreshedToken(token) {
44
44
  const client = getAuthenticatedClient();
45
45
  client.setCredentials(token);
46
46
  const refreshedToken = await client.refreshAccessToken();
47
- return refreshedToken.credentials;
47
+ // Preserve refresh_token — Google doesn't return it on refresh
48
+ return { ...token, ...refreshedToken.credentials, refresh_token: token.refresh_token };
48
49
  }
49
50
  // Use proxy for refresh
50
51
  if (!token.refresh_token)
@@ -53,5 +54,6 @@ export async function getRefreshedToken(token) {
53
54
  grant_type: 'refresh_token',
54
55
  refresh_token: token.refresh_token,
55
56
  });
56
- return res.data;
57
+ // Preserve refresh_token — proxy doesn't return it on refresh
58
+ return { ...token, ...res.data, refresh_token: token.refresh_token };
57
59
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clocktopus",
3
- "version": "1.1.6",
3
+ "version": "1.2.1",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "bin": {