clocktopus 1.6.17 → 1.8.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/dist/dashboard/routes/calendar.js +7 -0
- package/dist/dashboard/routes/clockify.js +6 -1
- package/dist/dashboard/routes/data.js +25 -1
- package/dist/dashboard/routes/jira.js +6 -1
- package/dist/dashboard/routes/status.js +48 -37
- package/dist/dashboard/routes/timer.js +145 -47
- package/dist/dashboard/views.js +342 -32
- package/dist/index.js +110 -46
- package/dist/lib/credentials.js +18 -0
- package/dist/lib/credentials.test.js +13 -0
- package/dist/lib/db.js +32 -4
- package/dist/scripts/log-calendar-events.js +5 -0
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -9,6 +9,7 @@ import { fileURLToPath } from 'url';
|
|
|
9
9
|
import { createRequire } from 'module';
|
|
10
10
|
import { execSync } from 'child_process';
|
|
11
11
|
import { completeLatestSession, getLatestSession, setSessionJiraWorklogId } from './lib/db.js';
|
|
12
|
+
import { isClockifyEnabled } from './lib/credentials.js';
|
|
12
13
|
import { stopJiraTimer } from './lib/jira.js';
|
|
13
14
|
import { startDashboard } from './dashboard/server.js';
|
|
14
15
|
import { ensureNativeAddons } from './lib/ensure-native-addons.js';
|
|
@@ -59,6 +60,20 @@ program
|
|
|
59
60
|
.option('-j, --jira <ticket>', 'Jira ticket number')
|
|
60
61
|
.option('--no-billable', 'Mark the time entry as non-billable')
|
|
61
62
|
.action(async (message, options) => {
|
|
63
|
+
if (!isClockifyEnabled()) {
|
|
64
|
+
if (!options.jira) {
|
|
65
|
+
console.error(chalk.red('Jira-only mode requires --jira <ticket>.'));
|
|
66
|
+
process.exit(1);
|
|
67
|
+
}
|
|
68
|
+
const { v4: uuidv4 } = await import('uuid');
|
|
69
|
+
const { logSessionStart } = await import('./lib/db.js');
|
|
70
|
+
const sessionId = uuidv4();
|
|
71
|
+
const startedAt = new Date().toISOString();
|
|
72
|
+
const description = (message && String(message).trim()) || options.jira;
|
|
73
|
+
logSessionStart(sessionId, null, description, startedAt, options.jira);
|
|
74
|
+
console.log(chalk.green(`Timer started for ${chalk.bold(options.jira)} (Jira-only mode).`));
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
62
77
|
const { workspaceId } = await getWorkspaceAndUser();
|
|
63
78
|
let projects = await clockify.getProjects(workspaceId);
|
|
64
79
|
let localProjects = await getLocalProjects();
|
|
@@ -96,49 +111,74 @@ program
|
|
|
96
111
|
.command('stop')
|
|
97
112
|
.description('Stop the currently running time entry.')
|
|
98
113
|
.action(async () => {
|
|
99
|
-
const { workspaceId, userId } = await getWorkspaceAndUser();
|
|
100
114
|
const latestSession = getLatestSession();
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
const
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
if (timeSpentSeconds >= 60) {
|
|
108
|
-
try {
|
|
109
|
-
const worklog = await stopJiraTimer(latestSession.jiraTicket, timeSpentSeconds);
|
|
110
|
-
if (worklog?.id)
|
|
111
|
-
setSessionJiraWorklogId(latestSession.id, worklog.id);
|
|
112
|
-
}
|
|
113
|
-
catch (error) {
|
|
114
|
-
console.error('Error stopping Jira timer:', error);
|
|
115
|
-
}
|
|
116
|
-
}
|
|
115
|
+
if (isClockifyEnabled()) {
|
|
116
|
+
const { workspaceId, userId } = await getWorkspaceAndUser();
|
|
117
|
+
const stoppedEntry = await clockify.stopTimer(workspaceId, userId);
|
|
118
|
+
if (!stoppedEntry) {
|
|
119
|
+
console.log(chalk.yellow('No timer was running.'));
|
|
120
|
+
return;
|
|
117
121
|
}
|
|
118
|
-
console.log(chalk.red('Timer stopped.'));
|
|
119
122
|
}
|
|
120
123
|
else {
|
|
121
|
-
|
|
124
|
+
if (!latestSession || latestSession.completedAt) {
|
|
125
|
+
console.log(chalk.yellow('No timer was running.'));
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
122
128
|
}
|
|
129
|
+
const completedAt = new Date().toISOString();
|
|
130
|
+
completeLatestSession(completedAt);
|
|
131
|
+
if (latestSession?.jiraTicket) {
|
|
132
|
+
const timeSpentSeconds = Math.round((new Date(completedAt).getTime() - new Date(latestSession.startedAt).getTime()) / 1000);
|
|
133
|
+
if (timeSpentSeconds >= 60) {
|
|
134
|
+
try {
|
|
135
|
+
const worklog = await stopJiraTimer(latestSession.jiraTicket, timeSpentSeconds);
|
|
136
|
+
if (worklog?.id)
|
|
137
|
+
setSessionJiraWorklogId(latestSession.id, worklog.id);
|
|
138
|
+
}
|
|
139
|
+
catch (error) {
|
|
140
|
+
console.error('Error stopping Jira timer:', error);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
console.log(chalk.red('Timer stopped.'));
|
|
123
145
|
});
|
|
124
146
|
program
|
|
125
147
|
.command('status')
|
|
126
148
|
.description('Check the status of the current timer.')
|
|
127
149
|
.action(async () => {
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
150
|
+
if (isClockifyEnabled()) {
|
|
151
|
+
const { workspaceId, userId } = await getWorkspaceAndUser();
|
|
152
|
+
const activeEntry = await clockify.getActiveTimer(workspaceId, userId);
|
|
153
|
+
if (activeEntry) {
|
|
154
|
+
const startTime = new Date(activeEntry.timeInterval.start);
|
|
155
|
+
const duration = (new Date().getTime() - startTime.getTime()) / 1000;
|
|
156
|
+
const hours = Math.floor(duration / 3600);
|
|
157
|
+
const minutes = Math.floor((duration % 3600) / 60);
|
|
158
|
+
console.log(chalk.green('🕒 A timer is currently running.'));
|
|
159
|
+
console.log(` - ${chalk.bold('Project:')} ${activeEntry.project.name}`);
|
|
160
|
+
console.log(` - ${chalk.bold('Running for:')} ${hours}h ${minutes}m`);
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
console.log(chalk.yellow('No timer is currently running.'));
|
|
164
|
+
return;
|
|
138
165
|
}
|
|
139
|
-
|
|
166
|
+
// Jira-only mode: read from DB
|
|
167
|
+
const { getOpenSession } = await import('./lib/db.js');
|
|
168
|
+
const open = getOpenSession();
|
|
169
|
+
if (!open) {
|
|
140
170
|
console.log(chalk.yellow('No timer is currently running.'));
|
|
171
|
+
return;
|
|
141
172
|
}
|
|
173
|
+
const startTime = new Date(open.startedAt);
|
|
174
|
+
const duration = (new Date().getTime() - startTime.getTime()) / 1000;
|
|
175
|
+
const hours = Math.floor(duration / 3600);
|
|
176
|
+
const minutes = Math.floor((duration % 3600) / 60);
|
|
177
|
+
console.log(chalk.green('🕒 A timer is currently running (Jira-only mode).'));
|
|
178
|
+
if (open.jiraTicket)
|
|
179
|
+
console.log(` - ${chalk.bold('Jira:')} ${open.jiraTicket}`);
|
|
180
|
+
console.log(` - ${chalk.bold('Description:')} ${open.description}`);
|
|
181
|
+
console.log(` - ${chalk.bold('Running for:')} ${hours}h ${minutes}m`);
|
|
142
182
|
});
|
|
143
183
|
function sleep(ms) {
|
|
144
184
|
return new Promise((res) => setTimeout(res, ms));
|
|
@@ -147,17 +187,27 @@ program
|
|
|
147
187
|
.command('monitor:run', { hidden: true })
|
|
148
188
|
.description('Run monitor in foreground (used by PM2).')
|
|
149
189
|
.action(async () => {
|
|
150
|
-
const
|
|
190
|
+
const creds = isClockifyEnabled() ? await getWorkspaceAndUser() : { workspaceId: '', userId: '' };
|
|
191
|
+
const { workspaceId, userId } = creds;
|
|
151
192
|
async function stopTimerAndLog(reason) {
|
|
152
|
-
const
|
|
153
|
-
|
|
154
|
-
|
|
193
|
+
const clockifyOn = isClockifyEnabled();
|
|
194
|
+
const latestSession = getLatestSession();
|
|
195
|
+
if (clockifyOn) {
|
|
196
|
+
const activeEntry = await clockify.getActiveTimer(workspaceId, userId);
|
|
197
|
+
if (!activeEntry)
|
|
198
|
+
return false;
|
|
199
|
+
}
|
|
200
|
+
else {
|
|
201
|
+
if (!latestSession || latestSession.completedAt)
|
|
202
|
+
return false;
|
|
203
|
+
}
|
|
155
204
|
console.log(chalk.yellow(reason));
|
|
156
205
|
const completedAt = new Date().toISOString();
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
206
|
+
if (clockifyOn) {
|
|
207
|
+
const stoppedEntry = await clockify.stopTimer(workspaceId, userId);
|
|
208
|
+
if (!stoppedEntry)
|
|
209
|
+
return false;
|
|
210
|
+
}
|
|
161
211
|
completeLatestSession(completedAt, true);
|
|
162
212
|
if (latestSession?.jiraTicket) {
|
|
163
213
|
const timeSpentSeconds = Math.round((new Date(completedAt).getTime() - new Date(latestSession.startedAt).getTime()) / 1000);
|
|
@@ -187,16 +237,30 @@ program
|
|
|
187
237
|
const latestSession = getLatestSession();
|
|
188
238
|
if (!latestSession)
|
|
189
239
|
return;
|
|
190
|
-
const activeEntry = await clockify.getActiveTimer(workspaceId, userId);
|
|
191
|
-
if (activeEntry)
|
|
192
|
-
return;
|
|
193
240
|
const twoHoursAgo = Date.now() - 2 * 60 * 60 * 1000;
|
|
194
|
-
const
|
|
195
|
-
|
|
196
|
-
|
|
241
|
+
const completedMs = latestSession.completedAt ? new Date(latestSession.completedAt).getTime() : 0;
|
|
242
|
+
if (!latestSession.isAutoCompleted || completedMs <= twoHoursAgo)
|
|
243
|
+
return;
|
|
244
|
+
if (isClockifyEnabled()) {
|
|
245
|
+
if (!latestSession.projectId)
|
|
246
|
+
return;
|
|
247
|
+
const activeEntry = await clockify.getActiveTimer(workspaceId, userId);
|
|
248
|
+
if (activeEntry)
|
|
249
|
+
return;
|
|
250
|
+
await clockify.startTimer(workspaceId, latestSession.projectId, latestSession.description, latestSession.jiraTicket ?? undefined);
|
|
251
|
+
console.log(chalk.green('Timer restarted for the last used project.'));
|
|
252
|
+
lastResumeAt = Date.now();
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
// Jira-only resume: new DB session with a fresh uuid, same ticket
|
|
256
|
+
if (!latestSession.jiraTicket)
|
|
197
257
|
return;
|
|
198
|
-
|
|
199
|
-
|
|
258
|
+
const { v4: uuidv4 } = await import('uuid');
|
|
259
|
+
const { logSessionStart } = await import('./lib/db.js');
|
|
260
|
+
const sessionId = uuidv4();
|
|
261
|
+
const startedAt = new Date().toISOString();
|
|
262
|
+
logSessionStart(sessionId, latestSession.projectId ?? null, latestSession.description, startedAt, latestSession.jiraTicket);
|
|
263
|
+
console.log(chalk.green(`Resumed Jira timer for ${latestSession.jiraTicket}.`));
|
|
200
264
|
lastResumeAt = Date.now();
|
|
201
265
|
}
|
|
202
266
|
console.log(chalk.blue('Monitoring display events (Unified Log) and idle time...'));
|
package/dist/lib/credentials.js
CHANGED
|
@@ -8,3 +8,21 @@ export function resolveCredential(key) {
|
|
|
8
8
|
export function saveCredential(key, value) {
|
|
9
9
|
setCredential(key, value);
|
|
10
10
|
}
|
|
11
|
+
export function isClockifyKeyValid(value) {
|
|
12
|
+
return typeof value === 'string' && value.length > 0;
|
|
13
|
+
}
|
|
14
|
+
export function isClockifyDisabled() {
|
|
15
|
+
return resolveCredential('CLOCKIFY_DISABLED') === '1';
|
|
16
|
+
}
|
|
17
|
+
export function setClockifyDisabled(disabled) {
|
|
18
|
+
setCredential('CLOCKIFY_DISABLED', disabled ? '1' : '0');
|
|
19
|
+
}
|
|
20
|
+
export function isClockifyEnabled() {
|
|
21
|
+
return isClockifyKeyValid(resolveCredential('CLOCKIFY_API_KEY')) && !isClockifyDisabled();
|
|
22
|
+
}
|
|
23
|
+
export function isJiraDisabled() {
|
|
24
|
+
return resolveCredential('JIRA_DISABLED') === '1';
|
|
25
|
+
}
|
|
26
|
+
export function setJiraDisabled(disabled) {
|
|
27
|
+
setCredential('JIRA_DISABLED', disabled ? '1' : '0');
|
|
28
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { describe, it, expect } from 'bun:test';
|
|
2
|
+
import { isClockifyKeyValid } from './credentials.js';
|
|
3
|
+
describe('isClockifyKeyValid', () => {
|
|
4
|
+
it('returns false when value is undefined', () => {
|
|
5
|
+
expect(isClockifyKeyValid(undefined)).toBe(false);
|
|
6
|
+
});
|
|
7
|
+
it('returns false when value is an empty string', () => {
|
|
8
|
+
expect(isClockifyKeyValid('')).toBe(false);
|
|
9
|
+
});
|
|
10
|
+
it('returns true when value is a non-empty string', () => {
|
|
11
|
+
expect(isClockifyKeyValid('abc123')).toBe(true);
|
|
12
|
+
});
|
|
13
|
+
});
|
package/dist/lib/db.js
CHANGED
|
@@ -17,7 +17,7 @@ if (!fs.existsSync(DB_DIR)) {
|
|
|
17
17
|
}
|
|
18
18
|
const SessionSchema = z.object({
|
|
19
19
|
id: z.string(),
|
|
20
|
-
projectId: z.string(),
|
|
20
|
+
projectId: z.string().nullable(),
|
|
21
21
|
description: z.string(),
|
|
22
22
|
startedAt: z.string(),
|
|
23
23
|
completedAt: z.string().nullable(),
|
|
@@ -33,7 +33,7 @@ function getDb() {
|
|
|
33
33
|
dbInstance.exec(`
|
|
34
34
|
CREATE TABLE IF NOT EXISTS sessions (
|
|
35
35
|
id TEXT PRIMARY KEY,
|
|
36
|
-
projectId TEXT
|
|
36
|
+
projectId TEXT,
|
|
37
37
|
description TEXT NOT NULL,
|
|
38
38
|
startedAt TEXT NOT NULL,
|
|
39
39
|
completedAt TEXT,
|
|
@@ -47,6 +47,34 @@ function getDb() {
|
|
|
47
47
|
if (!sessionCols.some((c) => c.name === 'jiraWorklogId')) {
|
|
48
48
|
dbInstance.exec('ALTER TABLE sessions ADD COLUMN jiraWorklogId TEXT');
|
|
49
49
|
}
|
|
50
|
+
// Migration: drop NOT NULL on sessions.projectId if it was created with the old schema
|
|
51
|
+
const projectIdCol = sessionCols.find((c) => c.name === 'projectId');
|
|
52
|
+
if (projectIdCol && projectIdCol.notnull === 1) {
|
|
53
|
+
dbInstance.exec('BEGIN');
|
|
54
|
+
try {
|
|
55
|
+
dbInstance.exec(`
|
|
56
|
+
CREATE TABLE sessions_new (
|
|
57
|
+
id TEXT PRIMARY KEY,
|
|
58
|
+
projectId TEXT,
|
|
59
|
+
description TEXT NOT NULL,
|
|
60
|
+
startedAt TEXT NOT NULL,
|
|
61
|
+
completedAt TEXT,
|
|
62
|
+
isAutoCompleted INTEGER DEFAULT 0,
|
|
63
|
+
jiraTicket TEXT,
|
|
64
|
+
jiraWorklogId TEXT
|
|
65
|
+
)
|
|
66
|
+
`);
|
|
67
|
+
dbInstance.exec('INSERT INTO sessions_new (id, projectId, description, startedAt, completedAt, isAutoCompleted, jiraTicket, jiraWorklogId) ' +
|
|
68
|
+
'SELECT id, projectId, description, startedAt, completedAt, isAutoCompleted, jiraTicket, jiraWorklogId FROM sessions');
|
|
69
|
+
dbInstance.exec('DROP TABLE sessions');
|
|
70
|
+
dbInstance.exec('ALTER TABLE sessions_new RENAME TO sessions');
|
|
71
|
+
dbInstance.exec('COMMIT');
|
|
72
|
+
}
|
|
73
|
+
catch (err) {
|
|
74
|
+
dbInstance.exec('ROLLBACK');
|
|
75
|
+
throw err;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
50
78
|
dbInstance.exec(`
|
|
51
79
|
CREATE TABLE IF NOT EXISTS google_tokens (
|
|
52
80
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
@@ -113,12 +141,12 @@ export function getLatestToken() {
|
|
|
113
141
|
export function logSessionStart(id, projectId, description, startedAt, jiraTicket) {
|
|
114
142
|
const db = getDb();
|
|
115
143
|
const stmt = db.prepare('INSERT OR IGNORE INTO sessions (id, projectId, description, startedAt, isAutoCompleted, jiraTicket) VALUES (?, ?, ?, ?, ?, ?)');
|
|
116
|
-
stmt.run(id, projectId, description, startedAt, 0, jiraTicket ?? null);
|
|
144
|
+
stmt.run(id, projectId ?? null, description, startedAt, 0, jiraTicket ?? null);
|
|
117
145
|
}
|
|
118
146
|
export function logCompletedSession(id, projectId, description, startedAt, completedAt, jiraTicket) {
|
|
119
147
|
const db = getDb();
|
|
120
148
|
const stmt = db.prepare('INSERT OR IGNORE INTO sessions (id, projectId, description, startedAt, completedAt, isAutoCompleted, jiraTicket) VALUES (?, ?, ?, ?, ?, 0, ?)');
|
|
121
|
-
stmt.run(id, projectId, description, startedAt, completedAt, jiraTicket ?? null);
|
|
149
|
+
stmt.run(id, projectId ?? null, description, startedAt, completedAt, jiraTicket ?? null);
|
|
122
150
|
}
|
|
123
151
|
export function completeLatestSession(completedAt, isAutoCompleted = false) {
|
|
124
152
|
const db = getDb();
|
|
@@ -50,6 +50,11 @@ if (!startDate || !endDate) {
|
|
|
50
50
|
process.exit(1);
|
|
51
51
|
}
|
|
52
52
|
async function main() {
|
|
53
|
+
const { isClockifyEnabled } = await import('../lib/credentials.js');
|
|
54
|
+
if (!isClockifyEnabled()) {
|
|
55
|
+
console.log('Calendar sync requires Clockify. Configure Clockify API key and re-run.');
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
53
58
|
const clockify = new Clockify();
|
|
54
59
|
const user = await clockify.getUser();
|
|
55
60
|
if (!user) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "clocktopus",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.8.0",
|
|
4
4
|
"description": "Time-tracking automation for Clockify with idle monitoring, Jira integration, Google Calendar sync, CLI, web dashboard, and desktop app.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|