13 Commits

Author SHA1 Message Date
simon.franken
1049410fee adaption 2026-03-09 11:20:53 +01:00
c9bd0abf18 feat: include ongoing timer in today's tracked time on Dashboard
The 'Today' stat card now adds the running timer's elapsed seconds to
the total, so the displayed duration ticks up live alongside the timer
widget. The timer is only counted when it started today (timers carried
over from the previous day are excluded).

A pulsing green indicator dot is shown on the stat card value while the
timer is active, consistent with the balance widget treatment. The dot
is implemented via a new optional 'indicator' prop on StatCard so it
can be reused elsewhere without changing existing call sites.
2026-03-09 11:14:21 +01:00
7ec76e3e8e feat: include ongoing timer in balance calculation
The balance now accounts for any active timer whose project belongs to
the tracked client. computeBalance() fetches the user's OngoingTimer,
computes its elapsed seconds, and adds them to the matching period's
tracked seconds before running the balance formula — so both
currentPeriodTrackedSeconds and totalBalanceSeconds reflect the live
timer without requiring a schema change.

On the frontend, useClientTargets polls every 30 s while a timer is
running, and a pulsing green dot is shown next to the balance figure on
the Dashboard and Clients pages to signal the live contribution.
2026-03-09 10:59:39 +01:00
784e71e187 fix: exclude soft-deleted entries from overlap conflict check 2026-03-05 12:18:48 +01:00
7677fdd73d revert 2026-02-24 21:57:21 +01:00
924b83eb4d fix: replace type=time with separate hours/minutes number inputs in correction form
type="time" renders a clock-time picker (with AM/PM), not a duration input.
Switch to two type="number" fields (h / m) so the intent is unambiguous.
2026-02-24 21:53:21 +01:00
91d13b19db fix: replace separate h/m number inputs with single HH:MM time input in correction form
- Remove stale corrHoursInt/corrMins state (leftover from previous refactor)
- Use corrDuration (HH:MM string) parsed once and reuse totalHours in submit handler
- Single type="time" input + +/− toggle button matches TimerWidget style
- flex-1 on Date and Duration columns for equal width and consistent height alignment
2026-02-24 21:49:54 +01:00
2a5e6d4a22 fix: display correction amounts as h/m and replace decimal input with h:m fields
- Correction list now shows '13h 32m' instead of '13.65h', using
  formatDurationHoursMinutes (same formatter used everywhere else)
- Sign shown as '−' (minus) for negative corrections instead of bare '-'
- Correction input replaced with separate hours + minutes integer fields
  and a +/− toggle button, removing the awkward decimal entry
2026-02-24 21:44:54 +01:00
b7bd875462 Merge pull request 'feat: implement client targets v2 (weekly/monthly periods, working days, pro-ration)' (#8) from client-targets-v2 into main
Reviewed-on: #8
2026-02-24 20:29:22 +00:00
a58dfcfa4a fix: clamp ongoing-period corrections to today to prevent future corrections inflating balance
A correction dated in the future (within the current period) was being
added to the balance immediately, while the corresponding expected hours
were not yet counted (elapsed working days only go up to today).

Fix: in the ongoing-period branch, sum only corrections whose date is
<= today, matching the same window used for elapsed working days and
tracked time.
2026-02-24 21:27:03 +01:00
7101f38bc8 feat: implement client targets v2 (weekly/monthly periods, working days, pro-ration)
- Add PeriodType enum and working_days column to ClientTarget schema
- Rename weekly_hours -> target_hours; remove Monday-only constraint
- Add migration 20260224000000_client_targets_v2
- Rewrite computeBalance() to support weekly/monthly periods, per-spec
  pro-ration for first period, ongoing vs completed period logic, and
  elapsed working-day counting (§4–§6 of requirements doc)
- Update Zod schemas and TypeScript input types for new fields
- Frontend: replace WeekBalance with PeriodBalance; update
  ClientTargetWithBalance to currentPeriod* fields
- ClientTargetPanel: period type radio, working-day toggles, free date
  picker, dynamic hours label
- DashboardPage: rename widget to Targets, dynamic This week/This month
  label
2026-02-24 19:02:32 +01:00
3850e2db06 docs: add client targets v2 feature requirements 2026-02-24 18:50:34 +01:00
5b7b8e47cb ui adaptions 2026-02-23 20:59:01 +01:00
13 changed files with 967 additions and 243 deletions

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,13 +99,20 @@ model OngoingTimer {
@@map("ongoing_timers")
}
enum PeriodType {
WEEKLY
MONTHLY
}
model ClientTarget {
id String @id @default(uuid())
weeklyHours Float @map("weekly_hours")
startDate DateTime @map("start_date") @db.Date // Always a Monday
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
deletedAt DateTime? @map("deleted_at")
id String @id @default(uuid())
targetHours Float @map("target_hours")
periodType PeriodType @default(WEEKLY) @map("period_type")
workingDays String[] @map("working_days") // e.g. ["MON","WED","FRI"]
startDate DateTime @map("start_date") @db.Date
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
deletedAt DateTime? @map("deleted_at")
userId String @map("user_id") @db.VarChar(255)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)

View File

@@ -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(),
});

