Compare commits
11 Commits
3850e2db06
...
feature/ba
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1049410fee | ||
| c9bd0abf18 | |||
| 7ec76e3e8e | |||
| 784e71e187 | |||
| 7677fdd73d | |||
| 924b83eb4d | |||
| 91d13b19db | |||
| 2a5e6d4a22 | |||
| b7bd875462 | |||
| a58dfcfa4a | |||
| 7101f38bc8 |
@@ -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[];
|
||||
@@ -99,10 +99,17 @@ 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
|
||||
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")
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
return mondays;
|
||||
/** Returns the first day of the month for a given date string. */
|
||||
function getMonthStart(dateStr: string): string {
|
||||
return dateStr.slice(0, 7) + '-01';
|
||||
}
|
||||
|
||||
interface WeekBalance {
|
||||
weekStart: string; // ISO date string (Monday)
|
||||
weekEnd: string; // ISO date string (Sunday)
|
||||
/** 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) };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,36 @@ 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[];
|
||||
/** True when an active timer is running for a project belonging to this client. */
|
||||
hasOngoingTimer: boolean;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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<ClientTargetWithBalance[]> {
|
||||
const targets = await prisma.clientTarget.findMany({
|
||||
@@ -76,7 +249,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 +263,15 @@ export class ClientTargetService {
|
||||
}
|
||||
|
||||
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
|
||||
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 +279,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 +300,9 @@ export class ClientTargetService {
|
||||
data: {
|
||||
userId,
|
||||
clientId: data.clientId,
|
||||
weeklyHours: data.weeklyHours,
|
||||
targetHours: data.targetHours,
|
||||
periodType,
|
||||
workingDays: data.workingDays,
|
||||
startDate,
|
||||
},
|
||||
include: {
|
||||
@@ -134,26 +311,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> {
|
||||
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 +339,7 @@ export class ClientTargetService {
|
||||
},
|
||||
});
|
||||
|
||||
return this.computeBalance(updated);
|
||||
return this.computeBalance(updated as unknown as TargetRecord);
|
||||
}
|
||||
|
||||
async delete(id: string, userId: string): Promise<void> {
|
||||
@@ -213,94 +388,211 @@ 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<ClientTargetWithBalance> {
|
||||
const mondays = getWeekMondays(target.startDate);
|
||||
// ---------------------------------------------------------------------------
|
||||
// Balance computation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
if (mondays.length === 0) {
|
||||
return this.emptyBalance(target);
|
||||
private async computeBalance(target: TargetRecord): Promise<ClientTargetWithBalance> {
|
||||
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 active timer for this user (if any) and check if it belongs to this client
|
||||
const ongoingTimer = await prisma.ongoingTimer.findUnique({
|
||||
where: { userId: target.userId },
|
||||
include: { project: { select: { clientId: true } } },
|
||||
});
|
||||
|
||||
const rows = await prisma.$queryRaw<TrackedRow[]>(Prisma.sql`
|
||||
// Elapsed seconds from the active timer attributed to this client target.
|
||||
// We only count it if the timer has a project assigned and that project
|
||||
// belongs to the same client as this target.
|
||||
let ongoingTimerSeconds = 0;
|
||||
let ongoingTimerPeriodStart: string | null = null;
|
||||
|
||||
if (
|
||||
ongoingTimer &&
|
||||
ongoingTimer.projectId !== null &&
|
||||
ongoingTimer.project?.clientId === target.clientId
|
||||
) {
|
||||
ongoingTimerSeconds = Math.floor(
|
||||
(Date.now() - ongoingTimer.startTime.getTime()) / 1000,
|
||||
);
|
||||
// Determine which period the timer's start time falls into
|
||||
const timerDateStr = ongoingTimer.startTime.toISOString().split('T')[0];
|
||||
const timerPeriod = getPeriodForDate(timerDateStr, periodType);
|
||||
ongoingTimerPeriodStart = timerPeriod.start;
|
||||
}
|
||||
|
||||
// Fetch all time tracked for this client across the full range in one query
|
||||
type TrackedRow = { period_start: string; tracked_seconds: bigint };
|
||||
|
||||
let trackedRows: TrackedRow[];
|
||||
if (periodType === 'weekly') {
|
||||
trackedRows = await prisma.$queryRaw<TrackedRow[]>(Prisma.sql`
|
||||
SELECT
|
||||
DATE_TRUNC('week', te.start_time AT TIME ZONE 'UTC') AS week_start,
|
||||
COALESCE(SUM(EXTRACT(EPOCH FROM (te.end_time - te.start_time)) - (te.break_minutes * 60)), 0)::bigint AS tracked_seconds
|
||||
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 >= ${periodStart}
|
||||
AND te.start_time <= ${periodEnd}
|
||||
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')
|
||||
`);
|
||||
|
||||
// Index tracked seconds by week start (ISO Monday string)
|
||||
const trackedByWeek = new Map<string, number>();
|
||||
for (const row of rows) {
|
||||
// DATE_TRUNC with 'week' gives Monday in Postgres (ISO week)
|
||||
const monday = getMondayOfWeek(new Date(row.week_start));
|
||||
const key = monday.toISOString().split('T')[0];
|
||||
trackedByWeek.set(key, Number(row.tracked_seconds));
|
||||
} else {
|
||||
trackedRows = await prisma.$queryRaw<TrackedRow[]>(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<string, number>();
|
||||
// Map tracked seconds by period start date string
|
||||
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) {
|
||||
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;
|
||||
|
||||
// Add ongoing timer seconds to the period it started in (if it belongs to this client)
|
||||
const timerContribution =
|
||||
ongoingTimerPeriodStart !== null && period.start === ongoingTimerPeriodStart
|
||||
? ongoingTimerSeconds
|
||||
: 0;
|
||||
|
||||
const trackedSeconds = (trackedByPeriod.get(period.start) ?? 0) + timerContribution;
|
||||
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;
|
||||
|
||||
// Only count corrections up to and including today — future corrections
|
||||
// within the ongoing period must not be counted until those days have elapsed,
|
||||
// otherwise a +8h correction for tomorrow inflates the balance immediately.
|
||||
const correctionHoursToDate = target.corrections.reduce((sum, c) => {
|
||||
const d = c.date.toISOString().split('T')[0];
|
||||
if (cmpDate(d, effectiveStart) >= 0 && cmpDate(d, today) <= 0) {
|
||||
return sum + c.hours;
|
||||
}
|
||||
return sum;
|
||||
}, 0);
|
||||
|
||||
balanceSeconds = Math.round(
|
||||
(trackedSeconds + correctionHoursToDate * 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 +603,33 @@ 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,
|
||||
hasOngoingTimer: ongoingTimerSeconds > 0,
|
||||
};
|
||||
}
|
||||
|
||||
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: [],
|
||||
hasOngoingTimer: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -19,6 +19,7 @@ export async function hasOverlappingEntries(
|
||||
const count = await prisma.timeEntry.count({
|
||||
where: {
|
||||
userId,
|
||||
deletedAt: null,
|
||||
...(excludeId ? { id: { not: excludeId } } : {}),
|
||||
// An entry overlaps when it starts before our end AND ends after our start.
|
||||
startTime: { lt: endTime },
|
||||
|
||||
@@ -3,27 +3,37 @@ interface StatCardProps {
|
||||
label: string;
|
||||
value: string;
|
||||
color: 'blue' | 'green' | 'purple' | 'orange';
|
||||
/** When true, renders a pulsing green dot to signal a live/active state. */
|
||||
indicator?: boolean;
|
||||
}
|
||||
|
||||
const colorClasses: Record<StatCardProps['color'], string> = {
|
||||
const colorClasses: Record<NonNullable<StatCardProps['color']>, string> = {
|
||||
blue: 'bg-blue-50 text-blue-600',
|
||||
green: 'bg-green-50 text-green-600',
|
||||
purple: 'bg-purple-50 text-purple-600',
|
||||
orange: 'bg-orange-50 text-orange-600',
|
||||
};
|
||||
|
||||
export function StatCard({ icon: Icon, label, value, color }: StatCardProps) {
|
||||
export function StatCard({ icon: Icon, label, value, color, indicator }: StatCardProps) {
|
||||
return (
|
||||
<div className="card p-4">
|
||||
<div className="flex items-center">
|
||||
<div className={`p-3 rounded-lg ${colorClasses[color]}`}>
|
||||
<Icon className="h-6 w-6" />
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<div className="ml-4 flex-1">
|
||||
<p className="text-sm font-medium text-gray-600">{label}</p>
|
||||
<div className="flex items-center gap-2">
|
||||
{indicator && (
|
||||
<span
|
||||
className="inline-block h-2.5 w-2.5 rounded-full bg-red-500 animate-pulse"
|
||||
title="Timer running"
|
||||
/>
|
||||
)}
|
||||
<p className="text-2xl font-bold text-gray-900">{value}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { clientTargetsApi } from '@/api/clientTargets';
|
||||
import { useTimer } from '@/contexts/TimerContext';
|
||||
import type {
|
||||
CreateClientTargetInput,
|
||||
UpdateClientTargetInput,
|
||||
@@ -8,10 +9,13 @@ import type {
|
||||
|
||||
export function useClientTargets() {
|
||||
const queryClient = useQueryClient();
|
||||
const { ongoingTimer } = useTimer();
|
||||
|
||||
const { data: targets, isLoading, error } = useQuery({
|
||||
queryKey: ['clientTargets'],
|
||||
queryFn: clientTargetsApi.getAll,
|
||||
// Poll every 30 s while a timer is running so the balance stays current
|
||||
refetchInterval: ongoingTimer ? 30_000 : false,
|
||||
});
|
||||
|
||||
const createTarget = useMutation({
|
||||
|
||||
@@ -14,33 +14,10 @@ import type {
|
||||
CreateCorrectionInput,
|
||||
} from '@/types';
|
||||
|
||||
// Convert a <input type="week"> 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<string, string> = {
|
||||
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<void>;
|
||||
onCreated: (input: {
|
||||
targetHours: number;
|
||||
periodType: 'weekly' | 'monthly';
|
||||
workingDays: string[];
|
||||
startDate: string;
|
||||
}) => Promise<void>;
|
||||
onDeleted: () => Promise<void>;
|
||||
}) {
|
||||
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<string[]>(['MON', 'TUE', 'WED', 'THU', 'FRI']);
|
||||
const [formStartDate, setFormStartDate] = useState('');
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
const [formSaving, setFormSaving] = useState(false);
|
||||
|
||||
@@ -81,13 +65,13 @@ function ClientTargetPanel({
|
||||
const [corrError, setCorrError] = useState<string | null>(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"
|
||||
>
|
||||
<Target className="h-3.5 w-3.5" />
|
||||
Set weekly target
|
||||
Set target
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (showForm) {
|
||||
const hoursLabel = formPeriodType === 'weekly' ? 'Hours/week' : 'Hours/month';
|
||||
return (
|
||||
<div className="mt-3 pt-3 border-t border-gray-100">
|
||||
<p className="text-xs font-medium text-gray-700 mb-2">
|
||||
{editing ? 'Edit target' : 'Set weekly target'}
|
||||
{editing ? 'Edit target' : 'Set target'}
|
||||
</p>
|
||||
<form onSubmit={handleFormSubmit} className="space-y-2">
|
||||
{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-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
|
||||
type="number"
|
||||
value={formHours}
|
||||
@@ -215,16 +246,41 @@ function ClientTargetPanel({
|
||||
/>
|
||||
</div>
|
||||
<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
|
||||
type="week"
|
||||
value={formWeek}
|
||||
onChange={e => setFormWeek(e.target.value)}
|
||||
type="date"
|
||||
value={formStartDate}
|
||||
onChange={e => setFormStartDate(e.target.value)}
|
||||
className="input text-sm py-1"
|
||||
required
|
||||
/>
|
||||
</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">
|
||||
<button
|
||||
type="button"
|
||||
@@ -248,6 +304,7 @@ function ClientTargetPanel({
|
||||
|
||||
// Target exists — show summary + expandable details
|
||||
const balance = balanceLabel(target!.totalBalanceSeconds);
|
||||
const periodLabel = target!.periodType === 'weekly' ? 'week' : 'month';
|
||||
|
||||
return (
|
||||
<div className="mt-3 pt-3 border-t border-gray-100">
|
||||
@@ -256,9 +313,15 @@ function ClientTargetPanel({
|
||||
<div className="flex items-center gap-2">
|
||||
<Target className="h-3.5 w-3.5 text-gray-400 shrink-0" />
|
||||
<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 className={`text-xs font-semibold ${balance.color}`}>{balance.text}</span>
|
||||
{target!.hasOngoingTimer && (
|
||||
<span
|
||||
className="inline-block h-2 w-2 rounded-full bg-green-500 animate-pulse"
|
||||
title="Timer running — balance updates every 30 s"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
@@ -531,8 +594,14 @@ export function ClientsPage() {
|
||||
<ClientTargetPanel
|
||||
client={client}
|
||||
target={target}
|
||||
onCreated={async (weeklyHours, startDate) => {
|
||||
await createTarget.mutateAsync({ clientId: client.id, weeklyHours, startDate });
|
||||
onCreated={async ({ targetHours, periodType, workingDays, startDate }) => {
|
||||
await createTarget.mutateAsync({
|
||||
clientId: client.id,
|
||||
targetHours,
|
||||
periodType,
|
||||
workingDays,
|
||||
startDate,
|
||||
});
|
||||
}}
|
||||
onDeleted={async () => {
|
||||
if (target) await deleteTarget.mutateAsync(target.id);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Link } from "react-router-dom";
|
||||
import { Clock, Calendar, Briefcase, TrendingUp, Target, Edit2, Trash2 } from "lucide-react";
|
||||
import { useTimeEntries } from "@/hooks/useTimeEntries";
|
||||
import { useClientTargets } from "@/hooks/useClientTargets";
|
||||
import { useTimer } from "@/contexts/TimerContext";
|
||||
import { ProjectColorDot } from "@/components/ProjectColorDot";
|
||||
import { StatCard } from "@/components/StatCard";
|
||||
import { TimeEntryFormModal } from "@/components/TimeEntryFormModal";
|
||||
@@ -30,6 +31,7 @@ export function DashboardPage() {
|
||||
});
|
||||
|
||||
const { targets } = useClientTargets();
|
||||
const { ongoingTimer, elapsedSeconds } = useTimer();
|
||||
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [editingEntry, setEditingEntry] = useState<TimeEntry | null>(null);
|
||||
@@ -54,12 +56,19 @@ export function DashboardPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const totalTodaySeconds =
|
||||
const completedTodaySeconds =
|
||||
todayEntries?.entries.reduce((total, entry) => {
|
||||
return total + calculateDuration(entry.startTime, entry.endTime, entry.breakMinutes);
|
||||
}, 0) || 0;
|
||||
}, 0) ?? 0;
|
||||
|
||||
const targetsWithData = targets?.filter(t => t.weeks.length > 0) ?? [];
|
||||
// Only add the running timer if it started today (not a timer left running from yesterday)
|
||||
const timerStartedToday =
|
||||
ongoingTimer !== null &&
|
||||
new Date(ongoingTimer.startTime) >= startOfDay(today);
|
||||
|
||||
const totalTodaySeconds = completedTodaySeconds + (timerStartedToday ? elapsedSeconds : 0);
|
||||
|
||||
const targetsWithData = targets?.filter(t => t.periods.length > 0) ?? [];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -78,6 +87,7 @@ export function DashboardPage() {
|
||||
label="Today"
|
||||
value={formatDurationHoursMinutes(totalTodaySeconds)}
|
||||
color="blue"
|
||||
indicator={timerStartedToday}
|
||||
/>
|
||||
<StatCard
|
||||
icon={Calendar}
|
||||
@@ -108,7 +118,7 @@ export function DashboardPage() {
|
||||
<div className="card">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<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 className="space-y-3">
|
||||
{targetsWithData.map(target => {
|
||||
@@ -116,8 +126,9 @@ export function DashboardPage() {
|
||||
const absBalance = Math.abs(balance);
|
||||
const isOver = balance > 0;
|
||||
const isEven = balance === 0;
|
||||
const currentWeekTracked = formatDurationHoursMinutes(target.currentWeekTrackedSeconds);
|
||||
const currentWeekTarget = formatDurationHoursMinutes(target.currentWeekTargetSeconds);
|
||||
const currentPeriodTracked = formatDurationHoursMinutes(target.currentPeriodTrackedSeconds);
|
||||
const currentPeriodTarget = formatDurationHoursMinutes(target.currentPeriodTargetSeconds);
|
||||
const periodLabel = target.periodType === 'weekly' ? 'This week' : 'This month';
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -127,10 +138,17 @@ export function DashboardPage() {
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900">{target.clientName}</p>
|
||||
<p className="text-xs text-gray-500 mt-0.5">
|
||||
This week: {currentWeekTracked} / {currentWeekTarget}
|
||||
{periodLabel}: {currentPeriodTracked} / {currentPeriodTarget}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="flex items-center justify-end gap-1.5">
|
||||
{target.hasOngoingTimer && (
|
||||
<span
|
||||
className="inline-block h-2 w-2 rounded-full bg-red-500 animate-pulse"
|
||||
title="Timer running — balance updates every 30 s"
|
||||
/>
|
||||
)}
|
||||
<p
|
||||
className={`text-sm font-bold ${
|
||||
isEven
|
||||
@@ -144,6 +162,7 @@ export function DashboardPage() {
|
||||
? '±0'
|
||||
: (isOver ? '+' : '−') + formatDurationHoursMinutes(absBalance)}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400">running balance</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -155,13 +155,19 @@ export interface BalanceCorrection {
|
||||
deletedAt: string | null;
|
||||
}
|
||||
|
||||
export interface WeekBalance {
|
||||
weekStart: string; // YYYY-MM-DD (Monday)
|
||||
weekEnd: string; // YYYY-MM-DD (Sunday)
|
||||
export interface PeriodBalance {
|
||||
periodStart: string; // YYYY-MM-DD
|
||||
periodEnd: string; // YYYY-MM-DD
|
||||
targetHours: number; // pro-rated for first period
|
||||
trackedSeconds: number;
|
||||
targetSeconds: number;
|
||||
correctionHours: number;
|
||||
balanceSeconds: number;
|
||||
isOngoing: boolean;
|
||||
// only present when isOngoing = true
|
||||
dailyRateHours?: number;
|
||||
workingDaysInPeriod?: number;
|
||||
elapsedWorkingDays?: number;
|
||||
expectedHours?: number;
|
||||
}
|
||||
|
||||
export interface ClientTargetWithBalance {
|
||||
@@ -169,26 +175,34 @@ export interface ClientTargetWithBalance {
|
||||
clientId: string;
|
||||
clientName: string;
|
||||
userId: string;
|
||||
weeklyHours: number;
|
||||
periodType: "weekly" | "monthly";
|
||||
targetHours: number;
|
||||
workingDays: string[]; // e.g. ["MON","WED"]
|
||||
startDate: string; // YYYY-MM-DD
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
corrections: BalanceCorrection[];
|
||||
totalBalanceSeconds: number;
|
||||
currentWeekTrackedSeconds: number;
|
||||
currentWeekTargetSeconds: number;
|
||||
weeks: WeekBalance[];
|
||||
currentPeriodTrackedSeconds: number;
|
||||
currentPeriodTargetSeconds: number;
|
||||
periods: PeriodBalance[];
|
||||
/** True when an active timer for a project belonging to this client is running. */
|
||||
hasOngoingTimer: boolean;
|
||||
}
|
||||
|
||||
export interface CreateClientTargetInput {
|
||||
clientId: string;
|
||||
weeklyHours: number;
|
||||
targetHours: number;
|
||||
periodType: "weekly" | "monthly";
|
||||
workingDays: string[]; // e.g. ["MON","WED","FRI"]
|
||||
startDate: string; // YYYY-MM-DD
|
||||
}
|
||||
|
||||
export interface UpdateClientTargetInput {
|
||||
weeklyHours?: number;
|
||||
startDate?: string;
|
||||
targetHours?: number;
|
||||
periodType?: "weekly" | "monthly";
|
||||
workingDays?: string[];
|
||||
startDate?: string; // YYYY-MM-DD
|
||||
}
|
||||
|
||||
export interface CreateCorrectionInput {
|
||||
|
||||
Reference in New Issue
Block a user