Add break time feature to time entries

- 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
This commit is contained in:
simon.franken
2026-02-23 14:39:30 +01:00
parent d09247d2a5
commit 685a311001
11 changed files with 69 additions and 16 deletions

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "time_entries" ADD COLUMN "break_minutes" INTEGER NOT NULL DEFAULT 0;

View File

@@ -62,12 +62,13 @@ model Project {
} }
model TimeEntry { model TimeEntry {
id String @id @default(uuid()) id String @id @default(uuid())
startTime DateTime @map("start_time") @db.Timestamptz() startTime DateTime @map("start_time") @db.Timestamptz()
endTime DateTime @map("end_time") @db.Timestamptz() endTime DateTime @map("end_time") @db.Timestamptz()
description String? @db.Text breakMinutes Int @default(0) @map("break_minutes")
createdAt DateTime @default(now()) @map("created_at") description String? @db.Text
updatedAt DateTime @updatedAt @map("updated_at") createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
userId String @map("user_id") @db.VarChar(255) userId String @map("user_id") @db.VarChar(255)
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)

View File

@@ -31,6 +31,7 @@ export const UpdateProjectSchema = z.object({
export const CreateTimeEntrySchema = z.object({ export const CreateTimeEntrySchema = z.object({
startTime: z.string().datetime(), startTime: z.string().datetime(),
endTime: z.string().datetime(), endTime: z.string().datetime(),
breakMinutes: z.number().int().min(0).optional(),
description: z.string().max(1000).optional(), description: z.string().max(1000).optional(),
projectId: z.string().uuid(), projectId: z.string().uuid(),
}); });
@@ -38,6 +39,7 @@ export const CreateTimeEntrySchema = z.object({
export const UpdateTimeEntrySchema = z.object({ export const UpdateTimeEntrySchema = z.object({
startTime: z.string().datetime().optional(), startTime: z.string().datetime().optional(),
endTime: z.string().datetime().optional(), endTime: z.string().datetime().optional(),
breakMinutes: z.number().int().min(0).optional(),
description: z.string().max(1000).optional(), description: z.string().max(1000).optional(),
projectId: z.string().uuid().optional(), projectId: z.string().uuid().optional(),
}); });

View File

@@ -222,7 +222,7 @@ export class ClientTargetService {
const rows = await prisma.$queryRaw<TrackedRow[]>(Prisma.sql` const rows = await prisma.$queryRaw<TrackedRow[]>(Prisma.sql`
SELECT SELECT
DATE_TRUNC('week', te.start_time AT TIME ZONE 'UTC') AS week_start, 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 FROM time_entries te
JOIN projects p ON p.id = te.project_id JOIN projects p ON p.id = te.project_id
WHERE te.user_id = ${target.userId} WHERE te.user_id = ${target.userId}

View File

@@ -42,7 +42,7 @@ export class TimeEntryService {
p.id AS project_id, p.id AS project_id,
p.name AS project_name, p.name AS project_name,
p.color AS project_color, 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 COUNT(te.id)::bigint AS entry_count
FROM time_entries te FROM time_entries te
JOIN projects p ON p.id = te.project_id JOIN projects p ON p.id = te.project_id
@@ -63,7 +63,7 @@ export class TimeEntryService {
SELECT SELECT
c.id AS client_id, c.id AS client_id,
c.name AS client_name, 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 COUNT(te.id)::bigint AS entry_count
FROM time_entries te FROM time_entries te
JOIN projects p ON p.id = te.project_id 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.$queryRaw<{ total_seconds: bigint; entry_count: bigint }[]>(
Prisma.sql` Prisma.sql`
SELECT 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 COUNT(te.id)::bigint AS entry_count
FROM time_entries te FROM time_entries te
JOIN projects p ON p.id = te.project_id JOIN projects p ON p.id = te.project_id
@@ -204,12 +204,19 @@ export class TimeEntryService {
async create(userId: string, data: CreateTimeEntryInput) { async create(userId: string, data: CreateTimeEntryInput) {
const startTime = new Date(data.startTime); const startTime = new Date(data.startTime);
const endTime = new Date(data.endTime); const endTime = new Date(data.endTime);
const breakMinutes = data.breakMinutes ?? 0;
// Validate end time is after start time // Validate end time is after start time
if (endTime <= startTime) { if (endTime <= startTime) {
throw new BadRequestError("End time must be after start time"); 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 // Verify the project belongs to the user
const project = await prisma.project.findFirst({ const project = await prisma.project.findFirst({
where: { id: data.projectId, userId }, where: { id: data.projectId, userId },
@@ -235,6 +242,7 @@ export class TimeEntryService {
data: { data: {
startTime, startTime,
endTime, endTime,
breakMinutes,
description: data.description, description: data.description,
userId, userId,
projectId: data.projectId, projectId: data.projectId,
@@ -267,12 +275,19 @@ export class TimeEntryService {
? new Date(data.startTime) ? new Date(data.startTime)
: entry.startTime; : entry.startTime;
const endTime = data.endTime ? new Date(data.endTime) : entry.endTime; const endTime = data.endTime ? new Date(data.endTime) : entry.endTime;
const breakMinutes = data.breakMinutes ?? entry.breakMinutes;
// Validate end time is after start time // Validate end time is after start time
if (endTime <= startTime) { if (endTime <= startTime) {
throw new BadRequestError("End time must be after start time"); 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 project changed, verify it belongs to the user
if (data.projectId && data.projectId !== entry.projectId) { if (data.projectId && data.projectId !== entry.projectId) {
const project = await prisma.project.findFirst({ const project = await prisma.project.findFirst({
@@ -302,6 +317,7 @@ export class TimeEntryService {
data: { data: {
startTime, startTime,
endTime, endTime,
breakMinutes,
description: data.description, description: data.description,
projectId: data.projectId, projectId: data.projectId,
}, },

View File

@@ -38,6 +38,7 @@ export interface UpdateProjectInput {
export interface CreateTimeEntryInput { export interface CreateTimeEntryInput {
startTime: string; startTime: string;
endTime: string; endTime: string;
breakMinutes?: number;
description?: string; description?: string;
projectId: string; projectId: string;
} }
@@ -45,6 +46,7 @@ export interface CreateTimeEntryInput {
export interface UpdateTimeEntryInput { export interface UpdateTimeEntryInput {
startTime?: string; startTime?: string;
endTime?: string; endTime?: string;
breakMinutes?: number;
description?: string; description?: string;
projectId?: string; projectId?: string;
} }

View File

@@ -20,6 +20,7 @@ export function TimeEntryFormModal({ entry, onClose, createTimeEntry, updateTime
return { return {
startTime: getLocalISOString(new Date(entry.startTime)), startTime: getLocalISOString(new Date(entry.startTime)),
endTime: getLocalISOString(new Date(entry.endTime)), endTime: getLocalISOString(new Date(entry.endTime)),
breakMinutes: entry.breakMinutes,
description: entry.description || '', description: entry.description || '',
projectId: entry.projectId, projectId: entry.projectId,
}; };
@@ -29,6 +30,7 @@ export function TimeEntryFormModal({ entry, onClose, createTimeEntry, updateTime
return { return {
startTime: getLocalISOString(oneHourAgo), startTime: getLocalISOString(oneHourAgo),
endTime: getLocalISOString(now), endTime: getLocalISOString(now),
breakMinutes: 0,
description: '', description: '',
projectId: projects?.[0]?.id || '', projectId: projects?.[0]?.id || '',
}; };
@@ -97,6 +99,16 @@ export function TimeEntryFormModal({ entry, onClose, createTimeEntry, updateTime
/> />
</div> </div>
</div> </div>
<div>
<label className="label">Break (minutes)</label>
<input
type="number"
min="0"
value={formData.breakMinutes ?? 0}
onChange={(e) => setFormData({ ...formData, breakMinutes: parseInt(e.target.value) || 0 })}
className="input"
/>
</div>
<div> <div>
<label className="label">Description</label> <label className="label">Description</label>
<textarea <textarea

View File

@@ -56,7 +56,7 @@ export function DashboardPage() {
const totalTodaySeconds = const totalTodaySeconds =
todayEntries?.entries.reduce((total, entry) => { todayEntries?.entries.reduce((total, entry) => {
return total + calculateDuration(entry.startTime, entry.endTime); return total + calculateDuration(entry.startTime, entry.endTime, entry.breakMinutes);
}, 0) || 0; }, 0) || 0;
const targetsWithData = targets?.filter(t => t.weeks.length > 0) ?? []; const targetsWithData = targets?.filter(t => t.weeks.length > 0) ?? [];
@@ -216,7 +216,10 @@ export function DashboardPage() {
<div className="text-xs text-gray-400">{formatTime(entry.startTime)} {formatTime(entry.endTime)}</div> <div className="text-xs text-gray-400">{formatTime(entry.startTime)} {formatTime(entry.endTime)}</div>
</td> </td>
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-900 font-mono"> <td className="px-4 py-3 whitespace-nowrap text-sm text-gray-900 font-mono">
{formatDurationFromDatesHoursMinutes(entry.startTime, entry.endTime)} {formatDurationFromDatesHoursMinutes(entry.startTime, entry.endTime, entry.breakMinutes)}
{entry.breakMinutes > 0 && (
<span className="text-xs text-gray-400 ml-1">({entry.breakMinutes}m break)</span>
)}
</td> </td>
<td className="px-4 py-3 whitespace-nowrap text-right"> <td className="px-4 py-3 whitespace-nowrap text-right">
<button onClick={() => handleOpenModal(entry)} className="p-1.5 text-gray-400 hover:text-gray-600 mr-1"><Edit2 className="h-4 w-4" /></button> <button onClick={() => handleOpenModal(entry)} className="p-1.5 text-gray-400 hover:text-gray-600 mr-1"><Edit2 className="h-4 w-4" /></button>

View File

@@ -78,7 +78,10 @@ export function TimeEntriesPage() {
</div> </div>
</td> </td>
<td className="px-4 py-3 whitespace-nowrap text-sm font-mono text-gray-900"> <td className="px-4 py-3 whitespace-nowrap text-sm font-mono text-gray-900">
{formatDurationFromDatesHoursMinutes(entry.startTime, entry.endTime)} {formatDurationFromDatesHoursMinutes(entry.startTime, entry.endTime, entry.breakMinutes)}
{entry.breakMinutes > 0 && (
<span className="text-xs text-gray-400 ml-1">({entry.breakMinutes}m)</span>
)}
</td> </td>
<td className="px-4 py-3 whitespace-nowrap text-right"> <td className="px-4 py-3 whitespace-nowrap text-right">
<button onClick={() => handleOpenModal(entry)} className="p-1.5 text-gray-400 hover:text-gray-600 mr-1"><Edit2 className="h-4 w-4" /></button> <button onClick={() => handleOpenModal(entry)} className="p-1.5 text-gray-400 hover:text-gray-600 mr-1"><Edit2 className="h-4 w-4" /></button>

View File

@@ -28,6 +28,7 @@ export interface TimeEntry {
id: string; id: string;
startTime: string; startTime: string;
endTime: string; endTime: string;
breakMinutes: number;
description: string | null; description: string | null;
projectId: string; projectId: string;
project: Pick<Project, 'id' | 'name' | 'color'> & { project: Pick<Project, 'id' | 'name' | 'color'> & {
@@ -129,6 +130,7 @@ export interface UpdateProjectInput {
export interface CreateTimeEntryInput { export interface CreateTimeEntryInput {
startTime: string; startTime: string;
endTime: string; endTime: string;
breakMinutes?: number;
description?: string; description?: string;
projectId: string; projectId: string;
} }
@@ -136,6 +138,7 @@ export interface CreateTimeEntryInput {
export interface UpdateTimeEntryInput { export interface UpdateTimeEntryInput {
startTime?: string; startTime?: string;
endTime?: string; endTime?: string;
breakMinutes?: number;
description?: string; description?: string;
projectId?: string; projectId?: string;
} }

View File

@@ -43,7 +43,14 @@ export function formatDurationHoursMinutes(totalSeconds: number): string {
return `${hours}h ${minutes}m`; return `${hours}h ${minutes}m`;
} }
export function calculateDuration(startTime: string, endTime: string): number { export function calculateDuration(startTime: string, endTime: string, breakMinutes: number = 0): number {
const start = parseISO(startTime);
const end = parseISO(endTime);
const totalSeconds = differenceInSeconds(end, start);
return totalSeconds - (breakMinutes * 60);
}
export function calculateGrossDuration(startTime: string, endTime: string): number {
const start = parseISO(startTime); const start = parseISO(startTime);
const end = parseISO(endTime); const end = parseISO(endTime);
return differenceInSeconds(end, start); return differenceInSeconds(end, start);
@@ -52,16 +59,18 @@ export function calculateDuration(startTime: string, endTime: string): number {
export function formatDurationFromDates( export function formatDurationFromDates(
startTime: string, startTime: string,
endTime: string, endTime: string,
breakMinutes: number = 0,
): string { ): string {
const seconds = calculateDuration(startTime, endTime); const seconds = calculateDuration(startTime, endTime, breakMinutes);
return formatDuration(seconds); return formatDuration(seconds);
} }
export function formatDurationFromDatesHoursMinutes( export function formatDurationFromDatesHoursMinutes(
startTime: string, startTime: string,
endTime: string, endTime: string,
breakMinutes: number = 0,
): string { ): string {
const seconds = calculateDuration(startTime, endTime); const seconds = calculateDuration(startTime, endTime, breakMinutes);
return formatDurationHoursMinutes(seconds); return formatDurationHoursMinutes(seconds);
} }