From 95f2b1d4a16b31e71c5bb0b510e883ae88d14041 Mon Sep 17 00:00:00 2001 From: paul-nothaft <40865108-paul-nothaft@users.noreply.replit.com> Date: Thu, 11 Sep 2025 22:27:05 +0000 Subject: [PATCH] Add timer functionality to track time spent on tasks Introduces a `useTimer` hook to manage starting and stopping timers for tasks, updating their `timeTracked` property, and displaying tracking status. This involves changes in `App.tsx`, `KanbanBoard.tsx`, `TaskCard.tsx`, and `TasksWithCalendar.tsx` to integrate the timer start/stop actions and display. Replit-Commit-Author: Agent Replit-Commit-Session-Id: ceced2fc-aa46-458d-ba87-ddd4b7bb1518 Replit-Commit-Checkpoint-Type: intermediate_checkpoint Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/659922a9-0087-461c-90dd-6d9a58b81d4d/ceced2fc-aa46-458d-ba87-ddd4b7bb1518/P2PZNJ9 --- .replit | 4 - client/src/App.tsx | 11 ++ client/src/components/KanbanBoard.tsx | 34 ++---- client/src/components/TaskCard.tsx | 43 ++++---- client/src/components/TasksWithCalendar.tsx | 18 ++-- client/src/hooks/useTimer.ts | 113 ++++++++++++++++++++ 6 files changed, 165 insertions(+), 58 deletions(-) create mode 100644 client/src/hooks/useTimer.ts diff --git a/.replit b/.replit index 3f84a4e..61099cd 100644 --- a/.replit +++ b/.replit @@ -18,10 +18,6 @@ externalPort = 80 localPort = 35345 externalPort = 3002 -[[ports]] -localPort = 40265 -externalPort = 3003 - [[ports]] localPort = 41353 externalPort = 3000 diff --git a/client/src/App.tsx b/client/src/App.tsx index eb91637..d068e92 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -17,6 +17,7 @@ import ProjectTemplate from './components/ProjectTemplate'; import ThemeToggle from './components/ThemeToggle'; import { Task } from '@shared/schema'; import { addDays, subDays } from 'date-fns'; +import { useTimer } from './hooks/useTimer'; // Mock data for prototype const initialTasks: Task[] = [ @@ -119,6 +120,12 @@ function App() { console.log('Task updated:', taskId, updates); }; + // Initialize the timer hook after handleTaskUpdate is defined + const { startTimer, stopTimer } = useTimer({ + tasks, + onTaskUpdate: handleTaskUpdate + }); + const handleTaskStatusChange = (taskId: string, newStatus: Task['status']) => { handleTaskUpdate(taskId, { status: newStatus }); }; @@ -156,6 +163,8 @@ function App() { tasks={tasks} onTaskUpdate={handleTaskUpdate} onTaskEdit={(task) => console.log('Edit task:', task.title)} + onStartTimer={startTimer} + onStopTimer={stopTimer} /> ); @@ -176,6 +185,8 @@ function App() { onTaskStatusChange={handleTaskStatusChange} onTaskUpdate={handleTaskUpdate} onTaskClick={handleTaskClick} + onStartTimer={startTimer} + onStopTimer={stopTimer} /> ); diff --git a/client/src/components/KanbanBoard.tsx b/client/src/components/KanbanBoard.tsx index 51efc18..7bbbb19 100644 --- a/client/src/components/KanbanBoard.tsx +++ b/client/src/components/KanbanBoard.tsx @@ -15,11 +15,13 @@ interface KanbanBoardProps { onTaskUpdate?: (taskId: string, updates: Partial) => void; onTaskClick?: (task: Task) => void; onTaskDelete?: (taskId: string) => void; + onStartTimer?: (taskId: string) => void; + onStopTimer?: (taskId: string) => void; } type ViewMode = 'traditional' | 'weekly' | 'monthly'; -export default function KanbanBoard({ tasks, onTaskStatusChange, onTaskUpdate, onTaskClick, onTaskDelete }: KanbanBoardProps) { +export default function KanbanBoard({ tasks, onTaskStatusChange, onTaskUpdate, onTaskClick, onTaskDelete, onStartTimer, onStopTimer }: KanbanBoardProps) { const [viewMode, setViewMode] = useState('traditional'); const [currentDate, setCurrentDate] = useState(new Date()); const [draggedTask, setDraggedTask] = useState(null); @@ -188,14 +190,8 @@ export default function KanbanBoard({ tasks, onTaskStatusChange, onTaskUpdate, o > { - onTaskUpdate?.(task.id, { isTracking: true }); - console.log(`Timer started for ${task.title}`); - }} - onPause={() => { - onTaskUpdate?.(task.id, { isTracking: false }); - console.log(`Timer paused for ${task.title}`); - }} + onStartTimer={() => onStartTimer?.(task.id)} + onStopTimer={() => onStopTimer?.(task.id)} onEdit={() => { onTaskClick?.(task); console.log(`Task clicked from board: ${task.title}`); @@ -258,14 +254,8 @@ export default function KanbanBoard({ tasks, onTaskStatusChange, onTaskUpdate, o > { - onTaskUpdate?.(task.id, { isTracking: true }); - console.log(`Timer started for ${task.title}`); - }} - onPause={() => { - onTaskUpdate?.(task.id, { isTracking: false }); - console.log(`Timer paused for ${task.title}`); - }} + onStartTimer={() => onStartTimer?.(task.id)} + onStopTimer={() => onStopTimer?.(task.id)} onEdit={() => { onTaskClick?.(task); console.log(`Task clicked from board: ${task.title}`); @@ -328,14 +318,8 @@ export default function KanbanBoard({ tasks, onTaskStatusChange, onTaskUpdate, o > { - onTaskUpdate?.(task.id, { isTracking: true }); - console.log(`Timer started for ${task.title}`); - }} - onPause={() => { - onTaskUpdate?.(task.id, { isTracking: false }); - console.log(`Timer paused for ${task.title}`); - }} + onStartTimer={() => onStartTimer?.(task.id)} + onStopTimer={() => onStopTimer?.(task.id)} onEdit={() => { onTaskClick?.(task); console.log(`Task clicked from board: ${task.title}`); diff --git a/client/src/components/TaskCard.tsx b/client/src/components/TaskCard.tsx index 8f7e7d6..c6afe05 100644 --- a/client/src/components/TaskCard.tsx +++ b/client/src/components/TaskCard.tsx @@ -9,22 +9,20 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { Clock, Calendar, Play, Pause, MoreHorizontal, Edit, Trash2, Timer, CheckCircle } from "lucide-react"; -import { useState } from "react"; import { Task, Label } from '@shared/schema'; import { useQuery } from '@tanstack/react-query'; interface TaskCardProps { task: Task; - onPlay?: () => void; - onPause?: () => void; + onStartTimer?: () => void; + onStopTimer?: () => void; onEdit?: () => void; onDelete?: () => void; onStatusChange?: (status: Task['status']) => void; isDragging?: boolean; } -export default function TaskCard({ task, onPlay, onPause, onEdit, onDelete, onStatusChange, isDragging }: TaskCardProps) { - const [isTimerRunning, setIsTimerRunning] = useState(task.isTracking); +export default function TaskCard({ task, onStartTimer, onStopTimer, onEdit, onDelete, onStatusChange, isDragging }: TaskCardProps) { // Fetch labels to get the label color const { data: labels = [] } = useQuery({ @@ -37,14 +35,13 @@ export default function TaskCard({ task, onPlay, onPause, onEdit, onDelete, onSt const taskLabel = task.labelId && labels.length > 0 ? labels.find(label => label.id === task.labelId) : null; const handleToggleTimer = () => { - if (isTimerRunning) { - onPause?.(); - console.log(`Timer paused for task: ${task.title}`); + if (task.isTracking) { + onStopTimer?.(); + console.log(`Timer stopped for task: ${task.title}`); } else { - onPlay?.(); + onStartTimer?.(); console.log(`Timer started for task: ${task.title}`); } - setIsTimerRunning(!isTimerRunning); }; const getPriorityColor = (priority: string) => { @@ -64,8 +61,14 @@ export default function TaskCard({ task, onPlay, onPause, onEdit, onDelete, onSt }; const formatTime = (minutes: number) => { + if (minutes < 60) { + return `${minutes}m`; + } const hours = Math.floor(minutes / 60); const mins = minutes % 60; + if (mins === 0) { + return `${hours}h`; + } return `${hours}h ${mins}m`; }; @@ -119,11 +122,15 @@ export default function TaskCard({ task, onPlay, onPause, onEdit, onDelete, onSt )} - {task.timeTracked > 0 && ( -
- - - {formatTime(task.timeTracked)} + {(task.timeTracked > 0 || task.isTracking) && ( +
+ + + {task.timeTracked > 0 ? formatTime(task.timeTracked) : '0m'} + {task.isTracking && ' (running)'}
)} @@ -163,7 +170,7 @@ export default function TaskCard({ task, onPlay, onPause, onEdit, onDelete, onSt data-testid={`menu-timer-${task.id}`} > - {isTimerRunning ? 'Stop Timer' : 'Start Timer'} + {task.isTracking ? 'Stop Timer' : 'Start Timer'} @@ -245,12 +252,12 @@ export default function TaskCard({ task, onPlay, onPause, onEdit, onDelete, onSt
diff --git a/client/src/components/TasksWithCalendar.tsx b/client/src/components/TasksWithCalendar.tsx index cbb8f89..ceda02c 100644 --- a/client/src/components/TasksWithCalendar.tsx +++ b/client/src/components/TasksWithCalendar.tsx @@ -15,12 +15,14 @@ interface TasksWithCalendarProps { tasks: Task[]; onTaskUpdate?: (taskId: string, updates: Partial) => void; onTaskEdit?: (task: Task) => void; + onStartTimer?: (taskId: string) => void; + onStopTimer?: (taskId: string) => void; } type SortOption = 'dueDate' | 'priority' | 'title' | 'status'; type FilterOption = 'all' | 'todo' | 'inProgress' | 'done' | 'overdue'; -export default function TasksWithCalendar({ tasks, onTaskUpdate, onTaskEdit }: TasksWithCalendarProps) { +export default function TasksWithCalendar({ tasks, onTaskUpdate, onTaskEdit, onStartTimer, onStopTimer }: TasksWithCalendarProps) { const [searchQuery, setSearchQuery] = useState(''); const [sortBy, setSortBy] = useState('dueDate'); const [filterBy, setFilterBy] = useState('all'); @@ -75,14 +77,14 @@ export default function TasksWithCalendar({ tasks, onTaskUpdate, onTaskEdit }: T case 'priority': const priorityOrder = { high: 3, medium: 2, low: 1 }; - return priorityOrder[b.priority] - priorityOrder[a.priority]; + return priorityOrder[b.priority as keyof typeof priorityOrder] - priorityOrder[a.priority as keyof typeof priorityOrder]; case 'title': return a.title.localeCompare(b.title); case 'status': const statusOrder = { todo: 1, inProgress: 2, done: 3 }; - return statusOrder[a.status] - statusOrder[b.status]; + return statusOrder[a.status as keyof typeof statusOrder] - statusOrder[b.status as keyof typeof statusOrder]; default: return 0; @@ -273,14 +275,8 @@ export default function TasksWithCalendar({ tasks, onTaskUpdate, onTaskEdit }: T > { - onTaskUpdate?.(task.id, { isTracking: true, timeTracked: task.timeTracked }); - console.log(`Timer started for ${task.title}`); - }} - onPause={() => { - onTaskUpdate?.(task.id, { isTracking: false }); - console.log(`Timer paused for ${task.title}`); - }} + onStartTimer={() => onStartTimer?.(task.id)} + onStopTimer={() => onStopTimer?.(task.id)} onEdit={() => { onTaskEdit?.(task); console.log(`Edit task ${task.title}`); diff --git a/client/src/hooks/useTimer.ts b/client/src/hooks/useTimer.ts new file mode 100644 index 0000000..2d62141 --- /dev/null +++ b/client/src/hooks/useTimer.ts @@ -0,0 +1,113 @@ +import { useEffect, useRef } from 'react'; +import { Task } from '@shared/schema'; + +interface UseTimerProps { + tasks: Task[]; + onTaskUpdate: (taskId: string, updates: Partial) => void; +} + +export function useTimer({ tasks, onTaskUpdate }: UseTimerProps) { + const intervalRef = useRef(null); + const lastUpdateRef = useRef<{ [taskId: string]: number }>({}); + + useEffect(() => { + // Find tasks that are currently being tracked + const trackingTasks = tasks.filter(task => task.isTracking); + + if (trackingTasks.length > 0) { + // Start the timer + intervalRef.current = setInterval(() => { + const now = Date.now(); + + trackingTasks.forEach(task => { + // Initialize last update time if not set + if (!lastUpdateRef.current[task.id]) { + lastUpdateRef.current[task.id] = now; + return; + } + + // Calculate elapsed time since last update (in minutes) + const elapsed = (now - lastUpdateRef.current[task.id]) / 1000 / 60; + + // Only update if at least 1 minute has passed (to avoid too frequent updates) + if (elapsed >= 1) { + const currentTime = task.timeTracked || 0; + const newTime = currentTime + Math.floor(elapsed); + + onTaskUpdate(task.id, { timeTracked: newTime }); + lastUpdateRef.current[task.id] = now; + + console.log(`Timer updated for "${task.title}": +${Math.floor(elapsed)}min (total: ${newTime}min)`); + } + }); + }, 60000); // Update every minute + } else { + // No tasks are being tracked, clear the timer + if (intervalRef.current) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + // Clear last update times for stopped tasks + lastUpdateRef.current = {}; + } + + // Cleanup on unmount or when tracking tasks change + return () => { + if (intervalRef.current) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + }; + }, [tasks.map(t => `${t.id}:${t.isTracking}`).join(','), onTaskUpdate]); + + // Initialize last update times for currently tracking tasks + useEffect(() => { + const now = Date.now(); + tasks.forEach(task => { + if (task.isTracking && !lastUpdateRef.current[task.id]) { + lastUpdateRef.current[task.id] = now; + } else if (!task.isTracking && lastUpdateRef.current[task.id]) { + delete lastUpdateRef.current[task.id]; + } + }); + }, [tasks]); + + const startTimer = (taskId: string) => { + const now = Date.now(); + lastUpdateRef.current[taskId] = now; + onTaskUpdate(taskId, { isTracking: true }); + console.log(`Timer started for task: ${taskId}`); + }; + + const stopTimer = (taskId: string) => { + const task = tasks.find(t => t.id === taskId); + if (task && lastUpdateRef.current[taskId]) { + const now = Date.now(); + const elapsed = (now - lastUpdateRef.current[taskId]) / 1000 / 60; + + if (elapsed >= 0.1) { // Add time if at least 6 seconds have passed + const currentTime = task.timeTracked || 0; + const additionalTime = Math.max(1, Math.round(elapsed)); // At least 1 minute + const newTime = currentTime + additionalTime; + + onTaskUpdate(taskId, { + isTracking: false, + timeTracked: newTime + }); + + console.log(`Timer stopped for "${task.title}": +${additionalTime}min (total: ${newTime}min)`); + } else { + onTaskUpdate(taskId, { isTracking: false }); + } + + delete lastUpdateRef.current[taskId]; + } else { + onTaskUpdate(taskId, { isTracking: false }); + } + }; + + return { + startTimer, + stopTimer + }; +} \ No newline at end of file