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 { eq, and, gte, lte, desc, sql } from 'drizzle-orm';
import { NotFoundError, BadRequestError } from '../utils/errors.js'; 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 * Start a new time entry
*/ */
export const startTimeEntry = async (userId, data) => { 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 // Check if user already has a running time entry
const [existingRunning] = await db const [existingRunning] = await db
@@ -16,8 +34,20 @@ export const startTimeEntry = async (userId, data) => {
.where(and(eq(timeEntries.userId, userId), eq(timeEntries.isRunning, true))) .where(and(eq(timeEntries.userId, userId), eq(timeEntries.isRunning, true)))
.limit(1); .limit(1);
// Automatically stop existing running timer
if (existingRunning) { 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 // Verify project exists if provided
@@ -63,10 +93,10 @@ export const startTimeEntry = async (userId, data) => {
.insert(timeEntries) .insert(timeEntries)
.values({ .values({
userId, userId,
projectId: projectId || null, projectId: projectId ?? null,
todoId: todoId || null, todoId: todoId ?? null,
companyId: companyId || null, companyId: companyId ?? null,
description: description || null, description: description ?? null,
startTime: new Date(), startTime: new Date(),
endTime: null, endTime: null,
duration: null, duration: null,
@@ -82,7 +112,10 @@ export const startTimeEntry = async (userId, data) => {
* Stop a running time entry * Stop a running time entry
*/ */
export const stopTimeEntry = async (entryId, userId, data = {}) => { 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); const entry = await getTimeEntryById(entryId);
@@ -98,7 +131,7 @@ 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); 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) { if (projectId) {
const [project] = await db const [project] = await db
.select() .select()
@@ -256,10 +289,14 @@ export const updateTimeEntry = async (entryId, userId, data) => {
throw new BadRequestError('Nemôžete upraviť bežiaci časovač. Najprv ho zastavte.'); 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 // Verify related entities if being changed
if (projectId !== undefined && projectId !== null) { if (projectId) {
const [project] = await db const [project] = await db
.select() .select()
.from(projects) .from(projects)
@@ -271,7 +308,7 @@ export const updateTimeEntry = async (entryId, userId, data) => {
} }
} }
if (todoId !== undefined && todoId !== null) { if (todoId) {
const [todo] = await db const [todo] = await db
.select() .select()
.from(todos) .from(todos)
@@ -283,7 +320,7 @@ export const updateTimeEntry = async (entryId, userId, data) => {
} }
} }
if (companyId !== undefined && companyId !== null) { if (companyId) {
const [company] = await db const [company] = await db
.select() .select()
.from(companies) .from(companies)

View File

@@ -107,25 +107,50 @@ export const updateNoteSchema = z.object({
}); });
// Time Tracking validators // 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({ export const startTimeEntrySchema = z.object({
projectId: z.string().uuid('Neplatný formát project ID').optional().or(z.literal('')), projectId: optionalUuid('Neplatný formát project ID'),
todoId: z.string().uuid('Neplatný formát todo ID').optional().or(z.literal('')), todoId: optionalUuid('Neplatný formát todo ID'),
companyId: z.string().uuid('Neplatný formát company ID').optional().or(z.literal('')), companyId: optionalUuid('Neplatný formát company ID'),
description: z.string().max(1000).optional(), description: optionalDescription,
}); });
export const stopTimeEntrySchema = z.object({ export const stopTimeEntrySchema = z.object({
projectId: z.string().uuid('Neplatný formát project ID').optional().or(z.literal('')), projectId: optionalUuid('Neplatný formát project ID'),
todoId: z.string().uuid('Neplatný formát todo ID').optional().or(z.literal('')), todoId: optionalUuid('Neplatný formát todo ID'),
companyId: z.string().uuid('Neplatný formát company ID').optional().or(z.literal('')), companyId: optionalUuid('Neplatný formát company ID'),
description: z.string().max(1000).optional(), description: optionalDescription,
}); });
export const updateTimeEntrySchema = z.object({ export const updateTimeEntrySchema = z.object({
startTime: z.string().optional(), startTime: z.string().optional(),
endTime: z.string().optional(), endTime: z.string().optional(),
projectId: z.string().uuid('Neplatný formát project ID').optional().or(z.literal('').or(z.null())), projectId: optionalUuid('Neplatný formát project ID'),
todoId: z.string().uuid('Neplatný formát todo ID').optional().or(z.literal('').or(z.null())), todoId: optionalUuid('Neplatný formát todo ID'),
companyId: z.string().uuid('Neplatný formát company ID').optional().or(z.literal('').or(z.null())), companyId: optionalUuid('Neplatný formát company ID'),
description: z.string().max(1000).optional(), description: optionalDescription,
}); });