fix(timer): resolve tab throttling and background freeze using wall-clock time and visibilitychange

This commit is contained in:
Gabriel Menezes 2026-10-13 09:38:16 -04:00
parent 00224cd650
commit 92aaeaf153
3 changed files with 104 additions and 16 deletions

View file

@ -100,7 +100,11 @@ export const TaskKanbanView: React.FC<TaskKanbanViewProps> = ({
{COLUMNS.map((col) => { {COLUMNS.map((col) => {
const columnTasks = tasks.filter((t) => t.status === col.status) const columnTasks = tasks.filter((t) => t.status === col.status)
const colTotalSeconds = columnTasks.reduce( const colTotalSeconds = columnTasks.reduce(
(acc, t) => acc + calculateTaskTotalSeconds(t), (acc, t) =>
acc +
(t.id === activeTaskId && activeTaskSeconds !== undefined
? activeTaskSeconds
: calculateTaskTotalSeconds(t)),
0 0
) )
const Icon = col.icon const Icon = col.icon

View file

@ -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 type { Task } from '../types/task'
import { taskService } from '../services/taskService' import { taskService } from '../services/taskService'
import { isTaskRunning, calculateTaskTotalSeconds } from '../lib/timeUtils' import { isTaskRunning, calculateBaseSeconds } from '../lib/timeUtils'
import { useAuth } from './AuthContext'
interface TimerContextType { interface TimerContextType {
activeTask: Task | null activeTask: Task | null
@ -14,35 +15,93 @@ interface TimerContextType {
const TimerContext = createContext<TimerContextType | undefined>(undefined) const TimerContext = createContext<TimerContextType | undefined>(undefined)
export const TimerProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { export const TimerProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { activeOrg } = useAuth()
const [activeTask, setActiveTask] = useState<Task | null>(null) const [activeTask, setActiveTask] = useState<Task | null>(null)
const [activeSeconds, setActiveSeconds] = useState<number>(0) const [activeSeconds, setActiveSeconds] = useState<number>(0)
// Track the baseline seconds and client-side timestamp when task data was fetched/started
const baseSecondsRef = useRef<number>(0)
const receivedAtRef = useRef<number>(Date.now())
// Sync state whenever tasks list updates // Sync state whenever tasks list updates
const syncWithTasks = useCallback((tasks: Task[]) => { const syncWithTasks = useCallback((tasks: Task[]) => {
const running = tasks.find((t) => isTaskRunning(t)) const running = tasks.find((t) => isTaskRunning(t))
if (running) { if (running) {
setActiveTask(running) setActiveTask(running)
setActiveSeconds(calculateTaskTotalSeconds(running)) const base = calculateBaseSeconds(running)
baseSecondsRef.current = base
receivedAtRef.current = Date.now()
setActiveSeconds(base)
} else { } else {
setActiveTask(null) setActiveTask(null)
baseSecondsRef.current = 0
setActiveSeconds(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(() => { useEffect(() => {
if (!activeTask) return if (!activeTask) return
const interval = setInterval(() => { const updateSeconds = () => {
setActiveSeconds((prev) => prev + 1) const elapsedSinceReceived = Math.max(0, Math.floor((Date.now() - receivedAtRef.current) / 1000))
}, 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]) }, [activeTask])
const startTimer = useCallback( const startTimer = useCallback(
async (task: Task): Promise<Task> => { async (task: Task): Promise<Task> => {
// 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) { if (activeTask && activeTask.id !== task.id) {
try { try {
await taskService.stopTask(activeTask.id) 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) const updated = await taskService.startTask(task.id)
setActiveTask(updated) setActiveTask(updated)
setActiveSeconds(calculateTaskTotalSeconds(updated)) const base = calculateBaseSeconds(updated)
baseSecondsRef.current = base
receivedAtRef.current = Date.now()
setActiveSeconds(base)
return updated return updated
}, },
[activeTask] [activeTask]
@ -66,6 +128,7 @@ export const TimerProvider: React.FC<{ children: React.ReactNode }> = ({ childre
const updated = await taskService.stopTask(targetId) const updated = await taskService.stopTask(targetId)
setActiveTask(null) setActiveTask(null)
baseSecondsRef.current = 0
setActiveSeconds(0) setActiveSeconds(0)
return updated return updated
}, },

View file

@ -8,15 +8,36 @@ export function isTaskRunning(task: Task): boolean {
export function calculateTaskTotalSeconds(task: Task): number { export function calculateTaskTotalSeconds(task: Task): number {
if (!task.history || task.history.length === 0) return 0 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 let total = 0
for (const item of task.history) { for (const item of task.history) {
if (item.elapsedSeconds) { if (item.elapsedSeconds) {
total += 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 return total