Merge branch 'dev'

This commit is contained in:
Mo Tarbin
2025-07-12 16:37:04 -04:00
3 changed files with 179 additions and 49 deletions

115
src/hooks/useTimer.js Normal file
View 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

View File

@@ -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'

View File

@@ -106,14 +106,14 @@ const NavBar = () => {
return (
<nav
className='mt-2 flex gap-2 p-3'
className='mt-2 flex gap-2 p-3 pt-5'
style={{
paddingTop: `calc( env(safe-area-inset-top, 0px))`,
position: 'sticky',
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)}>