feat: implement client targets v2 (weekly/monthly periods, working days, pro-ration) #8

Merged
simonfranken merged 2 commits from client-targets-v2 into main 2026-02-24 20:29:24 +00:00
8 changed files with 564 additions and 220 deletions
Showing only changes of commit 7101f38bc8 - Show all commits

View File

@@ -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[];

View File

@@ -99,10 +99,17 @@ model OngoingTimer {
@@map("ongoing_timers") @@map("ongoing_timers")
} }
enum PeriodType {
WEEKLY
MONTHLY
}
model ClientTarget { model ClientTarget {
id String @id @default(uuid()) id String @id @default(uuid())
weeklyHours Float @map("weekly_hours") targetHours Float @map("target_hours")
startDate DateTime @map("start_date") @db.Date // Always a Monday 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") createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at") updatedAt DateTime @updatedAt @map("updated_at")
deletedAt DateTime? @map("deleted_at") deletedAt DateTime? @map("deleted_at")

View File

@@ -73,14 +73,20 @@ export const StopTimerSchema = z.object({
projectId: z.string().uuid().optional(), projectId: z.string().uuid().optional(),
}); });
const WorkingDayEnum = z.enum(['MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT', 'SUN']);
export const CreateClientTargetSchema = z.object({ export const CreateClientTargetSchema = z.object({
clientId: z.string().uuid(), 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'), 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({ 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(), startDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'startDate must be a date in YYYY-MM-DD format').optional(),
}); });

View File

