Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions e2e/timer-behavior.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}) => {
Expand Down
2 changes: 2 additions & 0 deletions lib/constants/time.ts
Original file line number Diff line number Diff line change
@@ -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
67 changes: 59 additions & 8 deletions lib/stores/timerStore.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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<TimerState>()(
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,
Expand All @@ -48,12 +74,30 @@ export const useTimerStore = create<TimerState>()(
},

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,
})
},
Expand All @@ -66,7 +110,10 @@ export const useTimerStore = create<TimerState>()(
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({
Expand Down Expand Up @@ -106,7 +153,9 @@ export const useTimerStore = create<TimerState>()(
})
} 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,
Expand All @@ -131,7 +180,9 @@ export const useTimerStore = create<TimerState>()(
})
} else {
// Recalculate remaining time
const remainingSeconds = Math.ceil(remainingMs / 1000)
const remainingSeconds = Math.ceil(
remainingMs / MILLISECONDS_PER_SECOND,
)
set({
timeRemaining: remainingSeconds,
lastUpdateTime: now,
Expand Down
Loading