From 92064533948890a546de55074aa774fe4bfa10f8 Mon Sep 17 00:00:00 2001 From: Simon Franken Date: Mon, 16 Feb 2026 19:15:23 +0100 Subject: [PATCH] adds statistics --- backend/src/routes/timeEntry.routes.ts | 64 ++++-- backend/src/schemas/index.ts | 9 +- backend/src/services/timeEntry.service.ts | 209 +++++++++++++++-- backend/src/types/index.ts | 9 +- frontend/src/App.tsx | 2 + frontend/src/api/auth.ts | 3 +- frontend/src/api/client.ts | 2 +- frontend/src/api/timeEntries.ts | 9 + frontend/src/components/Navbar.tsx | 34 ++- frontend/src/hooks/useTimeEntries.ts | 34 ++- frontend/src/pages/StatisticsPage.tsx | 259 ++++++++++++++++++++++ frontend/src/types/index.ts | 37 +++- 12 files changed, 613 insertions(+), 58 deletions(-) create mode 100644 frontend/src/pages/StatisticsPage.tsx diff --git a/backend/src/routes/timeEntry.routes.ts b/backend/src/routes/timeEntry.routes.ts index c2597cd..82e8c78 100644 --- a/backend/src/routes/timeEntry.routes.ts +++ b/backend/src/routes/timeEntry.routes.ts @@ -1,16 +1,44 @@ -import { Router } from 'express'; -import { requireAuth } from '../middleware/auth'; -import { validateBody, validateParams, validateQuery } from '../middleware/validation'; -import { TimeEntryService } from '../services/timeEntry.service'; -import { CreateTimeEntrySchema, UpdateTimeEntrySchema, IdSchema, TimeEntryFiltersSchema } from '../schemas'; -import type { AuthenticatedRequest } from '../types'; +import { Router } from "express"; +import { requireAuth } from "../middleware/auth"; +import { + validateBody, + validateParams, + validateQuery, +} from "../middleware/validation"; +import { TimeEntryService } from "../services/timeEntry.service"; +import { + CreateTimeEntrySchema, + UpdateTimeEntrySchema, + IdSchema, + TimeEntryFiltersSchema, + StatisticsFiltersSchema, +} from "../schemas"; +import type { AuthenticatedRequest } from "../types"; const router = Router(); const timeEntryService = new TimeEntryService(); +// GET /api/time-entries/statistics - Get aggregated statistics +router.get( + "/statistics", + requireAuth, + validateQuery(StatisticsFiltersSchema), + async (req: AuthenticatedRequest, res, next) => { + try { + const stats = await timeEntryService.getStatistics( + req.user!.id, + req.query, + ); + res.json(stats); + } catch (error) { + next(error); + } + }, +); + // GET /api/time-entries - List user's entries router.get( - '/', + "/", requireAuth, validateQuery(TimeEntryFiltersSchema), async (req: AuthenticatedRequest, res, next) => { @@ -20,12 +48,12 @@ router.get( } catch (error) { next(error); } - } + }, ); // POST /api/time-entries - Create entry manually router.post( - '/', + "/", requireAuth, validateBody(CreateTimeEntrySchema), async (req: AuthenticatedRequest, res, next) => { @@ -35,28 +63,32 @@ router.post( } catch (error) { next(error); } - } + }, ); // PUT /api/time-entries/:id - Update entry router.put( - '/:id', + "/:id", requireAuth, validateParams(IdSchema), validateBody(UpdateTimeEntrySchema), async (req: AuthenticatedRequest, res, next) => { try { - const entry = await timeEntryService.update(req.params.id, req.user!.id, req.body); + const entry = await timeEntryService.update( + req.params.id, + req.user!.id, + req.body, + ); res.json(entry); } catch (error) { next(error); } - } + }, ); // DELETE /api/time-entries/:id - Delete entry router.delete( - '/:id', + "/:id", requireAuth, validateParams(IdSchema), async (req: AuthenticatedRequest, res, next) => { @@ -66,7 +98,7 @@ router.delete( } catch (error) { next(error); } - } + }, ); -export default router; \ No newline at end of file +export default router; diff --git a/backend/src/schemas/index.ts b/backend/src/schemas/index.ts index f7fe986..dcb95ff 100644 --- a/backend/src/schemas/index.ts +++ b/backend/src/schemas/index.ts @@ -51,6 +51,13 @@ export const TimeEntryFiltersSchema = z.object({ limit: z.coerce.number().int().min(1).max(100).default(50), }); +export const StatisticsFiltersSchema = z.object({ + startDate: z.string().datetime().optional(), + endDate: z.string().datetime().optional(), + projectId: z.string().uuid().optional(), + clientId: z.string().uuid().optional(), +}); + export const StartTimerSchema = z.object({ projectId: z.string().uuid().optional(), }); @@ -61,4 +68,4 @@ export const UpdateTimerSchema = z.object({ export const StopTimerSchema = z.object({ projectId: z.string().uuid().optional(), -}); \ No newline at end of file +}); diff --git a/backend/src/services/timeEntry.service.ts b/backend/src/services/timeEntry.service.ts index f8e6b48..7d9281e 100644 --- a/backend/src/services/timeEntry.service.ts +++ b/backend/src/services/timeEntry.service.ts @@ -1,9 +1,157 @@ -import { prisma } from '../prisma/client'; -import type { CreateTimeEntryInput, UpdateTimeEntryInput, TimeEntryFilters } from '../types'; +import { prisma } from "../prisma/client"; +import type { + CreateTimeEntryInput, + UpdateTimeEntryInput, + TimeEntryFilters, + StatisticsFilters, +} from "../types"; export class TimeEntryService { + async getStatistics(userId: string, filters: StatisticsFilters = {}) { + const { startDate, endDate, projectId, clientId } = filters; + const where: { + userId: string; + startTime?: { gte?: Date; lte?: Date }; + projectId?: string; + project?: { clientId?: string }; + } = { userId }; + + if (startDate || endDate) { + where.startTime = {}; + if (startDate) where.startTime.gte = new Date(startDate); + if (endDate) where.startTime.lte = new Date(endDate); + } + + if (projectId) { + where.projectId = projectId; + } + + if (clientId) { + where.project = { clientId }; + } + + const entries = await prisma.timeEntry.findMany({ + where, + include: { + project: { + select: { + id: true, + name: true, + color: true, + client: { + select: { + id: true, + name: true, + }, + }, + }, + }, + }, + }); + + // Calculate total duration in seconds + const totalSeconds = entries.reduce((total, entry) => { + const startTime = new Date(entry.startTime); + const endTime = new Date(entry.endTime); + return ( + total + Math.floor((endTime.getTime() - startTime.getTime()) / 1000) + ); + }, 0); + + // Calculate by project + const byProject = entries.reduce( + (acc, entry) => { + const projectId = entry.project.id; + const projectName = entry.project.name; + const projectColor = entry.project.color; + const startTime = new Date(entry.startTime); + const endTime = new Date(entry.endTime); + const duration = Math.floor( + (endTime.getTime() - startTime.getTime()) / 1000, + ); + + if (!acc[projectId]) { + acc[projectId] = { + projectId, + projectName, + projectColor, + totalSeconds: 0, + entryCount: 0, + }; + } + acc[projectId].totalSeconds += duration; + acc[projectId].entryCount += 1; + return acc; + }, + {} as Record< + string, + { + projectId: string; + projectName: string; + projectColor: string | null; + totalSeconds: number; + entryCount: number; + } + >, + ); + + // Calculate by client + const byClient = entries.reduce( + (acc, entry) => { + const clientId = entry.project.client.id; + const clientName = entry.project.client.name; + const startTime = new Date(entry.startTime); + const endTime = new Date(entry.endTime); + const duration = Math.floor( + (endTime.getTime() - startTime.getTime()) / 1000, + ); + + if (!acc[clientId]) { + acc[clientId] = { + clientId, + clientName, + totalSeconds: 0, + entryCount: 0, + }; + } + acc[clientId].totalSeconds += duration; + acc[clientId].entryCount += 1; + return acc; + }, + {} as Record< + string, + { + clientId: string; + clientName: string; + totalSeconds: number; + entryCount: number; + } + >, + ); + + return { + totalSeconds, + entryCount: entries.length, + byProject: Object.values(byProject), + byClient: Object.values(byClient), + filters: { + startDate: startDate || null, + endDate: endDate || null, + projectId: projectId || null, + clientId: clientId || null, + }, + }; + } + async findAll(userId: string, filters: TimeEntryFilters = {}) { - const { startDate, endDate, projectId, clientId, page = 1, limit = 50 } = filters; + const { + startDate, + endDate, + projectId, + clientId, + page = 1, + limit = 50, + } = filters; const skip = (page - 1) * limit; const where: { @@ -30,7 +178,7 @@ export class TimeEntryService { const [entries, total] = await Promise.all([ prisma.timeEntry.findMany({ where, - orderBy: { startTime: 'desc' }, + orderBy: { startTime: "desc" }, skip, take: limit, include: { @@ -90,7 +238,9 @@ export class TimeEntryService { // Validate end time is after start time if (endTime <= startTime) { - const error = new Error('End time must be after start time') as Error & { statusCode: number }; + const error = new Error("End time must be after start time") as Error & { + statusCode: number; + }; error.statusCode = 400; throw error; } @@ -101,15 +251,23 @@ export class TimeEntryService { }); if (!project) { - const error = new Error('Project not found') as Error & { statusCode: number }; + const error = new Error("Project not found") as Error & { + statusCode: number; + }; error.statusCode = 404; throw error; } // Check for overlapping entries - const hasOverlap = await this.hasOverlappingEntries(userId, startTime, endTime); + const hasOverlap = await this.hasOverlappingEntries( + userId, + startTime, + endTime, + ); if (hasOverlap) { - const error = new Error('This time entry overlaps with an existing entry') as Error & { statusCode: number }; + const error = new Error( + "This time entry overlaps with an existing entry", + ) as Error & { statusCode: number }; error.statusCode = 400; throw error; } @@ -143,17 +301,23 @@ export class TimeEntryService { async update(id: string, userId: string, data: UpdateTimeEntryInput) { const entry = await this.findById(id, userId); if (!entry) { - const error = new Error('Time entry not found') as Error & { statusCode: number }; + const error = new Error("Time entry not found") as Error & { + statusCode: number; + }; error.statusCode = 404; throw error; } - const startTime = data.startTime ? new Date(data.startTime) : entry.startTime; + const startTime = data.startTime + ? new Date(data.startTime) + : entry.startTime; const endTime = data.endTime ? new Date(data.endTime) : entry.endTime; // Validate end time is after start time if (endTime <= startTime) { - const error = new Error('End time must be after start time') as Error & { statusCode: number }; + const error = new Error("End time must be after start time") as Error & { + statusCode: number; + }; error.statusCode = 400; throw error; } @@ -165,16 +329,25 @@ export class TimeEntryService { }); if (!project) { - const error = new Error('Project not found') as Error & { statusCode: number }; + const error = new Error("Project not found") as Error & { + statusCode: number; + }; error.statusCode = 404; throw error; } } // Check for overlapping entries (excluding this entry) - const hasOverlap = await this.hasOverlappingEntries(userId, startTime, endTime, id); + const hasOverlap = await this.hasOverlappingEntries( + userId, + startTime, + endTime, + id, + ); if (hasOverlap) { - const error = new Error('This time entry overlaps with an existing entry') as Error & { statusCode: number }; + const error = new Error( + "This time entry overlaps with an existing entry", + ) as Error & { statusCode: number }; error.statusCode = 400; throw error; } @@ -208,7 +381,9 @@ export class TimeEntryService { async delete(id: string, userId: string) { const entry = await this.findById(id, userId); if (!entry) { - const error = new Error('Time entry not found') as Error & { statusCode: number }; + const error = new Error("Time entry not found") as Error & { + statusCode: number; + }; error.statusCode = 404; throw error; } @@ -222,7 +397,7 @@ export class TimeEntryService { userId: string, startTime: Date, endTime: Date, - excludeId?: string + excludeId?: string, ): Promise { const where: { userId: string; @@ -246,4 +421,4 @@ export class TimeEntryService { const count = await prisma.timeEntry.count({ where }); return count > 0; } -} \ No newline at end of file +} diff --git a/backend/src/types/index.ts b/backend/src/types/index.ts index ded382c..bd1cbb6 100644 --- a/backend/src/types/index.ts +++ b/backend/src/types/index.ts @@ -57,6 +57,13 @@ export interface TimeEntryFilters { limit?: number; } +export interface StatisticsFilters { + startDate?: string; + endDate?: string; + projectId?: string; + clientId?: string; +} + export interface StartTimerInput { projectId?: string; } @@ -67,4 +74,4 @@ export interface UpdateTimerInput { export interface StopTimerInput { projectId?: string; -} \ No newline at end of file +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index dff8f41..1797d36 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -9,6 +9,7 @@ import { DashboardPage } from "./pages/DashboardPage"; import { TimeEntriesPage } from "./pages/TimeEntriesPage"; import { ClientsPage } from "./pages/ClientsPage"; import { ProjectsPage } from "./pages/ProjectsPage"; +import { StatisticsPage } from "./pages/StatisticsPage"; function App() { return ( @@ -31,6 +32,7 @@ function App() { } /> } /> } /> + } /> diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts index 69473b1..5827d1c 100644 --- a/frontend/src/api/auth.ts +++ b/frontend/src/api/auth.ts @@ -1,7 +1,8 @@ import axios from "axios"; import type { User } from "@/types"; -const AUTH_BASE = import.meta.env.VITE_API_URL + "/auth"; +const AUTH_BASE = + (import.meta.env.VITE_API_URL || `${window.location.origin}/api`) + "/auth"; export const authApi = { login: (): void => { diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 288efd4..5ece7cf 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -1,7 +1,7 @@ import axios, { AxiosError } from "axios"; const apiClient = axios.create({ - baseURL: import.meta.env.VITE_API_URL, + baseURL: import.meta.env.VITE_API_URL || `${window.location.origin}/api`, headers: { "Content-Type": "application/json", }, diff --git a/frontend/src/api/timeEntries.ts b/frontend/src/api/timeEntries.ts index 1ba40ce..19b3871 100644 --- a/frontend/src/api/timeEntries.ts +++ b/frontend/src/api/timeEntries.ts @@ -5,6 +5,8 @@ import type { CreateTimeEntryInput, UpdateTimeEntryInput, TimeEntryFilters, + TimeStatistics, + StatisticsFilters, } from '@/types'; export const timeEntriesApi = { @@ -15,6 +17,13 @@ export const timeEntriesApi = { return data; }, + getStatistics: async (filters?: StatisticsFilters): Promise => { + const { data } = await apiClient.get('/time-entries/statistics', { + params: filters, + }); + return data; + }, + create: async (input: CreateTimeEntryInput): Promise => { const { data } = await apiClient.post('/time-entries', input); return data; diff --git a/frontend/src/components/Navbar.tsx b/frontend/src/components/Navbar.tsx index 369fda7..69c9630 100644 --- a/frontend/src/components/Navbar.tsx +++ b/frontend/src/components/Navbar.tsx @@ -1,15 +1,23 @@ -import { NavLink } from 'react-router-dom'; -import { Clock, List, Briefcase, FolderOpen, LogOut } from 'lucide-react'; -import { useAuth } from '@/contexts/AuthContext'; +import { NavLink } from "react-router-dom"; +import { + Clock, + List, + Briefcase, + FolderOpen, + BarChart3, + LogOut, +} from "lucide-react"; +import { useAuth } from "@/contexts/AuthContext"; export function Navbar() { const { user, logout } = useAuth(); const navItems = [ - { to: '/dashboard', label: 'Dashboard', icon: Clock }, - { to: '/time-entries', label: 'Time Entries', icon: List }, - { to: '/clients', label: 'Clients', icon: Briefcase }, - { to: '/projects', label: 'Projects', icon: FolderOpen }, + { to: "/dashboard", label: "Dashboard", icon: Clock }, + { to: "/time-entries", label: "Time Entries", icon: List }, + { to: "/statistics", label: "Statistics", icon: BarChart3 }, + { to: "/clients", label: "Clients", icon: Briefcase }, + { to: "/projects", label: "Projects", icon: FolderOpen }, ]; return ( @@ -19,7 +27,9 @@ export function Navbar() {
- TimeTracker + + TimeTracker +
{navItems.map((item) => ( @@ -29,8 +39,8 @@ export function Navbar() { className={({ isActive }) => `inline-flex items-center px-3 py-2 text-sm font-medium rounded-md transition-colors ${ isActive - ? 'text-primary-600 bg-primary-50' - : 'text-gray-600 hover:text-gray-900 hover:bg-gray-50' + ? "text-primary-600 bg-primary-50" + : "text-gray-600 hover:text-gray-900 hover:bg-gray-50" }` } > @@ -63,7 +73,7 @@ export function Navbar() { to={item.to} className={({ isActive }) => `flex flex-col items-center p-2 text-xs font-medium rounded-md ${ - isActive ? 'text-primary-600' : 'text-gray-600' + isActive ? "text-primary-600" : "text-gray-600" }` } > @@ -75,4 +85,4 @@ export function Navbar() {
); -} \ No newline at end of file +} diff --git a/frontend/src/hooks/useTimeEntries.ts b/frontend/src/hooks/useTimeEntries.ts index ca32fd9..5c1bfdd 100644 --- a/frontend/src/hooks/useTimeEntries.ts +++ b/frontend/src/hooks/useTimeEntries.ts @@ -1,19 +1,24 @@ -import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { timeEntriesApi } from '@/api/timeEntries'; -import type { CreateTimeEntryInput, UpdateTimeEntryInput, TimeEntryFilters } from '@/types'; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { timeEntriesApi } from "@/api/timeEntries"; +import type { + CreateTimeEntryInput, + UpdateTimeEntryInput, + TimeEntryFilters, + StatisticsFilters, +} from "@/types"; export function useTimeEntries(filters?: TimeEntryFilters) { const queryClient = useQueryClient(); const { data, isLoading, error } = useQuery({ - queryKey: ['timeEntries', filters], + queryKey: ["timeEntries", filters], queryFn: () => timeEntriesApi.getAll(filters), }); const createTimeEntry = useMutation({ mutationFn: (input: CreateTimeEntryInput) => timeEntriesApi.create(input), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['timeEntries'] }); + queryClient.invalidateQueries({ queryKey: ["timeEntries"] }); }, }); @@ -21,14 +26,14 @@ export function useTimeEntries(filters?: TimeEntryFilters) { mutationFn: ({ id, input }: { id: string; input: UpdateTimeEntryInput }) => timeEntriesApi.update(id, input), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['timeEntries'] }); + queryClient.invalidateQueries({ queryKey: ["timeEntries"] }); }, }); const deleteTimeEntry = useMutation({ mutationFn: (id: string) => timeEntriesApi.delete(id), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['timeEntries'] }); + queryClient.invalidateQueries({ queryKey: ["timeEntries"] }); }, }); @@ -40,4 +45,17 @@ export function useTimeEntries(filters?: TimeEntryFilters) { updateTimeEntry, deleteTimeEntry, }; -} \ No newline at end of file +} + +export function useStatistics(filters?: StatisticsFilters) { + const { data, isLoading, error } = useQuery({ + queryKey: ["statistics", filters], + queryFn: () => timeEntriesApi.getStatistics(filters), + }); + + return { + data, + isLoading, + error, + }; +} diff --git a/frontend/src/pages/StatisticsPage.tsx b/frontend/src/pages/StatisticsPage.tsx new file mode 100644 index 0000000..c721bfc --- /dev/null +++ b/frontend/src/pages/StatisticsPage.tsx @@ -0,0 +1,259 @@ +import { useState } from "react"; +import { + BarChart3, + Calendar, + Building2, + FolderOpen, + Clock, +} from "lucide-react"; +import { useStatistics } from "@/hooks/useTimeEntries"; +import { useClients } from "@/hooks/useClients"; +import { useProjects } from "@/hooks/useProjects"; +import { formatDuration } from "@/utils/dateUtils"; +import type { StatisticsFilters } from "@/types"; + +export function StatisticsPage() { + const [filters, setFilters] = useState({}); + const { data: statistics, isLoading } = useStatistics(filters); + const { clients } = useClients(); + const { projects } = useProjects(); + + const handleFilterChange = ( + key: keyof StatisticsFilters, + value: string | undefined, + ) => { + setFilters((prev) => ({ + ...prev, + [key]: value || undefined, + })); + }; + + const clearFilters = () => { + setFilters({}); + }; + + return ( +
+ {/* Page Header */} +
+

Statistics

+

+ View your working hours with filters +

+
+ + {/* Filters */} +
+
+ +

Filters

+
+ +
+ {/* Date Range */} +
+ + + handleFilterChange( + "startDate", + e.target.value ? `${e.target.value}T00:00:00` : undefined, + ) + } + className="input" + /> +
+ +
+ + + handleFilterChange( + "endDate", + e.target.value ? `${e.target.value}T23:59:59` : undefined, + ) + } + className="input" + /> +
+ + {/* Client Filter */} +
+ + +
+ + {/* Project Filter */} +
+ + +
+
+ + {/* Clear Filters Button */} + {(filters.startDate || + filters.endDate || + filters.clientId || + filters.projectId) && ( +
+ +
+ )} +
+ + {/* Total Hours Display */} +
+
+
+

