add time tracker with stats

This commit is contained in:
richardtekula
2025-11-24 09:10:04 +01:00
parent 540c1719d3
commit dfcf8056f3
2 changed files with 86 additions and 24 deletions

View File

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

View File

@@ -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,
});