Timer is persistent with Local Storage. Drag&Drop of planned sets fixed on mobile.

This commit is contained in:
AG
2025-12-12 20:59:51 +02:00
parent e1253f4100
commit 7d82444e94
5 changed files with 343 additions and 120 deletions

View File

@@ -10,14 +10,91 @@ interface UseRestTimerProps {
}
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
// Initial state function to restore from localStorage if available
const getInitialState = () => {
try {
const saved = localStorage.getItem('gymflow_rest_timer');
if (saved) {
const parsed = JSON.parse(saved);
// Validate parsed data structure lightly
if (parsed && typeof parsed.timeLeft === 'number') {
return parsed;
}
}
} catch (e) {
console.error("Failed to parse saved timer", e);
}
return null;
};
const endTimeRef = useRef<number | null>(null);
const savedState = getInitialState();
// If we have a saved running timer, we need to recalculate time left
let initialTimeLeft = defaultTime;
let initialStatus: TimerStatus = 'IDLE';
let initialDuration = defaultTime;
if (savedState) {
initialDuration = savedState.duration || defaultTime;
initialStatus = savedState.status;
initialTimeLeft = savedState.timeLeft;
if (initialStatus === 'RUNNING' && savedState.endTime) {
const now = Date.now();
const remaining = Math.max(0, Math.ceil((savedState.endTime - now) / 1000));
if (remaining > 0) {
initialTimeLeft = remaining;
} else {
initialStatus = 'FINISHED'; // It finished while we were away
initialTimeLeft = 0;
}
}
}
const [timeLeft, setTimeLeft] = useState(initialTimeLeft);
const [status, setStatus] = useState<TimerStatus>(initialStatus);
const [duration, setDuration] = useState(initialDuration);
const endTimeRef = useRef<number | null>(savedState?.endTime || null);
const rafRef = useRef<number | null>(null);
const prevDefaultTimeRef = useRef(defaultTime);
// Tick function - defined before effects
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();
endTimeRef.current = null; // Clear end time
// Auto-reset visuals after 3 seconds of "FINISHED" state?
setTimeout(() => {
setStatus(prev => prev === 'FINISHED' ? 'IDLE' : prev);
setTimeLeft(prev => prev === 0 ? duration : prev);
}, 3000);
} else {
rafRef.current = requestAnimationFrame(tick);
}
}, [duration, onFinish]);
// Save to localStorage whenever relevant state changes
useEffect(() => {
const stateToSave = {
status,
timeLeft,
duration,
endTime: endTimeRef.current
};
localStorage.setItem('gymflow_rest_timer', JSON.stringify(stateToSave));
}, [status, timeLeft, duration]);
// Update internal duration when defaultTime changes
useEffect(() => {
if (prevDefaultTimeRef.current !== defaultTime) {
@@ -30,34 +107,27 @@ export const useRestTimer = ({ defaultTime, onFinish }: UseRestTimerProps) => {
}
}, [defaultTime, status]);
// Manage RAF based on 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);
if (status === 'RUNNING') {
if (!rafRef.current) {
rafRef.current = requestAnimationFrame(tick);
}
} else {
rafRef.current = requestAnimationFrame(tick);
if (rafRef.current) {
cancelAnimationFrame(rafRef.current);
rafRef.current = null;
}
}
}, [onFinish]);
return () => {
if (rafRef.current) {
cancelAnimationFrame(rafRef.current);
rafRef.current = null;
}
};
}, [status, tick]);
const start = useCallback(() => {
if (status === 'RUNNING') return;
@@ -67,24 +137,23 @@ export const useRestTimer = ({ defaultTime, onFinish }: UseRestTimerProps) => {
endTimeRef.current = Date.now() + targetSeconds * 1000;
setStatus('RUNNING');
rafRef.current = requestAnimationFrame(tick);
}, [status, timeLeft, duration, tick]);
// Effect will trigger tick
}, [status, timeLeft, duration]);
const pause = useCallback(() => {
if (status !== 'RUNNING') return;
setStatus('PAUSED');
if (rafRef.current) cancelAnimationFrame(rafRef.current);
// Effect calls cancelAnimationFrame
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;
// Effect calls cancelAnimationFrame (since status becomes IDLE)
}, [duration]);
const addTime = useCallback((seconds: number) => {