View File

@@ -3,44 +3,191 @@ import { NotFoundError, BadRequestError } from '../errors/AppError';
import type { CreateClientTargetInput, UpdateClientTargetInput, CreateCorrectionInput } from '../types';
import { Prisma } from '@prisma/client';
// Returns the Monday of the week containing the given date
function getMondayOfWeek(date: Date): Date {
const d = new Date(date);
const day = d.getUTCDay(); // 0 = Sunday, 1 = Monday, ...
// ---------------------------------------------------------------------------
// Day-of-week helpers
// ---------------------------------------------------------------------------
const DAY_NAMES = ['SUN', 'MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT'] as const;
/** Returns the UTC day index (0=Sun … 6=Sat) for a YYYY-MM-DD string. */
function dayIndex(dateStr: string): number {
return new Date(dateStr + 'T00:00:00Z').getUTCDay();
}
/** Checks whether a day-name string (e.g. "MON") is in the working-days array. */
function isWorkingDay(dateStr: string, workingDays: string[]): boolean {
return workingDays.includes(DAY_NAMES[dayIndex(dateStr)]);
}
/** Adds `n` calendar days to a YYYY-MM-DD string and returns a new YYYY-MM-DD. */
function addDays(dateStr: string, n: number): string {
const d = new Date(dateStr + 'T00:00:00Z');
d.setUTCDate(d.getUTCDate() + n);
return d.toISOString().split('T')[0];
}
/** Returns the Monday of the ISO week that contains the given date string. */
function getMondayOfWeek(dateStr: string): string {
const d = new Date(dateStr + 'T00:00:00Z');
const day = d.getUTCDay(); // 0=Sun
const diff = day === 0 ? -6 : 1 - day;
d.setUTCDate(d.getUTCDate() + diff);
d.setUTCHours(0, 0, 0, 0);
return d;
return d.toISOString().split('T')[0];
}
// Returns the Sunday (end of week) for a given Monday
function getSundayOfWeek(monday: Date): Date {
const d = new Date(monday);
d.setUTCDate(d.getUTCDate() + 6);
d.setUTCHours(23, 59, 59, 999);
return d;
/** Returns the Sunday of the ISO week given its Monday date string. */
function getSundayOfWeek(monday: string): string {
return addDays(monday, 6);
}
// Returns all Mondays from startDate up to and including the current week's Monday
function getWeekMondays(startDate: Date): Date[] {
const mondays: Date[] = [];
const currentMonday = getMondayOfWeek(new Date());
let cursor = new Date(startDate);
cursor.setUTCHours(0, 0, 0, 0);
while (cursor <= currentMonday) {
mondays.push(new Date(cursor));
cursor.setUTCDate(cursor.getUTCDate() + 7);
/** Returns the first day of the month for a given date string. */
function getMonthStart(dateStr: string): string {
return dateStr.slice(0, 7) + '-01';
}
/** Returns the last day of the month for a given date string. */
function getMonthEnd(dateStr: string): string {
const d = new Date(dateStr + 'T00:00:00Z');
// Set to first day of next month then subtract 1 day
const last = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 1, 0));
return last.toISOString().split('T')[0];
}
/** Total calendar days in the month containing dateStr. */
function daysInMonth(dateStr: string): number {
const d = new Date(dateStr + 'T00:00:00Z');
return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 1, 0)).getUTCDate();
}
/** Compare two YYYY-MM-DD strings. Returns negative, 0, or positive. */
function cmpDate(a: string, b: string): number {
return a < b ? -1 : a > b ? 1 : 0;
}
// ---------------------------------------------------------------------------
// Period enumeration
// ---------------------------------------------------------------------------
interface Period {
start: string; // YYYY-MM-DD
end: string; // YYYY-MM-DD
}
/**
* Returns the period (start + end) that contains the given date.
* For weekly: 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) };
}
return mondays;
}
interface WeekBalance {
weekStart: string; // ISO date string (Monday)
weekEnd: string; // ISO date string (Sunday)
/**
* Returns the start of the NEXT period after `currentPeriodEnd`.
*/
function nextPeriodStart(currentPeriodEnd: string, periodType: 'weekly' | 'monthly'): string {
if (periodType === 'weekly') {
return addDays(currentPeriodEnd, 1); // Monday of next week
} else {
// First day of next month
const d = new Date(currentPeriodEnd + 'T00:00:00Z');
const next = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 1, 1));
return next.toISOString().split('T')[0];
}
}
/**
* Enumerates all periods from startDate's period through today's period (inclusive).
*/
function enumeratePeriods(startDate: string, periodType: 'weekly' | 'monthly'): Period[] {
const today = new Date().toISOString().split('T')[0];
const periods: Period[] = [];
const firstPeriod = getPeriodForDate(startDate, periodType);
let cursor = firstPeriod;
while (cmpDate(cursor.start, today) <= 0) {
periods.push(cursor);
const ns = nextPeriodStart(cursor.end, periodType);
cursor = getPeriodForDate(ns, periodType);
}
return periods;
}
// ---------------------------------------------------------------------------
// Working-day counting
// ---------------------------------------------------------------------------
/**
* Counts working days in [from, to] (both inclusive) matching the given pattern.
*/
function countWorkingDays(from: string, to: string, workingDays: string[]): number {
if (cmpDate(from, to) > 0) return 0;
let count = 0;
let cur = from;
while (cmpDate(cur, to) <= 0) {
if (isWorkingDay(cur, workingDays)) count++;
cur = addDays(cur, 1);
}
return count;
}
// ---------------------------------------------------------------------------
// Pro-ration helpers
// ---------------------------------------------------------------------------
/**
* Returns the pro-rated target hours for the first period, applying §5 of the spec.
* If startDate falls on the natural first day of the period, no pro-ration occurs.
*/
function computePeriodTargetHours(
period: Period,
startDate: string,
targetHours: number,
periodType: 'weekly' | 'monthly',
): number {
const naturalStart = period.start;
if (cmpDate(startDate, naturalStart) <= 0) {
// startDate is at or before the natural period start — no pro-ration needed
return targetHours;
}
// startDate is inside the period → pro-rate by calendar days
const fullDays = periodType === 'weekly' ? 7 : daysInMonth(period.start);
const remainingDays = daysBetween(startDate, period.end); // inclusive both ends
return (remainingDays / fullDays) * targetHours;
}
/** Calendar days between two dates (both inclusive). */
function daysBetween(from: string, to: string): number {
const a = new Date(from + 'T00:00:00Z').getTime();
const b = new Date(to + 'T00:00:00Z').getTime();
return Math.round((b - a) / 86400000) + 1;
}
// ---------------------------------------------------------------------------
// Response types
// ---------------------------------------------------------------------------
export interface PeriodBalance {
periodStart: string;
periodEnd: string;
targetHours: number;
trackedSeconds: number;
targetSeconds: number;
correctionHours: number;
balanceSeconds: number; // positive = overtime, negative = undertime
balanceSeconds: number;
isOngoing: boolean;
// only when isOngoing = true
dailyRateHours?: number;
workingDaysInPeriod?: number;
elapsedWorkingDays?: number;
expectedHours?: number;
}
export interface ClientTargetWithBalance {
@@ -48,7 +195,9 @@ export interface ClientTargetWithBalance {
clientId: string;
clientName: string;
userId: string;
weeklyHours: number;
periodType: 'weekly' | 'monthly';
targetHours: number;
workingDays: string[];
startDate: string;
createdAt: string;
updatedAt: string;
@@ -59,12 +208,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`
SELECT
DATE_TRUNC('week', te.start_time AT TIME ZONE 'UTC') AS week_start,
COALESCE(SUM(EXTRACT(EPOCH FROM (te.end_time - te.start_time)) - (te.break_minutes * 60)), 0)::bigint AS tracked_seconds
FROM time_entries te
JOIN projects p ON p.id = te.project_id
WHERE te.user_id = ${target.userId}
AND p.client_id = ${target.clientId}
AND te.start_time >= ${periodStart}
AND te.start_time <= ${periodEnd}
AND te.deleted_at IS NULL
AND p.deleted_at IS NULL
GROUP BY DATE_TRUNC('week', te.start_time AT TIME ZONE 'UTC')
`);
// 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;
// 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));
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;
}
// Index corrections by week
const correctionsByWeek = new Map<string, number>();
// 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
TO_CHAR(
DATE_TRUNC('week', te.start_time AT TIME ZONE 'UTC'),
'YYYY-MM-DD'
) AS period_start,
COALESCE(
SUM(EXTRACT(EPOCH FROM (te.end_time - te.start_time)) - (te.break_minutes * 60)),
0
)::bigint AS tracked_seconds
FROM time_entries te
JOIN projects p ON p.id = te.project_id
WHERE te.user_id = ${target.userId}
AND p.client_id = ${target.clientId}
AND te.start_time >= ${new Date(overallStart + 'T00:00:00Z')}
AND te.start_time <= ${new Date(overallEnd + 'T23:59:59Z')}
AND te.deleted_at IS NULL
AND p.deleted_at IS NULL
GROUP BY DATE_TRUNC('week', te.start_time AT TIME ZONE 'UTC')
`);
} else {
trackedRows = await prisma.$queryRaw<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')
`);
}
// 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,
};
}
}

