adds targets

This commit is contained in:
simon.franken
2026-02-18 14:27:44 +01:00
parent a352318e8a
commit 4cce62934e
12 changed files with 1198 additions and 32 deletions

View File

@@ -0,0 +1,327 @@
import { prisma } from '../prisma/client';
import { NotFoundError, BadRequestError } from '../errors/AppError';
import type { CreateClientTargetInput, UpdateClientTargetInput, CreateCorrectionInput } from '../types';
import { Prisma } from '@prisma/client';
// Returns the Monday of the week containing the given date
function getMondayOfWeek(date: Date): Date {
const d = new Date(date);
const day = d.getUTCDay(); // 0 = Sunday, 1 = Monday, ...
const diff = day === 0 ? -6 : 1 - day;
d.setUTCDate(d.getUTCDate() + diff);
d.setUTCHours(0, 0, 0, 0);
return d;
}
// Returns the Sunday (end of week) for a given Monday
function getSundayOfWeek(monday: Date): Date {
const d = new Date(monday);
d.setUTCDate(d.getUTCDate() + 6);
d.setUTCHours(23, 59, 59, 999);
return d;
}
// Returns all Mondays from startDate up to and including the current week's Monday
function getWeekMondays(startDate: Date): Date[] {
const mondays: Date[] = [];
const currentMonday = getMondayOfWeek(new Date());
let cursor = new Date(startDate);
cursor.setUTCHours(0, 0, 0, 0);
while (cursor <= currentMonday) {
mondays.push(new Date(cursor));
cursor.setUTCDate(cursor.getUTCDate() + 7);
}
return mondays;
}
interface WeekBalance {
weekStart: string; // ISO date string (Monday)
weekEnd: string; // ISO date string (Sunday)
trackedSeconds: number;
targetSeconds: number;
correctionHours: number;
balanceSeconds: number; // positive = overtime, negative = undertime
}
export interface ClientTargetWithBalance {
id: string;
clientId: string;
clientName: string;
userId: string;
weeklyHours: number;
startDate: string;
createdAt: string;
updatedAt: string;
corrections: Array<{
id: string;
date: string;
hours: number;
description: string | null;
createdAt: string;
}>;
totalBalanceSeconds: number; // running total across all weeks
currentWeekTrackedSeconds: number;
currentWeekTargetSeconds: number;
weeks: WeekBalance[];
}
export class ClientTargetService {
async findAll(userId: string): Promise<ClientTargetWithBalance[]> {
const targets = await prisma.clientTarget.findMany({
where: { userId },
include: {
client: { select: { id: true, name: true } },
corrections: { orderBy: { date: 'asc' } },
},
orderBy: { client: { name: 'asc' } },
});
return Promise.all(targets.map(t => this.computeBalance(t)));
}
async findById(id: string, userId: string) {
return prisma.clientTarget.findFirst({
where: { id, userId },
include: {
client: { select: { id: true, name: true } },
corrections: { orderBy: { date: 'asc' } },
},
});
}
async create(userId: string, data: CreateClientTargetInput): Promise<ClientTargetWithBalance> {
// Validate startDate is a Monday
const startDate = new Date(data.startDate + 'T00:00:00Z');
const dayOfWeek = startDate.getUTCDay();
if (dayOfWeek !== 1) {
throw new BadRequestError('startDate must be a Monday');
}
// Ensure the client belongs to this user
const client = await prisma.client.findFirst({ where: { id: data.clientId, userId } });
if (!client) {
throw new NotFoundError('Client not found');
}
// Check for existing target (unique per user+client)
const existing = await prisma.clientTarget.findFirst({ where: { userId, clientId: data.clientId } });
if (existing) {
throw new BadRequestError('A target already exists for this client. Delete the existing one first or update it.');
}
const target = await prisma.clientTarget.create({
data: {
userId,
clientId: data.clientId,
weeklyHours: data.weeklyHours,
startDate,
},
include: {
client: { select: { id: true, name: true } },
corrections: { orderBy: { date: 'asc' } },
},
});
return this.computeBalance(target);
}
async update(id: string, userId: string, data: UpdateClientTargetInput): Promise<ClientTargetWithBalance> {
const existing = await this.findById(id, userId);
if (!existing) throw new NotFoundError('Client target not found');
const updateData: { weeklyHours?: number; startDate?: Date } = {};
if (data.weeklyHours !== undefined) {
updateData.weeklyHours = data.weeklyHours;
}
if (data.startDate !== undefined) {
const startDate = new Date(data.startDate + 'T00:00:00Z');
if (startDate.getUTCDay() !== 1) {
throw new BadRequestError('startDate must be a Monday');
}
updateData.startDate = startDate;
}
const updated = await prisma.clientTarget.update({
where: { id },
data: updateData,
include: {
client: { select: { id: true, name: true } },
corrections: { orderBy: { date: 'asc' } },
},
});
return this.computeBalance(updated);
}
async delete(id: string, userId: string): Promise<void> {
const existing = await this.findById(id, userId);
if (!existing) throw new NotFoundError('Client target not found');
await prisma.clientTarget.delete({ where: { id } });
}
async addCorrection(targetId: string, userId: string, data: CreateCorrectionInput) {
const target = await this.findById(targetId, userId);
if (!target) throw new NotFoundError('Client target not found');
const correctionDate = new Date(data.date + 'T00:00:00Z');
const startDate = new Date(target.startDate);
startDate.setUTCHours(0, 0, 0, 0);
if (correctionDate < startDate) {
throw new BadRequestError('Correction date cannot be before the target start date');
}
return prisma.balanceCorrection.create({
data: {
clientTargetId: targetId,
date: correctionDate,
hours: data.hours,
description: data.description,
},
});
}
async deleteCorrection(targetId: string, correctionId: string, userId: string): Promise<void> {
const target = await this.findById(targetId, userId);
if (!target) throw new NotFoundError('Client target not found');
const correction = await prisma.balanceCorrection.findFirst({
where: { id: correctionId, clientTargetId: targetId },
});
if (!correction) throw new NotFoundError('Correction not found');
await prisma.balanceCorrection.delete({ where: { id: correctionId } });
}
private async computeBalance(target: {
id: string;
clientId: string;
userId: string;
weeklyHours: number;
startDate: Date;
createdAt: Date;
updatedAt: Date;
client: { id: string; name: string };
corrections: Array<{ id: string; date: Date; hours: number; description: string | null; createdAt: Date }>;
}): Promise<ClientTargetWithBalance> {
const mondays = getWeekMondays(target.startDate);
if (mondays.length === 0) {
return this.emptyBalance(target);
}
// Fetch all tracked time for this user on this client's projects in one query
// covering startDate to end of current week
const periodStart = mondays[0];
const periodEnd = getSundayOfWeek(mondays[mondays.length - 1]);
type TrackedRow = { week_start: Date; tracked_seconds: bigint };
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
FROM time_entries te
JOIN projects p ON p.id = te.project_id
WHERE te.user_id = ${target.userId}
AND p.client_id = ${target.clientId}
AND te.start_time >= ${periodStart}
AND te.start_time <= ${periodEnd}
GROUP BY DATE_TRUNC('week', te.start_time AT TIME ZONE 'UTC')
`);
// Index tracked seconds by week start (ISO Monday string)
const trackedByWeek = new Map<string, number>();
for (const row of rows) {
// DATE_TRUNC with 'week' gives Monday in Postgres (ISO week)
const monday = getMondayOfWeek(new Date(row.week_start));
const key = monday.toISOString().split('T')[0];
trackedByWeek.set(key, Number(row.tracked_seconds));
}
// Index corrections by week
const correctionsByWeek = new Map<string, number>();
for (const c of target.corrections) {
const monday = getMondayOfWeek(new Date(c.date));
const key = monday.toISOString().split('T')[0];
correctionsByWeek.set(key, (correctionsByWeek.get(key) ?? 0) + c.hours);
}
const targetSecondsPerWeek = target.weeklyHours * 3600;
const weeks: WeekBalance[] = [];
let totalBalanceSeconds = 0;
for (const monday of mondays) {
const key = monday.toISOString().split('T')[0];
const sunday = getSundayOfWeek(monday);
const trackedSeconds = trackedByWeek.get(key) ?? 0;
const correctionHours = correctionsByWeek.get(key) ?? 0;
const effectiveTargetSeconds = targetSecondsPerWeek - correctionHours * 3600;
const balanceSeconds = trackedSeconds - effectiveTargetSeconds;
totalBalanceSeconds += balanceSeconds;
weeks.push({
weekStart: key,
weekEnd: sunday.toISOString().split('T')[0],
trackedSeconds,
targetSeconds: effectiveTargetSeconds,
correctionHours,
balanceSeconds,
});
}
const currentWeek = weeks[weeks.length - 1];
return {
id: target.id,
clientId: target.clientId,
clientName: target.client.name,
userId: target.userId,
weeklyHours: target.weeklyHours,
startDate: target.startDate.toISOString().split('T')[0],
createdAt: target.createdAt.toISOString(),
updatedAt: target.updatedAt.toISOString(),
corrections: target.corrections.map(c => ({
id: c.id,
date: c.date.toISOString().split('T')[0],
hours: c.hours,
description: c.description,
createdAt: c.createdAt.toISOString(),
})),
totalBalanceSeconds,
currentWeekTrackedSeconds: currentWeek?.trackedSeconds ?? 0,
currentWeekTargetSeconds: currentWeek?.targetSeconds ?? targetSecondsPerWeek,
weeks,
};
}
private emptyBalance(target: {
id: string;
clientId: string;
userId: string;
weeklyHours: number;
startDate: Date;
createdAt: Date;
updatedAt: Date;
client: { id: string; name: string };
corrections: Array<{ id: string; date: Date; hours: number; description: string | null; createdAt: Date }>;
}): ClientTargetWithBalance {
return {
id: target.id,
clientId: target.clientId,
clientName: target.client.name,
userId: target.userId,
weeklyHours: target.weeklyHours,
startDate: target.startDate.toISOString().split('T')[0],
createdAt: target.createdAt.toISOString(),
updatedAt: target.updatedAt.toISOString(),
corrections: [],
totalBalanceSeconds: 0,
currentWeekTrackedSeconds: 0,
currentWeekTargetSeconds: target.weeklyHours * 3600,
weeks: [],
};
}
}