diff --git a/e2e/timer-behavior.spec.ts b/e2e/timer-behavior.spec.ts index eca22d5..3e4712f 100644 --- a/e2e/timer-behavior.spec.ts +++ b/e2e/timer-behavior.spec.ts @@ -183,6 +183,45 @@ test.describe('Timer Behavior', () => { await expect(page.getByRole('button', { name: /start/i })).toBeVisible() }) + test('completed timer restarts from typed duration without touching stepper buttons', async ({ + page, + }) => { + // Arrange + await page.goto('/en') + await page.waitForLoadState('domcontentloaded') + + await page.waitForTimeout(1000) + + const minutesInput = page.getByTestId('time-input-minutes') + const secondsInput = page.getByTestId('time-input-seconds') + const timerDisplay = page.locator('[role="timer"]') + const startButton = page.getByRole('button', { name: /start/i }) + + await minutesInput.fill('0') + await secondsInput.fill('2') + await expect(timerDisplay).toContainText('00:02') + await expect(startButton).toBeEnabled() + + await startButton.click() + await expect(page.getByRole('button', { name: /pause/i })).toBeVisible({ + timeout: 1000, + }) + await expect(timerDisplay).toContainText('00:00', { timeout: 5000 }) + await expect(startButton).toBeVisible() + await expect(minutesInput).toHaveValue('00') + await expect(secondsInput).toHaveValue('02') + + // Act + await expect(startButton).toBeEnabled() + await startButton.click() + + // Assert + await expect(page.getByRole('button', { name: /pause/i })).toBeVisible({ + timeout: 1000, + }) + await expect(timerDisplay).toContainText(/00:0[12]/) + }) + test.skip('timer completion respects "None" sound preset', async ({ page, }) => { diff --git a/lib/constants/time.ts b/lib/constants/time.ts index d69d123..3c1b33e 100644 --- a/lib/constants/time.ts +++ b/lib/constants/time.ts @@ -1,2 +1,4 @@ +export const MILLISECONDS_PER_SECOND = 1000 +export const DEFAULT_TIMER_SECONDS = 5 * 60 export const MAX_TIMER_MINUTES = 24 * 60 // 24 hours expressed in minutes export const MAX_TIMER_TOTAL_SECONDS = MAX_TIMER_MINUTES * 60 // 86,400 seconds diff --git a/lib/stores/timerStore.ts b/lib/stores/timerStore.ts index c52fb4c..36e30e0 100644 --- a/lib/stores/timerStore.ts +++ b/lib/stores/timerStore.ts @@ -1,6 +1,10 @@ import { create } from 'zustand' import { persist, createJSONStorage } from 'zustand/middleware' -import { MAX_TIMER_TOTAL_SECONDS } from '@/lib/constants/time' +import { + DEFAULT_TIMER_SECONDS, + MAX_TIMER_TOTAL_SECONDS, + MILLISECONDS_PER_SECOND, +} from '@/lib/constants/time' interface TimerState { // State @@ -20,12 +24,34 @@ interface TimerState { updateTimeRemaining: () => void // recalculate time based on timestamp } +/** + * Chooses the countdown length when Start is pressed so completed timers restart from the displayed duration. + * @param timeRemaining - Current countdown seconds left on the timer. + * @param initialTime - Duration seconds selected in the time input. + * @returns + * - Remaining seconds while idle or paused + * - Initial duration seconds after the timer has completed + * - 0 when no startable duration exists + * @example + * getStartDurationSeconds(0, 120) // => 120 + */ +const getStartDurationSeconds = ( + timeRemaining: number, + initialTime: number, +): number => { + if (timeRemaining > 0) { + return timeRemaining + } + + return initialTime +} + export const useTimerStore = create()( persist( (set, get) => ({ // Initial state - timeRemaining: 300, // 5 minutes default - initialTime: 300, + timeRemaining: DEFAULT_TIMER_SECONDS, + initialTime: DEFAULT_TIMER_SECONDS, isRunning: false, isPaused: false, targetEndTime: null, @@ -48,12 +74,30 @@ export const useTimerStore = create()( }, start: () => { - const { timeRemaining } = get() + const { initialTime, timeRemaining } = get() const now = Date.now() + const startDurationSeconds = getStartDurationSeconds( + timeRemaining, + initialTime, + ) + + if (startDurationSeconds <= 0) { + set({ + timeRemaining: 0, + isRunning: false, + isPaused: false, + targetEndTime: null, + lastUpdateTime: now, + }) + return + } + + // A completed timer has 0 remaining but still has a selected duration. set({ + timeRemaining: startDurationSeconds, isRunning: true, isPaused: false, - targetEndTime: now + timeRemaining * 1000, + targetEndTime: now + startDurationSeconds * MILLISECONDS_PER_SECOND, lastUpdateTime: now, }) }, @@ -66,7 +110,10 @@ export const useTimerStore = create()( let currentRemaining = 0 if (targetEndTime) { const remainingMs = targetEndTime - now - currentRemaining = Math.max(0, Math.ceil(remainingMs / 1000)) + currentRemaining = Math.max( + 0, + Math.ceil(remainingMs / MILLISECONDS_PER_SECOND), + ) } set({ @@ -106,7 +153,9 @@ export const useTimerStore = create()( }) } else { // Update remaining time based on actual elapsed time - const remainingSeconds = Math.ceil(remainingMs / 1000) + const remainingSeconds = Math.ceil( + remainingMs / MILLISECONDS_PER_SECOND, + ) set({ timeRemaining: remainingSeconds, lastUpdateTime: now, @@ -131,7 +180,9 @@ export const useTimerStore = create()( }) } else { // Recalculate remaining time - const remainingSeconds = Math.ceil(remainingMs / 1000) + const remainingSeconds = Math.ceil( + remainingMs / MILLISECONDS_PER_SECOND, + ) set({ timeRemaining: remainingSeconds, lastUpdateTime: now,