From 7ec76e3e8e150a4ff0bbbe574899f10486fe5988 Mon Sep 17 00:00:00 2001 From: Simon Franken Date: Mon, 9 Mar 2026 10:59:39 +0100 Subject: [PATCH] feat: include ongoing timer in balance calculation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- backend/src/services/clientTarget.service.ts | 38 +++++++++++++++++++- frontend/src/hooks/useClientTargets.ts | 4 +++ frontend/src/pages/ClientsPage.tsx | 6 ++++ frontend/src/pages/DashboardPage.tsx | 34 +++++++++++------- frontend/src/types/index.ts | 2 ++ 5 files changed, 70 insertions(+), 14 deletions(-) diff --git a/backend/src/services/clientTarget.service.ts b/backend/src/services/clientTarget.service.ts index db6a160..f3b6a0d 100644 --- a/backend/src/services/clientTarget.service.ts +++ b/backend/src/services/clientTarget.service.ts @@ -212,6 +212,8 @@ export interface ClientTargetWithBalance { currentPeriodTrackedSeconds: number; currentPeriodTargetSeconds: number; periods: PeriodBalance[]; + /** True when an active timer is running for a project belonging to this client. */ + hasOngoingTimer: boolean; } // --------------------------------------------------------------------------- @@ -405,6 +407,32 @@ export class ClientTargetService { const overallEnd = periods[periods.length - 1].end; const today = new Date().toISOString().split('T')[0]; + // 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 } } }, + }); + + // 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 }; @@ -489,7 +517,13 @@ export class ClientTargetService { ? computePeriodTargetHours(period, startDateStr, target.targetHours, periodType) : target.targetHours; - const trackedSeconds = trackedByPeriod.get(period.start) ?? 0; + // 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; @@ -574,6 +608,7 @@ export class ClientTargetService { ? Math.round(currentPeriod.targetHours * 3600) : Math.round(target.targetHours * 3600), periods: periodBalances, + hasOngoingTimer: ongoingTimerSeconds > 0, }; } @@ -594,6 +629,7 @@ export class ClientTargetService { currentPeriodTrackedSeconds: 0, currentPeriodTargetSeconds: Math.round(target.targetHours * 3600), periods: [], + hasOngoingTimer: false, }; } } diff --git a/frontend/src/hooks/useClientTargets.ts b/frontend/src/hooks/useClientTargets.ts index 4c243e9..65ee5b2 100644 --- a/frontend/src/hooks/useClientTargets.ts +++ b/frontend/src/hooks/useClientTargets.ts @@ -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({ diff --git a/frontend/src/pages/ClientsPage.tsx b/frontend/src/pages/ClientsPage.tsx index 6dae4a1..7d4ee7a 100644 --- a/frontend/src/pages/ClientsPage.tsx +++ b/frontend/src/pages/ClientsPage.tsx @@ -316,6 +316,12 @@ function ClientTargetPanel({ {target!.targetHours}h/{periodLabel} {balance.text} + {target!.hasOngoingTimer && ( + + )}
-

- {isEven - ? '±0' - : (isOver ? '+' : '−') + formatDurationHoursMinutes(absBalance)} -

+
+ {target.hasOngoingTimer && ( + + )} +

+ {isEven + ? '±0' + : (isOver ? '+' : '−') + formatDurationHoursMinutes(absBalance)} +

+

running balance

diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 3d309f1..65c070b 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -186,6 +186,8 @@ export interface ClientTargetWithBalance { 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 {