Compare commits
3 Commits
784e71e187
...
1049410fee
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1049410fee | ||
| c9bd0abf18 | |||
| 7ec76e3e8e |
@@ -212,6 +212,8 @@ export interface ClientTargetWithBalance {
|
|||||||
currentPeriodTrackedSeconds: number;
|
currentPeriodTrackedSeconds: number;
|
||||||
currentPeriodTargetSeconds: number;
|
currentPeriodTargetSeconds: number;
|
||||||
periods: PeriodBalance[];
|
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 overallEnd = periods[periods.length - 1].end;
|
||||||
const today = new Date().toISOString().split('T')[0];
|
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
|
// Fetch all time tracked for this client across the full range in one query
|
||||||
type TrackedRow = { period_start: string; tracked_seconds: bigint };
|
type TrackedRow = { period_start: string; tracked_seconds: bigint };
|
||||||
|
|
||||||
@@ -489,7 +517,13 @@ export class ClientTargetService {
|
|||||||
? computePeriodTargetHours(period, startDateStr, target.targetHours, periodType)
|
? computePeriodTargetHours(period, startDateStr, target.targetHours, periodType)
|
||||||
: target.targetHours;
|
: 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 correctionHours = correctionsByPeriod.get(period.start) ?? 0;
|
||||||
|
|
||||||
const isOngoing = cmpDate(period.start, today) <= 0 && cmpDate(today, period.end) <= 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(currentPeriod.targetHours * 3600)
|
||||||
: Math.round(target.targetHours * 3600),
|
: Math.round(target.targetHours * 3600),
|
||||||
periods: periodBalances,
|
periods: periodBalances,
|
||||||
|
hasOngoingTimer: ongoingTimerSeconds > 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -594,6 +629,7 @@ export class ClientTargetService {
|
|||||||
currentPeriodTrackedSeconds: 0,
|
currentPeriodTrackedSeconds: 0,
|
||||||
currentPeriodTargetSeconds: Math.round(target.targetHours * 3600),
|
currentPeriodTargetSeconds: Math.round(target.targetHours * 3600),
|
||||||
periods: [],
|
periods: [],
|
||||||
|
hasOngoingTimer: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,25 +3,35 @@ 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>
|
||||||
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -316,6 +316,12 @@ function ClientTargetPanel({
|
|||||||
<span className="font-medium">{target!.targetHours}h</span>/{periodLabel}
|
<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
|
||||||
|
|||||||
@@ -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,10 +56,17 @@ 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;
|
||||||
|
|
||||||
|
// 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) ?? [];
|
const targetsWithData = targets?.filter(t => t.periods.length > 0) ?? [];
|
||||||
|
|
||||||
@@ -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}
|
||||||
@@ -132,19 +142,27 @@ export function DashboardPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
<p
|
<div className="flex items-center justify-end gap-1.5">
|
||||||
className={`text-sm font-bold ${
|
{target.hasOngoingTimer && (
|
||||||
isEven
|
<span
|
||||||
? 'text-gray-500'
|
className="inline-block h-2 w-2 rounded-full bg-red-500 animate-pulse"
|
||||||
: isOver
|
title="Timer running — balance updates every 30 s"
|
||||||
? 'text-green-600'
|
/>
|
||||||
: 'text-red-600'
|
)}
|
||||||
}`}
|
<p
|
||||||
>
|
className={`text-sm font-bold ${
|
||||||
{isEven
|
isEven
|
||||||
? '±0'
|
? 'text-gray-500'
|
||||||
: (isOver ? '+' : '−') + formatDurationHoursMinutes(absBalance)}
|
: isOver
|
||||||
</p>
|
? 'text-green-600'
|
||||||
|
: 'text-red-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isEven
|
||||||
|
? '±0'
|
||||||
|
: (isOver ? '+' : '−') + formatDurationHoursMinutes(absBalance)}
|
||||||
|
</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>
|
||||||
|
|||||||
@@ -186,6 +186,8 @@ export interface ClientTargetWithBalance {
|
|||||||
currentPeriodTrackedSeconds: number;
|
currentPeriodTrackedSeconds: number;
|
||||||
currentPeriodTargetSeconds: number;
|
currentPeriodTargetSeconds: number;
|
||||||
periods: PeriodBalance[];
|
periods: PeriodBalance[];
|
||||||
|
/** True when an active timer for a project belonging to this client is running. */
|
||||||
|
hasOngoingTimer: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateClientTargetInput {
|
export interface CreateClientTargetInput {
|
||||||
|
|||||||
Reference in New Issue
Block a user