diff --git a/worklenz-frontend/src/api/tasks/task-time-logs.api.service.ts b/worklenz-frontend/src/api/tasks/task-time-logs.api.service.ts index f4565837..37673590 100644 --- a/worklenz-frontend/src/api/tasks/task-time-logs.api.service.ts +++ b/worklenz-frontend/src/api/tasks/task-time-logs.api.service.ts @@ -5,6 +5,16 @@ import { ITaskLogViewModel } from "@/types/tasks/task-log-view.types"; const rootUrl = `${API_BASE_URL}/task-time-log`; +export interface IRunningTimer { + task_id: string; + start_time: string; + task_name: string; + project_id: string; + project_name: string; + parent_task_id?: string; + parent_task_name?: string; +} + export const taskTimeLogsApiService = { getByTask: async (id: string) : Promise> => { const response = await apiClient.get(`${rootUrl}/task/${id}`); @@ -26,6 +36,11 @@ export const taskTimeLogsApiService = { return response.data; }, + getRunningTimers: async (): Promise> => { + const response = await apiClient.get(`${rootUrl}/running-timers`); + return response.data; + }, + exportToExcel(taskId: string) { window.location.href = `${import.meta.env.VITE_API_URL}${API_BASE_URL}/task-time-log/export/${taskId}`; }, diff --git a/worklenz-frontend/src/features/navbar/navbar.tsx b/worklenz-frontend/src/features/navbar/navbar.tsx index 41d7c0e7..430318d3 100644 --- a/worklenz-frontend/src/features/navbar/navbar.tsx +++ b/worklenz-frontend/src/features/navbar/navbar.tsx @@ -5,7 +5,6 @@ import { Col, ConfigProvider, Flex, Menu, MenuProps, Alert } from 'antd'; import { createPortal } from 'react-dom'; import InviteTeamMembers from '../../components/common/invite-team-members/invite-team-members'; -import HelpButton from './help/HelpButton'; import InviteButton from './invite/InviteButton'; import MobileMenuButton from './mobileMenu/MobileMenuButton'; import NavbarLogo from './navbar-logo'; @@ -22,6 +21,7 @@ import { useAuthService } from '@/hooks/useAuth'; import { authApiService } from '@/api/auth/auth.api.service'; import { ISUBSCRIPTION_TYPE } from '@/shared/constants'; import logger from '@/utils/errorLogger'; +import TimerButton from './timers/timer-button'; const Navbar = () => { const [current, setCurrent] = useState('home'); @@ -90,6 +90,7 @@ const Navbar = () => { }, [location]); return ( + { - + diff --git a/worklenz-frontend/src/features/navbar/timers/timer-button.tsx b/worklenz-frontend/src/features/navbar/timers/timer-button.tsx new file mode 100644 index 00000000..c4a229e8 --- /dev/null +++ b/worklenz-frontend/src/features/navbar/timers/timer-button.tsx @@ -0,0 +1,275 @@ +import { ClockCircleOutlined, StopOutlined } from '@ant-design/icons'; +import { Badge, Button, Dropdown, List, Tooltip, Typography, Space, Divider, theme } from 'antd'; +import React, { useEffect, useState, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { taskTimeLogsApiService, IRunningTimer } from '@/api/tasks/task-time-logs.api.service'; +import { useAppDispatch } from '@/hooks/useAppDispatch'; +import { useSocket } from '@/socket/socketContext'; +import { SocketEvents } from '@/shared/socket-events'; +import { updateTaskTimeTracking } from '@/features/tasks/tasks.slice'; +import moment from 'moment'; + +const { Text } = Typography; +const { useToken } = theme; + +const TimerButton = () => { + const [runningTimers, setRunningTimers] = useState([]); + const [loading, setLoading] = useState(false); + const [currentTimes, setCurrentTimes] = useState>({}); + const [dropdownOpen, setDropdownOpen] = useState(false); + const { t } = useTranslation('navbar'); + const { token } = useToken(); + const dispatch = useAppDispatch(); + const { socket } = useSocket(); + + const fetchRunningTimers = useCallback(async () => { + try { + setLoading(true); + const response = await taskTimeLogsApiService.getRunningTimers(); + if (response.done) { + setRunningTimers(response.body || []); + } + } catch (error) { + console.error('Error fetching running timers:', error); + } finally { + setLoading(false); + } + }, []); + + const updateCurrentTimes = () => { + const newTimes: Record = {}; + runningTimers.forEach(timer => { + const startTime = moment(timer.start_time); + const now = moment(); + const duration = moment.duration(now.diff(startTime)); + const hours = Math.floor(duration.asHours()); + const minutes = duration.minutes(); + const seconds = duration.seconds(); + newTimes[timer.task_id] = `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`; + }); + setCurrentTimes(newTimes); + }; + + useEffect(() => { + fetchRunningTimers(); + + // Set up polling to refresh timers every 30 seconds + const pollInterval = setInterval(() => { + fetchRunningTimers(); + }, 30000); + + return () => clearInterval(pollInterval); + }, [fetchRunningTimers]); + + useEffect(() => { + if (runningTimers.length > 0) { + updateCurrentTimes(); + const interval = setInterval(updateCurrentTimes, 1000); + return () => clearInterval(interval); + } + }, [runningTimers]); + + // Listen for timer start/stop events and project updates to refresh the count + useEffect(() => { + if (!socket) return; + + const handleTimerStart = (data: string) => { + try { + const { id } = typeof data === 'string' ? JSON.parse(data) : data; + if (id) { + // Refresh the running timers list when a new timer is started + fetchRunningTimers(); + } + } catch (error) { + console.error('Error parsing timer start event:', error); + } + }; + + const handleTimerStop = (data: string) => { + try { + const { id } = typeof data === 'string' ? JSON.parse(data) : data; + if (id) { + // Refresh the running timers list when a timer is stopped + fetchRunningTimers(); + } + } catch (error) { + console.error('Error parsing timer stop event:', error); + } + }; + + const handleProjectUpdates = () => { + // Refresh timers when project updates are available + fetchRunningTimers(); + }; + + socket.on(SocketEvents.TASK_TIMER_START.toString(), handleTimerStart); + socket.on(SocketEvents.TASK_TIMER_STOP.toString(), handleTimerStop); + socket.on(SocketEvents.PROJECT_UPDATES_AVAILABLE.toString(), handleProjectUpdates); + + return () => { + socket.off(SocketEvents.TASK_TIMER_START.toString(), handleTimerStart); + socket.off(SocketEvents.TASK_TIMER_STOP.toString(), handleTimerStop); + socket.off(SocketEvents.PROJECT_UPDATES_AVAILABLE.toString(), handleProjectUpdates); + }; + }, [socket, fetchRunningTimers]); + + const hasRunningTimers = () => { + return runningTimers.length > 0; + }; + + const timerCount = () => { + return runningTimers.length; + }; + + const handleStopTimer = (taskId: string) => { + if (!socket) return; + + socket.emit(SocketEvents.TASK_TIMER_STOP.toString(), JSON.stringify({ task_id: taskId })); + dispatch(updateTaskTimeTracking({ taskId, timeTracking: null })); + }; + + const dropdownContent = ( +
+ {runningTimers.length === 0 ? ( +
+ No running timers +
+ ) : ( + ( + +
+ + + {timer.task_name} + +
+ {timer.project_name} +
+ {timer.parent_task_name && ( + + Parent: {timer.parent_task_name} + + )} +
+
+
+ + Started: {moment(timer.start_time).format('HH:mm')} + + + {currentTimes[timer.task_id] || '00:00:00'} + +
+
+ +
+
+
+
+ )} + /> + )} + {runningTimers.length > 0 && ( + <> + +
+ + {runningTimers.length} timer{runningTimers.length !== 1 ? 's' : ''} running + +
+ + )} +
+ ); + + return ( + dropdownContent} + trigger={['click']} + placement="bottomRight" + open={dropdownOpen} + onOpenChange={(open) => { + setDropdownOpen(open); + if (open) { + fetchRunningTimers(); + } + }} + > + +