add time tracker with stats
This commit is contained in:
@@ -3,11 +3,29 @@ import { timeEntries, projects, todos, companies, users } from '../db/schema.js'
|
||||
import { eq, and, gte, lte, desc, sql } from 'drizzle-orm';
|
||||
import { NotFoundError, BadRequestError } from '../utils/errors.js';
|
||||
|
||||
// Helpers to normalize optional payload fields
|
||||
const normalizeOptionalId = (value) => {
|
||||
if (value === undefined) return undefined;
|
||||
if (value === null || value === '') return null;
|
||||
return value;
|
||||
};
|
||||
|
||||
const normalizeOptionalText = (value) => {
|
||||
if (value === undefined) return undefined;
|
||||
if (value === null) return null;
|
||||
if (typeof value !== 'string') return value;
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length ? trimmed : null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Start a new time entry
|
||||
*/
|
||||
export const startTimeEntry = async (userId, data) => {
|
||||
const { projectId, todoId, companyId, description } = data;
|
||||
const projectId = normalizeOptionalId(data.projectId);
|
||||
const todoId = normalizeOptionalId(data.todoId);
|
||||
const companyId = normalizeOptionalId(data.companyId);
|
||||
const description = normalizeOptionalText(data.description);
|
||||
|
||||
// Check if user already has a running time entry
|
||||
const [existingRunning] = await db
|
||||
@@ -16,8 +34,20 @@ export const startTimeEntry = async (userId, data) => {
|
||||
.where(and(eq(timeEntries.userId, userId), eq(timeEntries.isRunning, true)))
|
||||
.limit(1);
|
||||
|
||||
// Automatically stop existing running timer
|
||||
if (existingRunning) {
|
||||
throw new BadRequestError('Máte už spustený časovač. Prosím zastavte ho pred spustením nového.');
|
||||
const endTime = new Date();
|
||||
const durationInMinutes = Math.round((endTime - new Date(existingRunning.startTime)) / 60000);
|
||||
|
||||
await db
|
||||
.update(timeEntries)
|
||||
.set({
|
||||
endTime,
|
||||
duration: durationInMinutes,
|
||||
isRunning: false,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(timeEntries.id, existingRunning.id));
|
||||
}
|
||||
|
||||
// Verify project exists if provided
|
||||
@@ -63,10 +93,10 @@ export const startTimeEntry = async (userId, data) => {
|
||||
.insert(timeEntries)
|
||||
.values({
|
||||
userId,
|
||||
projectId: projectId || null,
|
||||
todoId: todoId || null,
|
||||
companyId: companyId || null,
|
||||
description: description || null,
|
||||
projectId: projectId ?? null,
|
||||
todoId: todoId ?? null,
|
||||
companyId: companyId ?? null,
|
||||
description: description ?? null,
|
||||
startTime: new Date(),
|
||||
endTime: null,
|
||||
duration: null,
|
||||
@@ -82,7 +112,10 @@ export const startTimeEntry = async (userId, data) => {
|
||||
* Stop a running time entry
|
||||
*/
|
||||
export const stopTimeEntry = async (entryId, userId, data = {}) => {
|
||||
const { projectId, todoId, companyId, description } = data;
|
||||
const projectId = normalizeOptionalId(data.projectId);
|
||||
const todoId = normalizeOptionalId(data.todoId);
|
||||
const companyId = normalizeOptionalId(data.companyId);
|
||||
const description = normalizeOptionalText(data.description);
|
||||
|
||||
const entry = await getTimeEntryById(entryId);
|
||||
|
||||
@@ -98,7 +131,7 @@ export const stopTimeEntry = async (entryId, userId, data = {}) => {
|
||||
const endTime = new Date();
|
||||
const durationInMinutes = Math.round((endTime - new Date(entry.startTime)) / 60000);
|
||||
|
||||
// Verify related entities if provided
|
||||
// Verify related entities if provided (skip validation for null/undefined)
|
||||
if (projectId) {
|
||||
const [project] = await db
|
||||
.select()
|
||||
@@ -256,10 +289,14 @@ export const updateTimeEntry = async (entryId, userId, data) => {
|
||||
throw new BadRequestError('Nemôžete upraviť bežiaci časovač. Najprv ho zastavte.');
|
||||
}
|
||||
|
||||
const { startTime, endTime, projectId, todoId, companyId, description } = data;
|
||||
const { startTime, endTime } = data;
|
||||
const projectId = normalizeOptionalId(data.projectId);
|
||||
const todoId = normalizeOptionalId(data.todoId);
|
||||
const companyId = normalizeOptionalId(data.companyId);
|
||||
const description = normalizeOptionalText(data.description);
|
||||
|
||||
// Verify related entities if being changed
|
||||
if (projectId !== undefined && projectId !== null) {
|
||||
if (projectId) {
|
||||
const [project] = await db
|
||||
.select()
|
||||
.from(projects)
|
||||
@@ -271,7 +308,7 @@ export const updateTimeEntry = async (entryId, userId, data) => {
|
||||
}
|
||||
}
|
||||
|
||||
if (todoId !== undefined && todoId !== null) {
|
||||
if (todoId) {
|
||||
const [todo] = await db
|
||||
.select()
|
||||
.from(todos)
|
||||
@@ -283,7 +320,7 @@ export const updateTimeEntry = async (entryId, userId, data) => {
|
||||
}
|
||||
}
|
||||
|
||||
if (companyId !== undefined && companyId !== null) {
|
||||
if (companyId) {
|
||||
const [company] = await db
|
||||
.select()
|
||||
.from(companies)
|
||||
|
||||
@@ -107,25 +107,50 @@ export const updateNoteSchema = z.object({
|
||||
});
|
||||
|
||||
// Time Tracking validators
|
||||
const optionalUuid = (message) =>
|
||||
z
|
||||
.preprocess(
|
||||
(val) => {
|
||||
if (val === undefined) return undefined;
|
||||
if (val === null || val === '') return null;
|
||||
return val;
|
||||
},
|
||||
z.string().uuid(message).nullable()
|
||||
)
|
||||
.optional();
|
||||
|
||||
const optionalDescription = z
|
||||
.preprocess(
|
||||
(val) => {
|
||||
if (val === undefined) return undefined;
|
||||
if (val === null) return null;
|
||||
if (typeof val !== 'string') return val;
|
||||
const trimmed = val.trim();
|
||||
return trimmed === '' ? null : trimmed;
|
||||
},
|
||||
z.string().max(1000).nullable()
|
||||
)
|
||||
.optional();
|
||||
|
||||
export const startTimeEntrySchema = z.object({
|
||||
projectId: z.string().uuid('Neplatný formát project ID').optional().or(z.literal('')),
|
||||
todoId: z.string().uuid('Neplatný formát todo ID').optional().or(z.literal('')),
|
||||
companyId: z.string().uuid('Neplatný formát company ID').optional().or(z.literal('')),
|
||||
description: z.string().max(1000).optional(),
|
||||
projectId: optionalUuid('Neplatný formát project ID'),
|
||||
todoId: optionalUuid('Neplatný formát todo ID'),
|
||||
companyId: optionalUuid('Neplatný formát company ID'),
|
||||
description: optionalDescription,
|
||||
});
|
||||
|
||||
export const stopTimeEntrySchema = z.object({
|
||||
projectId: z.string().uuid('Neplatný formát project ID').optional().or(z.literal('')),
|
||||
todoId: z.string().uuid('Neplatný formát todo ID').optional().or(z.literal('')),
|
||||
companyId: z.string().uuid('Neplatný formát company ID').optional().or(z.literal('')),
|
||||
description: z.string().max(1000).optional(),
|
||||
projectId: optionalUuid('Neplatný formát project ID'),
|
||||
todoId: optionalUuid('Neplatný formát todo ID'),
|
||||
companyId: optionalUuid('Neplatný formát company ID'),
|
||||
description: optionalDescription,
|
||||
});
|
||||
|
||||
export const updateTimeEntrySchema = z.object({
|
||||
startTime: z.string().optional(),
|
||||
endTime: z.string().optional(),
|
||||
projectId: z.string().uuid('Neplatný formát project ID').optional().or(z.literal('').or(z.null())),
|
||||
todoId: z.string().uuid('Neplatný formát todo ID').optional().or(z.literal('').or(z.null())),
|
||||
companyId: z.string().uuid('Neplatný formát company ID').optional().or(z.literal('').or(z.null())),
|
||||
description: z.string().max(1000).optional(),
|
||||
projectId: optionalUuid('Neplatný formát project ID'),
|
||||
todoId: optionalUuid('Neplatný formát todo ID'),
|
||||
companyId: optionalUuid('Neplatný formát company ID'),
|
||||
description: optionalDescription,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user