fix(timer): resolve tab throttling and background freeze using wall-clock time and visibilitychange
This commit is contained in:
parent
00224cd650
commit
92aaeaf153
3 changed files with 104 additions and 16 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue