clocktopus 1.10.0 → 1.10.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/index.js +34 -2
- package/dist/lib/db.js +35 -1
- package/dist/lib/jira.js +13 -0
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -6,7 +6,7 @@ import * as path from 'path';
|
|
|
6
6
|
import { fileURLToPath } from 'url';
|
|
7
7
|
import { createRequire } from 'module';
|
|
8
8
|
import { execSync } from 'child_process';
|
|
9
|
-
import { completeLatestSession, getLatestSession, setSessionJiraWorklogId } from './lib/db.js';
|
|
9
|
+
import { closeStaleOpenSessions, completeLatestSession, getLatestSession, setSessionJiraWorklogId } from './lib/db.js';
|
|
10
10
|
import { isClockifyEnabled } from './lib/credentials.js';
|
|
11
11
|
import { ensureNativeAddons } from './lib/ensure-native-addons.js';
|
|
12
12
|
import { DASHBOARD_PORT, DASHBOARD_URL } from './lib/constants.js';
|
|
@@ -198,6 +198,23 @@ program
|
|
|
198
198
|
.action(async () => {
|
|
199
199
|
const creds = isClockifyEnabled() ? await getWorkspaceAndUser() : { workspaceId: '', userId: '' };
|
|
200
200
|
const { workspaceId, userId } = creds;
|
|
201
|
+
// Auto-close any session left open longer than this on monitor startup,
|
|
202
|
+
// so a PM2 restart after a long sleep doesn't accidentally bill the
|
|
203
|
+
// entire gap to Jira when the next idle/lock event fires.
|
|
204
|
+
const MAX_OPEN_SESSION_MS = 12 * 60 * 60 * 1000; // 12h
|
|
205
|
+
try {
|
|
206
|
+
const stale = closeStaleOpenSessions(MAX_OPEN_SESSION_MS);
|
|
207
|
+
if (stale.length > 0) {
|
|
208
|
+
console.log(chalk.yellow(`Auto-closed ${stale.length} stale open session(s) older than ${MAX_OPEN_SESSION_MS / 3600000}h. ` +
|
|
209
|
+
`No Jira worklog was posted for these; review and log manually if needed.`));
|
|
210
|
+
for (const s of stale) {
|
|
211
|
+
console.log(chalk.gray(` - id=${s.id} jira=${s.jiraTicket ?? '-'} startedAt=${s.startedAt} closedAt=${s.completedAt}`));
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
catch (err) {
|
|
216
|
+
console.error(chalk.red('Failed to scan for stale open sessions:'), err);
|
|
217
|
+
}
|
|
201
218
|
async function stopTimerAndLog(reason) {
|
|
202
219
|
const clockifyOn = isClockifyEnabled();
|
|
203
220
|
const latestSession = getLatestSession();
|
|
@@ -211,7 +228,22 @@ program
|
|
|
211
228
|
return false;
|
|
212
229
|
}
|
|
213
230
|
console.log(chalk.yellow(reason));
|
|
214
|
-
|
|
231
|
+
// Use idleTime to rewind end-of-work to the moment user actually went idle,
|
|
232
|
+
// so a weekend gap or long sleep doesn't get billed to Jira.
|
|
233
|
+
let idleSec = 0;
|
|
234
|
+
try {
|
|
235
|
+
const idleModule = await import('desktop-idle');
|
|
236
|
+
idleSec = Math.max(0, Math.floor(idleModule.default.getIdleTime() || 0));
|
|
237
|
+
}
|
|
238
|
+
catch { }
|
|
239
|
+
let completedMs = Date.now() - idleSec * 1000;
|
|
240
|
+
if (latestSession) {
|
|
241
|
+
const startedMs = new Date(latestSession.startedAt).getTime();
|
|
242
|
+
// Don't let the adjusted end fall before start; floor at start+1s.
|
|
243
|
+
if (completedMs < startedMs)
|
|
244
|
+
completedMs = startedMs + 1000;
|
|
245
|
+
}
|
|
246
|
+
const completedAt = new Date(completedMs).toISOString();
|
|
215
247
|
if (clockifyOn) {
|
|
216
248
|
const stoppedEntry = await (await clockify()).stopTimer(workspaceId, userId);
|
|
217
249
|
if (!stoppedEntry)
|
package/dist/lib/db.js
CHANGED
|
@@ -3,10 +3,17 @@ import * as path from 'path';
|
|
|
3
3
|
import { Database } from 'bun:sqlite';
|
|
4
4
|
import { z } from 'zod';
|
|
5
5
|
function getDataDir() {
|
|
6
|
+
// Explicit override always wins (useful for tests, CI, or per-shell isolation).
|
|
7
|
+
const override = process.env.CLOCKTOPUS_DATA_DIR;
|
|
8
|
+
if (override && override.trim())
|
|
9
|
+
return path.resolve(override);
|
|
6
10
|
const scriptDir = path.dirname(new URL(import.meta.url).pathname);
|
|
7
11
|
const isDev = scriptDir.includes('/Projects/') || scriptDir.includes('/src/');
|
|
8
12
|
if (isDev) {
|
|
9
|
-
|
|
13
|
+
// Anchor to the repo (scriptDir is <repo>/dist/lib), NOT process.cwd().
|
|
14
|
+
// Otherwise the git post-checkout hook, which runs in the target project's
|
|
15
|
+
// cwd, would spawn a stray data/ directory there.
|
|
16
|
+
return path.resolve(scriptDir, '..', '..', 'data', 'db');
|
|
10
17
|
}
|
|
11
18
|
return path.join(process.env.HOME || '~', '.clocktopus', 'data');
|
|
12
19
|
}
|
|
@@ -200,6 +207,33 @@ export function getLatestSession() {
|
|
|
200
207
|
`);
|
|
201
208
|
return SessionSchema.parse(stmt.get());
|
|
202
209
|
}
|
|
210
|
+
// Close any session that has been open longer than maxAgeMs.
|
|
211
|
+
// completedAt is set to min(startedAt + maxAgeMs, now - maxAgeMs) so the
|
|
212
|
+
// row's duration stays bounded AND completedAt is always at least maxAgeMs
|
|
213
|
+
// in the past — that guarantees safeRestartTimerIfNeeded's 2h-recency
|
|
214
|
+
// check rejects these rows and they don't get auto-resumed.
|
|
215
|
+
// Does NOT post to Jira; caller controls that.
|
|
216
|
+
// Returns the closed rows so the caller can warn or audit.
|
|
217
|
+
export function closeStaleOpenSessions(maxAgeMs) {
|
|
218
|
+
const db = getDb();
|
|
219
|
+
const cutoffMs = Date.now() - maxAgeMs;
|
|
220
|
+
const cutoffIso = new Date(cutoffMs).toISOString();
|
|
221
|
+
const rows = db
|
|
222
|
+
.prepare('SELECT id, jiraTicket, startedAt FROM sessions WHERE completedAt IS NULL AND startedAt < ?')
|
|
223
|
+
.all(cutoffIso);
|
|
224
|
+
if (rows.length === 0)
|
|
225
|
+
return [];
|
|
226
|
+
const stmt = db.prepare('UPDATE sessions SET completedAt = ?, isAutoCompleted = 1 WHERE id = ?');
|
|
227
|
+
const closed = [];
|
|
228
|
+
for (const r of rows) {
|
|
229
|
+
const startedMs = new Date(r.startedAt).getTime();
|
|
230
|
+
const completedMs = Math.min(startedMs + maxAgeMs, cutoffMs);
|
|
231
|
+
const completedAt = new Date(completedMs).toISOString();
|
|
232
|
+
stmt.run(completedAt, r.id);
|
|
233
|
+
closed.push({ ...r, completedAt });
|
|
234
|
+
}
|
|
235
|
+
return closed;
|
|
236
|
+
}
|
|
203
237
|
export function deleteOldSessions(days) {
|
|
204
238
|
const db = getDb();
|
|
205
239
|
const date = new Date();
|
package/dist/lib/jira.js
CHANGED
|
@@ -61,7 +61,20 @@ async function jiraApiRequest(endpoint, method, body) {
|
|
|
61
61
|
return null;
|
|
62
62
|
}
|
|
63
63
|
}
|
|
64
|
+
// Hard cap so a corrupted duration (orphaned session, missed idle, etc.)
|
|
65
|
+
// cannot post a multi-day worklog to Jira.
|
|
66
|
+
export const MAX_WORKLOG_SECONDS = 12 * 60 * 60; // 12h
|
|
64
67
|
export async function stopJiraTimer(ticketId, timeSpentSeconds) {
|
|
68
|
+
if (!Number.isFinite(timeSpentSeconds) || timeSpentSeconds <= 0) {
|
|
69
|
+
console.warn(`[jira] stopJiraTimer: refusing non-positive duration (${timeSpentSeconds}s) for ${ticketId}.`);
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
if (timeSpentSeconds > MAX_WORKLOG_SECONDS) {
|
|
73
|
+
const hours = (timeSpentSeconds / 3600).toFixed(1);
|
|
74
|
+
console.warn(`[jira] stopJiraTimer: refusing oversized worklog (${timeSpentSeconds}s ≈ ${hours}h) for ${ticketId}. ` +
|
|
75
|
+
`Cap is ${MAX_WORKLOG_SECONDS}s (${MAX_WORKLOG_SECONDS / 3600}h). Log manually via the dashboard if needed.`);
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
65
78
|
const body = {
|
|
66
79
|
timeSpentSeconds,
|
|
67
80
|
comment: {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "clocktopus",
|
|
3
|
-
"version": "1.10.
|
|
3
|
+
"version": "1.10.1",
|
|
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",
|