implement custom useTimer hook for improved timer functionality
This commit is contained in:
115
src/hooks/useTimer.js
Normal file
115
src/hooks/useTimer.js
Normal file
@@ -0,0 +1,115 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
/**
|
||||
* Custom hook for timer functionality with high-resolution timing
|
||||
* Fixes timing drift issues by using timestamps instead of interval counting
|
||||
*/
|
||||
const useTimer = (onTimeUpdate = () => {}) => {
|
||||
const [timerState, setTimerState] = useState('stopped') // 'stopped' | 'running' | 'paused'
|
||||
const [time, setTime] = useState(0) // Current time in seconds
|
||||
|
||||
// Refs for timing calculations
|
||||
const startTimeRef = useRef(null)
|
||||
const pausedTimeRef = useRef(0)
|
||||
const intervalRef = useRef(null)
|
||||
const lastNotifiedTimeRef = useRef(0)
|
||||
|
||||
// Update display and notify parent
|
||||
const updateTime = useCallback(() => {
|
||||
if (timerState === 'running' && startTimeRef.current) {
|
||||
const elapsed = Math.floor((Date.now() - startTimeRef.current) / 1000)
|
||||
const newTime = pausedTimeRef.current + elapsed
|
||||
|
||||
setTime(newTime)
|
||||
|
||||
// Only call onTimeUpdate when the second changes to avoid excessive calls
|
||||
if (newTime !== lastNotifiedTimeRef.current) {
|
||||
lastNotifiedTimeRef.current = newTime
|
||||
onTimeUpdate(newTime)
|
||||
}
|
||||
}
|
||||
}, [timerState, onTimeUpdate])
|
||||
|
||||
// Timer effect with high-frequency updates for smooth display
|
||||
useEffect(() => {
|
||||
if (timerState === 'running') {
|
||||
intervalRef.current = setInterval(updateTime, 200)
|
||||
} else {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current)
|
||||
intervalRef.current = null
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current)
|
||||
intervalRef.current = null
|
||||
}
|
||||
}
|
||||
}, [timerState, updateTime])
|
||||
|
||||
// Timer control functions
|
||||
const startTimer = useCallback(() => {
|
||||
const now = Date.now()
|
||||
startTimeRef.current = now
|
||||
pausedTimeRef.current = 0
|
||||
lastNotifiedTimeRef.current = 0
|
||||
setTime(0)
|
||||
setTimerState('running')
|
||||
onTimeUpdate(0)
|
||||
}, [onTimeUpdate])
|
||||
|
||||
const pauseTimer = useCallback(() => {
|
||||
if (timerState === 'running' && startTimeRef.current) {
|
||||
// Calculate and store the elapsed time
|
||||
const elapsed = Math.floor((Date.now() - startTimeRef.current) / 1000)
|
||||
pausedTimeRef.current = pausedTimeRef.current + elapsed
|
||||
setTimerState('paused')
|
||||
}
|
||||
}, [timerState])
|
||||
|
||||
const resumeTimer = useCallback(() => {
|
||||
if (timerState === 'paused') {
|
||||
// Reset start time for resumed session
|
||||
startTimeRef.current = Date.now()
|
||||
setTimerState('running')
|
||||
}
|
||||
}, [timerState])
|
||||
|
||||
const stopTimer = useCallback(() => {
|
||||
setTimerState('stopped')
|
||||
setTime(0)
|
||||
pausedTimeRef.current = 0
|
||||
startTimeRef.current = null
|
||||
lastNotifiedTimeRef.current = 0
|
||||
onTimeUpdate(0)
|
||||
}, [onTimeUpdate])
|
||||
|
||||
const resetTimer = useCallback(() => {
|
||||
stopTimer()
|
||||
}, [stopTimer])
|
||||
|
||||
// Computed properties
|
||||
const isRunning = timerState === 'running'
|
||||
const isPaused = timerState === 'paused'
|
||||
const isStopped = timerState === 'stopped'
|
||||
|
||||
return {
|
||||
// State
|
||||
time,
|
||||
timerState,
|
||||
isRunning,
|
||||
isPaused,
|
||||
isStopped,
|
||||
|
||||
// Actions
|
||||
startTimer,
|
||||
pauseTimer,
|
||||
resumeTimer,
|
||||
stopTimer,
|
||||
resetTimer,
|
||||
}
|
||||
}
|
||||
|
||||
export default useTimer
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Pause, PlayArrow, Stop, WatchLater } from '@mui/icons-material'
|
||||
import { Box, Card, CardContent, IconButton, Typography } from '@mui/joy'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useEffect, useMemo } from 'react'
|
||||
import useTimer from '../../hooks/useTimer'
|
||||
|
||||
const TimerCard = ({
|
||||
variant = 'standalone', // 'standalone' | 'infoCard' | 'floating'
|
||||
@@ -8,55 +9,59 @@ const TimerCard = ({
|
||||
onTimeUpdate = () => {},
|
||||
title = 'Timer',
|
||||
}) => {
|
||||
const [time, setTime] = useState(0) // Time in seconds
|
||||
const [isRunning, setIsRunning] = useState(false)
|
||||
const [isPaused, setIsPaused] = useState(false)
|
||||
const intervalRef = useRef(null)
|
||||
// Use the custom timer hook
|
||||
const {
|
||||
time,
|
||||
isRunning,
|
||||
isPaused,
|
||||
startTimer,
|
||||
pauseTimer,
|
||||
resumeTimer,
|
||||
stopTimer,
|
||||
} = useTimer(onTimeUpdate)
|
||||
|
||||
// Format time as HH:MM:SS
|
||||
const formatTime = seconds => {
|
||||
const hours = Math.floor(seconds / 3600)
|
||||
const minutes = Math.floor((seconds % 3600) / 60)
|
||||
const secs = seconds % 60
|
||||
// Memoize formatted time for better performance
|
||||
const formattedTime = useMemo(() => {
|
||||
const hours = Math.floor(time / 3600)
|
||||
const minutes = Math.floor((time % 3600) / 60)
|
||||
const secs = time % 60
|
||||
return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`
|
||||
}
|
||||
}, [time])
|
||||
|
||||
// Handle timer logic
|
||||
// Add keyboard shortcuts
|
||||
useEffect(() => {
|
||||
if (isRunning && !isPaused) {
|
||||
intervalRef.current = setInterval(() => {
|
||||
setTime(prevTime => {
|
||||
const newTime = prevTime + 1
|
||||
onTimeUpdate(newTime)
|
||||
return newTime
|
||||
})
|
||||
}, 1000)
|
||||
} else {
|
||||
clearInterval(intervalRef.current)
|
||||
const handleKeyPress = event => {
|
||||
// Only handle if no input is focused
|
||||
if (
|
||||
document.activeElement?.tagName === 'INPUT' ||
|
||||
document.activeElement?.tagName === 'TEXTAREA'
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
switch (event.code) {
|
||||
case 'Space':
|
||||
event.preventDefault()
|
||||
if (!isRunning) {
|
||||
startTimer()
|
||||
} else if (isPaused) {
|
||||
resumeTimer()
|
||||
} else {
|
||||
pauseTimer()
|
||||
}
|
||||
break
|
||||
case 'Escape':
|
||||
event.preventDefault()
|
||||
stopTimer()
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return () => clearInterval(intervalRef.current)
|
||||
}, [isRunning, isPaused, onTimeUpdate])
|
||||
|
||||
const startTimer = () => {
|
||||
setIsRunning(true)
|
||||
setIsPaused(false)
|
||||
}
|
||||
|
||||
const pauseTimer = () => {
|
||||
setIsPaused(true)
|
||||
}
|
||||
|
||||
const stopTimer = () => {
|
||||
setIsRunning(false)
|
||||
setIsPaused(false)
|
||||
setTime(0)
|
||||
onTimeUpdate(0)
|
||||
}
|
||||
|
||||
const resumeTimer = () => {
|
||||
setIsPaused(false)
|
||||
}
|
||||
window.addEventListener('keydown', handleKeyPress)
|
||||
return () => window.removeEventListener('keydown', handleKeyPress)
|
||||
}, [isRunning, isPaused, startTimer, pauseTimer, resumeTimer, stopTimer])
|
||||
|
||||
// Info Card variant - fits in ChoreView grid
|
||||
if (variant === 'infoCard') {
|
||||
@@ -103,7 +108,7 @@ const TimerCard = ({
|
||||
transition: 'color 0.3s ease',
|
||||
}}
|
||||
>
|
||||
{formatTime(time)}
|
||||
{formattedTime}
|
||||
</Typography>
|
||||
{!isRunning ? (
|
||||
<IconButton
|
||||
@@ -112,6 +117,8 @@ const TimerCard = ({
|
||||
size='sm'
|
||||
onClick={startTimer}
|
||||
sx={{ width: 24, height: 24 }}
|
||||
aria-label='Start timer'
|
||||
title='Start timer (Spacebar)'
|
||||
>
|
||||
<PlayArrow sx={{ fontSize: '1rem' }} />
|
||||
</IconButton>
|
||||
@@ -123,6 +130,12 @@ const TimerCard = ({
|
||||
size='sm'
|
||||
onClick={isPaused ? resumeTimer : pauseTimer}
|
||||
sx={{ width: 24, height: 24 }}
|
||||
aria-label={isPaused ? 'Resume timer' : 'Pause timer'}
|
||||
title={
|
||||
isPaused
|
||||
? 'Resume timer (Spacebar)'
|
||||
: 'Pause timer (Spacebar)'
|
||||
}
|
||||
>
|
||||
{isPaused ? (
|
||||
<PlayArrow sx={{ fontSize: '1rem' }} />
|
||||
@@ -136,6 +149,8 @@ const TimerCard = ({
|
||||
size='sm'
|
||||
onClick={stopTimer}
|
||||
sx={{ width: 24, height: 24 }}
|
||||
aria-label='Stop timer'
|
||||
title='Stop timer (Escape)'
|
||||
>
|
||||
<Stop sx={{ fontSize: '1rem' }} />
|
||||
</IconButton>
|
||||
@@ -193,7 +208,7 @@ const TimerCard = ({
|
||||
transition: 'color 0.3s ease',
|
||||
}}
|
||||
>
|
||||
{formatTime(time)}
|
||||
{formattedTime}
|
||||
</Typography>
|
||||
<Typography level='body-xs' color='text.secondary'>
|
||||
{isRunning && !isPaused ? 'Running' : isPaused ? 'Paused' : 'Ready'}
|
||||
@@ -350,7 +365,7 @@ const TimerCard = ({
|
||||
mb: 0.5,
|
||||
}}
|
||||
>
|
||||
{formatTime(time)}
|
||||
{formattedTime}
|
||||
</Typography>
|
||||
<Typography
|
||||
level='body-xs'
|
||||
|
||||
@@ -113,7 +113,7 @@ const NavBar = () => {
|
||||
zIndex: 10000,
|
||||
top: 0,
|
||||
minHeight: '45px',
|
||||
backgroundColor: 'var(--joy-palette-background-surface)',
|
||||
backgroundColor: 'var(--joy-palette-background-body)',
|
||||
}}
|
||||
>
|
||||
<IconButton size='md' variant='plain' onClick={() => setDrawerOpen(true)}>
|
||||
|
||||
Reference in New Issue
Block a user