@@ -3,44 +3,191 @@ import { NotFoundError, BadRequestError } from '../errors/AppError';
import type { CreateClientTargetInput, UpdateClientTargetInput, CreateCorrectionInput } from '../types'; import type { CreateClientTargetInput, UpdateClientTargetInput, CreateCorrectionInput } from '../types';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
// Returns the Monday of the week containing the given date // ---------------------------------------------------------------------------
function getMondayOfWeek(date: Date): Date { // Day-of-week helpers
const d = new Date(date); // ---------------------------------------------------------------------------
const day = d.getUTCDay(); // 0 = Sunday, 1 = Monday, ...
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; const diff = day === 0 ? -6 : 1 - day;
d.setUTCDate(d.getUTCDate() + diff); d.setUTCDate(d.getUTCDate() + diff);
d.setUTCHours(0, 0, 0, 0); return d.toISOString().split('T')[0];
return d;
} }
// Returns the Sunday (end of week) for a given Monday /** Returns the Sunday of the ISO week given its Monday date string. */
function getSundayOfWeek(monday: Date): Date { function getSundayOfWeek(monday: string): string {
const d = new Date(monday); return addDays(monday, 6);
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 /** Returns the first day of the month for a given date string. */
function getWeekMondays(startDate: Date): Date[] { function getMonthStart(dateStr: string): string {
const mondays: Date[] = []; return dateStr.slice(0, 7) + '-01';
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 { /** Returns the last day of the month for a given date string. */
weekStart: string; // ISO date string (Monday) function getMonthEnd(dateStr: string): string {
weekEnd: string; // ISO date string (Sunday) 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: MonSun.
* For monthly: 1stlast 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) };
}
}
/**
* 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; trackedSeconds: number;
targetSeconds: number;
correctionHours: 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 { export interface ClientTargetWithBalance {
@@ -48,7 +195,9 @@ export interface ClientTargetWithBalance {
clientId: string; clientId: string;
clientName: string; clientName: string;
userId: string; userId: string;
weeklyHours: number; periodType: 'weekly' | 'monthly';
targetHours: number;
workingDays: string[];
startDate: string; startDate: string;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
@@ -59,12 +208,34 @@ export interface ClientTargetWithBalance {
description: string | null; description: string | null;
createdAt: string; createdAt: string;
}>; }>;
totalBalanceSeconds: number; // running total across all weeks totalBalanceSeconds: number;
currentWeekTrackedSeconds: number; currentPeriodTrackedSeconds: number;
currentWeekTargetSeconds: number; currentPeriodTargetSeconds: number;
weeks: WeekBalance[]; 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 { export class ClientTargetService {
async findAll(userId: string): Promise<ClientTargetWithBalance[]> { async findAll(userId: string): Promise<ClientTargetWithBalance[]> {
const targets = await prisma.clientTarget.findMany({ const targets = await prisma.clientTarget.findMany({
@@ -76,7 +247,7 @@ export class ClientTargetService {
orderBy: { client: { name: 'asc' } }, 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) { async findById(id: string, userId: string) {
@@ -90,19 +261,15 @@ export class ClientTargetService {
} }
async create(userId: string, data: CreateClientTargetInput): Promise<ClientTargetWithBalance> { 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 and is not soft-deleted // 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 } }); const client = await prisma.client.findFirst({ where: { id: data.clientId, userId, deletedAt: null } });
if (!client) { if (!client) {
throw new NotFoundError('Client not found'); 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) // Check for existing target (unique per user+client)
const existing = await prisma.clientTarget.findFirst({ where: { userId, clientId: data.clientId } }); const existing = await prisma.clientTarget.findFirst({ where: { userId, clientId: data.clientId } });
if (existing) { if (existing) {
@@ -110,13 +277,19 @@ export class ClientTargetService {
// Reactivate the soft-deleted target with the new settings // Reactivate the soft-deleted target with the new settings
const reactivated = await prisma.clientTarget.update({ const reactivated = await prisma.clientTarget.update({
where: { id: existing.id }, where: { id: existing.id },
data: { deletedAt: null, weeklyHours: data.weeklyHours, startDate }, data: {
deletedAt: null,
targetHours: data.targetHours,
periodType,
workingDays: data.workingDays,
startDate,
},
include: { include: {
client: { select: { id: true, name: true } }, client: { select: { id: true, name: true } },
corrections: { where: { deletedAt: null }, orderBy: { date: 'asc' } }, 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.'); 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: { data: {
userId, userId,
clientId: data.clientId, clientId: data.clientId,
weeklyHours: data.weeklyHours, targetHours: data.targetHours,
periodType,
workingDays: data.workingDays,
startDate, startDate,
}, },
include: { 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<ClientTargetWithBalance> { async update(id: string, userId: string, data: UpdateClientTargetInput): Promise<ClientTargetWithBalance> {
const existing = await this.findById(id, userId); const existing = await this.findById(id, userId);
if (!existing) throw new NotFoundError('Client target not found'); 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) { if (data.targetHours !== undefined) updateData.targetHours = data.targetHours;
updateData.weeklyHours = data.weeklyHours; 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');
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({ const updated = await prisma.clientTarget.update({
where: { id }, 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<void> { async delete(id: string, userId: string): Promise<void> {
@@ -213,94 +386,168 @@ export class ClientTargetService {
}); });
} }
private async computeBalance(target: { // ---------------------------------------------------------------------------
id: string; // Balance computation
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) { private async computeBalance(target: TargetRecord): Promise<ClientTargetWithBalance> {
return this.emptyBalance(target); 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 const overallStart = periods[0].start;
// covering startDate to end of current week const overallEnd = periods[periods.length - 1].end;
const periodStart = mondays[0]; const today = new Date().toISOString().split('T')[0];
const periodEnd = getSundayOfWeek(mondays[mondays.length - 1]);
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<TrackedRow[]>(Prisma.sql` let trackedRows: TrackedRow[];
if (periodType === 'weekly') {
trackedRows = await prisma.$queryRaw<TrackedRow[]>(Prisma.sql`
SELECT SELECT
DATE_TRUNC('week', te.start_time AT TIME ZONE 'UTC') AS week_start, TO_CHAR(
COALESCE(SUM(EXTRACT(EPOCH FROM (te.end_time - te.start_time)) - (te.break_minutes * 60)), 0)::bigint AS tracked_seconds 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 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}
AND p.client_id = ${target.clientId} AND p.client_id = ${target.clientId}
AND te.start_time >= ${periodStart} AND te.start_time >= ${new Date(overallStart + 'T00:00:00Z')}
AND te.start_time <= ${periodEnd} AND te.start_time <= ${new Date(overallEnd + 'T23:59:59Z')}
AND te.deleted_at IS NULL AND te.deleted_at IS NULL
AND p.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_TRUNC('week', te.start_time AT TIME ZONE 'UTC')
`); `);
} else {
// Index tracked seconds by week start (ISO Monday string) trackedRows = await prisma.$queryRaw<TrackedRow[]>(Prisma.sql`
const trackedByWeek = new Map<string, number>(); SELECT
for (const row of rows) { TO_CHAR(
// DATE_TRUNC with 'week' gives Monday in Postgres (ISO week) DATE_TRUNC('month', te.start_time AT TIME ZONE 'UTC'),
const monday = getMondayOfWeek(new Date(row.week_start)); 'YYYY-MM-DD'
const key = monday.toISOString().split('T')[0]; ) AS period_start,
trackedByWeek.set(key, Number(row.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}
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 // Map tracked seconds by period start date string
const correctionsByWeek = new Map<string, number>(); const trackedByPeriod = new Map<string, number>();
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<string, number>();
for (const c of target.corrections) { for (const c of target.corrections) {
const monday = getMondayOfWeek(new Date(c.date)); const corrDateStr = c.date.toISOString().split('T')[0];
const key = monday.toISOString().split('T')[0]; const period = getPeriodForDate(corrDateStr, periodType);
correctionsByWeek.set(key, (correctionsByWeek.get(key) ?? 0) + c.hours); const key = period.start;
correctionsByPeriod.set(key, (correctionsByPeriod.get(key) ?? 0) + c.hours);
} }
const targetSecondsPerWeek = target.weeklyHours * 3600; const periodBalances: PeriodBalance[] = [];
const weeks: WeekBalance[] = [];
let totalBalanceSeconds = 0; 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<PeriodBalance> = {};
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; totalBalanceSeconds += balanceSeconds;
weeks.push({ periodBalances.push({
weekStart: key, periodStart: period.start,
weekEnd: sunday.toISOString().split('T')[0], periodEnd: period.end,
targetHours: periodTargetHours,
trackedSeconds, trackedSeconds,
targetSeconds: effectiveTargetSeconds,
correctionHours, correctionHours,
balanceSeconds, balanceSeconds,
isOngoing,
...extra,
}); });
} }
const currentWeek = weeks[weeks.length - 1]; const currentPeriod = periodBalances.find(p => p.isOngoing) ?? periodBalances[periodBalances.length - 1];
return { return {
id: target.id, id: target.id,
clientId: target.clientId, clientId: target.clientId,
clientName: target.client.name, clientName: target.client.name,
userId: target.userId, userId: target.userId,
weeklyHours: target.weeklyHours, periodType,
startDate: target.startDate.toISOString().split('T')[0], targetHours: target.targetHours,
workingDays,
startDate: startDateStr,
createdAt: target.createdAt.toISOString(), createdAt: target.createdAt.toISOString(),
updatedAt: target.updatedAt.toISOString(), updatedAt: target.updatedAt.toISOString(),
corrections: target.corrections.map(c => ({ corrections: target.corrections.map(c => ({
@@ -311,37 +558,31 @@ export class ClientTargetService {
createdAt: c.createdAt.toISOString(), createdAt: c.createdAt.toISOString(),
})), })),
totalBalanceSeconds, totalBalanceSeconds,
currentWeekTrackedSeconds: currentWeek?.trackedSeconds ?? 0, currentPeriodTrackedSeconds: currentPeriod?.trackedSeconds ?? 0,
currentWeekTargetSeconds: currentWeek?.targetSeconds ?? targetSecondsPerWeek, currentPeriodTargetSeconds: currentPeriod
weeks, ? Math.round(currentPeriod.targetHours * 3600)
: Math.round(target.targetHours * 3600),
periods: periodBalances,
}; };
} }
private emptyBalance(target: { private emptyBalance(target: TargetRecord, periodType: 'weekly' | 'monthly'): ClientTargetWithBalance {
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 { return {
id: target.id, id: target.id,
clientId: target.clientId, clientId: target.clientId,
clientName: target.client.name, clientName: target.client.name,
userId: target.userId, userId: target.userId,
weeklyHours: target.weeklyHours, periodType,
targetHours: target.targetHours,
workingDays: target.workingDays,
startDate: target.startDate.toISOString().split('T')[0], startDate: target.startDate.toISOString().split('T')[0],
createdAt: target.createdAt.toISOString(), createdAt: target.createdAt.toISOString(),
updatedAt: target.updatedAt.toISOString(), updatedAt: target.updatedAt.toISOString(),
corrections: [], corrections: [],
totalBalanceSeconds: 0, totalBalanceSeconds: 0,
currentWeekTrackedSeconds: 0, currentPeriodTrackedSeconds: 0,
currentWeekTargetSeconds: target.weeklyHours * 3600, currentPeriodTargetSeconds: Math.round(target.targetHours * 3600),
weeks: [], periods: [],
}; };
} }
} }

View File

@@ -82,13 +82,17 @@ export interface StopTimerInput {
export interface CreateClientTargetInput { export interface CreateClientTargetInput {
clientId: string; clientId: string;
weeklyHours: number; targetHours: number;
startDate: string; // YYYY-MM-DD, always a Monday periodType: 'weekly' | 'monthly';
workingDays: string[]; // e.g. ["MON","WED","FRI"]
startDate: string; // YYYY-MM-DD
} }
export interface UpdateClientTargetInput { export interface UpdateClientTargetInput {
weeklyHours?: number; targetHours?: number;
startDate?: string; // YYYY-MM-DD, always a Monday periodType?: 'weekly' | 'monthly';
workingDays?: string[];
startDate?: string; // YYYY-MM-DD
} }
export interface CreateCorrectionInput { export interface CreateCorrectionInput {

View File

@@ -14,33 +14,10 @@ import type {
CreateCorrectionInput, CreateCorrectionInput,
} from '@/types'; } from '@/types';
// Convert a <input type="week"> value like "2026-W07" to the Monday date "2026-02-16" const ALL_DAYS = ['MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT', 'SUN'] as const;
function weekInputToMonday(weekValue: string): string { const DAY_LABELS: Record<string, string> = {
const [yearStr, weekStr] = weekValue.split('-W'); MON: 'Mon', TUE: 'Tue', WED: 'Wed', THU: 'Thu', FRI: 'Fri', SAT: 'Sat', SUN: 'Sun',
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')}`;
}
function balanceLabel(seconds: number): { text: string; color: string } { function balanceLabel(seconds: number): { text: string; color: string } {
if (seconds === 0) return { text: '±0', color: 'text-gray-500' }; if (seconds === 0) return { text: '±0', color: 'text-gray-500' };
@@ -58,7 +35,12 @@ function ClientTargetPanel({
}: { }: {
client: Client; client: Client;
target: ClientTargetWithBalance | undefined; target: ClientTargetWithBalance | undefined;
onCreated: (weeklyHours: number, startDate: string) => Promise<void>; onCreated: (input: {
targetHours: number;
periodType: 'weekly' | 'monthly';
workingDays: string[];
startDate: string;
}) => Promise<void>;
onDeleted: () => Promise<void>; onDeleted: () => Promise<void>;
}) { }) {
const { addCorrection, deleteCorrection, updateTarget } = useClientTargets(); const { addCorrection, deleteCorrection, updateTarget } = useClientTargets();
@@ -69,7 +51,9 @@ function ClientTargetPanel({
// Create/edit form state // Create/edit form state
const [formHours, setFormHours] = useState(''); const [formHours, setFormHours] = useState('');
const [formWeek, setFormWeek] = useState(''); const [formPeriodType, setFormPeriodType] = useState<'weekly' | 'monthly'>('weekly');
const [formWorkingDays, setFormWorkingDays] = useState<string[]>(['MON', 'TUE', 'WED', 'THU', 'FRI']);
const [formStartDate, setFormStartDate] = useState('');
const [formError, setFormError] = useState<string | null>(null); const [formError, setFormError] = useState<string | null>(null);
const [formSaving, setFormSaving] = useState(false); const [formSaving, setFormSaving] = useState(false);
@@ -81,13 +65,13 @@ function ClientTargetPanel({
const [corrError, setCorrError] = useState<string | null>(null); const [corrError, setCorrError] = useState<string | null>(null);
const [corrSaving, setCorrSaving] = useState(false); const [corrSaving, setCorrSaving] = useState(false);
const todayIso = new Date().toISOString().split('T')[0];
const openCreate = () => { const openCreate = () => {
setFormHours(''); setFormHours('');
const today = new Date(); setFormPeriodType('weekly');
const day = today.getUTCDay() || 7; setFormWorkingDays(['MON', 'TUE', 'WED', 'THU', 'FRI']);
const monday = new Date(today); setFormStartDate(todayIso);
monday.setUTCDate(today.getUTCDate() - day + 1);
setFormWeek(mondayToWeekInput(monday.toISOString().split('T')[0]));
setFormError(null); setFormError(null);
setEditing(false); setEditing(false);
setShowForm(true); setShowForm(true);
@@ -95,32 +79,56 @@ function ClientTargetPanel({
const openEdit = () => { const openEdit = () => {
if (!target) return; if (!target) return;
setFormHours(String(target.weeklyHours)); setFormHours(String(target.targetHours));
setFormWeek(mondayToWeekInput(target.startDate)); setFormPeriodType(target.periodType);
setFormWorkingDays([...target.workingDays]);
setFormStartDate(target.startDate);
setFormError(null); setFormError(null);
setEditing(true); setEditing(true);
setShowForm(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) => { const handleFormSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
setFormError(null); setFormError(null);
const hours = parseFloat(formHours); const hours = parseFloat(formHours);
if (isNaN(hours) || hours <= 0 || hours > 168) { 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; return;
} }
if (!formWeek) { if (formWorkingDays.length === 0) {
setFormError('Please select a start week'); setFormError('Select at least one working day');
return;
}
if (!formStartDate) {
setFormError('Please select a start date');
return; return;
} }
const startDate = weekInputToMonday(formWeek);
setFormSaving(true); setFormSaving(true);
try { try {
if (editing && target) { 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 { } else {
await onCreated(hours, startDate); await onCreated({
targetHours: hours,
periodType: formPeriodType,
workingDays: formWorkingDays,
startDate: formStartDate,
});
} }
setShowForm(false); setShowForm(false);
} catch (err) { } 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" className="flex items-center gap-1.5 text-xs text-primary-600 hover:text-primary-700 font-medium"
> >
<Target className="h-3.5 w-3.5" /> <Target className="h-3.5 w-3.5" />
Set weekly target Set target
</button> </button>
</div> </div>
); );
} }
if (showForm) { if (showForm) {
const hoursLabel = formPeriodType === 'weekly' ? 'Hours/week' : 'Hours/month';
return ( return (
<div className="mt-3 pt-3 border-t border-gray-100"> <div className="mt-3 pt-3 border-t border-gray-100">
<p className="text-xs font-medium text-gray-700 mb-2"> <p className="text-xs font-medium text-gray-700 mb-2">
{editing ? 'Edit target' : 'Set weekly target'} {editing ? 'Edit target' : 'Set target'}
</p> </p>
<form onSubmit={handleFormSubmit} className="space-y-2"> <form onSubmit={handleFormSubmit} className="space-y-2">
{formError && <p className="text-xs text-red-600">{formError}</p>} {formError && <p className="text-xs text-red-600">{formError}</p>}
{/* Period type */}
<div>
<label className="block text-xs text-gray-500 mb-0.5">Period</label>
<div className="flex gap-2">
{(['weekly', 'monthly'] as const).map(pt => (
<label key={pt} className="flex items-center gap-1 text-xs cursor-pointer">
<input
type="radio"
name="periodType"
value={pt}
checked={formPeriodType === pt}
onChange={() => setFormPeriodType(pt)}
className="accent-primary-600"
/>
{pt.charAt(0).toUpperCase() + pt.slice(1)}
</label>
))}
</div>
</div>
{/* Hours + Start Date */}
<div className="flex gap-2"> <div className="flex gap-2">
<div className="flex-1"> <div className="flex-1">
<label className="block text-xs text-gray-500 mb-0.5">Hours/week</label> <label className="block text-xs text-gray-500 mb-0.5">{hoursLabel}</label>
<input <input
type="number" type="number"
value={formHours} value={formHours}
@@ -215,16 +246,41 @@ function ClientTargetPanel({
/> />
</div> </div>
<div className="flex-1"> <div className="flex-1">
<label className="block text-xs text-gray-500 mb-0.5">Starting week</label> <label className="block text-xs text-gray-500 mb-0.5">Start date</label>
<input <input
type="week" type="date"
value={formWeek} value={formStartDate}
onChange={e => setFormWeek(e.target.value)} onChange={e => setFormStartDate(e.target.value)}
className="input text-sm py-1" className="input text-sm py-1"
required required
/> />
</div> </div>
</div> </div>
{/* Working days */}
<div>
<label className="block text-xs text-gray-500 mb-0.5">Working days</label>
<div className="flex gap-1 flex-wrap">
{ALL_DAYS.map(day => {
const active = formWorkingDays.includes(day);
return (
<button
key={day}
type="button"
onClick={() => toggleDay(day)}
className={`text-xs px-2 py-0.5 rounded border font-medium transition-colors ${
active
? 'bg-primary-600 border-primary-600 text-white'
: 'bg-white border-gray-300 text-gray-600 hover:border-primary-400'
}`}
>
{DAY_LABELS[day]}
</button>
);
})}
</div>
</div>
<div className="flex gap-2 justify-end"> <div className="flex gap-2 justify-end">
<button <button
type="button" type="button"
@@ -248,6 +304,7 @@ function ClientTargetPanel({
// Target exists — show summary + expandable details // Target exists — show summary + expandable details
const balance = balanceLabel(target!.totalBalanceSeconds); const balance = balanceLabel(target!.totalBalanceSeconds);
const periodLabel = target!.periodType === 'weekly' ? 'week' : 'month';
return ( return (
<div className="mt-3 pt-3 border-t border-gray-100"> <div className="mt-3 pt-3 border-t border-gray-100">
@@ -256,7 +313,7 @@ function ClientTargetPanel({
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Target className="h-3.5 w-3.5 text-gray-400 shrink-0" /> <Target className="h-3.5 w-3.5 text-gray-400 shrink-0" />
<span className="text-xs text-gray-600"> <span className="text-xs text-gray-600">
<span className="font-medium">{target!.weeklyHours}h</span>/week <span className="font-medium">{target!.targetHours}h</span>/{periodLabel}
</span> </span>
<span className={`text-xs font-semibold ${balance.color}`}>{balance.text}</span> <span className={`text-xs font-semibold ${balance.color}`}>{balance.text}</span>
</div> </div>
@@ -531,8 +588,14 @@ export function ClientsPage() {
<ClientTargetPanel <ClientTargetPanel
client={client} client={client}
target={target} target={target}
onCreated={async (weeklyHours, startDate) => { onCreated={async ({ targetHours, periodType, workingDays, startDate }) => {
await createTarget.mutateAsync({ clientId: client.id, weeklyHours, startDate }); await createTarget.mutateAsync({
clientId: client.id,
targetHours,
periodType,
workingDays,
startDate,
});
}} }}
onDeleted={async () => { onDeleted={async () => {
if (target) await deleteTarget.mutateAsync(target.id); if (target) await deleteTarget.mutateAsync(target.id);

View File

@@ -59,7 +59,7 @@ export function DashboardPage() {
return total + calculateDuration(entry.startTime, entry.endTime, entry.breakMinutes); 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.periods.length > 0) ?? [];
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -108,7 +108,7 @@ export function DashboardPage() {
<div className="card"> <div className="card">
<div className="flex items-center gap-2 mb-4"> <div className="flex items-center gap-2 mb-4">
<Target className="h-5 w-5 text-primary-600" /> <Target className="h-5 w-5 text-primary-600" />
<h2 className="text-lg font-semibold text-gray-900">Weekly Targets</h2> <h2 className="text-lg font-semibold text-gray-900">Targets</h2>
</div> </div>
<div className="space-y-3"> <div className="space-y-3">
{targetsWithData.map(target => { {targetsWithData.map(target => {
@@ -116,8 +116,9 @@ export function DashboardPage() {
const absBalance = Math.abs(balance); const absBalance = Math.abs(balance);
const isOver = balance > 0; const isOver = balance > 0;
const isEven = balance === 0; const isEven = balance === 0;
const currentWeekTracked = formatDurationHoursMinutes(target.currentWeekTrackedSeconds); const currentPeriodTracked = formatDurationHoursMinutes(target.currentPeriodTrackedSeconds);
const currentWeekTarget = formatDurationHoursMinutes(target.currentWeekTargetSeconds); const currentPeriodTarget = formatDurationHoursMinutes(target.currentPeriodTargetSeconds);
const periodLabel = target.periodType === 'weekly' ? 'This week' : 'This month';
return ( return (
<div <div
@@ -127,7 +128,7 @@ export function DashboardPage() {
<div> <div>
<p className="text-sm font-medium text-gray-900">{target.clientName}</p> <p className="text-sm font-medium text-gray-900">{target.clientName}</p>
<p className="text-xs text-gray-500 mt-0.5"> <p className="text-xs text-gray-500 mt-0.5">
This week: {currentWeekTracked} / {currentWeekTarget} {periodLabel}: {currentPeriodTracked} / {currentPeriodTarget}
</p> </p>
</div> </div>
<div className="text-right"> <div className="text-right">

View File

@@ -155,13 +155,19 @@ export interface BalanceCorrection {
deletedAt: string | null; deletedAt: string | null;
} }
export interface WeekBalance { export interface PeriodBalance {
weekStart: string; // YYYY-MM-DD (Monday) periodStart: string; // YYYY-MM-DD
weekEnd: string; // YYYY-MM-DD (Sunday) periodEnd: string; // YYYY-MM-DD
targetHours: number; // pro-rated for first period
trackedSeconds: number; trackedSeconds: number;
targetSeconds: number;
correctionHours: number; correctionHours: number;
balanceSeconds: number; balanceSeconds: number;
isOngoing: boolean;
// only present when isOngoing = true
dailyRateHours?: number;
workingDaysInPeriod?: number;
elapsedWorkingDays?: number;
expectedHours?: number;
} }
export interface ClientTargetWithBalance { export interface ClientTargetWithBalance {
@@ -169,26 +175,32 @@ export interface ClientTargetWithBalance {
clientId: string; clientId: string;
clientName: string; clientName: string;
userId: string; userId: string;
weeklyHours: number; periodType: "weekly" | "monthly";
targetHours: number;
workingDays: string[]; // e.g. ["MON","WED"]
startDate: string; // YYYY-MM-DD startDate: string; // YYYY-MM-DD
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
corrections: BalanceCorrection[]; corrections: BalanceCorrection[];
totalBalanceSeconds: number; totalBalanceSeconds: number;
currentWeekTrackedSeconds: number; currentPeriodTrackedSeconds: number;
currentWeekTargetSeconds: number; currentPeriodTargetSeconds: number;
weeks: WeekBalance[]; periods: PeriodBalance[];
} }
export interface CreateClientTargetInput { export interface CreateClientTargetInput {
clientId: string; clientId: string;
weeklyHours: number; targetHours: number;
periodType: "weekly" | "monthly";
workingDays: string[]; // e.g. ["MON","WED","FRI"]
startDate: string; // YYYY-MM-DD startDate: string; // YYYY-MM-DD
} }
export interface UpdateClientTargetInput { export interface UpdateClientTargetInput {
weeklyHours?: number; targetHours?: number;
startDate?: string; periodType?: "weekly" | "monthly";
workingDays?: string[];
startDate?: string; // YYYY-MM-DD
} }
export interface CreateCorrectionInput { export interface CreateCorrectionInput {