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) => {
|
{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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue