From 23ab6b40967589eadee478508a9b3fc3f559e6b4 Mon Sep 17 00:00:00 2001 From: Alvaro Torres Date: Wed, 4 Mar 2026 13:50:07 +0100 Subject: [PATCH] fix(timer): pause rest timer interval when app goes to background The rest timer setInterval kept firing while the app was in background, causing unnecessary state updates and premature finish detection. Now the interval is cleared on background/inactive and restarted on foreground return, with proper time recalculation using Date.now timestamps. --- src/hooks/useRestTimer.test.ts | 131 +++++++++++++++++++++++++++++++++ src/hooks/useRestTimer.ts | 32 +++++--- 2 files changed, 151 insertions(+), 12 deletions(-) diff --git a/src/hooks/useRestTimer.test.ts b/src/hooks/useRestTimer.test.ts index 3ca6f68..7e699af 100644 --- a/src/hooks/useRestTimer.test.ts +++ b/src/hooks/useRestTimer.test.ts @@ -297,4 +297,135 @@ describe('useRestTimer', () => { expect(result.current.state).toBe('idle'); expect(result.current.remainingSeconds).toBe(0); }); + + it('should stop interval when app goes to background', () => { + const { result } = renderHook(() => useRestTimer()); + + act(() => { + result.current.start(60); + }); + + act(() => { + jest.advanceTimersByTime(5000); + }); + + expect(result.current.remainingSeconds).toBe(55); + + // Simulate going to background + act(() => { + appStateCallback?.('background'); + }); + + // Advance timers — interval should be cleared so no further decrements via interval + const remainingAfterBackground = result.current.remainingSeconds; + act(() => { + jest.advanceTimersByTime(10000); + }); + + // remainingSeconds should NOT change because the interval was cleared + // (Date.now still advances in fake timers, but no interval tick to update state) + expect(result.current.remainingSeconds).toBe(remainingAfterBackground); + expect(result.current.state).toBe('running'); + }); + + it('should restart interval when app returns to foreground after background', () => { + const { result } = renderHook(() => useRestTimer()); + + act(() => { + result.current.start(60); + }); + + act(() => { + jest.advanceTimersByTime(5000); + }); + + expect(result.current.remainingSeconds).toBe(55); + + // Go to background + act(() => { + appStateCallback?.('background'); + }); + + // Simulate 10s passing in background (Date.now advances but no interval) + act(() => { + jest.advanceTimersByTime(10000); + }); + + // Come back to foreground + act(() => { + appStateCallback?.('active'); + }); + + // Should have recalculated: 60 - 15 = 45 + expect(result.current.remainingSeconds).toBe(45); + expect(result.current.state).toBe('running'); + + // Interval should be running again — advance 2s + act(() => { + jest.advanceTimersByTime(2000); + }); + + expect(result.current.remainingSeconds).toBe(43); + }); + + it('should finish timer on foreground return when timer expired during background', () => { + const { result } = renderHook(() => useRestTimer()); + + act(() => { + result.current.start(10); + }); + + act(() => { + jest.advanceTimersByTime(3000); + }); + + expect(result.current.remainingSeconds).toBe(7); + + // Go to background + act(() => { + appStateCallback?.('background'); + }); + + // Simulate 20s in background — more than remaining 7s + act(() => { + jest.advanceTimersByTime(20000); + }); + + // Still running because interval was cleared in background + expect(result.current.state).toBe('running'); + + // Come back to foreground — timer should detect expiration + act(() => { + appStateCallback?.('active'); + }); + + expect(result.current.state).toBe('finished'); + expect(result.current.remainingSeconds).toBe(0); + }); + + it('should not stop interval when going to background if timer is idle', () => { + const { result } = renderHook(() => useRestTimer()); + + // Go to background while idle — should not throw or change state + act(() => { + appStateCallback?.('background'); + }); + + expect(result.current.state).toBe('idle'); + }); + + it('should not restart interval when returning from background if timer is idle', () => { + const { result } = renderHook(() => useRestTimer()); + + act(() => { + appStateCallback?.('background'); + }); + + act(() => { + appStateCallback?.('active'); + }); + + expect(result.current.state).toBe('idle'); + expect(result.current.remainingSeconds).toBe(0); + }); }); diff --git a/src/hooks/useRestTimer.ts b/src/hooks/useRestTimer.ts index bca5072..6447888 100644 --- a/src/hooks/useRestTimer.ts +++ b/src/hooks/useRestTimer.ts @@ -65,15 +65,30 @@ export function useRestTimer(defaultSeconds: number = 90): UseRestTimerReturn { playFinishFeedback(); }, [clearTimer, playFinishFeedback]); - // Recalculate on AppState change (background -> foreground) + const startInterval = useCallback(() => { + clearTimer(); + intervalRef.current = setInterval(() => { + const remaining = calcRemaining(); + if (remaining <= 0) { + finishTimer(); + } else { + setRemainingSeconds(remaining); + } + }, 1000); + }, [clearTimer, calcRemaining, finishTimer]); + + // Pause interval on background, recalculate and resume on foreground useEffect(() => { const subscription = AppState.addEventListener('change', (nextState) => { - if (nextState === 'active' && startedAtRef.current > 0) { + if (nextState === 'background' || nextState === 'inactive') { + clearTimer(); + } else if (nextState === 'active' && startedAtRef.current > 0) { const remaining = calcRemaining(); if (remaining <= 0) { finishTimer(); } else { setRemainingSeconds(remaining); + startInterval(); } } }); @@ -81,7 +96,7 @@ export function useRestTimer(defaultSeconds: number = 90): UseRestTimerReturn { return () => { subscription.remove(); }; - }, [calcRemaining, finishTimer]); + }, [calcRemaining, finishTimer, clearTimer, startInterval]); // Cleanup on unmount useEffect(() => { @@ -101,16 +116,9 @@ export function useRestTimer(defaultSeconds: number = 90): UseRestTimerReturn { setTotalSeconds(duration); setState('running'); - intervalRef.current = setInterval(() => { - const remaining = calcRemaining(); - if (remaining <= 0) { - finishTimer(); - } else { - setRemainingSeconds(remaining); - } - }, 1000); + startInterval(); }, - [defaultSeconds, clearTimer, calcRemaining, finishTimer], + [defaultSeconds, clearTimer, startInterval], ); const skip = useCallback(() => {