From be21da5e3b1a3a521dbf5bcdcd18715d99c98b88 Mon Sep 17 00:00:00 2001 From: Mo Tarbin Date: Sat, 12 Jul 2025 16:36:41 -0400 Subject: [PATCH] implement custom useTimer hook for improved timer functionality --- src/hooks/useTimer.js | 115 +++++++++++++++++++++++++++++++ src/views/TestView/TimerCard.jsx | 109 ++++++++++++++++------------- src/views/components/NavBar.jsx | 2 +- 3 files changed, 178 insertions(+), 48 deletions(-) create mode 100644 src/hooks/useTimer.js diff --git a/src/hooks/useTimer.js b/src/hooks/useTimer.js new file mode 100644 index 0000000..af78e35 --- /dev/null +++ b/src/hooks/useTimer.js @@ -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 diff --git a/src/views/TestView/TimerCard.jsx b/src/views/TestView/TimerCard.jsx index d8429ce..510d026 100644 --- a/src/views/TestView/TimerCard.jsx +++ b/src/views/TestView/TimerCard.jsx @@ -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} {!isRunning ? ( @@ -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 ? ( @@ -136,6 +149,8 @@ const TimerCard = ({ size='sm' onClick={stopTimer} sx={{ width: 24, height: 24 }} + aria-label='Stop timer' + title='Stop timer (Escape)' > @@ -193,7 +208,7 @@ const TimerCard = ({ transition: 'color 0.3s ease', }} > - {formatTime(time)} + {formattedTime} {isRunning && !isPaused ? 'Running' : isPaused ? 'Paused' : 'Ready'} @@ -350,7 +365,7 @@ const TimerCard = ({ mb: 0.5, }} > - {formatTime(time)} + {formattedTime} { zIndex: 10000, top: 0, minHeight: '45px', - backgroundColor: 'var(--joy-palette-background-surface)', + backgroundColor: 'var(--joy-palette-background-body)', }} > setDrawerOpen(true)}>