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.
This commit is contained in:
2026-03-09 10:59:39 +01:00
parent 784e71e187
commit 7ec76e3e8e
5 changed files with 70 additions and 14 deletions

View File

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

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

@@ -316,6 +316,12 @@ function ClientTargetPanel({
<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

View File

@@ -132,19 +132,27 @@ export function DashboardPage() {
</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-green-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

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