View File

@@ -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 {

View File

@@ -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 },

View File

@@ -0,0 +1,285 @@
# Client Targets v2 — Feature Requirements
## Overview
This document defines the requirements for the second iteration of the Client Targets feature. The main additions are:
- Targets can be set on a **weekly or monthly** period.
- Each target defines a **fixed weekly working-day pattern** (e.g. Mon + Wed).
- The balance for the **current period** is calculated proportionally based on elapsed working days, so the user can see at any point in time whether they are ahead or behind.
- The **start date** can be any calendar day (no longer restricted to Mondays).
- Manual **balance corrections** are preserved and continue to work as before.
---
## 1. Target Configuration
| Field | Type | Constraints |
|---|---|---|
| `periodType` | `WEEKLY \| MONTHLY` | Required |
| `weeklyOrMonthlyHours` | positive float, ≤ 168 | Required; represents hours per week or per month |
| `workingDays` | array of day names | At least one of `MON TUE WED THU FRI SAT SUN`; fixed repeating pattern |
| `startDate` | `YYYY-MM-DD` | Any calendar day; no longer restricted to Mondays |
| `clientId` | UUID | Must belong to the authenticated user |
**One active target per client** — the unique `(userId, clientId)` constraint is preserved. To change period type, hours, or working days the user creates a new target with a new `startDate`; the old target is soft-deleted. History from the old target is retained as-is and is no longer recalculated.
---
## 2. Period Definitions
| `periodType` | Period start | Period end |
|---|---|---|
| `WEEKLY` | Monday 00:00 of the calendar week | Sunday 23:59 of that same calendar week |
| `MONTHLY` | 1st of the calendar month 00:00 | Last day of the calendar month 23:59 |
---
## 3. Balance Calculation — Overview
The total balance is the **sum of individual period balances** from the period containing `startDate` up to and including the **current period** (the period that contains today).
Each period is classified as either **completed** or **ongoing**.
```
total_balance_seconds = SUM( balance_seconds ) over all periods
```
Positive = overtime. Negative = undertime.
---
## 4. Completed Period Balance
A period is **completed** when its end date is strictly before today.
```
balance = tracked_hours + correction_hours - period_target_hours
```
- `period_target_hours` — see §5 (pro-ration) for the first period; full `weeklyOrMonthlyHours` for all subsequent periods.
- `tracked_hours` — sum of all time entries for this client whose date falls within `[period_start, period_end]`.
- `correction_hours` — sum of manual corrections whose `date` falls within `[period_start, period_end]`.
No working-day logic is applied to completed periods. The target is simply the (optionally pro-rated) hours for that period.
---
## 5. First Period Pro-ration
If `startDate` does not fall on the natural first day of a period (Monday for weekly, 1st for monthly), the target hours for that first period are pro-rated by calendar days.
### Monthly
```
full_period_days = total calendar days in that month
remaining_days = (last day of month) startDate + 1 // inclusive
period_target_hours = (remaining_days / full_period_days) × weeklyOrMonthlyHours
```
**Example:** startDate = Jan 25, target = 40 h/month, January has 31 days.
`remaining_days = 7`, `period_target_hours = (7 / 31) × 40 = 9.032 h`
### Weekly
```
full_period_days = 7
remaining_days = Sunday of that calendar week startDate + 1 // inclusive
period_target_hours = (remaining_days / 7) × weeklyOrMonthlyHours
```
**Example:** startDate = Wednesday, target = 40 h/week.
`remaining_days = 5 (WedSun)`, `period_target_hours = (5 / 7) × 40 = 28.571 h`
All periods after the first use the full `weeklyOrMonthlyHours`.
---
## 6. Ongoing Period Balance (Current Period)
The current period is **ongoing** when today falls within it. The balance reflects how the user is doing *so far* — future working days within the current period are not considered.
### Step 1 — Period target hours
Apply §5 if this is the first period; otherwise use full `weeklyOrMonthlyHours`.
### Step 2 — Daily rate
```
working_days_in_period = COUNT of days in [period_start, period_end]
that match the working day pattern
daily_rate_hours = period_target_hours / working_days_in_period
```
The rate is fixed at the start of the period and does not change as time passes.
### Step 3 — Elapsed working days
```
elapsed_working_days = COUNT of days in [period_start, TODAY] (both inclusive)
that match the working day pattern
```
- If today matches the working day pattern, it is counted as a **full** elapsed working day.
- If today does not match the working day pattern, it is not counted.
### Step 4 — Expected hours so far
```
expected_hours = elapsed_working_days × daily_rate_hours
```
### Step 5 — Balance
```
tracked_hours = SUM of time entries for this client in [period_start, today]
correction_hours = SUM of manual corrections whose date ∈ [period_start, today]
balance = tracked_hours + correction_hours expected_hours
```
### Worked example
> Target: 40 h/month. Working days: Mon + Wed.
> Current month has 4 Mondays and 4 Wednesdays → `working_days_in_period = 8`.
> `daily_rate_hours = 40 / 8 = 5 h`.
> 3 working days have elapsed → `expected_hours = 15 h`.
> Tracked so far: 13 h, no corrections.
> `balance = 13 15 = 2 h` (2 hours behind).
---
## 7. Manual Balance Corrections
| Field | Type | Constraints |
|---|---|---|
| `date` | `YYYY-MM-DD` | Must be ≥ `startDate`; not more than one period in the future |
| `hours` | signed float | Positive = extra credit (reduces deficit). Negative = reduces tracked credit |
| `description` | string | Optional, max 255 chars |
- The system automatically assigns a correction to the period that contains its `date`.
- Corrections in **completed periods** are included in the completed period formula (§4).
- Corrections in the **ongoing period** are included in the ongoing balance formula (§6).
- Corrections in a **future period** (not yet started) are stored and will be applied when that period becomes active.
- A correction whose `date` is before `startDate` is rejected with a validation error.
---
## 8. Edge Cases
| Scenario | Behaviour |
|---|---|
| `startDate` = 1st of month / Monday | No pro-ration; `period_target_hours = weeklyOrMonthlyHours` |
| `startDate` = last day of period | `remaining_days = 1`; target is heavily reduced (e.g. 1/31 × hours) |
| Working pattern has no matches in the partial first period | `elapsed_working_days = 0`; `expected_hours = 0`; balance = `tracked + corrections` |
| Current period has zero elapsed working days | `expected_hours = 0`; balance = `tracked + corrections` (cannot divide by zero — guard required) |
| `working_days_in_period = 0` | Impossible by validation (at least one day required), but system must guard: treat as `daily_rate_hours = 0` |
| Today is not a working day | `elapsed_working_days` does not include today |
| Correction date before `startDate` | Rejected with a validation error |
| Correction date in future period | Accepted and stored; applied when that period is ongoing or completed |
| User changes working days or period type | Must create a new target with a new `startDate`; old target history is frozen |
| Two periods with the same client exist (old soft-deleted, new active) | Only the active target's periods contribute to the displayed balance |
| A month with only partial working day coverage (e.g. all Mondays are public holidays) | No automatic holiday handling; user adds manual corrections to compensate |
---
## 9. Data Model Changes
### `ClientTarget` table — additions / changes
| Column | Change | Notes |
|---|---|---|
| `period_type` | **Add** | Enum: `WEEKLY`, `MONTHLY` |
| `working_days` | **Add** | Array/bitmask of day names: `MON TUE WED THU FRI SAT SUN` |
| `start_date` | **Modify** | Remove "must be Monday" validation constraint |
| `weekly_hours` | **Rename** | → `target_hours` (represents hours per week or per month depending on `period_type`) |
### `BalanceCorrection` table — no structural changes
Date-to-period assignment is computed at query time, not stored.
---
## 10. API Changes
### `ClientTargetWithBalance` response shape
```typescript
interface ClientTargetWithBalance {
id: string
clientId: string
clientName: string
userId: string
periodType: "weekly" | "monthly"
targetHours: number // renamed from weeklyHours
workingDays: string[] // e.g. ["MON", "WED"]
startDate: string // YYYY-MM-DD
createdAt: string
updatedAt: string
corrections: BalanceCorrection[]
totalBalanceSeconds: number // running total across all periods
currentPeriodTrackedSeconds: number // replaces currentWeekTrackedSeconds
currentPeriodTargetSeconds: number // replaces currentWeekTargetSeconds
periods: PeriodBalance[] // replaces weeks[]
}
interface PeriodBalance {
periodStart: string // YYYY-MM-DD (Monday or 1st of month)
periodEnd: string // YYYY-MM-DD (Sunday or last of month)
targetHours: number // pro-rated for first period
trackedSeconds: number
correctionHours: number
balanceSeconds: number
isOngoing: boolean
// only present when isOngoing = true
dailyRateHours?: number
workingDaysInPeriod?: number
elapsedWorkingDays?: number
expectedHours?: number
}
```
### Endpoint changes
| Method | Path | Change |
|---|---|---|
| `POST /client-targets` | Create | Accepts `periodType`, `workingDays`, `targetHours`; `startDate` unconstrained |
| `PUT /client-targets/:id` | Update | Accepts same new fields |
| `GET /client-targets` | List | Returns updated `ClientTargetWithBalance` shape |
| `POST /client-targets/:id/corrections` | Add correction | No change to signature |
| `DELETE /client-targets/:id/corrections/:corrId` | Delete correction | No change |
### Zod schema changes
- `CreateClientTargetSchema` / `UpdateClientTargetSchema`:
- Add `periodType: z.enum(["weekly", "monthly"])`
- Add `workingDays: z.array(z.enum(["MON","TUE","WED","THU","FRI","SAT","SUN"])).min(1)`
- Rename `weeklyHours``targetHours`
- Remove Monday-only regex constraint from `startDate`
---
## 11. Frontend Changes
### Types (`frontend/src/types/index.ts`)
- `ClientTargetWithBalance` — add `periodType`, `workingDays`, `targetHours`; replace `weeks``periods: PeriodBalance[]`; replace `currentWeek*``currentPeriod*`
- Add `PeriodBalance` interface
- `CreateClientTargetInput` / `UpdateClientTargetInput` — same field additions
### Hook (`frontend/src/hooks/useClientTargets.ts`)
- No structural changes; mutations pass through new fields
### API client (`frontend/src/api/clientTargets.ts`)
- No structural changes; payload shapes updated
### `ClientsPage` — `ClientTargetPanel`
- Working day selector (checkboxes: MonSun, at least one required)
- Period type selector (Weekly / Monthly)
- Label for hours input updates dynamically: "Hours/week" or "Hours/month"
- Start date picker: free date input (no week-picker)
- Balance display: label changes from "this week" to "this week" or "this month" based on `periodType`
- Expanded period list replaces the expanded week list
### `DashboardPage`
- "Weekly Targets" widget renamed to "Targets"
- "This week" label becomes "This week" / "This month" dynamically
- `currentWeek*` fields replaced with `currentPeriod*`

