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:
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "time_entries" ADD COLUMN "break_minutes" INTEGER NOT NULL DEFAULT 0;
|
||||
@@ -65,6 +65,7 @@ model TimeEntry {
|
||||
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")
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
@@ -222,7 +222,7 @@ export class ClientTargetService {
|
||||
const rows = await prisma.$queryRaw<TrackedRow[]>(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}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
/>
|
||||
</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>
|
||||
<label className="label">Description</label>
|
||||
<textarea
|
||||
|
||||
@@ -56,7 +56,7 @@ export function DashboardPage() {
|
||||
|
||||
const totalTodaySeconds =
|
||||
todayEntries?.entries.reduce((total, entry) => {
|
||||
return total + calculateDuration(entry.startTime, entry.endTime);
|
||||
return total + calculateDuration(entry.startTime, entry.endTime, entry.breakMinutes);
|
||||
}, 0) || 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>
|
||||
</td>
|
||||
<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 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>
|
||||
|
||||
@@ -78,7 +78,10 @@ export function TimeEntriesPage() {
|
||||
</div>
|
||||
</td>
|
||||
<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 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>
|
||||
|
||||
@@ -28,6 +28,7 @@ export interface TimeEntry {
|
||||
id: string;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
breakMinutes: number;
|
||||
description: string | null;
|
||||
projectId: string;
|
||||
project: Pick<Project, 'id' | 'name' | 'color'> & {
|
||||
@@ -129,6 +130,7 @@ export interface UpdateProjectInput {
|
||||
export interface CreateTimeEntryInput {
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
breakMinutes?: number;
|
||||
description?: string;
|
||||
projectId: string;
|
||||
}
|
||||
@@ -136,6 +138,7 @@ export interface CreateTimeEntryInput {
|
||||
export interface UpdateTimeEntryInput {
|
||||
startTime?: string;
|
||||
endTime?: string;
|
||||
breakMinutes?: number;
|
||||
description?: string;
|
||||
projectId?: string;
|
||||
}
|
||||
|
||||
@@ -43,7 +43,14 @@ export function formatDurationHoursMinutes(totalSeconds: number): string {
|
||||
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 end = parseISO(endTime);
|
||||
return differenceInSeconds(end, start);
|
||||
@@ -52,16 +59,18 @@ export function calculateDuration(startTime: string, endTime: string): number {
|
||||
export function formatDurationFromDates(
|
||||
startTime: string,
|
||||
endTime: string,
|
||||
breakMinutes: number = 0,
|
||||
): string {
|
||||
const seconds = calculateDuration(startTime, endTime);
|
||||
const seconds = calculateDuration(startTime, endTime, breakMinutes);
|
||||
return formatDuration(seconds);
|
||||
}
|
||||
|
||||
export function formatDurationFromDatesHoursMinutes(
|
||||
startTime: string,
|
||||
endTime: string,
|
||||
breakMinutes: number = 0,
|
||||
): string {
|
||||
const seconds = calculateDuration(startTime, endTime);
|
||||
const seconds = calculateDuration(startTime, endTime, breakMinutes);
|
||||
return formatDurationHoursMinutes(seconds);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user