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 <noreply@anthropic.com>
This commit is contained in:
richardtekula
2026-01-27 07:15:57 +01:00
parent d26e537244
commit 95688be45b
6 changed files with 173 additions and 5 deletions

View File

@@ -2,6 +2,8 @@ import * as timeTrackingService from '../services/time-tracking.service.js';
import { import {
logTimerStarted, logTimerStarted,
logTimerStopped, logTimerStopped,
logTimerPaused,
logTimerResumed,
logTimeEntryUpdated, logTimeEntryUpdated,
logTimeEntryDeleted, logTimeEntryDeleted,
} from '../services/audit.service.js'; } 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 running time entry for current user
* GET /api/time-tracking/running * GET /api/time-tracking/running

View File

@@ -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;

View File

@@ -282,6 +282,8 @@ export const timeEntries = pgTable('time_entries', {
description: text('description'), // popis práce description: text('description'), // popis práce
isRunning: boolean('is_running').default(false).notNull(), // či práve beží isRunning: boolean('is_running').default(false).notNull(), // či práve beží
isEdited: boolean('is_edited').default(false).notNull(), // či bol editovaný 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(), createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(), updatedAt: timestamp('updated_at').defaultNow().notNull(),
}); });

View File

@@ -30,6 +30,20 @@ router.post(
timeTrackingController.stopTimeEntry 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 // Get running time entry
router.get('/running', timeTrackingController.getRunningTimeEntry); router.get('/running', timeTrackingController.getRunningTimeEntry);

View File

@@ -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 // Companies
export const logCompanyCreated = async (userId, companyId, companyName, ipAddress, userAgent) => { export const logCompanyCreated = async (userId, companyId, companyName, ipAddress, userAgent) => {
await logAuditEvent({ await logAuditEvent({

View File

@@ -62,14 +62,20 @@ export const startTimeEntry = async (userId, data) => {
// Automatically stop existing running timer // Automatically stop existing running timer
if (existingRunning) { if (existingRunning) {
const endTime = new Date(); 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 await db
.update(timeEntries) .update(timeEntries)
.set({ .set({
endTime, endTime,
duration: durationInMinutes, duration: Math.max(0, durationInMinutes),
isRunning: false, isRunning: false,
pausedAt: null,
updatedAt: new Date(), updatedAt: new Date(),
}) })
.where(eq(timeEntries.id, existingRunning.id)); .where(eq(timeEntries.id, existingRunning.id));
@@ -154,7 +160,12 @@ export const stopTimeEntry = async (entryId, userId, data = {}) => {
} }
const endTime = new Date(); 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) // Verify related entities if provided (skip validation for null/undefined)
if (projectId) { if (projectId) {
@@ -197,8 +208,9 @@ export const stopTimeEntry = async (entryId, userId, data = {}) => {
.update(timeEntries) .update(timeEntries)
.set({ .set({
endTime, endTime,
duration: durationInMinutes, duration: Math.max(0, durationInMinutes),
isRunning: false, isRunning: false,
pausedAt: null,
projectId: projectId !== undefined ? projectId : entry.projectId, projectId: projectId !== undefined ? projectId : entry.projectId,
todoId: todoId !== undefined ? todoId : entry.todoId, todoId: todoId !== undefined ? todoId : entry.todoId,
companyId: companyId !== undefined ? companyId : entry.companyId, companyId: companyId !== undefined ? companyId : entry.companyId,
@@ -211,6 +223,71 @@ export const stopTimeEntry = async (entryId, userId, data = {}) => {
return updated; 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 * Get running time entry for a user
*/ */
@@ -233,6 +310,8 @@ export const getAllRunningTimeEntries = async () => {
id: timeEntries.id, id: timeEntries.id,
userId: timeEntries.userId, userId: timeEntries.userId,
startTime: timeEntries.startTime, startTime: timeEntries.startTime,
pausedAt: timeEntries.pausedAt,
pausedDuration: timeEntries.pausedDuration,
firstName: users.firstName, firstName: users.firstName,
lastName: users.lastName, lastName: users.lastName,
}) })
@@ -601,7 +680,8 @@ export const updateTimeEntry = async (entryId, actor, data) => {
if (newEndTime <= newStartTime) { if (newEndTime <= newStartTime) {
throw new BadRequestError('Čas ukončenia musí byť po čase začiatku'); 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 const [updated] = await db