From 7101f38bc810e135f86c090415de1aabb7f46201 Mon Sep 17 00:00:00 2001 From: Simon Franken Date: Tue, 24 Feb 2026 19:02:32 +0100 Subject: [PATCH] feat: implement client targets v2 (weekly/monthly periods, working days, pro-ration) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add PeriodType enum and working_days column to ClientTarget schema - Rename weekly_hours -> target_hours; remove Monday-only constraint - Add migration 20260224000000_client_targets_v2 - Rewrite computeBalance() to support weekly/monthly periods, per-spec pro-ration for first period, ongoing vs completed period logic, and elapsed working-day counting (§4–§6 of requirements doc) - Update Zod schemas and TypeScript input types for new fields - Frontend: replace WeekBalance with PeriodBalance; update ClientTargetWithBalance to currentPeriod* fields - ClientTargetPanel: period type radio, working-day toggles, free date picker, dynamic hours label - DashboardPage: rename widget to Targets, dynamic This week/This month label --- .../migration.sql | 10 + backend/prisma/schema.prisma | 19 +- backend/src/schemas/index.ts | 10 +- backend/src/services/clientTarget.service.ts | 515 +++++++++++++----- backend/src/types/index.ts | 12 +- frontend/src/pages/ClientsPage.tsx | 167 ++++-- frontend/src/pages/DashboardPage.tsx | 13 +- frontend/src/types/index.ts | 38 +- 8 files changed, 564 insertions(+), 220 deletions(-) create mode 100644 backend/prisma/migrations/20260224000000_client_targets_v2/migration.sql diff --git a/backend/prisma/migrations/20260224000000_client_targets_v2/migration.sql b/backend/prisma/migrations/20260224000000_client_targets_v2/migration.sql new file mode 100644 index 0000000..070e388 --- /dev/null +++ b/backend/prisma/migrations/20260224000000_client_targets_v2/migration.sql @@ -0,0 +1,10 @@ +-- CreateEnum +CREATE TYPE "PeriodType" AS ENUM ('WEEKLY', 'MONTHLY'); + +-- AlterTable: rename weekly_hours -> target_hours, add period_type, add working_days +ALTER TABLE "client_targets" + RENAME COLUMN "weekly_hours" TO "target_hours"; + +ALTER TABLE "client_targets" + ADD COLUMN "period_type" "PeriodType" NOT NULL DEFAULT 'WEEKLY', + ADD COLUMN "working_days" TEXT[] NOT NULL DEFAULT ARRAY['MON','TUE','WED','THU','FRI']::TEXT[]; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 83b49fb..99dbb88 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -99,13 +99,20 @@ model OngoingTimer { @@map("ongoing_timers") } +enum PeriodType { + WEEKLY + MONTHLY +} + model ClientTarget { - id String @id @default(uuid()) - weeklyHours Float @map("weekly_hours") - startDate DateTime @map("start_date") @db.Date // Always a Monday - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @updatedAt @map("updated_at") - deletedAt DateTime? @map("deleted_at") + id String @id @default(uuid()) + targetHours Float @map("target_hours") + periodType PeriodType @default(WEEKLY) @map("period_type") + workingDays String[] @map("working_days") // e.g. ["MON","WED","FRI"] + startDate DateTime @map("start_date") @db.Date + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + deletedAt DateTime? @map("deleted_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 a0b73e8..058293e 100644 --- a/backend/src/schemas/index.ts +++ b/backend/src/schemas/index.ts @@ -73,14 +73,20 @@ export const StopTimerSchema = z.object({ projectId: z.string().uuid().optional(), }); +const WorkingDayEnum = z.enum(['MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT', 'SUN']); + export const CreateClientTargetSchema = z.object({ clientId: z.string().uuid(), - weeklyHours: z.number().positive().max(168), + targetHours: z.number().positive().max(168), + periodType: z.enum(['weekly', 'monthly']), + workingDays: z.array(WorkingDayEnum).min(1, 'At least one working day is required'), startDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'startDate must be a date in YYYY-MM-DD format'), }); export const UpdateClientTargetSchema = z.object({ - weeklyHours: z.number().positive().max(168).optional(), + targetHours: z.number().positive().max(168).optional(), + periodType: z.enum(['weekly', 'monthly']).optional(), + workingDays: z.array(WorkingDayEnum).min(1, 'At least one working day is required').optional(), startDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'startDate must be a date in YYYY-MM-DD format').optional(), }); diff --git a/backend/src/services/clientTarget.service.ts b/backend/src/services/clientTarget.service.ts index e2e0419..71793a4 100644 --- a/backend/src/services/clientTarget.service.ts +++ b/backend/src/services/clientTarget.service.ts @@ -3,44 +3,191 @@ 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, ... +// --------------------------------------------------------------------------- +// Day-of-week helpers +// --------------------------------------------------------------------------- + +const DAY_NAMES = ['SUN', 'MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT'] as const; + +/** Returns the UTC day index (0=Sun … 6=Sat) for a YYYY-MM-DD string. */ +function dayIndex(dateStr: string): number { + return new Date(dateStr + 'T00:00:00Z').getUTCDay(); +} + +/** Checks whether a day-name string (e.g. "MON") is in the working-days array. */ +function isWorkingDay(dateStr: string, workingDays: string[]): boolean { + return workingDays.includes(DAY_NAMES[dayIndex(dateStr)]); +} + +/** Adds `n` calendar days to a YYYY-MM-DD string and returns a new YYYY-MM-DD. */ +function addDays(dateStr: string, n: number): string { + const d = new Date(dateStr + 'T00:00:00Z'); + d.setUTCDate(d.getUTCDate() + n); + return d.toISOString().split('T')[0]; +} + +/** Returns the Monday of the ISO week that contains the given date string. */ +function getMondayOfWeek(dateStr: string): string { + const d = new Date(dateStr + 'T00:00:00Z'); + const day = d.getUTCDay(); // 0=Sun const diff = day === 0 ? -6 : 1 - day; d.setUTCDate(d.getUTCDate() + diff); - d.setUTCHours(0, 0, 0, 0); - return d; + return d.toISOString().split('T')[0]; } -// 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 the Sunday of the ISO week given its Monday date string. */ +function getSundayOfWeek(monday: string): string { + return addDays(monday, 6); } -// 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); +/** Returns the first day of the month for a given date string. */ +function getMonthStart(dateStr: string): string { + return dateStr.slice(0, 7) + '-01'; +} + +/** Returns the last day of the month for a given date string. */ +function getMonthEnd(dateStr: string): string { + const d = new Date(dateStr + 'T00:00:00Z'); + // Set to first day of next month then subtract 1 day + const last = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 1, 0)); + return last.toISOString().split('T')[0]; +} + +/** Total calendar days in the month containing dateStr. */ +function daysInMonth(dateStr: string): number { + const d = new Date(dateStr + 'T00:00:00Z'); + return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 1, 0)).getUTCDate(); +} + +/** Compare two YYYY-MM-DD strings. Returns negative, 0, or positive. */ +function cmpDate(a: string, b: string): number { + return a < b ? -1 : a > b ? 1 : 0; +} + +// --------------------------------------------------------------------------- +// Period enumeration +// --------------------------------------------------------------------------- + +interface Period { + start: string; // YYYY-MM-DD + end: string; // YYYY-MM-DD +} + +/** + * Returns the period (start + end) that contains the given date. + * For weekly: Mon–Sun. + * For monthly: 1st–last day of month. + */ +function getPeriodForDate(dateStr: string, periodType: 'weekly' | 'monthly'): Period { + if (periodType === 'weekly') { + const monday = getMondayOfWeek(dateStr); + return { start: monday, end: getSundayOfWeek(monday) }; + } else { + return { start: getMonthStart(dateStr), end: getMonthEnd(dateStr) }; } - return mondays; } -interface WeekBalance { - weekStart: string; // ISO date string (Monday) - weekEnd: string; // ISO date string (Sunday) +/** + * Returns the start of the NEXT period after `currentPeriodEnd`. + */ +function nextPeriodStart(currentPeriodEnd: string, periodType: 'weekly' | 'monthly'): string { + if (periodType === 'weekly') { + return addDays(currentPeriodEnd, 1); // Monday of next week + } else { + // First day of next month + const d = new Date(currentPeriodEnd + 'T00:00:00Z'); + const next = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 1, 1)); + return next.toISOString().split('T')[0]; + } +} + +/** + * Enumerates all periods from startDate's period through today's period (inclusive). + */ +function enumeratePeriods(startDate: string, periodType: 'weekly' | 'monthly'): Period[] { + const today = new Date().toISOString().split('T')[0]; + const periods: Period[] = []; + + const firstPeriod = getPeriodForDate(startDate, periodType); + let cursor = firstPeriod; + + while (cmpDate(cursor.start, today) <= 0) { + periods.push(cursor); + const ns = nextPeriodStart(cursor.end, periodType); + cursor = getPeriodForDate(ns, periodType); + } + + return periods; +} + +// --------------------------------------------------------------------------- +// Working-day counting +// --------------------------------------------------------------------------- + +/** + * Counts working days in [from, to] (both inclusive) matching the given pattern. + */ +function countWorkingDays(from: string, to: string, workingDays: string[]): number { + if (cmpDate(from, to) > 0) return 0; + let count = 0; + let cur = from; + while (cmpDate(cur, to) <= 0) { + if (isWorkingDay(cur, workingDays)) count++; + cur = addDays(cur, 1); + } + return count; +} + +// --------------------------------------------------------------------------- +// Pro-ration helpers +// --------------------------------------------------------------------------- + +/** + * Returns the pro-rated target hours for the first period, applying §5 of the spec. + * If startDate falls on the natural first day of the period, no pro-ration occurs. + */ +function computePeriodTargetHours( + period: Period, + startDate: string, + targetHours: number, + periodType: 'weekly' | 'monthly', +): number { + const naturalStart = period.start; + if (cmpDate(startDate, naturalStart) <= 0) { + // startDate is at or before the natural period start — no pro-ration needed + return targetHours; + } + + // startDate is inside the period → pro-rate by calendar days + const fullDays = periodType === 'weekly' ? 7 : daysInMonth(period.start); + const remainingDays = daysBetween(startDate, period.end); // inclusive both ends + return (remainingDays / fullDays) * targetHours; +} + +/** Calendar days between two dates (both inclusive). */ +function daysBetween(from: string, to: string): number { + const a = new Date(from + 'T00:00:00Z').getTime(); + const b = new Date(to + 'T00:00:00Z').getTime(); + return Math.round((b - a) / 86400000) + 1; +} + +// --------------------------------------------------------------------------- +// Response types +// --------------------------------------------------------------------------- + +export interface PeriodBalance { + periodStart: string; + periodEnd: string; + targetHours: number; trackedSeconds: number; - targetSeconds: number; correctionHours: number; - balanceSeconds: number; // positive = overtime, negative = undertime + balanceSeconds: number; + isOngoing: boolean; + // only when isOngoing = true + dailyRateHours?: number; + workingDaysInPeriod?: number; + elapsedWorkingDays?: number; + expectedHours?: number; } export interface ClientTargetWithBalance { @@ -48,7 +195,9 @@ export interface ClientTargetWithBalance { clientId: string; clientName: string; userId: string; - weeklyHours: number; + periodType: 'weekly' | 'monthly'; + targetHours: number; + workingDays: string[]; startDate: string; createdAt: string; updatedAt: string; @@ -59,12 +208,34 @@ export interface ClientTargetWithBalance { description: string | null; createdAt: string; }>; - totalBalanceSeconds: number; // running total across all weeks - currentWeekTrackedSeconds: number; - currentWeekTargetSeconds: number; - weeks: WeekBalance[]; + totalBalanceSeconds: number; + currentPeriodTrackedSeconds: number; + currentPeriodTargetSeconds: number; + periods: PeriodBalance[]; } +// --------------------------------------------------------------------------- +// Prisma record shape accepted by computeBalance +// --------------------------------------------------------------------------- + +type TargetRecord = { + id: string; + clientId: string; + userId: string; + targetHours: number; + periodType: 'WEEKLY' | 'MONTHLY'; + workingDays: string[]; + startDate: Date; + createdAt: Date; + updatedAt: Date; + client: { id: string; name: string }; + corrections: Array<{ id: string; date: Date; hours: number; description: string | null; createdAt: Date }>; +}; + +// --------------------------------------------------------------------------- +// Service +// --------------------------------------------------------------------------- + export class ClientTargetService { async findAll(userId: string): Promise { const targets = await prisma.clientTarget.findMany({ @@ -76,7 +247,7 @@ export class ClientTargetService { orderBy: { client: { name: 'asc' } }, }); - return Promise.all(targets.map(t => this.computeBalance(t))); + return Promise.all(targets.map(t => this.computeBalance(t as unknown as TargetRecord))); } async findById(id: string, userId: string) { @@ -90,19 +261,15 @@ export class ClientTargetService { } async create(userId: string, data: CreateClientTargetInput): Promise { - // 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 and is not soft-deleted const client = await prisma.client.findFirst({ where: { id: data.clientId, userId, deletedAt: null } }); if (!client) { throw new NotFoundError('Client not found'); } + const startDate = new Date(data.startDate + 'T00:00:00Z'); + const periodType = data.periodType.toUpperCase() as 'WEEKLY' | 'MONTHLY'; + // Check for existing target (unique per user+client) const existing = await prisma.clientTarget.findFirst({ where: { userId, clientId: data.clientId } }); if (existing) { @@ -110,13 +277,19 @@ export class ClientTargetService { // Reactivate the soft-deleted target with the new settings const reactivated = await prisma.clientTarget.update({ where: { id: existing.id }, - data: { deletedAt: null, weeklyHours: data.weeklyHours, startDate }, + data: { + deletedAt: null, + targetHours: data.targetHours, + periodType, + workingDays: data.workingDays, + startDate, + }, include: { client: { select: { id: true, name: true } }, corrections: { where: { deletedAt: null }, orderBy: { date: 'asc' } }, }, }); - return this.computeBalance(reactivated); + return this.computeBalance(reactivated as unknown as TargetRecord); } throw new BadRequestError('A target already exists for this client. Delete the existing one first or update it.'); } @@ -125,7 +298,9 @@ export class ClientTargetService { data: { userId, clientId: data.clientId, - weeklyHours: data.weeklyHours, + targetHours: data.targetHours, + periodType, + workingDays: data.workingDays, startDate, }, include: { @@ -134,26 +309,24 @@ export class ClientTargetService { }, }); - return this.computeBalance(target); + return this.computeBalance(target as unknown as TargetRecord); } async update(id: string, userId: string, data: UpdateClientTargetInput): Promise { const existing = await this.findById(id, userId); if (!existing) throw new NotFoundError('Client target not found'); - const updateData: { weeklyHours?: number; startDate?: Date } = {}; + const updateData: { + targetHours?: number; + periodType?: 'WEEKLY' | 'MONTHLY'; + workingDays?: string[]; + 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; - } + if (data.targetHours !== undefined) updateData.targetHours = data.targetHours; + if (data.periodType !== undefined) updateData.periodType = data.periodType.toUpperCase() as 'WEEKLY' | 'MONTHLY'; + if (data.workingDays !== undefined) updateData.workingDays = data.workingDays; + if (data.startDate !== undefined) updateData.startDate = new Date(data.startDate + 'T00:00:00Z'); const updated = await prisma.clientTarget.update({ where: { id }, @@ -164,7 +337,7 @@ export class ClientTargetService { }, }); - return this.computeBalance(updated); + return this.computeBalance(updated as unknown as TargetRecord); } async delete(id: string, userId: string): Promise { @@ -213,94 +386,168 @@ export class ClientTargetService { }); } - 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 { - const mondays = getWeekMondays(target.startDate); + // --------------------------------------------------------------------------- + // Balance computation + // --------------------------------------------------------------------------- - if (mondays.length === 0) { - return this.emptyBalance(target); + private async computeBalance(target: TargetRecord): Promise { + const startDateStr = target.startDate.toISOString().split('T')[0]; + const periodType = target.periodType.toLowerCase() as 'weekly' | 'monthly'; + const workingDays = target.workingDays; + + const periods = enumeratePeriods(startDateStr, periodType); + + if (periods.length === 0) { + return this.emptyBalance(target, periodType); } - // 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]); + const overallStart = periods[0].start; + const overallEnd = periods[periods.length - 1].end; + const today = new Date().toISOString().split('T')[0]; - type TrackedRow = { week_start: Date; tracked_seconds: bigint }; + // Fetch all time tracked for this client across the full range in one query + type TrackedRow = { period_start: string; tracked_seconds: bigint }; - 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)) - (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} - AND p.client_id = ${target.clientId} - AND te.start_time >= ${periodStart} - AND te.start_time <= ${periodEnd} - AND te.deleted_at IS NULL - AND p.deleted_at IS NULL - 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(); - 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)); + let trackedRows: TrackedRow[]; + if (periodType === 'weekly') { + trackedRows = await prisma.$queryRaw(Prisma.sql` + SELECT + TO_CHAR( + DATE_TRUNC('week', te.start_time AT TIME ZONE 'UTC'), + 'YYYY-MM-DD' + ) AS period_start, + 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} + AND p.client_id = ${target.clientId} + AND te.start_time >= ${new Date(overallStart + 'T00:00:00Z')} + AND te.start_time <= ${new Date(overallEnd + 'T23:59:59Z')} + AND te.deleted_at IS NULL + AND p.deleted_at IS NULL + GROUP BY DATE_TRUNC('week', te.start_time AT TIME ZONE 'UTC') + `); + } else { + trackedRows = await prisma.$queryRaw(Prisma.sql` + SELECT + TO_CHAR( + DATE_TRUNC('month', te.start_time AT TIME ZONE 'UTC'), + 'YYYY-MM-DD' + ) AS period_start, + 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} + AND p.client_id = ${target.clientId} + AND te.start_time >= ${new Date(overallStart + 'T00:00:00Z')} + AND te.start_time <= ${new Date(overallEnd + 'T23:59:59Z')} + AND te.deleted_at IS NULL + AND p.deleted_at IS NULL + GROUP BY DATE_TRUNC('month', te.start_time AT TIME ZONE 'UTC') + `); } - // Index corrections by week - const correctionsByWeek = new Map(); + // Map tracked seconds by period start date string + const trackedByPeriod = new Map(); + for (const row of trackedRows) { + // Normalise: for weekly, Postgres DATE_TRUNC('week') already gives Monday + const key = typeof row.period_start === 'string' + ? row.period_start + : (row.period_start as Date).toISOString().split('T')[0]; + trackedByPeriod.set(key, Number(row.tracked_seconds)); + } + + // Index corrections by period start date + const correctionsByPeriod = new Map(); 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 corrDateStr = c.date.toISOString().split('T')[0]; + const period = getPeriodForDate(corrDateStr, periodType); + const key = period.start; + correctionsByPeriod.set(key, (correctionsByPeriod.get(key) ?? 0) + c.hours); } - const targetSecondsPerWeek = target.weeklyHours * 3600; - const weeks: WeekBalance[] = []; + const periodBalances: PeriodBalance[] = []; let totalBalanceSeconds = 0; + const isFirstPeriod = (i: number) => i === 0; + + for (let i = 0; i < periods.length; i++) { + const period = periods[i]; + + // Effective start for this period (clamped to startDate for first period) + const effectiveStart = isFirstPeriod(i) && cmpDate(startDateStr, period.start) > 0 + ? startDateStr + : period.start; + + // Period target hours (with possible pro-ration on the first period) + const periodTargetHours = isFirstPeriod(i) + ? computePeriodTargetHours(period, startDateStr, target.targetHours, periodType) + : target.targetHours; + + const trackedSeconds = trackedByPeriod.get(period.start) ?? 0; + const correctionHours = correctionsByPeriod.get(period.start) ?? 0; + + const isOngoing = cmpDate(period.start, today) <= 0 && cmpDate(today, period.end) <= 0; + + let balanceSeconds: number; + let extra: Partial = {}; + + if (isOngoing) { + // §6: ongoing period — expected hours based on elapsed working days + const workingDaysInPeriod = countWorkingDays(effectiveStart, period.end, workingDays); + const dailyRateHours = workingDaysInPeriod > 0 ? periodTargetHours / workingDaysInPeriod : 0; + + const elapsedEnd = today < period.end ? today : period.end; + const elapsedWorkingDays = countWorkingDays(effectiveStart, elapsedEnd, workingDays); + const expectedHours = elapsedWorkingDays * dailyRateHours; + + balanceSeconds = Math.round( + (trackedSeconds + correctionHours * 3600) - expectedHours * 3600, + ); + + extra = { + dailyRateHours, + workingDaysInPeriod, + elapsedWorkingDays, + expectedHours, + }; + } else { + // §4: completed period — simple formula + balanceSeconds = Math.round( + (trackedSeconds + correctionHours * 3600) - periodTargetHours * 3600, + ); + } - 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], + periodBalances.push({ + periodStart: period.start, + periodEnd: period.end, + targetHours: periodTargetHours, trackedSeconds, - targetSeconds: effectiveTargetSeconds, correctionHours, balanceSeconds, + isOngoing, + ...extra, }); } - const currentWeek = weeks[weeks.length - 1]; + const currentPeriod = periodBalances.find(p => p.isOngoing) ?? periodBalances[periodBalances.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], + periodType, + targetHours: target.targetHours, + workingDays, + startDate: startDateStr, createdAt: target.createdAt.toISOString(), updatedAt: target.updatedAt.toISOString(), corrections: target.corrections.map(c => ({ @@ -311,37 +558,31 @@ export class ClientTargetService { createdAt: c.createdAt.toISOString(), })), totalBalanceSeconds, - currentWeekTrackedSeconds: currentWeek?.trackedSeconds ?? 0, - currentWeekTargetSeconds: currentWeek?.targetSeconds ?? targetSecondsPerWeek, - weeks, + currentPeriodTrackedSeconds: currentPeriod?.trackedSeconds ?? 0, + currentPeriodTargetSeconds: currentPeriod + ? Math.round(currentPeriod.targetHours * 3600) + : Math.round(target.targetHours * 3600), + periods: periodBalances, }; } - 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 { + private emptyBalance(target: TargetRecord, periodType: 'weekly' | 'monthly'): ClientTargetWithBalance { return { id: target.id, clientId: target.clientId, clientName: target.client.name, userId: target.userId, - weeklyHours: target.weeklyHours, + periodType, + targetHours: target.targetHours, + workingDays: target.workingDays, 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: [], + currentPeriodTrackedSeconds: 0, + currentPeriodTargetSeconds: Math.round(target.targetHours * 3600), + periods: [], }; } } diff --git a/backend/src/types/index.ts b/backend/src/types/index.ts index 47aa4cb..356274b 100644 --- a/backend/src/types/index.ts +++ b/backend/src/types/index.ts @@ -82,13 +82,17 @@ export interface StopTimerInput { export interface CreateClientTargetInput { clientId: string; - weeklyHours: number; - startDate: string; // YYYY-MM-DD, always a Monday + targetHours: number; + periodType: 'weekly' | 'monthly'; + workingDays: string[]; // e.g. ["MON","WED","FRI"] + startDate: string; // YYYY-MM-DD } export interface UpdateClientTargetInput { - weeklyHours?: number; - startDate?: string; // YYYY-MM-DD, always a Monday + targetHours?: number; + periodType?: 'weekly' | 'monthly'; + workingDays?: string[]; + startDate?: string; // YYYY-MM-DD } export interface CreateCorrectionInput { diff --git a/frontend/src/pages/ClientsPage.tsx b/frontend/src/pages/ClientsPage.tsx index fc511cf..6dae4a1 100644 --- a/frontend/src/pages/ClientsPage.tsx +++ b/frontend/src/pages/ClientsPage.tsx @@ -14,33 +14,10 @@ import type { CreateCorrectionInput, } from '@/types'; -// Convert a value like "2026-W07" to the Monday date "2026-02-16" -function weekInputToMonday(weekValue: string): string { - const [yearStr, weekStr] = weekValue.split('-W'); - const year = parseInt(yearStr, 10); - const week = parseInt(weekStr, 10); - // ISO week 1 is the week containing the first Thursday of January - const jan4 = new Date(Date.UTC(year, 0, 4)); - const jan4Day = jan4.getUTCDay() || 7; // Mon=1..Sun=7 - const monday = new Date(jan4); - monday.setUTCDate(jan4.getUTCDate() - jan4Day + 1 + (week - 1) * 7); - return monday.toISOString().split('T')[0]; -} - -// Convert a YYYY-MM-DD Monday to "YYYY-Www" for the week input -function mondayToWeekInput(dateStr: string): string { - const date = new Date(dateStr + 'T00:00:00Z'); - // ISO week number calculation - const jan4 = new Date(Date.UTC(date.getUTCFullYear(), 0, 4)); - const jan4Day = jan4.getUTCDay() || 7; - const firstMonday = new Date(jan4); - firstMonday.setUTCDate(jan4.getUTCDate() - jan4Day + 1); - const diff = date.getTime() - firstMonday.getTime(); - const week = Math.floor(diff / (7 * 24 * 3600 * 1000)) + 1; - // Handle year boundary: if week > 52 we might be in week 1 of next year - const year = date.getUTCFullYear(); - return `${year}-W${week.toString().padStart(2, '0')}`; -} +const ALL_DAYS = ['MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT', 'SUN'] as const; +const DAY_LABELS: Record = { + MON: 'Mon', TUE: 'Tue', WED: 'Wed', THU: 'Thu', FRI: 'Fri', SAT: 'Sat', SUN: 'Sun', +}; function balanceLabel(seconds: number): { text: string; color: string } { if (seconds === 0) return { text: '±0', color: 'text-gray-500' }; @@ -58,7 +35,12 @@ function ClientTargetPanel({ }: { client: Client; target: ClientTargetWithBalance | undefined; - onCreated: (weeklyHours: number, startDate: string) => Promise; + onCreated: (input: { + targetHours: number; + periodType: 'weekly' | 'monthly'; + workingDays: string[]; + startDate: string; + }) => Promise; onDeleted: () => Promise; }) { const { addCorrection, deleteCorrection, updateTarget } = useClientTargets(); @@ -69,7 +51,9 @@ function ClientTargetPanel({ // Create/edit form state const [formHours, setFormHours] = useState(''); - const [formWeek, setFormWeek] = useState(''); + const [formPeriodType, setFormPeriodType] = useState<'weekly' | 'monthly'>('weekly'); + const [formWorkingDays, setFormWorkingDays] = useState(['MON', 'TUE', 'WED', 'THU', 'FRI']); + const [formStartDate, setFormStartDate] = useState(''); const [formError, setFormError] = useState(null); const [formSaving, setFormSaving] = useState(false); @@ -81,13 +65,13 @@ function ClientTargetPanel({ const [corrError, setCorrError] = useState(null); const [corrSaving, setCorrSaving] = useState(false); + const todayIso = new Date().toISOString().split('T')[0]; + const openCreate = () => { setFormHours(''); - const today = new Date(); - const day = today.getUTCDay() || 7; - const monday = new Date(today); - monday.setUTCDate(today.getUTCDate() - day + 1); - setFormWeek(mondayToWeekInput(monday.toISOString().split('T')[0])); + setFormPeriodType('weekly'); + setFormWorkingDays(['MON', 'TUE', 'WED', 'THU', 'FRI']); + setFormStartDate(todayIso); setFormError(null); setEditing(false); setShowForm(true); @@ -95,32 +79,56 @@ function ClientTargetPanel({ const openEdit = () => { if (!target) return; - setFormHours(String(target.weeklyHours)); - setFormWeek(mondayToWeekInput(target.startDate)); + setFormHours(String(target.targetHours)); + setFormPeriodType(target.periodType); + setFormWorkingDays([...target.workingDays]); + setFormStartDate(target.startDate); setFormError(null); setEditing(true); setShowForm(true); }; + const toggleDay = (day: string) => { + setFormWorkingDays(prev => + prev.includes(day) ? prev.filter(d => d !== day) : [...prev, day], + ); + }; + const handleFormSubmit = async (e: React.FormEvent) => { e.preventDefault(); setFormError(null); const hours = parseFloat(formHours); if (isNaN(hours) || hours <= 0 || hours > 168) { - setFormError('Weekly hours must be between 0 and 168'); + setFormError(`${formPeriodType === 'weekly' ? 'Weekly' : 'Monthly'} hours must be between 0 and 168`); return; } - if (!formWeek) { - setFormError('Please select a start week'); + if (formWorkingDays.length === 0) { + setFormError('Select at least one working day'); + return; + } + if (!formStartDate) { + setFormError('Please select a start date'); return; } - const startDate = weekInputToMonday(formWeek); setFormSaving(true); try { if (editing && target) { - await updateTarget.mutateAsync({ id: target.id, input: { weeklyHours: hours, startDate } }); + await updateTarget.mutateAsync({ + id: target.id, + input: { + targetHours: hours, + periodType: formPeriodType, + workingDays: formWorkingDays, + startDate: formStartDate, + }, + }); } else { - await onCreated(hours, startDate); + await onCreated({ + targetHours: hours, + periodType: formPeriodType, + workingDays: formWorkingDays, + startDate: formStartDate, + }); } setShowForm(false); } catch (err) { @@ -185,23 +193,46 @@ function ClientTargetPanel({ className="flex items-center gap-1.5 text-xs text-primary-600 hover:text-primary-700 font-medium" > - Set weekly target + Set target ); } if (showForm) { + const hoursLabel = formPeriodType === 'weekly' ? 'Hours/week' : 'Hours/month'; return (

- {editing ? 'Edit target' : 'Set weekly target'} + {editing ? 'Edit target' : 'Set target'}

{formError &&

{formError}

} + + {/* Period type */} +
+ +
+ {(['weekly', 'monthly'] as const).map(pt => ( + + ))} +
+
+ + {/* Hours + Start Date */}
- +
- + setFormWeek(e.target.value)} + type="date" + value={formStartDate} + onChange={e => setFormStartDate(e.target.value)} className="input text-sm py-1" required />
+ + {/* Working days */} +
+ +
+ {ALL_DAYS.map(day => { + const active = formWorkingDays.includes(day); + return ( + + ); + })} +
+
+