clocktopus 1.10.0 → 1.10.2
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/views.js +33 -27
- 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/dashboard/views.js
CHANGED
|
@@ -13,14 +13,16 @@ export function indexPage() {
|
|
|
13
13
|
<style>
|
|
14
14
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
15
15
|
html.browser { background: #0d1117; }
|
|
16
|
+
html, body { border-radius: 12px; }
|
|
17
|
+
html { overflow: hidden; background: transparent; }
|
|
16
18
|
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: transparent; color: #e1e4e8; padding: 2rem; }
|
|
17
19
|
h1 { font-size: 1.8rem; margin-bottom: 0; color: #fff; }
|
|
18
20
|
h2 { font-size: 1.1rem; color: #fff; margin-bottom: 1rem; }
|
|
19
21
|
|
|
20
22
|
/* Nav */
|
|
21
23
|
.header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 1.5rem; }
|
|
22
|
-
.nav { display: flex; gap: 0.25rem; background: #1c1f26; border-radius: 10px; padding: 0.3rem; }
|
|
23
|
-
.nav-btn { margin-top: 0; padding: 0.5rem
|
|
24
|
+
.nav { display: flex; gap: 0.25rem; background: #1c1f26; border-radius: 10px; padding: 0.3rem; flex-wrap: nowrap; }
|
|
25
|
+
.nav-btn { margin-top: 0; padding: 0.5rem 0.85rem; border: none; border-radius: 6px; background: transparent; color: #8b949e; font-size: 0.9rem; cursor: pointer; white-space: nowrap; }
|
|
24
26
|
.nav-btn:hover { color: #e1e4e8; }
|
|
25
27
|
.nav-btn.active { background: #30363d; color: #fff; }
|
|
26
28
|
.tab-content { display: none; }
|
|
@@ -112,7 +114,7 @@ export function indexPage() {
|
|
|
112
114
|
.header { flex-direction: column; gap: 0; align-items: stretch; margin-bottom: 1rem; }
|
|
113
115
|
.header h1 { display: none; }
|
|
114
116
|
.nav { justify-content: center; flex-wrap: wrap; }
|
|
115
|
-
.nav-btn { padding: 0.
|
|
117
|
+
.nav-btn { padding: 0.4rem 0.7rem; font-size: 0.8rem; }
|
|
116
118
|
}
|
|
117
119
|
/* Project toggles */
|
|
118
120
|
.project-item { display: flex; align-items: center; gap: 0.75rem; padding: 0.4rem 0; border-bottom: 1px solid #21262d; }
|
|
@@ -147,6 +149,7 @@ export function indexPage() {
|
|
|
147
149
|
<h1>Clocktopus</h1>
|
|
148
150
|
<div class="nav">
|
|
149
151
|
<button class="nav-btn active" onclick="switchTab('home')" id="nav-home">Home</button>
|
|
152
|
+
<button class="nav-btn" onclick="switchTab('sessions')" id="nav-sessions">Sessions</button>
|
|
150
153
|
<button class="nav-btn" onclick="switchTab('projects')" id="nav-projects">Projects</button>
|
|
151
154
|
<button class="nav-btn" onclick="switchTab('calendar')" id="nav-calendar">Calendar</button>
|
|
152
155
|
<button class="nav-btn" onclick="switchTab('settings')" id="nav-settings">Settings</button>
|
|
@@ -262,30 +265,33 @@ export function indexPage() {
|
|
|
262
265
|
</div>
|
|
263
266
|
</div>
|
|
264
267
|
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
</
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
<
|
|
282
|
-
</
|
|
283
|
-
</
|
|
284
|
-
<
|
|
285
|
-
<
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
268
|
+
</div>
|
|
269
|
+
</div>
|
|
270
|
+
|
|
271
|
+
<!-- SESSIONS TAB -->
|
|
272
|
+
<div id="tab-sessions" class="tab-content">
|
|
273
|
+
<div class="card card-full">
|
|
274
|
+
<h2>Recent Sessions</h2>
|
|
275
|
+
<div id="sessions-container" class="table-wrap">
|
|
276
|
+
<table class="sessions-table">
|
|
277
|
+
<thead>
|
|
278
|
+
<tr>
|
|
279
|
+
<th>Description</th>
|
|
280
|
+
<th>Project</th>
|
|
281
|
+
<th>Started</th>
|
|
282
|
+
<th>Duration</th>
|
|
283
|
+
<th>Jira</th>
|
|
284
|
+
<th></th>
|
|
285
|
+
</tr>
|
|
286
|
+
</thead>
|
|
287
|
+
<tbody id="sessions-body">
|
|
288
|
+
<tr><td colspan="6" class="empty-state">Loading...</td></tr>
|
|
289
|
+
</tbody>
|
|
290
|
+
</table>
|
|
291
|
+
<div id="pagination" style="display:none; margin-top:1rem; align-items:center; justify-content:center; gap:0.75rem; flex-wrap:wrap;">
|
|
292
|
+
<button id="prev-btn" onclick="changePage(-1)" style="background:#30363d; margin-top:0; padding:0.3rem 0.75rem;" disabled><</button>
|
|
293
|
+
<span id="page-info" style="font-size:0.85rem; color:#8b949e;"></span>
|
|
294
|
+
<button id="next-btn" onclick="changePage(1)" style="background:#30363d; margin-top:0; padding:0.3rem 0.75rem;">></button>
|
|
289
295
|
</div>
|
|
290
296
|
</div>
|
|
291
297
|
</div>
|
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.2",
|
|
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",
|