feat: implement monthly targets and work-day-aware balance calculation

Add support for monthly and weekly targets with work-day selection:
- Users can now set targets as 'monthly' or 'weekly'
- Users select which days of the week they work
- Balance is calculated per working day, evenly distributed
- Target hours = (periodHours / workingDaysInPeriod)
- Corrections are applied at period level
- Daily balance tracking replaces weekly granularity

Database changes:
- Rename weekly_hours -> target_hours
- Add period_type (weekly|monthly, default=weekly)
- Add work_days (integer array of ISO weekdays, default=[1,2,3,4,5])
- Add constraints for valid period_type and non-empty work_days

Backend:
- Rewrite balance calculation in ClientTargetService
- Support monthly period enumeration
- Calculate per-day targets based on selected working days
- Update Zod schemas for new fields
- Update TypeScript types

Frontend:
- Add period type selector in target form (weekly/monthly)
- Add work days multi-checkbox selector
- Conditional start date input (week vs month)
- Update DashboardPage to show 'this period' instead of 'this week'
- Update ClientsPage to display working days and period type
- Support both weekly and monthly targets in UI

Migration:
- Add migration to extend client_targets table with new columns
- Backfill existing targets with default values (weekly, Mon-Fri)
This commit is contained in:
2026-02-24 18:11:45 +01:00
parent 5b7b8e47cb
commit 4f23c1c653
8 changed files with 481 additions and 145 deletions

View File

@@ -0,0 +1,17 @@
-- Extend client_targets with period_type and work_days, rename weekly_hours to target_hours
-- This migration adds support for monthly targets and work-day-aware balance calculation
-- Rename weekly_hours to target_hours
ALTER TABLE "client_targets" RENAME COLUMN "weekly_hours" TO "target_hours";
-- Add period_type column with default 'weekly' for backwards compatibility
ALTER TABLE "client_targets" ADD COLUMN "period_type" VARCHAR(10) NOT NULL DEFAULT 'weekly';
-- Add work_days column with default Mon-Fri (1-5) for backwards compatibility
ALTER TABLE "client_targets" ADD COLUMN "work_days" INTEGER[] NOT NULL DEFAULT '{1,2,3,4,5}';
-- Add check constraint to ensure period_type is valid
ALTER TABLE "client_targets" ADD CONSTRAINT "period_type_check" CHECK ("period_type" IN ('weekly', 'monthly'));
-- Add check constraint to ensure at least one work day is selected
ALTER TABLE "client_targets" ADD CONSTRAINT "work_days_not_empty_check" CHECK (array_length("work_days", 1) > 0);

View File

@@ -100,12 +100,14 @@ model OngoingTimer {
}
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 String @default("weekly") @map("period_type") @db.VarChar(10) // 'weekly' | 'monthly'
workDays Int[] @default([1, 2, 3, 4, 5]) @map("work_days") // ISO weekday numbers (1=Mon, 7=Sun)
startDate DateTime @map("start_date") @db.Date // Monday for weekly, 1st of month for monthly
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)

View File

