diff --git a/backend/prisma/migrations/20260218000001_remove_unique_project_id_from_ongoing_timers/migration.sql b/backend/prisma/migrations/20260218000001_remove_unique_project_id_from_ongoing_timers/migration.sql new file mode 100644 index 0000000..f07f267 --- /dev/null +++ b/backend/prisma/migrations/20260218000001_remove_unique_project_id_from_ongoing_timers/migration.sql @@ -0,0 +1,4 @@ +-- Remove the erroneous UNIQUE constraint on ongoing_timers.project_id. +-- Multiple users must be able to have concurrent timers on the same project. +-- The one-timer-per-user constraint is correctly enforced by the UNIQUE on user_id. +DROP INDEX IF EXISTS "ongoing_timers_project_id_key"; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index f43018b..2062670 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -51,7 +51,7 @@ model Project { client Client @relation(fields: [clientId], references: [id], onDelete: Cascade) timeEntries TimeEntry[] - ongoingTimer OngoingTimer? + ongoingTimers OngoingTimer[] @@index([userId]) @@index([clientId]) @@ -85,7 +85,7 @@ model OngoingTimer { userId String @map("user_id") @db.VarChar(255) @unique user User @relation(fields: [userId], references: [id], onDelete: Cascade) - projectId String? @unique @map("project_id") + projectId String? @map("project_id") project Project? @relation(fields: [projectId], references: [id], onDelete: SetNull) @@index([userId]) diff --git a/backend/src/config/index.ts b/backend/src/config/index.ts index b571f4b..78b4d62 100644 --- a/backend/src/config/index.ts +++ b/backend/src/config/index.ts @@ -40,8 +40,8 @@ export function validateConfig(): void { } if (config.session.secret.length < 32) { - console.warn( - "Warning: SESSION_SECRET should be at least 32 characters for security", + throw new Error( + "SESSION_SECRET must be at least 32 characters. Set a strong secret in your environment.", ); } } diff --git a/backend/src/routes/auth.routes.ts b/backend/src/routes/auth.routes.ts index 4516db7..c5ac858 100644 --- a/backend/src/routes/auth.routes.ts +++ b/backend/src/routes/auth.routes.ts @@ -6,7 +6,7 @@ import { handleCallback, getUserInfo, } from "../auth/oidc"; -import { syncUser } from "../middleware/auth"; +import { requireAuth, syncUser } from "../middleware/auth"; import type { AuthenticatedRequest } from "../types"; const router = Router(); @@ -84,12 +84,8 @@ router.post("/logout", (req: AuthenticatedRequest, res) => { }); // GET /auth/me - Get current user -router.get("/me", (req: AuthenticatedRequest, res) => { - if (!req.session?.user) { - res.status(401).json({ error: "Not authenticated" }); - return; - } - res.json(req.session.user); +router.get("/me", requireAuth, (req: AuthenticatedRequest, res) => { + res.json(req.user); }); export default router; diff --git a/backend/src/services/timeEntry.service.ts b/backend/src/services/timeEntry.service.ts index e3fea4f..affd05b 100644 --- a/backend/src/services/timeEntry.service.ts +++ b/backend/src/services/timeEntry.service.ts @@ -1,9 +1,11 @@ import { prisma } from "../prisma/client"; +import { Prisma } from "@prisma/client"; import { NotFoundError, BadRequestError, ConflictError, } from "../errors/AppError"; +import { hasOverlappingEntries } from "../utils/timeUtils"; import type { CreateTimeEntryInput, UpdateTimeEntryInput, @@ -14,131 +16,93 @@ import type { 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); - } + // Build an array of safe Prisma SQL filter fragments to append as AND clauses. + const extraFilters: Prisma.Sql[] = []; + if (startDate) extraFilters.push(Prisma.sql`AND te.start_time >= ${new Date(startDate)}`); + if (endDate) extraFilters.push(Prisma.sql`AND te.start_time <= ${new Date(endDate)}`); + if (projectId) extraFilters.push(Prisma.sql`AND te.project_id = ${projectId}`); + if (clientId) extraFilters.push(Prisma.sql`AND p.client_id = ${clientId}`); - if (projectId) { - where.projectId = projectId; - } + const filterClause = extraFilters.length + ? Prisma.join(extraFilters, " ") + : Prisma.empty; - 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, + const [projectGroups, clientGroups, totalAgg] = await Promise.all([ + prisma.$queryRaw< { - projectId: string; - projectName: string; - projectColor: string | null; - totalSeconds: number; - entryCount: number; - } - >, - ); + project_id: string; + project_name: string; + project_color: string | null; + total_seconds: bigint; + entry_count: bigint; + }[] + >(Prisma.sql` + SELECT + p.id AS project_id, + p.name AS project_name, + p.color AS project_color, + COALESCE(SUM(EXTRACT(EPOCH FROM (te.end_time - te.start_time))), 0)::bigint AS total_seconds, + COUNT(te.id)::bigint AS entry_count + FROM time_entries te + JOIN projects p ON p.id = te.project_id + WHERE te.user_id = ${userId} + ${filterClause} + GROUP BY p.id, p.name, p.color + ORDER BY total_seconds DESC + `), - // 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, + prisma.$queryRaw< { - clientId: string; - clientName: string; - totalSeconds: number; - entryCount: number; - } - >, - ); + client_id: string; + client_name: string; + total_seconds: bigint; + entry_count: bigint; + }[] + >(Prisma.sql` + SELECT + c.id AS client_id, + c.name AS client_name, + COALESCE(SUM(EXTRACT(EPOCH FROM (te.end_time - te.start_time))), 0)::bigint AS total_seconds, + COUNT(te.id)::bigint AS entry_count + FROM time_entries te + JOIN projects p ON p.id = te.project_id + JOIN clients c ON c.id = p.client_id + WHERE te.user_id = ${userId} + ${filterClause} + GROUP BY c.id, c.name + ORDER BY total_seconds DESC + `), + + prisma.$queryRaw<{ total_seconds: bigint; entry_count: bigint }[]>( + Prisma.sql` + SELECT + COALESCE(SUM(EXTRACT(EPOCH FROM (te.end_time - te.start_time))), 0)::bigint AS total_seconds, + COUNT(te.id)::bigint AS entry_count + FROM time_entries te + JOIN projects p ON p.id = te.project_id + WHERE te.user_id = ${userId} + ${filterClause} + `, + ), + ]); return { - totalSeconds, - entryCount: entries.length, - byProject: Object.values(byProject), - byClient: Object.values(byClient), + totalSeconds: Number(totalAgg[0]?.total_seconds ?? 0), + entryCount: Number(totalAgg[0]?.entry_count ?? 0), + byProject: projectGroups.map((r) => ({ + projectId: r.project_id, + projectName: r.project_name, + projectColor: r.project_color, + totalSeconds: Number(r.total_seconds), + entryCount: Number(r.entry_count), + })), + byClient: clientGroups.map((r) => ({ + clientId: r.client_id, + clientName: r.client_name, + totalSeconds: Number(r.total_seconds), + entryCount: Number(r.entry_count), + })), filters: { startDate: startDate || null, endDate: endDate || null, @@ -256,7 +220,7 @@ export class TimeEntryService { } // Check for overlapping entries - const hasOverlap = await this.hasOverlappingEntries( + const hasOverlap = await hasOverlappingEntries( userId, startTime, endTime, @@ -321,7 +285,7 @@ export class TimeEntryService { } // Check for overlapping entries (excluding this entry) - const hasOverlap = await this.hasOverlappingEntries( + const hasOverlap = await hasOverlappingEntries( userId, startTime, endTime, @@ -369,33 +333,4 @@ export class TimeEntryService { where: { id }, }); } - - private async hasOverlappingEntries( - userId: string, - startTime: Date, - endTime: Date, - excludeId?: string, - ): Promise { - const where: { - userId: string; - id?: { not: string }; - OR: Array<{ - startTime?: { lt: Date }; - endTime?: { gt: Date }; - }>; - } = { - userId, - OR: [ - // Entry starts during the new entry - { startTime: { lt: endTime }, endTime: { gt: startTime } }, - ], - }; - - if (excludeId) { - where.id = { not: excludeId }; - } - - const count = await prisma.timeEntry.count({ where }); - return count > 0; - } } diff --git a/backend/src/services/timer.service.ts b/backend/src/services/timer.service.ts index 9d8661a..a35b8f5 100644 --- a/backend/src/services/timer.service.ts +++ b/backend/src/services/timer.service.ts @@ -4,6 +4,7 @@ import { BadRequestError, ConflictError, } from "../errors/AppError"; +import { hasOverlappingEntries } from "../utils/timeUtils"; import type { StartTimerInput, UpdateTimerInput, @@ -155,7 +156,7 @@ export class TimerService { const startTime = timer.startTime; // Check for overlapping entries - const hasOverlap = await this.hasOverlappingEntries( + const hasOverlap = await hasOverlappingEntries( userId, startTime, endTime, @@ -198,18 +199,4 @@ export class TimerService { return result[1]; // Return the created time entry } - - private async hasOverlappingEntries( - userId: string, - startTime: Date, - endTime: Date, - ): Promise { - const count = await prisma.timeEntry.count({ - where: { - userId, - OR: [{ startTime: { lt: endTime }, endTime: { gt: startTime } }], - }, - }); - return count > 0; - } } diff --git a/backend/src/utils/timeUtils.ts b/backend/src/utils/timeUtils.ts new file mode 100644 index 0000000..13d31ea --- /dev/null +++ b/backend/src/utils/timeUtils.ts @@ -0,0 +1,29 @@ +import { prisma } from "../prisma/client"; + +/** + * Checks whether a user already has a time entry that overlaps with the given + * [startTime, endTime] interval. + * + * @param userId - The user whose entries are checked. + * @param startTime - Start of the interval to check. + * @param endTime - End of the interval to check. + * @param excludeId - Optional time-entry id to exclude (used when updating an + * existing entry so it does not collide with itself). + */ +export async function hasOverlappingEntries( + userId: string, + startTime: Date, + endTime: Date, + excludeId?: string, +): Promise { + const count = await prisma.timeEntry.count({ + where: { + userId, + ...(excludeId ? { id: { not: excludeId } } : {}), + // An entry overlaps when it starts before our end AND ends after our start. + startTime: { lt: endTime }, + endTime: { gt: startTime }, + }, + }); + return count > 0; +} diff --git a/frontend/src/components/Modal.tsx b/frontend/src/components/Modal.tsx new file mode 100644 index 0000000..d45b8ae --- /dev/null +++ b/frontend/src/components/Modal.tsx @@ -0,0 +1,39 @@ +import { X } from 'lucide-react'; + +interface ModalProps { + title: string; + onClose: () => void; + children: React.ReactNode; + /** Optional override for the max-width of the modal panel (default: max-w-md) */ + maxWidth?: string; +} + +/** + * Generic modal overlay with a header and close button. + * Render form content (or any JSX) as children. + * + * @example + * + *
...
+ *
+ */ +export function Modal({ title, onClose, children, maxWidth = 'max-w-md' }: ModalProps) { + return ( +
+
+
+

{title}

+ +
+
{children}
+
+
+ ); +} diff --git a/frontend/src/components/ProjectColorDot.tsx b/frontend/src/components/ProjectColorDot.tsx new file mode 100644 index 0000000..5829503 --- /dev/null +++ b/frontend/src/components/ProjectColorDot.tsx @@ -0,0 +1,15 @@ +interface ProjectColorDotProps { + color: string | null | undefined; + /** Tailwind size classes (default: w-3 h-3) */ + size?: string; +} + +/** A small filled circle used to represent a project's colour. */ +export function ProjectColorDot({ color, size = 'w-3 h-3' }: ProjectColorDotProps) { + return ( +
+ ); +} diff --git a/frontend/src/components/ProtectedRoute.tsx b/frontend/src/components/ProtectedRoute.tsx index 14d480f..5d31e9d 100644 --- a/frontend/src/components/ProtectedRoute.tsx +++ b/frontend/src/components/ProtectedRoute.tsx @@ -1,5 +1,6 @@ import { Navigate } from 'react-router-dom'; import { useAuth } from '@/contexts/AuthContext'; +import { Spinner } from '@/components/Spinner'; interface ProtectedRouteProps { children: React.ReactNode; @@ -11,7 +12,7 @@ export function ProtectedRoute({ children }: ProtectedRouteProps) { if (isLoading) { return (
-
+
); } diff --git a/frontend/src/components/Spinner.tsx b/frontend/src/components/Spinner.tsx new file mode 100644 index 0000000..bb18929 --- /dev/null +++ b/frontend/src/components/Spinner.tsx @@ -0,0 +1,12 @@ +interface SpinnerProps { + /** Height/width class (default: h-12 w-12) */ + size?: string; +} + +export function Spinner({ size = 'h-12 w-12' }: SpinnerProps) { + return ( +
+
+
+ ); +} diff --git a/frontend/src/components/StatCard.tsx b/frontend/src/components/StatCard.tsx new file mode 100644 index 0000000..b719705 --- /dev/null +++ b/frontend/src/components/StatCard.tsx @@ -0,0 +1,29 @@ +interface StatCardProps { + icon: React.ElementType; + label: string; + value: string; + color: 'blue' | 'green' | 'purple' | 'orange'; +} + +const colorClasses: Record = { + blue: 'bg-blue-50 text-blue-600', + green: 'bg-green-50 text-green-600', + purple: 'bg-purple-50 text-purple-600', + orange: 'bg-orange-50 text-orange-600', +}; + +export function StatCard({ icon: Icon, label, value, color }: StatCardProps) { + return ( +
+
+
+ +
+
+

{label}

+

{value}

+
+
+
+ ); +} diff --git a/frontend/src/components/TimerWidget.tsx b/frontend/src/components/TimerWidget.tsx index 13dd0bc..3a40bff 100644 --- a/frontend/src/components/TimerWidget.tsx +++ b/frontend/src/components/TimerWidget.tsx @@ -3,6 +3,7 @@ import { Play, Square, ChevronDown } from 'lucide-react'; import { useTimer } from '@/contexts/TimerContext'; import { useProjects } from '@/hooks/useProjects'; import { formatDuration } from '@/utils/dateUtils'; +import { ProjectColorDot } from '@/components/ProjectColorDot'; export function TimerWidget() { const { ongoingTimer, isLoading, elapsedSeconds, startTimer, stopTimer, updateTimerProject } = useTimer(); @@ -80,10 +81,7 @@ export function TimerWidget() { > {ongoingTimer.project ? ( <> -
+ {ongoingTimer.project.name} @@ -110,10 +108,7 @@ export function TimerWidget() { onClick={() => handleProjectChange(project.id)} className="w-full px-4 py-2 text-left text-sm hover:bg-gray-50 flex items-center space-x-2" > -
+
{project.name}
{project.client.name}
diff --git a/frontend/src/pages/ClientsPage.tsx b/frontend/src/pages/ClientsPage.tsx index d7ac7fe..475cea2 100644 --- a/frontend/src/pages/ClientsPage.tsx +++ b/frontend/src/pages/ClientsPage.tsx @@ -1,6 +1,8 @@ import { useState } from 'react'; import { Plus, Edit2, Trash2, Building2 } from 'lucide-react'; import { useClients } from '@/hooks/useClients'; +import { Modal } from '@/components/Modal'; +import { Spinner } from '@/components/Spinner'; import type { Client, CreateClientInput, UpdateClientInput } from '@/types'; export function ClientsPage() { @@ -66,11 +68,7 @@ export function ClientsPage() { }; if (isLoading) { - return ( -
-
-
- ); + return ; } return ( @@ -145,67 +143,62 @@ export function ClientsPage() { {/* Modal */} {isModalOpen && ( -
-
-
-

- {editingClient ? 'Edit Client' : 'Add Client'} -

+ +
+ {error && ( +
+ {error} +
+ )} + +
+ + setFormData({ ...formData, name: e.target.value })} + className="input" + placeholder="Enter client name" + autoFocus + />
- - {error && ( -
- {error} -
- )} +
+ +