From 95688be45bb50e05ff8bafd983f0393b74837ead Mon Sep 17 00:00:00 2001 From: richardtekula Date: Tue, 27 Jan 2026 07:15:57 +0100 Subject: [PATCH] feat: Add pause/resume functionality to time tracking Add pausedAt and pausedDuration columns to time_entries table. New pause/resume endpoints with audit logging. Duration calculations now correctly exclude paused time across start, stop, auto-stop, and edit flows. Co-Authored-By: Claude Opus 4.5 --- src/controllers/time-tracking.controller.js | 48 +++++++++++ src/db/migrations/0010_add_timer_pause.sql | 2 + src/db/schema.js | 2 + src/routes/time-tracking.routes.js | 14 ++++ src/services/audit.service.js | 22 +++++ src/services/time-tracking.service.js | 90 +++++++++++++++++++-- 6 files changed, 173 insertions(+), 5 deletions(-) create mode 100644 src/db/migrations/0010_add_timer_pause.sql diff --git a/src/controllers/time-tracking.controller.js b/src/controllers/time-tracking.controller.js index d107d53..8d8d4cd 100644 --- a/src/controllers/time-tracking.controller.js +++ b/src/controllers/time-tracking.controller.js @@ -2,6 +2,8 @@ import * as timeTrackingService from '../services/time-tracking.service.js'; import { logTimerStarted, logTimerStopped, + logTimerPaused, + logTimerResumed, logTimeEntryUpdated, logTimeEntryDeleted, } from '../services/audit.service.js'; @@ -65,6 +67,52 @@ export const stopTimeEntry = async (req, res, next) => { } }; +/** + * Pause a running time entry + * POST /api/time-tracking/:entryId/pause + */ +export const pauseTimeEntry = async (req, res, next) => { + try { + const userId = req.userId; + const { entryId } = req.params; + + const entry = await timeTrackingService.pauseTimeEntry(entryId, userId); + + await logTimerPaused(userId, entry.id, req.ip, req.headers['user-agent']); + + res.status(200).json({ + success: true, + data: entry, + message: 'Časovač bol pozastavený', + }); + } catch (error) { + next(error); + } +}; + +/** + * Resume a paused time entry + * POST /api/time-tracking/:entryId/resume + */ +export const resumeTimeEntry = async (req, res, next) => { + try { + const userId = req.userId; + const { entryId } = req.params; + + const entry = await timeTrackingService.resumeTimeEntry(entryId, userId); + + await logTimerResumed(userId, entry.id, req.ip, req.headers['user-agent']); + + res.status(200).json({ + success: true, + data: entry, + message: 'Časovač bol obnovený', + }); + } catch (error) { + next(error); + } +}; + /** * Get running time entry for current user * GET /api/time-tracking/running diff --git a/src/db/migrations/0010_add_timer_pause.sql b/src/db/migrations/0010_add_timer_pause.sql new file mode 100644 index 0000000..d45695e --- /dev/null +++ b/src/db/migrations/0010_add_timer_pause.sql @@ -0,0 +1,2 @@ +ALTER TABLE "time_entries" ADD COLUMN IF NOT EXISTS "paused_at" timestamp; +ALTER TABLE "time_entries" ADD COLUMN IF NOT EXISTS "paused_duration" integer NOT NULL DEFAULT 0; diff --git a/src/db/schema.js b/src/db/schema.js index 1acb3b5..aa11841 100644 --- a/src/db/schema.js +++ b/src/db/schema.js @@ -282,6 +282,8 @@ export const timeEntries = pgTable('time_entries', { description: text('description'), // popis práce isRunning: boolean('is_running').default(false).notNull(), // či práve beží isEdited: boolean('is_edited').default(false).notNull(), // či bol editovaný + pausedAt: timestamp('paused_at'), // kedy bola aktuálna pauza spustená (null ak nepauznutý) + pausedDuration: integer('paused_duration').default(0).notNull(), // celkový čas pauzy v sekundách createdAt: timestamp('created_at').defaultNow().notNull(), updatedAt: timestamp('updated_at').defaultNow().notNull(), }); diff --git a/src/routes/time-tracking.routes.js b/src/routes/time-tracking.routes.js index acbc32a..a43d2e6 100644 --- a/src/routes/time-tracking.routes.js +++ b/src/routes/time-tracking.routes.js @@ -30,6 +30,20 @@ router.post( timeTrackingController.stopTimeEntry ); +// Pause running time entry +router.post( + '/:entryId/pause', + validateParams(z.object({ entryId: z.string().uuid() })), + timeTrackingController.pauseTimeEntry +); + +// Resume paused time entry +router.post( + '/:entryId/resume', + validateParams(z.object({ entryId: z.string().uuid() })), + timeTrackingController.resumeTimeEntry +); + // Get running time entry router.get('/running', timeTrackingController.getRunningTimeEntry); diff --git a/src/services/audit.service.js b/src/services/audit.service.js index cc0eeae..5c799ed 100644 --- a/src/services/audit.service.js +++ b/src/services/audit.service.js @@ -194,6 +194,28 @@ export const logTimerStopped = async (userId, entryId, projectName, duration, ip }); }; +export const logTimerPaused = async (userId, entryId, ipAddress, userAgent) => { + await logAuditEvent({ + userId, + action: 'timer_paused', + resource: 'time_entry', + resourceId: entryId, + ipAddress, + userAgent, + }); +}; + +export const logTimerResumed = async (userId, entryId, ipAddress, userAgent) => { + await logAuditEvent({ + userId, + action: 'timer_resumed', + resource: 'time_entry', + resourceId: entryId, + ipAddress, + userAgent, + }); +}; + // Companies export const logCompanyCreated = async (userId, companyId, companyName, ipAddress, userAgent) => { await logAuditEvent({ diff --git a/src/services/time-tracking.service.js b/src/services/time-tracking.service.js index 52f5c34..afded26 100644 --- a/src/services/time-tracking.service.js +++ b/src/services/time-tracking.service.js @@ -62,14 +62,20 @@ export const startTimeEntry = async (userId, data) => { // Automatically stop existing running timer if (existingRunning) { const endTime = new Date(); - const durationInMinutes = Math.round((endTime - new Date(existingRunning.startTime)) / 60000); + // Account for any active pause and accumulated pause duration + let totalPausedSeconds = existingRunning.pausedDuration || 0; + if (existingRunning.pausedAt) { + totalPausedSeconds += Math.floor((endTime - new Date(existingRunning.pausedAt)) / 1000); + } + const durationInMinutes = Math.round(((endTime - new Date(existingRunning.startTime)) / 60000) - (totalPausedSeconds / 60)); await db .update(timeEntries) .set({ endTime, - duration: durationInMinutes, + duration: Math.max(0, durationInMinutes), isRunning: false, + pausedAt: null, updatedAt: new Date(), }) .where(eq(timeEntries.id, existingRunning.id)); @@ -154,7 +160,12 @@ export const stopTimeEntry = async (entryId, userId, data = {}) => { } const endTime = new Date(); - const durationInMinutes = Math.round((endTime - new Date(entry.startTime)) / 60000); + // Account for any active pause and accumulated pause duration + let totalPausedSeconds = entry.pausedDuration || 0; + if (entry.pausedAt) { + totalPausedSeconds += Math.floor((endTime - new Date(entry.pausedAt)) / 1000); + } + const durationInMinutes = Math.round(((endTime - new Date(entry.startTime)) / 60000) - (totalPausedSeconds / 60)); // Verify related entities if provided (skip validation for null/undefined) if (projectId) { @@ -197,8 +208,9 @@ export const stopTimeEntry = async (entryId, userId, data = {}) => { .update(timeEntries) .set({ endTime, - duration: durationInMinutes, + duration: Math.max(0, durationInMinutes), isRunning: false, + pausedAt: null, projectId: projectId !== undefined ? projectId : entry.projectId, todoId: todoId !== undefined ? todoId : entry.todoId, companyId: companyId !== undefined ? companyId : entry.companyId, @@ -211,6 +223,71 @@ export const stopTimeEntry = async (entryId, userId, data = {}) => { return updated; }; +/** + * Pause a running time entry + */ +export const pauseTimeEntry = async (entryId, userId) => { + const entry = await getTimeEntryById(entryId); + + if (entry.userId !== userId) { + throw new BadRequestError('Nemáte oprávnenie pozastaviť tento časovač'); + } + + if (!entry.isRunning) { + throw new BadRequestError('Tento časovač nie je spustený'); + } + + if (entry.pausedAt) { + throw new BadRequestError('Tento časovač je už pozastavený'); + } + + const [updated] = await db + .update(timeEntries) + .set({ + pausedAt: new Date(), + updatedAt: new Date(), + }) + .where(eq(timeEntries.id, entryId)) + .returning(); + + return updated; +}; + +/** + * Resume a paused time entry + */ +export const resumeTimeEntry = async (entryId, userId) => { + const entry = await getTimeEntryById(entryId); + + if (entry.userId !== userId) { + throw new BadRequestError('Nemáte oprávnenie obnoviť tento časovač'); + } + + if (!entry.isRunning) { + throw new BadRequestError('Tento časovač nie je spustený'); + } + + if (!entry.pausedAt) { + throw new BadRequestError('Tento časovač nie je pozastavený'); + } + + const now = new Date(); + const pauseSeconds = Math.floor((now - new Date(entry.pausedAt)) / 1000); + const newPausedDuration = (entry.pausedDuration || 0) + pauseSeconds; + + const [updated] = await db + .update(timeEntries) + .set({ + pausedAt: null, + pausedDuration: newPausedDuration, + updatedAt: new Date(), + }) + .where(eq(timeEntries.id, entryId)) + .returning(); + + return updated; +}; + /** * Get running time entry for a user */ @@ -233,6 +310,8 @@ export const getAllRunningTimeEntries = async () => { id: timeEntries.id, userId: timeEntries.userId, startTime: timeEntries.startTime, + pausedAt: timeEntries.pausedAt, + pausedDuration: timeEntries.pausedDuration, firstName: users.firstName, lastName: users.lastName, }) @@ -601,7 +680,8 @@ export const updateTimeEntry = async (entryId, actor, data) => { if (newEndTime <= newStartTime) { throw new BadRequestError('Čas ukončenia musí byť po čase začiatku'); } - newDuration = Math.round((newEndTime - newStartTime) / 60000); + const pausedSeconds = entry.pausedDuration || 0; + newDuration = Math.max(0, Math.round(((newEndTime - newStartTime) / 60000) - (pausedSeconds / 60))); } const [updated] = await db