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 +14 -0
- package/dist/dashboard/routes/calendar.js +101 -0
- package/dist/dashboard/routes/timer.js +30 -3
- package/dist/dashboard/server.js +2 -0
- package/dist/dashboard/views.js +167 -3
- package/dist/lib/db.js +9 -1
- package/dist/lib/google.js +4 -2
- package/package.json +1 -1
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:
|
|
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
|
-
|
|
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 {
|
package/dist/dashboard/server.js
CHANGED
|
@@ -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}`);
|
package/dist/dashboard/views.js
CHANGED
|
@@ -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
|
-
|
|
303
|
-
|
|
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(`
|
package/dist/lib/google.js
CHANGED
|
@@ -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
|
|
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
|
|
57
|
+
// Preserve refresh_token — proxy doesn't return it on refresh
|
|
58
|
+
return { ...token, ...res.data, refresh_token: token.refresh_token };
|
|
57
59
|
}
|