diff --git a/new-frontend/src/components/tasks/TaskKanbanView.tsx b/new-frontend/src/components/tasks/TaskKanbanView.tsx index 3a8fe84..a1ffd9a 100644 --- a/new-frontend/src/components/tasks/TaskKanbanView.tsx +++ b/new-frontend/src/components/tasks/TaskKanbanView.tsx @@ -100,7 +100,11 @@ export const TaskKanbanView: React.FC = ({ {COLUMNS.map((col) => { const columnTasks = tasks.filter((t) => t.status === col.status) const colTotalSeconds = columnTasks.reduce( - (acc, t) => acc + calculateTaskTotalSeconds(t), + (acc, t) => + acc + + (t.id === activeTaskId && activeTaskSeconds !== undefined + ? activeTaskSeconds + : calculateTaskTotalSeconds(t)), 0 ) const Icon = col.icon diff --git a/new-frontend/src/contexts/TimerContext.tsx b/new-frontend/src/contexts/TimerContext.tsx index cb1cc57..09fcabe 100644 --- a/new-frontend/src/contexts/TimerContext.tsx +++ b/new-frontend/src/contexts/TimerContext.tsx @@ -1,7 +1,8 @@ -import React, { createContext, useContext, useState, useEffect, useCallback } from 'react' +import React, { createContext, useContext, useState, useEffect, useCallback, useRef } from 'react' import type { Task } from '../types/task' import { taskService } from '../services/taskService' -import { isTaskRunning, calculateTaskTotalSeconds } from '../lib/timeUtils' +import { isTaskRunning, calculateBaseSeconds } from '../lib/timeUtils' +import { useAuth } from './AuthContext' interface TimerContextType { activeTask: Task | null @@ -14,35 +15,93 @@ interface TimerContextType { const TimerContext = createContext(undefined) export const TimerProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const { activeOrg } = useAuth() const [activeTask, setActiveTask] = useState(null) const [activeSeconds, setActiveSeconds] = useState(0) + // Track the baseline seconds and client-side timestamp when task data was fetched/started + const baseSecondsRef = useRef(0) + const receivedAtRef = useRef(Date.now()) + // Sync state whenever tasks list updates const syncWithTasks = useCallback((tasks: Task[]) => { const running = tasks.find((t) => isTaskRunning(t)) if (running) { setActiveTask(running) - setActiveSeconds(calculateTaskTotalSeconds(running)) + const base = calculateBaseSeconds(running) + baseSecondsRef.current = base + receivedAtRef.current = Date.now() + setActiveSeconds(base) } else { setActiveTask(null) + baseSecondsRef.current = 0 setActiveSeconds(0) } }, []) - // Live timer interval + // Auto-fetch active running task on mount or workspace change + useEffect(() => { + if (!activeOrg?.id) return + let isMounted = true + + taskService + .listMyTasks({ organizationId: activeOrg.id }) + .then((tasks) => { + if (!isMounted) return + const running = tasks.find((t) => isTaskRunning(t)) + if (running) { + syncWithTasks(tasks) + } + }) + .catch((err) => { + console.warn('Could not auto-fetch running task on init', err) + }) + + return () => { + isMounted = false + } + }, [activeOrg?.id, syncWithTasks]) + + // Live timer interval: uses wall-clock Date.now() and visibilitychange/focus + // This guarantees that switching tabs or minimizing the browser NEVER freezes or drifts the timer useEffect(() => { if (!activeTask) return - const interval = setInterval(() => { - setActiveSeconds((prev) => prev + 1) - }, 1000) + const updateSeconds = () => { + const elapsedSinceReceived = Math.max(0, Math.floor((Date.now() - receivedAtRef.current) / 1000)) + setActiveSeconds(baseSecondsRef.current + elapsedSinceReceived) + } - return () => clearInterval(interval) + // Immediate initial sync + updateSeconds() + + // 1-second interval + const interval = setInterval(updateSeconds, 1000) + + // Instantly catch up when user returns from another tab or focuses window + const handleVisibilityChange = () => { + if (document.visibilityState === 'visible') { + updateSeconds() + } + } + + const handleFocus = () => { + updateSeconds() + } + + document.addEventListener('visibilitychange', handleVisibilityChange) + window.addEventListener('focus', handleFocus) + + return () => { + clearInterval(interval) + document.removeEventListener('visibilitychange', handleVisibilityChange) + window.removeEventListener('focus', handleFocus) + } }, [activeTask]) const startTimer = useCallback( async (task: Task): Promise => { - // If there was an active task running different from this one, stop it first or handle + // If there was an active task running different from this one, stop it first if (activeTask && activeTask.id !== task.id) { try { await taskService.stopTask(activeTask.id) @@ -53,7 +112,10 @@ export const TimerProvider: React.FC<{ children: React.ReactNode }> = ({ childre const updated = await taskService.startTask(task.id) setActiveTask(updated) - setActiveSeconds(calculateTaskTotalSeconds(updated)) + const base = calculateBaseSeconds(updated) + baseSecondsRef.current = base + receivedAtRef.current = Date.now() + setActiveSeconds(base) return updated }, [activeTask] @@ -66,6 +128,7 @@ export const TimerProvider: React.FC<{ children: React.ReactNode }> = ({ childre const updated = await taskService.stopTask(targetId) setActiveTask(null) + baseSecondsRef.current = 0 setActiveSeconds(0) return updated }, diff --git a/new-frontend/src/lib/timeUtils.ts b/new-frontend/src/lib/timeUtils.ts index b435e13..c4f7a22 100644 --- a/new-frontend/src/lib/timeUtils.ts +++ b/new-frontend/src/lib/timeUtils.ts @@ -8,15 +8,36 @@ export function isTaskRunning(task: Task): boolean { export function calculateTaskTotalSeconds(task: Task): number { if (!task.history || task.history.length === 0) return 0 + let total = 0 + for (const item of task.history) { + if (item.endAt) { + total += item.elapsedSeconds || 0 + } else { + // Running interval: calculate based on start timestamp if available or elapsedSeconds + if (item.startAt) { + // Backend stores UTC LocalDateTime without timezone offset suffix. Append 'Z' to parse as UTC. + const utcDateStr = item.startAt.endsWith('Z') ? item.startAt : `${item.startAt}Z` + const start = new Date(utcDateStr).getTime() + if (!isNaN(start)) { + const diffSec = Math.max(0, Math.floor((Date.now() - start) / 1000)) + total += Math.max(diffSec, item.elapsedSeconds || 0) + } else { + total += item.elapsedSeconds || 0 + } + } else { + total += item.elapsedSeconds || 0 + } + } + } + return total +} + +export function calculateBaseSeconds(task: Task): number { + if (!task.history || task.history.length === 0) return 0 let total = 0 for (const item of task.history) { if (item.elapsedSeconds) { total += item.elapsedSeconds - } else if (item.startAt && !item.endAt) { - const start = new Date(item.startAt).getTime() - const now = Date.now() - const diffSec = Math.max(0, Math.floor((now - start) / 1000)) - total += diffSec } } return total