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:
@@ -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
|
||||
|
||||
2
src/db/migrations/0010_add_timer_pause.sql
Normal file
2
src/db/migrations/0010_add_timer_pause.sql
Normal 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;
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user