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) => {
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

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 { 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<TimerContextType | undefined>(undefined)
export const TimerProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { activeOrg } = useAuth()
const [activeTask, setActiveTask] = useState<Task | null>(null)
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
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<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) {
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
},

View file

@ -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