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)}>