From 685a311001affaec253f1ea41fc9e6eedf0c65ac Mon Sep 17 00:00:00 2001 From: "simon.franken" Date: Mon, 23 Feb 2026 14:39:30 +0100 Subject: [PATCH] Add break time feature to time entries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add breakMinutes field to TimeEntry model and database migration - Users can now add break duration (minutes) to time entries - Break time is subtracted from total tracked duration - Validation ensures break time cannot exceed total entry duration - Statistics and client target balance calculations account for breaks - Frontend UI includes break time input in TimeEntryFormModal - Duration displays show break time deduction (e.g., '7h (−1h break)') - Both project/client statistics and weekly balance calculations updated --- .../migration.sql | 2 ++ backend/prisma/schema.prisma | 13 ++++++----- backend/src/schemas/index.ts | 2 ++ backend/src/services/clientTarget.service.ts | 2 +- backend/src/services/timeEntry.service.ts | 22 ++++++++++++++++--- backend/src/types/index.ts | 2 ++ .../src/components/TimeEntryFormModal.tsx | 12 ++++++++++ frontend/src/pages/DashboardPage.tsx | 7 ++++-- frontend/src/pages/TimeEntriesPage.tsx | 5 ++++- frontend/src/types/index.ts | 3 +++ frontend/src/utils/dateUtils.ts | 15 ++++++++++--- 11 files changed, 69 insertions(+), 16 deletions(-) create mode 100644 backend/prisma/migrations/20260223123527_add_break_minutes/migration.sql diff --git a/backend/prisma/migrations/20260223123527_add_break_minutes/migration.sql b/backend/prisma/migrations/20260223123527_add_break_minutes/migration.sql new file mode 100644 index 0000000..438090c --- /dev/null +++ b/backend/prisma/migrations/20260223123527_add_break_minutes/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "time_entries" ADD COLUMN "break_minutes" INTEGER NOT NULL DEFAULT 0; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index daedaf2..399cb79 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -62,12 +62,13 @@ model Project { } model TimeEntry { - id String @id @default(uuid()) - startTime DateTime @map("start_time") @db.Timestamptz() - endTime DateTime @map("end_time") @db.Timestamptz() - description String? @db.Text - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @updatedAt @map("updated_at") + id String @id @default(uuid()) + startTime DateTime @map("start_time") @db.Timestamptz() + endTime DateTime @map("end_time") @db.Timestamptz() + breakMinutes Int @default(0) @map("break_minutes") + description String? @db.Text + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") userId String @map("user_id") @db.VarChar(255) user User @relation(fields: [userId], references: [id], onDelete: Cascade) diff --git a/backend/src/schemas/index.ts b/backend/src/schemas/index.ts index f0815eb..a0b73e8 100644 --- a/backend/src/schemas/index.ts +++ b/backend/src/schemas/index.ts @@ -31,6 +31,7 @@ export const UpdateProjectSchema = z.object({ export const CreateTimeEntrySchema = z.object({ startTime: z.string().datetime(), endTime: z.string().datetime(), + breakMinutes: z.number().int().min(0).optional(), description: z.string().max(1000).optional(), projectId: z.string().uuid(), }); @@ -38,6 +39,7 @@ export const CreateTimeEntrySchema = z.object({ export const UpdateTimeEntrySchema = z.object({ startTime: z.string().datetime().optional(), endTime: z.string().datetime().optional(), + breakMinutes: z.number().int().min(0).optional(), description: z.string().max(1000).optional(), projectId: z.string().uuid().optional(), }); diff --git a/backend/src/services/clientTarget.service.ts b/backend/src/services/clientTarget.service.ts index e7f7a12..c73cc40 100644 --- a/backend/src/services/clientTarget.service.ts +++ b/backend/src/services/clientTarget.service.ts @@ -222,7 +222,7 @@ export class ClientTargetService { const rows = await prisma.$queryRaw(Prisma.sql` SELECT DATE_TRUNC('week', te.start_time AT TIME ZONE 'UTC') AS week_start, - COALESCE(SUM(EXTRACT(EPOCH FROM (te.end_time - te.start_time))), 0)::bigint AS tracked_seconds + COALESCE(SUM(EXTRACT(EPOCH FROM (te.end_time - te.start_time)) - (te.break_minutes * 60)), 0)::bigint AS tracked_seconds FROM time_entries te JOIN projects p ON p.id = te.project_id WHERE te.user_id = ${target.userId} diff --git a/backend/src/services/timeEntry.service.ts b/backend/src/services/timeEntry.service.ts index affd05b..898693f 100644 --- a/backend/src/services/timeEntry.service.ts +++ b/backend/src/services/timeEntry.service.ts @@ -42,7 +42,7 @@ export class TimeEntryService { 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, + COALESCE(SUM(EXTRACT(EPOCH FROM (te.end_time - te.start_time)) - (te.break_minutes * 60)), 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 @@ -63,7 +63,7 @@ export class TimeEntryService { 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, + COALESCE(SUM(EXTRACT(EPOCH FROM (te.end_time - te.start_time)) - (te.break_minutes * 60)), 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 @@ -77,7 +77,7 @@ export class TimeEntryService { 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, + COALESCE(SUM(EXTRACT(EPOCH FROM (te.end_time - te.start_time)) - (te.break_minutes * 60)), 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 @@ -204,12 +204,19 @@ export class TimeEntryService { async create(userId: string, data: CreateTimeEntryInput) { const startTime = new Date(data.startTime); const endTime = new Date(data.endTime); + const breakMinutes = data.breakMinutes ?? 0; // Validate end time is after start time if (endTime <= startTime) { throw new BadRequestError("End time must be after start time"); } + // Validate break time doesn't exceed duration + const durationMinutes = (endTime.getTime() - startTime.getTime()) / 60000; + if (breakMinutes > durationMinutes) { + throw new BadRequestError("Break time cannot exceed total duration"); + } + // Verify the project belongs to the user const project = await prisma.project.findFirst({ where: { id: data.projectId, userId }, @@ -235,6 +242,7 @@ export class TimeEntryService { data: { startTime, endTime, + breakMinutes, description: data.description, userId, projectId: data.projectId, @@ -267,12 +275,19 @@ export class TimeEntryService { ? new Date(data.startTime) : entry.startTime; const endTime = data.endTime ? new Date(data.endTime) : entry.endTime; + const breakMinutes = data.breakMinutes ?? entry.breakMinutes; // Validate end time is after start time if (endTime <= startTime) { throw new BadRequestError("End time must be after start time"); } + // Validate break time doesn't exceed duration + const durationMinutes = (endTime.getTime() - startTime.getTime()) / 60000; + if (breakMinutes > durationMinutes) { + throw new BadRequestError("Break time cannot exceed total duration"); + } + // If project changed, verify it belongs to the user if (data.projectId && data.projectId !== entry.projectId) { const project = await prisma.project.findFirst({ @@ -302,6 +317,7 @@ export class TimeEntryService { data: { startTime, endTime, + breakMinutes, description: data.description, projectId: data.projectId, }, diff --git a/backend/src/types/index.ts b/backend/src/types/index.ts index 95fb778..47aa4cb 100644 --- a/backend/src/types/index.ts +++ b/backend/src/types/index.ts @@ -38,6 +38,7 @@ export interface UpdateProjectInput { export interface CreateTimeEntryInput { startTime: string; endTime: string; + breakMinutes?: number; description?: string; projectId: string; } @@ -45,6 +46,7 @@ export interface CreateTimeEntryInput { export interface UpdateTimeEntryInput { startTime?: string; endTime?: string; + breakMinutes?: number; description?: string; projectId?: string; } diff --git a/frontend/src/components/TimeEntryFormModal.tsx b/frontend/src/components/TimeEntryFormModal.tsx index ad949ee..5ce7693 100644 --- a/frontend/src/components/TimeEntryFormModal.tsx +++ b/frontend/src/components/TimeEntryFormModal.tsx @@ -20,6 +20,7 @@ export function TimeEntryFormModal({ entry, onClose, createTimeEntry, updateTime return { startTime: getLocalISOString(new Date(entry.startTime)), endTime: getLocalISOString(new Date(entry.endTime)), + breakMinutes: entry.breakMinutes, description: entry.description || '', projectId: entry.projectId, }; @@ -29,6 +30,7 @@ export function TimeEntryFormModal({ entry, onClose, createTimeEntry, updateTime return { startTime: getLocalISOString(oneHourAgo), endTime: getLocalISOString(now), + breakMinutes: 0, description: '', projectId: projects?.[0]?.id || '', }; @@ -97,6 +99,16 @@ export function TimeEntryFormModal({ entry, onClose, createTimeEntry, updateTime /> +
+ + setFormData({ ...formData, breakMinutes: parseInt(e.target.value) || 0 })} + className="input" + /> +