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 {
|
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
|
||||||
|
|||||||
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
|
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(),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user