Timer implemented. No working tests.

This commit is contained in:
AG
2025-12-10 23:07:31 +02:00
parent 3df4abba47
commit b86664816d
24 changed files with 806 additions and 116 deletions

120
src/hooks/useRestTimer.ts Normal file
View File

@@ -0,0 +1,120 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { playTimeUpSignal } from '../utils/audio';
export type TimerStatus = 'IDLE' | 'RUNNING' | 'PAUSED' | 'FINISHED';
interface UseRestTimerProps {
defaultTime: number; // in seconds
onFinish?: () => void;
autoStart?: boolean;
}
export const useRestTimer = ({ defaultTime, onFinish }: UseRestTimerProps) => {
const [timeLeft, setTimeLeft] = useState(defaultTime);
const [status, setStatus] = useState<TimerStatus>('IDLE');
const [duration, setDuration] = useState(defaultTime); // The set duration to reset to
const endTimeRef = useRef<number | null>(null);
const rafRef = useRef<number | null>(null);
const prevDefaultTimeRef = useRef(defaultTime);
// Update internal duration when defaultTime changes
useEffect(() => {
if (prevDefaultTimeRef.current !== defaultTime) {
prevDefaultTimeRef.current = defaultTime;
setDuration(defaultTime);
// Only update visible time if IDLE. If running, it will apply on next reset.
if (status === 'IDLE') {
setTimeLeft(defaultTime);
}
}
}, [defaultTime, status]);
useEffect(() => {
return () => {
if (rafRef.current) cancelAnimationFrame(rafRef.current);
};
}, []);
const tick = useCallback(() => {
if (!endTimeRef.current) return;
const now = Date.now();
const remaining = Math.max(0, Math.ceil((endTimeRef.current - now) / 1000));
setTimeLeft(remaining);
if (remaining <= 0) {
setStatus('FINISHED');
playTimeUpSignal();
if (onFinish) onFinish();
// Auto-reset visuals after 3 seconds of "FINISHED" state?
// Requirement says: "The FAB must first change color to red for 3 seconds, and then return to the idle state"
// So the hook stays in FINISHED.
setTimeout(() => {
reset();
}, 3000);
} else {
rafRef.current = requestAnimationFrame(tick);
}
}, [onFinish]);
const start = useCallback(() => {
if (status === 'RUNNING') return;
// If starting from IDLE or PAUSED
const targetSeconds = status === 'PAUSED' ? timeLeft : duration;
endTimeRef.current = Date.now() + targetSeconds * 1000;
setStatus('RUNNING');
rafRef.current = requestAnimationFrame(tick);
}, [status, timeLeft, duration, tick]);
const pause = useCallback(() => {
if (status !== 'RUNNING') return;
setStatus('PAUSED');
if (rafRef.current) cancelAnimationFrame(rafRef.current);
endTimeRef.current = null;
}, [status]);
const reset = useCallback((newDuration?: number) => {
if (rafRef.current) cancelAnimationFrame(rafRef.current);
const nextDuration = newDuration !== undefined ? newDuration : duration;
setDuration(nextDuration);
setTimeLeft(nextDuration);
setStatus('IDLE');
endTimeRef.current = null;
}, [duration]);
const addTime = useCallback((seconds: number) => {
setDuration(prev => prev + seconds);
if (status === 'IDLE') {
setTimeLeft(prev => prev + seconds);
} else if (status === 'RUNNING') {
// Add to current target
if (endTimeRef.current) {
endTimeRef.current += seconds * 1000;
// Force immediate update to avoid flicker
const now = Date.now();
setTimeLeft(Math.max(0, Math.ceil((endTimeRef.current - now) / 1000)));
}
} else if (status === 'PAUSED') {
setTimeLeft(prev => prev + seconds);
}
}, [status]);
return {
timeLeft,
status,
start,
pause,
reset,
addTime,
setDuration: (val: number) => {
setDuration(val);
if (status === 'IDLE') setTimeLeft(val);
}
};
};