11 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
11 changed files with 670 additions and 238 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,10 +99,17 @@ model OngoingTimer {
@@map("ongoing_timers") @@map("ongoing_timers")
} }
enum PeriodType {
WEEKLY
MONTHLY
}
model ClientTarget { model ClientTarget {
id String @id @default(uuid()) id String @id @default(uuid())
weeklyHours Float @map("weekly_hours") targetHours Float @map("target_hours")
startDate DateTime @map("start_date") @db.Date // Always a Monday periodType PeriodType @default(WEEKLY) @map("period_type")
workingDays String[] @map("working_days") // e.g. ["MON","WED","FRI"]
startDate DateTime @map("start_date") @db.Date
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at") updatedAt DateTime @updatedAt @map("updated_at")
deletedAt DateTime? @map("deleted_at") deletedAt DateTime? @map("deleted_at")

View File

@@ -73,14 +73,20 @@ export const StopTimerSchema = z.object({
projectId: z.string().uuid().optional(), projectId: z.string().uuid().optional(),
}); });
const WorkingDayEnum = z.enum(['MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT', 'SUN']);
export const CreateClientTargetSchema = z.object({ export const CreateClientTargetSchema = z.object({
clientId: z.string().uuid(), clientId: z.string().uuid(),
weeklyHours: z.number().positive().max(168), targetHours: z.number().positive().max(168),
periodType: z.enum(['weekly', 'monthly']),
workingDays: z.array(WorkingDayEnum).min(1, 'At least one working day is required'),
startDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'startDate must be a date in YYYY-MM-DD format'), startDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'startDate must be a date in YYYY-MM-DD format'),
}); });
export const UpdateClientTargetSchema = z.object({ export const UpdateClientTargetSchema = z.object({
weeklyHours: z.number().positive().max(168).optional(), targetHours: z.number().positive().max(168).optional(),
periodType: z.enum(['weekly', 'monthly']).optional(),
workingDays: z.array(WorkingDayEnum).min(1, 'At least one working day is required').optional(),
startDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'startDate must be a date in YYYY-MM-DD format').optional(), startDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'startDate must be a date in YYYY-MM-DD format').optional(),
}); });

View File