+ Total Working Time +

+

+ {isLoading ? ( + Loading... + ) : ( + formatDuration(statistics?.totalSeconds || 0) + )} +

+
+
+ +
+
+

+ {statistics?.entryCount || 0} time entries +

+
+ + {/* Breakdown by Project */} + {statistics && statistics.byProject.length > 0 && ( +
+

+ By Project +

+
+ {statistics.byProject.map((project) => ( +
+
+
+ + {project.projectName} + + + ({project.entryCount} entries) + +
+ + {formatDuration(project.totalSeconds)} + +
+ ))} +
+
+ )} + + {/* Breakdown by Client */} + {statistics && statistics.byClient.length > 0 && ( +
+

+ By Client +

+
+ {statistics.byClient.map((client) => ( +
+
+ + + {client.clientName} + + + ({client.entryCount} entries) + +
+ + {formatDuration(client.totalSeconds)} + +
+ ))} +
+
+ )} + + {/* Empty State */} + {!isLoading && statistics && statistics.entryCount === 0 && ( +
+ +

+ No data available +

+

+ No time entries found for the selected filters. +

+
+ )} +
+ ); +} diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index cd8d0df..e06525a 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -56,6 +56,41 @@ export interface TimeEntryFilters { limit?: number; } +export interface StatisticsFilters { + startDate?: string; + endDate?: string; + projectId?: string; + clientId?: string; +} + +export interface ProjectStatistics { + projectId: string; + projectName: string; + projectColor: string | null; + totalSeconds: number; + entryCount: number; +} + +export interface ClientStatistics { + clientId: string; + clientName: string; + totalSeconds: number; + entryCount: number; +} + +export interface TimeStatistics { + totalSeconds: number; + entryCount: number; + byProject: ProjectStatistics[]; + byClient: ClientStatistics[]; + filters: { + startDate: string | null; + endDate: string | null; + projectId: string | null; + clientId: string | null; + }; +} + export interface PaginatedTimeEntries { entries: TimeEntry[]; pagination: { @@ -102,4 +137,4 @@ export interface UpdateTimeEntryInput { endTime?: string; description?: string; projectId?: string; -} \ No newline at end of file +}