Merge branch 'dev'
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 { Pause, PlayArrow, Stop, WatchLater } from '@mui/icons-material'
|
||||||
import { Box, Card, CardContent, IconButton, Typography } from '@mui/joy'
|
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 = ({
|
const TimerCard = ({
|
||||||
variant = 'standalone', // 'standalone' | 'infoCard' | 'floating'
|
variant = 'standalone', // 'standalone' | 'infoCard' | 'floating'
|
||||||
@@ -8,55 +9,59 @@ const TimerCard = ({
|
|||||||
onTimeUpdate = () => {},
|
onTimeUpdate = () => {},
|
||||||
title = 'Timer',
|
title = 'Timer',
|
||||||
}) => {
|
}) => {
|
||||||
const [time, setTime] = useState(0) // Time in seconds
|
// Use the custom timer hook
|
||||||
const [isRunning, setIsRunning] = useState(false)
|
const {
|
||||||
const [isPaused, setIsPaused] = useState(false)
|
time,
|
||||||
const intervalRef = useRef(null)
|
isRunning,
|
||||||
|
isPaused,
|
||||||
|
startTimer,
|
||||||
|
pauseTimer,
|
||||||
|
resumeTimer,
|
||||||
|
stopTimer,
|
||||||
|
} = useTimer(onTimeUpdate)
|
||||||
|
|
||||||
// Format time as HH:MM:SS
|
// Memoize formatted time for better performance
|
||||||
const formatTime = seconds => {
|
const formattedTime = useMemo(() => {
|
||||||
const hours = Math.floor(seconds / 3600)
|
const hours = Math.floor(time / 3600)
|
||||||
const minutes = Math.floor((seconds % 3600) / 60)
|
const minutes = Math.floor((time % 3600) / 60)
|
||||||
const secs = seconds % 60
|
const secs = time % 60
|
||||||
return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`
|
return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`
|
||||||
}
|
}, [time])
|
||||||
|
|
||||||
// Handle timer logic
|
// Add keyboard shortcuts
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isRunning && !isPaused) {
|
const handleKeyPress = event => {
|
||||||
intervalRef.current = setInterval(() => {
|
// Only handle if no input is focused
|
||||||
setTime(prevTime => {
|
if (
|
||||||
const newTime = prevTime + 1
|
document.activeElement?.tagName === 'INPUT' ||
|
||||||
onTimeUpdate(newTime)
|
document.activeElement?.tagName === 'TEXTAREA'
|
||||||
return newTime
|
) {
|
||||||
})
|
return
|
||||||
}, 1000)
|
}
|
||||||
} else {
|
|
||||||
clearInterval(intervalRef.current)
|
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)
|
window.addEventListener('keydown', handleKeyPress)
|
||||||
}, [isRunning, isPaused, onTimeUpdate])
|
return () => window.removeEventListener('keydown', handleKeyPress)
|
||||||
|
}, [isRunning, isPaused, startTimer, pauseTimer, resumeTimer, stopTimer])
|
||||||
const startTimer = () => {
|
|
||||||
setIsRunning(true)
|
|
||||||
setIsPaused(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
const pauseTimer = () => {
|
|
||||||
setIsPaused(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
const stopTimer = () => {
|
|
||||||
setIsRunning(false)
|
|
||||||
setIsPaused(false)
|
|
||||||
setTime(0)
|
|
||||||
onTimeUpdate(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
const resumeTimer = () => {
|
|
||||||
setIsPaused(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Info Card variant - fits in ChoreView grid
|
// Info Card variant - fits in ChoreView grid
|
||||||
if (variant === 'infoCard') {
|
if (variant === 'infoCard') {
|
||||||
@@ -103,7 +108,7 @@ const TimerCard = ({
|
|||||||
transition: 'color 0.3s ease',
|
transition: 'color 0.3s ease',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{formatTime(time)}
|
{formattedTime}
|
||||||
</Typography>
|
</Typography>
|
||||||
{!isRunning ? (
|
{!isRunning ? (
|
||||||
<IconButton
|
<IconButton
|
||||||
@@ -112,6 +117,8 @@ const TimerCard = ({
|
|||||||
size='sm'
|
size='sm'
|
||||||
onClick={startTimer}
|
onClick={startTimer}
|
||||||
sx={{ width: 24, height: 24 }}
|
sx={{ width: 24, height: 24 }}
|
||||||
|
aria-label='Start timer'
|
||||||
|
title='Start timer (Spacebar)'
|
||||||
>
|
>
|
||||||
<PlayArrow sx={{ fontSize: '1rem' }} />
|
<PlayArrow sx={{ fontSize: '1rem' }} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
@@ -123,6 +130,12 @@ const TimerCard = ({
|
|||||||
size='sm'
|
size='sm'
|
||||||
onClick={isPaused ? resumeTimer : pauseTimer}
|
onClick={isPaused ? resumeTimer : pauseTimer}
|
||||||
sx={{ width: 24, height: 24 }}
|
sx={{ width: 24, height: 24 }}
|
||||||
|
aria-label={isPaused ? 'Resume timer' : 'Pause timer'}
|
||||||
|
title={
|
||||||
|
isPaused
|
||||||
|
? 'Resume timer (Spacebar)'
|
||||||
|
: 'Pause timer (Spacebar)'
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{isPaused ? (
|
{isPaused ? (
|
||||||
<PlayArrow sx={{ fontSize: '1rem' }} />
|
<PlayArrow sx={{ fontSize: '1rem' }} />
|
||||||
@@ -136,6 +149,8 @@ const TimerCard = ({
|
|||||||
size='sm'
|
size='sm'
|
||||||
onClick={stopTimer}
|
onClick={stopTimer}
|
||||||
sx={{ width: 24, height: 24 }}
|
sx={{ width: 24, height: 24 }}
|
||||||
|
aria-label='Stop timer'
|
||||||
|
title='Stop timer (Escape)'
|
||||||
>
|
>
|
||||||
<Stop sx={{ fontSize: '1rem' }} />
|
<Stop sx={{ fontSize: '1rem' }} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
@@ -193,7 +208,7 @@ const TimerCard = ({
|
|||||||
transition: 'color 0.3s ease',
|
transition: 'color 0.3s ease',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{formatTime(time)}
|
{formattedTime}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography level='body-xs' color='text.secondary'>
|
<Typography level='body-xs' color='text.secondary'>
|
||||||
{isRunning && !isPaused ? 'Running' : isPaused ? 'Paused' : 'Ready'}
|
{isRunning && !isPaused ? 'Running' : isPaused ? 'Paused' : 'Ready'}
|
||||||
@@ -350,7 +365,7 @@ const TimerCard = ({
|
|||||||
mb: 0.5,
|
mb: 0.5,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{formatTime(time)}
|
{formattedTime}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography
|
<Typography
|
||||||
level='body-xs'
|
level='body-xs'
|
||||||
|
|||||||
@@ -106,14 +106,14 @@ const NavBar = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<nav
|
<nav
|
||||||
className='mt-2 flex gap-2 p-3'
|
className='mt-2 flex gap-2 p-3 pt-5'
|
||||||
style={{
|
style={{
|
||||||
paddingTop: `calc( env(safe-area-inset-top, 0px))`,
|
paddingTop: `calc( env(safe-area-inset-top, 0px))`,
|
||||||
position: 'sticky',
|
position: 'sticky',
|
||||||
zIndex: 10000,
|
zIndex: 10000,
|
||||||
top: 0,
|
top: 0,
|
||||||
minHeight: '45px',
|
minHeight: '45px',
|
||||||
backgroundColor: 'var(--joy-palette-background-surface)',
|
backgroundColor: 'var(--joy-palette-background-body)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<IconButton size='md' variant='plain' onClick={() => setDrawerOpen(true)}>
|
<IconButton size='md' variant='plain' onClick={() => setDrawerOpen(true)}>
|
||||||
|
|||||||
Reference in New Issue
Block a user