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
|
|
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
|
|
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();
|
package/dist/dashboard/views.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
}
|