View File

@@ -47,20 +47,27 @@ export function Navbar() {
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between h-16">
<div className="flex">
<div className="flex-shrink-0 flex items-center">
<img src="/icon.svg" alt="TimeTracker Logo" className="h-8 w-8 drop-shadow-sm" />
<NavLink
className="flex-shrink-0 flex items-center"
to={"/dashboard"}
>
<img
src="/icon.svg"
alt="TimeTracker Logo"
className="h-8 w-8 drop-shadow-sm"
/>
<span className="ml-2 text-xl font-bold text-gray-900">
TimeTracker
</span>
</div>
<div className="hidden sm:ml-8 sm:flex sm:space-x-4">
</NavLink>
<div className="hidden sm:ml-8 sm:flex sm:space-x-4 items-center">
{/* Main Navigation Items */}
{mainNavItems.map((item) => (
<NavLink
key={item.to}
to={item.to}
className={({ isActive }) =>
`inline-flex items-center px-3 py-2 text-sm font-medium rounded-md transition-colors ${
`inline-flex items-center px-3 py-2 text-sm font-medium rounded-md transition-colors h-min ${
isActive
? "text-primary-600 bg-primary-50"
: "text-gray-600 hover:text-gray-900 hover:bg-gray-50"

View File

@@ -3,25 +3,35 @@ 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>
<p className="text-2xl font-bold text-gray-900">{value}</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>

View File

@@ -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({

View File

@@ -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);

View File

@@ -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,23 +138,31 @@ 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}
</p>
{periodLabel}: {currentPeriodTracked} / {currentPeriodTarget}
</p>
</div>
<div className="text-right">
<p
className={`text-sm font-bold ${
isEven
? 'text-gray-500'
: isOver
? 'text-green-600'
: 'text-red-600'
}`}
>
{isEven
? '±0'
: (isOver ? '+' : '') + formatDurationHoursMinutes(absBalance)}
</p>
<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
? 'text-gray-500'
: isOver
? 'text-green-600'
: 'text-red-600'
}`}
>
{isEven
? '±0'
: (isOver ? '+' : '') + formatDurationHoursMinutes(absBalance)}
</p>
</div>
<p className="text-xs text-gray-400">running balance</p>
</div>
</div>

View File

@@ -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;
startDate: string; // YYYY-MM-DD
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;
startDate: string; // YYYY-MM-DD
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 {