@@ -75,12 +75,16 @@ export const StopTimerSchema = z.object({
export const CreateClientTargetSchema = z.object({
clientId: z.string().uuid(),
weeklyHours: z.number().positive().max(168),
periodType: z.enum(['weekly', 'monthly']).default('weekly'),
targetHours: z.number().positive().max(744), // 31 days * 24h for monthly max
workDays: z.array(z.number().int().min(1).max(7)).min(1).max(7).default([1, 2, 3, 4, 5]),
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(),
periodType: z.enum(['weekly', 'monthly']).optional(),
targetHours: z.number().positive().max(744).optional(),
workDays: z.array(z.number().int().min(1).max(7)).min(1).max(7).optional(),
startDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'startDate must be a date in YYYY-MM-DD format').optional(),
});

View File

@@ -13,10 +13,18 @@ function getMondayOfWeek(date: Date): Date {
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);
// Returns the first day of the month for a given date
function getFirstOfMonth(date: Date): Date {
const d = new Date(date);
d.setUTCDate(1);
d.setUTCHours(0, 0, 0, 0);
return d;
}
// Returns the last day of the month for a given date
function getLastOfMonth(date: Date): Date {
const d = new Date(date);
d.setUTCMonth(d.getUTCMonth() + 1, 0);
d.setUTCHours(23, 59, 59, 999);
return d;
}
@@ -34,13 +42,56 @@ function getWeekMondays(startDate: Date): Date[] {
return mondays;
}
interface WeekBalance {
weekStart: string; // ISO date string (Monday)
weekEnd: string; // ISO date string (Sunday)
// Returns all month starts from startDate up to and including the current month
function getMonthStarts(startDate: Date): Date[] {
const months: Date[] = [];
const currentMonthStart = getFirstOfMonth(new Date());
let cursor = getFirstOfMonth(new Date(startDate));
while (cursor <= currentMonthStart) {
months.push(new Date(cursor));
cursor.setUTCMonth(cursor.getUTCMonth() + 1);
}
return months;
}
// Get all working days in a period that fall on the given weekdays
function getWorkingDaysInPeriod(
periodStart: Date,
periodEnd: Date,
workDays: number[]
): Date[] {
const workingDays: Date[] = [];
const current = new Date(periodStart);
current.setUTCHours(0, 0, 0, 0);
while (current <= periodEnd) {
const dayOfWeek = current.getUTCDay() === 0 ? 7 : current.getUTCDay(); // Convert to ISO (1=Mon, 7=Sun)
if (workDays.includes(dayOfWeek)) {
workingDays.push(new Date(current));
}
current.setUTCDate(current.getUTCDate() + 1);
}
return workingDays;
}
// Get working days in a period up to and including today
function getWorkingDaysUpToToday(
periodStart: Date,
periodEnd: Date,
workDays: number[]
): Date[] {
const today = new Date();
today.setUTCHours(0, 0, 0, 0);
const endDate = periodEnd < today ? periodEnd : today;
return getWorkingDaysInPeriod(periodStart, endDate, workDays);
}
interface DayBalance {
date: string; // YYYY-MM-DD
trackedSeconds: number;
targetSeconds: number;
correctionHours: number;
balanceSeconds: number; // positive = overtime, negative = undertime
balanceSeconds: number;
}
export interface ClientTargetWithBalance {
@@ -48,7 +99,9 @@ export interface ClientTargetWithBalance {
clientId: string;
clientName: string;
userId: string;
weeklyHours: number;
periodType: 'weekly' | 'monthly';
targetHours: number;
workDays: number[];
startDate: string;
createdAt: string;
updatedAt: string;
@@ -59,10 +112,10 @@ 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;
days: DayBalance[];
}
export class ClientTargetService {
@@ -90,11 +143,30 @@ export class ClientTargetService {
}
async create(userId: string, data: CreateClientTargetInput): Promise<ClientTargetWithBalance> {
// Validate startDate is a Monday
// Validate startDate format
const startDate = new Date(data.startDate + 'T00:00:00Z');
const dayOfWeek = startDate.getUTCDay();
if (dayOfWeek !== 1) {
throw new BadRequestError('startDate must be a Monday');
if (isNaN(startDate.getTime())) {
throw new BadRequestError('Invalid startDate format');
}
// Validate startDate based on periodType
if (data.periodType === 'weekly') {
const dayOfWeek = startDate.getUTCDay();
if (dayOfWeek !== 1) {
throw new BadRequestError('For weekly targets, startDate must be a Monday');
}
} else if (data.periodType === 'monthly') {
if (startDate.getUTCDate() !== 1) {
throw new BadRequestError('For monthly targets, startDate must be the 1st of a month');
}
}
// Validate workDays
if (!data.workDays || data.workDays.length === 0) {
throw new BadRequestError('At least one working day must be selected');
}
if (new Set(data.workDays).size !== data.workDays.length) {
throw new BadRequestError('workDays must not contain duplicates');
}
// Ensure the client belongs to this user and is not soft-deleted
@@ -110,7 +182,13 @@ 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: data.periodType,
workDays: data.workDays,
startDate,
},
include: {
client: { select: { id: true, name: true } },
corrections: { where: { deletedAt: null }, orderBy: { date: 'asc' } },
@@ -125,7 +203,9 @@ export class ClientTargetService {
data: {
userId,
clientId: data.clientId,
weeklyHours: data.weeklyHours,
targetHours: data.targetHours,
periodType: data.periodType,
workDays: data.workDays,
startDate,
},
include: {
@@ -141,16 +221,46 @@ export class ClientTargetService {
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?: string;
workDays?: number[];
startDate?: Date;
} = {};
if (data.weeklyHours !== undefined) {
updateData.weeklyHours = data.weeklyHours;
if (data.targetHours !== undefined) {
updateData.targetHours = data.targetHours;
}
if (data.periodType !== undefined) {
updateData.periodType = data.periodType;
}
if (data.workDays !== undefined) {
if (data.workDays.length === 0) {
throw new BadRequestError('At least one working day must be selected');
}
if (new Set(data.workDays).size !== data.workDays.length) {
throw new BadRequestError('workDays must not contain duplicates');
}
updateData.workDays = data.workDays;
}
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');
if (isNaN(startDate.getTime())) {
throw new BadRequestError('Invalid startDate format');
}
const periodType = data.periodType ?? existing.periodType;
if (periodType === 'weekly') {
if (startDate.getUTCDay() !== 1) {
throw new BadRequestError('For weekly targets, startDate must be a Monday');
}
} else if (periodType === 'monthly') {
if (startDate.getUTCDate() !== 1) {
throw new BadRequestError('For monthly targets, startDate must be the 1st of a month');
}
}
updateData.startDate = startDate;
}
@@ -217,89 +327,154 @@ export class ClientTargetService {
id: string;
clientId: string;
userId: string;
weeklyHours: number;
targetHours: number;
periodType: string;
workDays: 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);
const startDateObj = new Date(target.startDate);
startDateObj.setUTCHours(0, 0, 0, 0);
if (mondays.length === 0) {
// Get all periods (weeks or months) from startDate to now
const periods: Array<{ start: Date; end: Date }> = [];
if (target.periodType === 'weekly') {
const mondays = getWeekMondays(startDateObj);
for (const monday of mondays) {
const sunday = new Date(monday);
sunday.setUTCDate(sunday.getUTCDate() + 6);
sunday.setUTCHours(23, 59, 59, 999);
periods.push({ start: monday, end: sunday });
}
} else {
const months = getMonthStarts(startDateObj);
for (const monthStart of months) {
const monthEnd = getLastOfMonth(monthStart);
periods.push({ start: monthStart, end: monthEnd });
}
}
if (periods.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]);
// Fetch all tracked time for this user on this client's projects
const periodStart = periods[0].start;
const periodEnd = periods[periods.length - 1].end;
type TrackedRow = { week_start: Date; tracked_seconds: bigint };
type TrackedRow = { date: string; 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,
DATE(te.start_time AT TIME ZONE 'UTC') AS date,
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 DATE(te.start_time AT TIME ZONE 'UTC') >= ${periodStart}::date
AND DATE(te.start_time AT TIME ZONE 'UTC') <= ${periodEnd}::date
AND te.deleted_at IS NULL
AND p.deleted_at IS NULL
GROUP BY DATE_TRUNC('week', te.start_time AT TIME ZONE 'UTC')
GROUP BY DATE(te.start_time AT TIME ZONE 'UTC')
ORDER BY date ASC
`);
// Index tracked seconds by week start (ISO Monday string)
const trackedByWeek = new Map<string, number>();
// Index tracked seconds by date (ISO string)
const trackedByDate = 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));
trackedByDate.set(row.date, Number(row.tracked_seconds));
}
// Index corrections by week
const correctionsByWeek = new Map<string, number>();
// Index corrections by period
const correctionsByPeriod = new Map<number, number>(); // period index -> total hours
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 correctionDate = new Date(c.date);
correctionDate.setUTCHours(0, 0, 0, 0);
for (let i = 0; i < periods.length; i++) {
if (correctionDate >= periods[i].start && correctionDate <= periods[i].end) {
correctionsByPeriod.set(i, (correctionsByPeriod.get(i) ?? 0) + c.hours);
break;
}
}
}
const targetSecondsPerWeek = target.weeklyHours * 3600;
const weeks: WeekBalance[] = [];
// Compute daily balances
const days: DayBalance[] = [];
let totalBalanceSeconds = 0;
let currentPeriodTrackedSeconds = 0;
let currentPeriodTargetSeconds = 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;
const today = new Date();
today.setUTCHours(0, 0, 0, 0);
weeks.push({
weekStart: key,
weekEnd: sunday.toISOString().split('T')[0],
trackedSeconds,
targetSeconds: effectiveTargetSeconds,
correctionHours,
balanceSeconds,
});
for (let periodIdx = 0; periodIdx < periods.length; periodIdx++) {
const period = periods[periodIdx];
// Get working days for this period up to today
const workingDaysInPeriod = getWorkingDaysUpToToday(
period.start,
period.end,
target.workDays
);
if (workingDaysInPeriod.length === 0) {
continue; // No working days in this period up to today
}
// Calculate effective target for this period (after corrections)
const correctionHours = correctionsByPeriod.get(periodIdx) ?? 0;
const effectiveTargetSeconds = (target.targetHours * 3600) - (correctionHours * 3600);
const targetSecondsPerDay = effectiveTargetSeconds / workingDaysInPeriod.length;
// Is this the current period?
const isCurrentPeriod = periodIdx === periods.length - 1;
if (isCurrentPeriod) {
// For full month/week target, count all working days in the period (for display)
const allWorkingDaysInPeriod = getWorkingDaysInPeriod(period.start, period.end, target.workDays);
currentPeriodTargetSeconds = (target.targetHours * 3600) - (correctionHours * 3600);
}
// Process each working day
for (const workingDay of workingDaysInPeriod) {
const dateStr = workingDay.toISOString().split('T')[0];
const trackedSeconds = trackedByDate.get(dateStr) ?? 0;
const balanceSeconds = trackedSeconds - targetSecondsPerDay;
days.push({
date: dateStr,
trackedSeconds,
targetSeconds: targetSecondsPerDay,
balanceSeconds,
});
totalBalanceSeconds += balanceSeconds;
if (isCurrentPeriod) {
currentPeriodTrackedSeconds += trackedSeconds;
}
}
}
const currentWeek = weeks[weeks.length - 1];
// If no days were added but we have periods, use defaults
if (days.length === 0) {
const lastPeriod = periods[periods.length - 1];
const correctionHours = correctionsByPeriod.get(periods.length - 1) ?? 0;
currentPeriodTargetSeconds = (target.targetHours * 3600) - (correctionHours * 3600);
}
return {
id: target.id,
clientId: target.clientId,
clientName: target.client.name,
userId: target.userId,
weeklyHours: target.weeklyHours,
periodType: target.periodType as 'weekly' | 'monthly',
targetHours: target.targetHours,
workDays: target.workDays,
startDate: target.startDate.toISOString().split('T')[0],
createdAt: target.createdAt.toISOString(),
updatedAt: target.updatedAt.toISOString(),
@@ -311,9 +486,9 @@ export class ClientTargetService {
createdAt: c.createdAt.toISOString(),
})),
totalBalanceSeconds,
currentWeekTrackedSeconds: currentWeek?.trackedSeconds ?? 0,
currentWeekTargetSeconds: currentWeek?.targetSeconds ?? targetSecondsPerWeek,
weeks,
currentPeriodTrackedSeconds,
currentPeriodTargetSeconds,
days,
};
}
@@ -321,7 +496,9 @@ export class ClientTargetService {
id: string;
clientId: string;
userId: string;
weeklyHours: number;
targetHours: number;
periodType: string;
workDays: number[];
startDate: Date;
createdAt: Date;
updatedAt: Date;
@@ -333,15 +510,17 @@ export class ClientTargetService {
clientId: target.clientId,
clientName: target.client.name,
userId: target.userId,
weeklyHours: target.weeklyHours,
periodType: target.periodType as 'weekly' | 'monthly',
targetHours: target.targetHours,
workDays: target.workDays,
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: target.targetHours * 3600,
days: [],
};
}
}

View File

@@ -82,13 +82,17 @@ export interface StopTimerInput {
export interface CreateClientTargetInput {
clientId: string;
weeklyHours: number;
startDate: string; // YYYY-MM-DD, always a Monday
periodType: 'weekly' | 'monthly';
targetHours: number;
workDays: number[]; // ISO weekday numbers (1=Mon, 7=Sun)
startDate: string; // YYYY-MM-DD, Monday for weekly, 1st for monthly
}
export interface UpdateClientTargetInput {
weeklyHours?: number;
startDate?: string; // YYYY-MM-DD, always a Monday
periodType?: 'weekly' | 'monthly';
targetHours?: number;
workDays?: number[];
startDate?: string; // YYYY-MM-DD
}
export interface CreateCorrectionInput {