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 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
- const completedAt = new Date().toISOString();
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
- return path.join(process.cwd(), 'data/db');
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.0",
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",