@@ -3,44 +3,191 @@ import { NotFoundError, BadRequestError } from '../errors/AppError';
import type { CreateClientTargetInput, UpdateClientTargetInput, CreateCorrectionInput } from '../types'; import type { CreateClientTargetInput, UpdateClientTargetInput, CreateCorrectionInput } from '../types';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
// Returns the Monday of the week containing the given date // ---------------------------------------------------------------------------
function getMondayOfWeek(date: Date): Date { // Day-of-week helpers
const d = new Date(date); // ---------------------------------------------------------------------------
const day = d.getUTCDay(); // 0 = Sunday, 1 = Monday, ...
const DAY_NAMES = ['SUN', 'MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT'] as const;
/** Returns the UTC day index (0=Sun … 6=Sat) for a YYYY-MM-DD string. */
function dayIndex(dateStr: string): number {
return new Date(dateStr + 'T00:00:00Z').getUTCDay();
}
/** Checks whether a day-name string (e.g. "MON") is in the working-days array. */
function isWorkingDay(dateStr: string, workingDays: string[]): boolean {
return workingDays.includes(DAY_NAMES[dayIndex(dateStr)]);
}
/** Adds `n` calendar days to a YYYY-MM-DD string and returns a new YYYY-MM-DD. */
function addDays(dateStr: string, n: number): string {
const d = new Date(dateStr + 'T00:00:00Z');
d.setUTCDate(d.getUTCDate() + n);
return d.toISOString().split('T')[0];
}
/** Returns the Monday of the ISO week that contains the given date string. */
function getMondayOfWeek(dateStr: string): string {
const d = new Date(dateStr + 'T00:00:00Z');
const day = d.getUTCDay(); // 0=Sun
const diff = day === 0 ? -6 : 1 - day; const diff = day === 0 ? -6 : 1 - day;
d.setUTCDate(d.getUTCDate() + diff); d.setUTCDate(d.getUTCDate() + diff);
d.setUTCHours(0, 0, 0, 0); return d.toISOString().split('T')[0];
return d;
} }
// Returns the Sunday (end of week) for a given Monday /** Returns the Sunday of the ISO week given its Monday date string. */
function getSundayOfWeek(monday: Date): Date { function getSundayOfWeek(monday: string): string {
const d = new Date(monday); return addDays(monday, 6);
d.setUTCDate(d.getUTCDate() + 6);
d.setUTCHours(23, 59, 59, 999);
return d;
} }
// Returns all Mondays from startDate up to and including the current week's Monday /** Returns the first day of the month for a given date string. */
function getWeekMondays(startDate: Date): Date[] { function getMonthStart(dateStr: string): string {
const mondays: Date[] = []; return dateStr.slice(0, 7) + '-01';
const currentMonday = getMondayOfWeek(new Date());
let cursor = new Date(startDate);
cursor.setUTCHours(0, 0, 0, 0);
while (cursor <= currentMonday) {
mondays.push(new Date(cursor));
cursor.setUTCDate(cursor.getUTCDate() + 7);
}
return mondays;
} }
interface WeekBalance { /** Returns the last day of the month for a given date string. */
weekStart: string; // ISO date string (Monday) function getMonthEnd(dateStr: string): string {
weekEnd: string; // ISO date string (Sunday) const d = new Date(dateStr + 'T00:00:00Z');
// Set to first day of next month then subtract 1 day
const last = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 1, 0));
return last.toISOString().split('T')[0];
}
/** Total calendar days in the month containing dateStr. */
function daysInMonth(dateStr: string): number {
const d = new Date(dateStr + 'T00:00:00Z');
return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 1, 0)).getUTCDate();
}
/** Compare two YYYY-MM-DD strings. Returns negative, 0, or positive. */
function cmpDate(a: string, b: string): number {
return a < b ? -1 : a > b ? 1 : 0;
}
// ---------------------------------------------------------------------------
// Period enumeration
// ---------------------------------------------------------------------------
interface Period {
start: string; // YYYY-MM-DD
end: string; // YYYY-MM-DD
}
/**
* Returns the period (start + end) that contains the given date.
* For weekly: MonSun.
* For monthly: 1stlast day of month.
*/
function getPeriodForDate(dateStr: string, periodType: 'weekly' | 'monthly'): Period {
if (periodType === 'weekly') {
const monday = getMondayOfWeek(dateStr);
return { start: monday, end: getSundayOfWeek(monday) };
} else {
return { start: getMonthStart(dateStr), end: getMonthEnd(dateStr) };
}
}
/**
* Returns the start of the NEXT period after `currentPeriodEnd`.
*/
function nextPeriodStart(currentPeriodEnd: string, periodType: 'weekly' | 'monthly'): string {
if (periodType === 'weekly') {
return addDays(currentPeriodEnd, 1); // Monday of next week
} else {
// First day of next month
const d = new Date(currentPeriodEnd + 'T00:00:00Z');
const next = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 1, 1));
return next.toISOString().split('T')[0];
}
}
/**
* Enumerates all periods from startDate's period through today's period (inclusive).
*/
function enumeratePeriods(startDate: string, periodType: 'weekly' | 'monthly'): Period[] {
const today = new Date().toISOString().split('T')[0];
const periods: Period[] = [];
const firstPeriod = getPeriodForDate(startDate, periodType);
let cursor = firstPeriod;
while (cmpDate(cursor.start, today) <= 0) {
periods.push(cursor);
const ns = nextPeriodStart(cursor.end, periodType);
cursor = getPeriodForDate(ns, periodType);
}
return periods;
}
// ---------------------------------------------------------------------------
// Working-day counting
// ---------------------------------------------------------------------------
/**
* Counts working days in [from, to] (both inclusive) matching the given pattern.
*/
function countWorkingDays(from: string, to: string, workingDays: string[]): number {
if (cmpDate(from, to) > 0) return 0;
let count = 0;
let cur = from;
while (cmpDate(cur, to) <= 0) {
if (isWorkingDay(cur, workingDays)) count++;
cur = addDays(cur, 1);
}
return count;
}
// ---------------------------------------------------------------------------
// Pro-ration helpers
// ---------------------------------------------------------------------------
/**
* Returns the pro-rated target hours for the first period, applying §5 of the spec.
* If startDate falls on the natural first day of the period, no pro-ration occurs.
*/
function computePeriodTargetHours(
period: Period,
startDate: string,
targetHours: number,
periodType: 'weekly' | 'monthly',
): number {
const naturalStart = period.start;
if (cmpDate(startDate, naturalStart) <= 0) {
// startDate is at or before the natural period start — no pro-ration needed
return targetHours;
}
// startDate is inside the period → pro-rate by calendar days
const fullDays = periodType === 'weekly' ? 7 : daysInMonth(period.start);
const remainingDays = daysBetween(startDate, period.end); // inclusive both ends
return (remainingDays / fullDays) * targetHours;
}
/** Calendar days between two dates (both inclusive). */
function daysBetween(from: string, to: string): number {
const a = new Date(from + 'T00:00:00Z').getTime();
const b = new Date(to + 'T00:00:00Z').getTime();
return Math.round((b - a) / 86400000) + 1;
}
// ---------------------------------------------------------------------------
// Response types
// ---------------------------------------------------------------------------
export interface PeriodBalance {
periodStart: string;
periodEnd: string;
targetHours: number;
trackedSeconds: number; trackedSeconds: number;
targetSeconds: number;
correctionHours: number; correctionHours: number;
balanceSeconds: number; // positive = overtime, negative = undertime balanceSeconds: number;
isOngoing: boolean;
// only when isOngoing = true
dailyRateHours?: number;
workingDaysInPeriod?: number;
elapsedWorkingDays?: number;
expectedHours?: number;
} }
export interface ClientTargetWithBalance { export interface ClientTargetWithBalance {
@@ -48,7 +195,9 @@ export interface ClientTargetWithBalance {
clientId: string; clientId: string;
clientName: string; clientName: string;
userId: string; userId: string;
weeklyHours: number; periodType: 'weekly' | 'monthly';
targetHours: number;
workingDays: string[];
startDate: string; startDate: string;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
@@ -59,12 +208,36 @@ export interface ClientTargetWithBalance {
description: string | null; description: string | null;
createdAt: string; createdAt: string;
}>; }>;
totalBalanceSeconds: number; // running total across all weeks totalBalanceSeconds: number;
currentWeekTrackedSeconds: number; currentPeriodTrackedSeconds: number;
currentWeekTargetSeconds: number; currentPeriodTargetSeconds: number;
weeks: WeekBalance[]; periods: PeriodBalance[];
/** 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 { export class ClientTargetService {
async findAll(userId: string): Promise<ClientTargetWithBalance[]> { async findAll(userId: string): Promise<ClientTargetWithBalance[]> {
const targets = await prisma.clientTarget.findMany({ const targets = await prisma.clientTarget.findMany({
@@ -76,7 +249,7 @@ export class ClientTargetService {
orderBy: { client: { name: 'asc' } }, orderBy: { client: { name: 'asc' } },
}); });
return Promise.all(targets.map(t => this.computeBalance(t))); return Promise.all(targets.map(t => this.computeBalance(t as unknown as TargetRecord)));
} }
async findById(id: string, userId: string) { async findById(id: string, userId: string) {
@@ -90,19 +263,15 @@ export class ClientTargetService {
} }
async create(userId: string, data: CreateClientTargetInput): Promise<ClientTargetWithBalance> { async create(userId: string, data: CreateClientTargetInput): Promise<ClientTargetWithBalance> {
// Validate startDate is a Monday
const startDate = new Date(data.startDate + 'T00:00:00Z');
const dayOfWeek = startDate.getUTCDay();
if (dayOfWeek !== 1) {
throw new BadRequestError('startDate must be a Monday');
}
// Ensure the client belongs to this user and is not soft-deleted // Ensure the client belongs to this user and is not soft-deleted
const client = await prisma.client.findFirst({ where: { id: data.clientId, userId, deletedAt: null } }); const client = await prisma.client.findFirst({ where: { id: data.clientId, userId, deletedAt: null } });
if (!client) { if (!client) {
throw new NotFoundError('Client not found'); throw new NotFoundError('Client not found');
} }
const startDate = new Date(data.startDate + 'T00:00:00Z');
const periodType = data.periodType.toUpperCase() as 'WEEKLY' | 'MONTHLY';
// Check for existing target (unique per user+client) // Check for existing target (unique per user+client)
const existing = await prisma.clientTarget.findFirst({ where: { userId, clientId: data.clientId } }); const existing = await prisma.clientTarget.findFirst({ where: { userId, clientId: data.clientId } });
if (existing) { if (existing) {
@@ -110,13 +279,19 @@ export class ClientTargetService {
// Reactivate the soft-deleted target with the new settings // Reactivate the soft-deleted target with the new settings
const reactivated = await prisma.clientTarget.update({ const reactivated = await prisma.clientTarget.update({
where: { id: existing.id }, where: { id: existing.id },
data: { deletedAt: null, weeklyHours: data.weeklyHours, startDate }, data: {
deletedAt: null,
targetHours: data.targetHours,
periodType,
workingDays: data.workingDays,
startDate,
},
include: { include: {
client: { select: { id: true, name: true } }, client: { select: { id: true, name: true } },
corrections: { where: { deletedAt: null }, orderBy: { date: 'asc' } }, corrections: { where: { deletedAt: null }, orderBy: { date: 'asc' } },
}, },
}); });
return this.computeBalance(reactivated); return this.computeBalance(reactivated as unknown as TargetRecord);
} }
throw new BadRequestError('A target already exists for this client. Delete the existing one first or update it.'); throw new BadRequestError('A target already exists for this client. Delete the existing one first or update it.');
} }
@@ -125,7 +300,9 @@ export class ClientTargetService {
data: { data: {
userId, userId,
clientId: data.clientId, clientId: data.clientId,
weeklyHours: data.weeklyHours, targetHours: data.targetHours,
periodType,
workingDays: data.workingDays,
startDate, startDate,
}, },
include: { include: {
@@ -134,26 +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> { async update(id: string, userId: string, data: UpdateClientTargetInput): Promise<ClientTargetWithBalance> {
const existing = await this.findById(id, userId); const existing = await this.findById(id, userId);
if (!existing) throw new NotFoundError('Client target not found'); if (!existing) throw new NotFoundError('Client target not found');
const updateData: { weeklyHours?: number; startDate?: Date } = {}; const updateData: {
targetHours?: number;
periodType?: 'WEEKLY' | 'MONTHLY';
workingDays?: string[];
startDate?: Date;
} = {};
if (data.weeklyHours !== undefined) { if (data.targetHours !== undefined) updateData.targetHours = data.targetHours;
updateData.weeklyHours = data.weeklyHours; if (data.periodType !== undefined) updateData.periodType = data.periodType.toUpperCase() as 'WEEKLY' | 'MONTHLY';
} if (data.workingDays !== undefined) updateData.workingDays = data.workingDays;
if (data.startDate !== undefined) updateData.startDate = new Date(data.startDate + 'T00:00:00Z');
if (data.startDate !== undefined) {
const startDate = new Date(data.startDate + 'T00:00:00Z');
if (startDate.getUTCDay() !== 1) {
throw new BadRequestError('startDate must be a Monday');
}
updateData.startDate = startDate;
}
const updated = await prisma.clientTarget.update({ const updated = await prisma.clientTarget.update({
where: { id }, where: { id },
@@ -164,7 +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> { async delete(id: string, userId: string): Promise<void> {
@@ -213,94 +388,211 @@ export class ClientTargetService {
}); });
} }
private async computeBalance(target: { // ---------------------------------------------------------------------------
id: string; // Balance computation
clientId: string; // ---------------------------------------------------------------------------
userId: string;
weeklyHours: number;
startDate: Date;
createdAt: Date;
updatedAt: Date;
client: { id: string; name: string };
corrections: Array<{ id: string; date: Date; hours: number; description: string | null; createdAt: Date }>;
}): Promise<ClientTargetWithBalance> {
const mondays = getWeekMondays(target.startDate);
if (mondays.length === 0) { private async computeBalance(target: TargetRecord): Promise<ClientTargetWithBalance> {
return this.emptyBalance(target); const startDateStr = target.startDate.toISOString().split('T')[0];
const periodType = target.periodType.toLowerCase() as 'weekly' | 'monthly';
const workingDays = target.workingDays;
const periods = enumeratePeriods(startDateStr, periodType);
if (periods.length === 0) {
return this.emptyBalance(target, periodType);
} }
// Fetch all tracked time for this user on this client's projects in one query const overallStart = periods[0].start;
// covering startDate to end of current week const overallEnd = periods[periods.length - 1].end;
const periodStart = mondays[0]; const today = new Date().toISOString().split('T')[0];
const periodEnd = getSundayOfWeek(mondays[mondays.length - 1]);
type TrackedRow = { week_start: Date; tracked_seconds: bigint }; // Fetch 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 SELECT
DATE_TRUNC('week', te.start_time AT TIME ZONE 'UTC') AS week_start, TO_CHAR(
COALESCE(SUM(EXTRACT(EPOCH FROM (te.end_time - te.start_time)) - (te.break_minutes * 60)), 0)::bigint AS tracked_seconds DATE_TRUNC('week', te.start_time AT TIME ZONE 'UTC'),
'YYYY-MM-DD'
) AS period_start,
COALESCE(
SUM(EXTRACT(EPOCH FROM (te.end_time - te.start_time)) - (te.break_minutes * 60)),
0
)::bigint AS tracked_seconds
FROM time_entries te FROM time_entries te
JOIN projects p ON p.id = te.project_id JOIN projects p ON p.id = te.project_id
WHERE te.user_id = ${target.userId} WHERE te.user_id = ${target.userId}
AND p.client_id = ${target.clientId} AND p.client_id = ${target.clientId}
AND te.start_time >= ${periodStart} AND te.start_time >= ${new Date(overallStart + 'T00:00:00Z')}
AND te.start_time <= ${periodEnd} AND te.start_time <= ${new Date(overallEnd + 'T23:59:59Z')}
AND te.deleted_at IS NULL AND te.deleted_at IS NULL
AND p.deleted_at IS NULL AND p.deleted_at IS NULL
GROUP BY DATE_TRUNC('week', te.start_time AT TIME ZONE 'UTC') GROUP BY DATE_TRUNC('week', te.start_time AT TIME ZONE 'UTC')
`); `);
} else {
// Index tracked seconds by week start (ISO Monday string) trackedRows = await prisma.$queryRaw<TrackedRow[]>(Prisma.sql`
const trackedByWeek = new Map<string, number>(); SELECT
for (const row of rows) { TO_CHAR(
// DATE_TRUNC with 'week' gives Monday in Postgres (ISO week) DATE_TRUNC('month', te.start_time AT TIME ZONE 'UTC'),
const monday = getMondayOfWeek(new Date(row.week_start)); 'YYYY-MM-DD'
const key = monday.toISOString().split('T')[0]; ) AS period_start,
trackedByWeek.set(key, Number(row.tracked_seconds)); COALESCE(
SUM(EXTRACT(EPOCH FROM (te.end_time - te.start_time)) - (te.break_minutes * 60)),
0
)::bigint AS tracked_seconds
FROM time_entries te
JOIN projects p ON p.id = te.project_id
WHERE te.user_id = ${target.userId}
AND p.client_id = ${target.clientId}
AND te.start_time >= ${new Date(overallStart + 'T00:00:00Z')}
AND te.start_time <= ${new Date(overallEnd + 'T23:59:59Z')}
AND te.deleted_at IS NULL
AND p.deleted_at IS NULL
GROUP BY DATE_TRUNC('month', te.start_time AT TIME ZONE 'UTC')
`);
} }
// Index corrections by week // Map tracked seconds by period start date string
const correctionsByWeek = new Map<string, number>(); const trackedByPeriod = new Map<string, number>();
for (const row of trackedRows) {
// Normalise: for weekly, Postgres DATE_TRUNC('week') already gives Monday
const key = typeof row.period_start === 'string'
? row.period_start
: (row.period_start as Date).toISOString().split('T')[0];
trackedByPeriod.set(key, Number(row.tracked_seconds));
}
// Index corrections by period start date
const correctionsByPeriod = new Map<string, number>();
for (const c of target.corrections) { for (const c of target.corrections) {
const monday = getMondayOfWeek(new Date(c.date)); const corrDateStr = c.date.toISOString().split('T')[0];
const key = monday.toISOString().split('T')[0]; const period = getPeriodForDate(corrDateStr, periodType);
correctionsByWeek.set(key, (correctionsByWeek.get(key) ?? 0) + c.hours); const key = period.start;
correctionsByPeriod.set(key, (correctionsByPeriod.get(key) ?? 0) + c.hours);
} }
const targetSecondsPerWeek = target.weeklyHours * 3600; const periodBalances: PeriodBalance[] = [];
const weeks: WeekBalance[] = [];
let totalBalanceSeconds = 0; let totalBalanceSeconds = 0;
const isFirstPeriod = (i: number) => i === 0;
for (let i = 0; i < periods.length; i++) {
const period = periods[i];
// Effective start for this period (clamped to startDate for first period)
const effectiveStart = isFirstPeriod(i) && cmpDate(startDateStr, period.start) > 0
? startDateStr
: period.start;
// Period target hours (with possible pro-ration on the first period)
const periodTargetHours = isFirstPeriod(i)
? computePeriodTargetHours(period, startDateStr, target.targetHours, periodType)
: target.targetHours;
// 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; totalBalanceSeconds += balanceSeconds;
weeks.push({ periodBalances.push({
weekStart: key, periodStart: period.start,
weekEnd: sunday.toISOString().split('T')[0], periodEnd: period.end,
targetHours: periodTargetHours,
trackedSeconds, trackedSeconds,
targetSeconds: effectiveTargetSeconds,
correctionHours, correctionHours,
balanceSeconds, balanceSeconds,
isOngoing,
...extra,
}); });
} }
const currentWeek = weeks[weeks.length - 1]; const currentPeriod = periodBalances.find(p => p.isOngoing) ?? periodBalances[periodBalances.length - 1];
return { return {
id: target.id, id: target.id,
clientId: target.clientId, clientId: target.clientId,
clientName: target.client.name, clientName: target.client.name,
userId: target.userId, userId: target.userId,
weeklyHours: target.weeklyHours, periodType,
startDate: target.startDate.toISOString().split('T')[0], targetHours: target.targetHours,
workingDays,
startDate: startDateStr,
createdAt: target.createdAt.toISOString(), createdAt: target.createdAt.toISOString(),
updatedAt: target.updatedAt.toISOString(), updatedAt: target.updatedAt.toISOString(),
corrections: target.corrections.map(c => ({ corrections: target.corrections.map(c => ({
@@ -311,37 +603,33 @@ export class ClientTargetService {
createdAt: c.createdAt.toISOString(), createdAt: c.createdAt.toISOString(),
})), })),
totalBalanceSeconds, totalBalanceSeconds,
currentWeekTrackedSeconds: currentWeek?.trackedSeconds ?? 0, currentPeriodTrackedSeconds: currentPeriod?.trackedSeconds ?? 0,
currentWeekTargetSeconds: currentWeek?.targetSeconds ?? targetSecondsPerWeek, currentPeriodTargetSeconds: currentPeriod
weeks, ? Math.round(currentPeriod.targetHours * 3600)
: Math.round(target.targetHours * 3600),
periods: periodBalances,
hasOngoingTimer: ongoingTimerSeconds > 0,
}; };
} }
private emptyBalance(target: { private emptyBalance(target: TargetRecord, periodType: 'weekly' | 'monthly'): ClientTargetWithBalance {
id: string;
clientId: string;
userId: string;
weeklyHours: number;
startDate: Date;
createdAt: Date;
updatedAt: Date;
client: { id: string; name: string };
corrections: Array<{ id: string; date: Date; hours: number; description: string | null; createdAt: Date }>;
}): ClientTargetWithBalance {
return { return {
id: target.id, id: target.id,
clientId: target.clientId, clientId: target.clientId,
clientName: target.client.name, clientName: target.client.name,
userId: target.userId, userId: target.userId,
weeklyHours: target.weeklyHours, periodType,
targetHours: target.targetHours,
workingDays: target.workingDays,
startDate: target.startDate.toISOString().split('T')[0], startDate: target.startDate.toISOString().split('T')[0],
createdAt: target.createdAt.toISOString(), createdAt: target.createdAt.toISOString(),
updatedAt: target.updatedAt.toISOString(), updatedAt: target.updatedAt.toISOString(),
corrections: [], corrections: [],
totalBalanceSeconds: 0, totalBalanceSeconds: 0,
currentWeekTrackedSeconds: 0, currentPeriodTrackedSeconds: 0,
currentWeekTargetSeconds: target.weeklyHours * 3600, currentPeriodTargetSeconds: Math.round(target.targetHours * 3600),
weeks: [], periods: [],
hasOngoingTimer: false,
}; };
} }
} }

View File

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

View File

@@ -19,6 +19,7 @@ export async function hasOverlappingEntries(
const count = await prisma.timeEntry.count({ const count = await prisma.timeEntry.count({
where: { where: {
userId, userId,
deletedAt: null,
...(excludeId ? { id: { not: excludeId } } : {}), ...(excludeId ? { id: { not: excludeId } } : {}),
// An entry overlaps when it starts before our end AND ends after our start. // An entry overlaps when it starts before our end AND ends after our start.
startTime: { lt: endTime }, startTime: { lt: endTime },

View File

@@ -3,27 +3,37 @@ interface StatCardProps {
label: string; label: string;
value: string; value: string;
color: 'blue' | 'green' | 'purple' | 'orange'; 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', blue: 'bg-blue-50 text-blue-600',
green: 'bg-green-50 text-green-600', green: 'bg-green-50 text-green-600',
purple: 'bg-purple-50 text-purple-600', purple: 'bg-purple-50 text-purple-600',
orange: 'bg-orange-50 text-orange-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 ( return (
<div className="card p-4"> <div className="card p-4">
<div className="flex items-center"> <div className="flex items-center">
<div className={`p-3 rounded-lg ${colorClasses[color]}`}> <div className={`p-3 rounded-lg ${colorClasses[color]}`}>
<Icon className="h-6 w-6" /> <Icon className="h-6 w-6" />
</div> </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-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> <p className="text-2xl font-bold text-gray-900">{value}</p>
</div> </div>
</div> </div>
</div> </div>
</div>
); );
} }

View File

@@ -1,5 +1,6 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { clientTargetsApi } from '@/api/clientTargets'; import { clientTargetsApi } from '@/api/clientTargets';
import { useTimer } from '@/contexts/TimerContext';
import type { import type {
CreateClientTargetInput, CreateClientTargetInput,
UpdateClientTargetInput, UpdateClientTargetInput,
@@ -8,10 +9,13 @@ import type {
export function useClientTargets() { export function useClientTargets() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { ongoingTimer } = useTimer();
const { data: targets, isLoading, error } = useQuery({ const { data: targets, isLoading, error } = useQuery({
queryKey: ['clientTargets'], queryKey: ['clientTargets'],
queryFn: clientTargetsApi.getAll, 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({ const createTarget = useMutation({

View File

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

View File

@@ -3,6 +3,7 @@ import { Link } from "react-router-dom";
import { Clock, Calendar, Briefcase, TrendingUp, Target, Edit2, Trash2 } from "lucide-react"; import { Clock, Calendar, Briefcase, TrendingUp, Target, Edit2, Trash2 } from "lucide-react";
import { useTimeEntries } from "@/hooks/useTimeEntries"; import { useTimeEntries } from "@/hooks/useTimeEntries";
import { useClientTargets } from "@/hooks/useClientTargets"; import { useClientTargets } from "@/hooks/useClientTargets";
import { useTimer } from "@/contexts/TimerContext";
import { ProjectColorDot } from "@/components/ProjectColorDot"; import { ProjectColorDot } from "@/components/ProjectColorDot";
import { StatCard } from "@/components/StatCard"; import { StatCard } from "@/components/StatCard";
import { TimeEntryFormModal } from "@/components/TimeEntryFormModal"; import { TimeEntryFormModal } from "@/components/TimeEntryFormModal";
@@ -30,6 +31,7 @@ export function DashboardPage() {
}); });
const { targets } = useClientTargets(); const { targets } = useClientTargets();
const { ongoingTimer, elapsedSeconds } = useTimer();
const [isModalOpen, setIsModalOpen] = useState(false); const [isModalOpen, setIsModalOpen] = useState(false);
const [editingEntry, setEditingEntry] = useState<TimeEntry | null>(null); const [editingEntry, setEditingEntry] = useState<TimeEntry | null>(null);
@@ -54,12 +56,19 @@ export function DashboardPage() {
} }
}; };
const totalTodaySeconds = const completedTodaySeconds =
todayEntries?.entries.reduce((total, entry) => { todayEntries?.entries.reduce((total, entry) => {
return total + calculateDuration(entry.startTime, entry.endTime, entry.breakMinutes); return total + calculateDuration(entry.startTime, entry.endTime, entry.breakMinutes);
}, 0) || 0; }, 0) ?? 0;
const targetsWithData = targets?.filter(t => t.weeks.length > 0) ?? []; // 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 ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -78,6 +87,7 @@ export function DashboardPage() {
label="Today" label="Today"
value={formatDurationHoursMinutes(totalTodaySeconds)} value={formatDurationHoursMinutes(totalTodaySeconds)}
color="blue" color="blue"
indicator={timerStartedToday}
/> />
<StatCard <StatCard
icon={Calendar} icon={Calendar}
@@ -108,7 +118,7 @@ export function DashboardPage() {
<div className="card"> <div className="card">
<div className="flex items-center gap-2 mb-4"> <div className="flex items-center gap-2 mb-4">
<Target className="h-5 w-5 text-primary-600" /> <Target className="h-5 w-5 text-primary-600" />
<h2 className="text-lg font-semibold text-gray-900">Weekly Targets</h2> <h2 className="text-lg font-semibold text-gray-900">Targets</h2>
</div> </div>
<div className="space-y-3"> <div className="space-y-3">
{targetsWithData.map(target => { {targetsWithData.map(target => {
@@ -116,8 +126,9 @@ export function DashboardPage() {
const absBalance = Math.abs(balance); const absBalance = Math.abs(balance);
const isOver = balance > 0; const isOver = balance > 0;
const isEven = balance === 0; const isEven = balance === 0;
const currentWeekTracked = formatDurationHoursMinutes(target.currentWeekTrackedSeconds); const currentPeriodTracked = formatDurationHoursMinutes(target.currentPeriodTrackedSeconds);
const currentWeekTarget = formatDurationHoursMinutes(target.currentWeekTargetSeconds); const currentPeriodTarget = formatDurationHoursMinutes(target.currentPeriodTargetSeconds);
const periodLabel = target.periodType === 'weekly' ? 'This week' : 'This month';
return ( return (
<div <div
@@ -127,10 +138,17 @@ export function DashboardPage() {
<div> <div>
<p className="text-sm font-medium text-gray-900">{target.clientName}</p> <p className="text-sm font-medium text-gray-900">{target.clientName}</p>
<p className="text-xs text-gray-500 mt-0.5"> <p className="text-xs text-gray-500 mt-0.5">
This week: {currentWeekTracked} / {currentWeekTarget} {periodLabel}: {currentPeriodTracked} / {currentPeriodTarget}
</p> </p>
</div> </div>
<div className="text-right"> <div className="text-right">
<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 <p
className={`text-sm font-bold ${ className={`text-sm font-bold ${
isEven isEven
@@ -144,6 +162,7 @@ export function DashboardPage() {
? '±0' ? '±0'
: (isOver ? '+' : '') + formatDurationHoursMinutes(absBalance)} : (isOver ? '+' : '') + formatDurationHoursMinutes(absBalance)}
</p> </p>
</div>
<p className="text-xs text-gray-400">running balance</p> <p className="text-xs text-gray-400">running balance</p>
</div> </div>
</div> </div>

View File

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