5 Commits

Author SHA1 Message Date
1964f76f74 fix: add bg-gray-50 back to Layout 2026-03-13 17:07:32 +00:00
1f4e12298e fix 2026-03-13 17:46:53 +01:00
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
8 changed files with 100 additions and 24 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

@@ -4,9 +4,9 @@ import { TimerWidget } from './TimerWidget';
export function Layout() {
return (
<div className="min-h-screen bg-gray-50">
<div className="h-[100vh] w-[100vw] flex flex-col bg-gray-50">
<Navbar />
<main className="pt-4 pb-24">
<main className="pt-4 pb-8 grow overflow-auto">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<Outlet />
</div>

View File

@@ -3,27 +3,37 @@ interface StatCardProps {
label: string;
value: string;
color: 'blue' | 'green' | 'purple' | 'orange';
/** When true, renders a pulsing green dot to signal a live/active state. */
indicator?: boolean;
}
const colorClasses: Record<StatCardProps['color'], string> = {
const colorClasses: Record<NonNullable<StatCardProps['color']>, string> = {
blue: 'bg-blue-50 text-blue-600',
green: 'bg-green-50 text-green-600',
purple: 'bg-purple-50 text-purple-600',
orange: 'bg-orange-50 text-orange-600',
};
export function StatCard({ icon: Icon, label, value, color }: StatCardProps) {
export function StatCard({ icon: Icon, label, value, color, indicator }: StatCardProps) {
return (
<div className="card p-4">
<div className="flex items-center">
<div className={`p-3 rounded-lg ${colorClasses[color]}`}>
<Icon className="h-6 w-6" />
</div>
<div className="ml-4">
<div className="ml-4 flex-1">
<p className="text-sm font-medium text-gray-600">{label}</p>
<div className="flex items-center gap-2">
{indicator && (
<span
className="inline-block h-2.5 w-2.5 rounded-full bg-red-500 animate-pulse"
title="Timer running"
/>
)}
<p className="text-2xl font-bold text-gray-900">{value}</p>
</div>
</div>
</div>
</div>
);
}

View File

@@ -147,7 +147,7 @@ export function TimerWidget() {
if (isLoading) {
return (
<div className="fixed bottom-0 left-0 right-0 bg-white border-t border-gray-200 py-4 shadow-lg">
<div className="bg-white border-t border-gray-200 py-4 shadow-lg">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 flex items-center justify-center">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary-600"></div>
</div>
@@ -156,7 +156,7 @@ export function TimerWidget() {
}
return (
<div className="fixed bottom-0 left-0 right-0 bg-white border-t border-gray-200 py-4 shadow-lg z-50">
<div className="bg-white border-t border-gray-200 py-4 shadow-lg">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 flex flex-wrap sm:flex-nowrap items-center gap-2 sm:justify-between">
{ongoingTimer ? (
<>

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

@@ -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,10 +56,17 @@ 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;
// 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) ?? [];
@@ -78,6 +87,7 @@ export function DashboardPage() {
label="Today"
value={formatDurationHoursMinutes(totalTodaySeconds)}
color="blue"
indicator={timerStartedToday}
/>
<StatCard
icon={Calendar}
@@ -132,6 +142,13 @@ export function DashboardPage() {
</p>
</div>
<div className="text-right">
<div className="flex items-center justify-end gap-1.5">
{target.hasOngoingTimer && (
<span
className="inline-block h-2 w-2 rounded-full bg-red-500 animate-pulse"
title="Timer running — balance updates every 30 s"
/>
)}
<p
className={`text-sm font-bold ${
isEven
@@ -145,6 +162,7 @@ export function DashboardPage() {
? '±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 {