Skip to content

Commit 0a8f7ba

Browse files
fix: enable timer to continue running when browser tab is inactive
Resolves #6 - Timer now works reliably in background using timestamp-based calculation instead of setInterval which browsers throttle when tabs are inactive. Changes: - Refactor timerStore to use targetEndTime timestamp instead of countdown - Add Page Visibility API listener to recalculate time when tab becomes visible - Implement updateTimeRemaining() method for accurate background timing - Add comprehensive Playwright tests for background timer functionality Technical details: - Timer stores target end timestamp (Date.now() + remaining seconds) - Recalculates actual remaining time based on wall-clock elapsed time - Handles tab switching, system sleep/wake, and rapid visibility changes - Maintains backward compatibility with existing pause/resume functionality Tests: All 6 new background timer tests passing on Desktop/Mobile/Tablet
1 parent 888a5f6 commit 0a8f7ba

File tree

3 files changed

+417
-9
lines changed

3 files changed

+417
-9
lines changed

app/[locale]/page.tsx

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,21 @@ export default function Home() {
6767
return () => clearInterval(interval)
6868
}, [isRunning])
6969

70+
// Handle Page Visibility API for background timer
71+
useEffect(() => {
72+
const handleVisibilityChange = () => {
73+
if (document.visibilityState === 'visible') {
74+
// Tab became visible - recalculate time remaining
75+
useTimerStore.getState().updateTimeRemaining()
76+
}
77+
}
78+
79+
document.addEventListener('visibilitychange', handleVisibilityChange)
80+
return () => {
81+
document.removeEventListener('visibilitychange', handleVisibilityChange)
82+
}
83+
}, [])
84+
7085
// Play sound and show notification when timer completes
7186
useEffect(() => {
7287
// Detect when timer just hit 0 (but not if user manually set it to 0)

e2e/background-timer.spec.ts

Lines changed: 329 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,329 @@
1+
import { test, expect } from '@playwright/test'
2+
3+
/**
4+
* Background Timer Tests
5+
*
6+
* These tests verify that the timer continues to run accurately
7+
* when the browser tab is inactive (Issue #6).
8+
*
9+
* Key behaviors:
10+
* 1. Timer continues counting in background (tab switched away)
11+
* 2. Timer completes and triggers notifications while tab is inactive
12+
* 3. Timer display updates correctly when returning to tab
13+
* 4. Pause/resume works correctly with tab switching
14+
*/
15+
test.describe('Background Timer Functionality', () => {
16+
test('timer continues running when tab becomes hidden', async ({
17+
page,
18+
context,
19+
}) => {
20+
await page.goto('/en')
21+
await page.waitForLoadState('domcontentloaded')
22+
await page.waitForTimeout(1000)
23+
24+
// Set a 5 second timer
25+
const minutesInput = page.getByRole('spinbutton', { name: /minutes/i })
26+
const secondsInput = page.getByRole('spinbutton', { name: /seconds/i })
27+
28+
await minutesInput.fill('0')
29+
await secondsInput.fill('5')
30+
31+
// Verify timer is set
32+
const timerDisplay = page.locator('[role="timer"]')
33+
await expect(timerDisplay).toContainText('00:05')
34+
35+
// Start the timer
36+
const startButton = page.getByRole('button', { name: /start/i })
37+
await startButton.click()
38+
39+
// Verify timer started
40+
await expect(page.getByRole('button', { name: /pause/i })).toBeVisible()
41+
42+
// Wait 2 seconds while visible
43+
await page.waitForTimeout(2000)
44+
45+
// Verify timer is counting down (should show 00:03)
46+
const displayText = await timerDisplay.textContent()
47+
expect(displayText).toMatch(/00:03/)
48+
49+
// Open a new tab (simulates user switching away)
50+
const newPage = await context.newPage()
51+
await newPage.goto('/en')
52+
await newPage.waitForLoadState('domcontentloaded')
53+
54+
// Wait 2 seconds in the new tab (timer should continue in background)
55+
await newPage.waitForTimeout(2000)
56+
57+
// Close new tab and return to timer tab
58+
await newPage.close()
59+
60+
// Wait for visibility change to trigger updateTimeRemaining
61+
await page.waitForTimeout(500)
62+
63+
// Timer should show approximately 00:01 (5 - 2 - 2 = 1)
64+
const updatedText = await timerDisplay.textContent()
65+
expect(updatedText).toMatch(/00:0[01]/) // Allow 00:01 or 00:00
66+
})
67+
68+
test('timer completes in background and triggers notification', async ({
69+
page,
70+
context,
71+
}) => {
72+
await page.goto('/en')
73+
await page.waitForLoadState('domcontentloaded')
74+
await page.waitForTimeout(1000)
75+
76+
// Set a 3 second timer
77+
const minutesInput = page.getByRole('spinbutton', { name: /minutes/i })
78+
const secondsInput = page.getByRole('spinbutton', { name: /seconds/i })
79+
80+
await minutesInput.fill('0')
81+
await secondsInput.fill('3')
82+
83+
// Verify timer is set
84+
const timerDisplay = page.locator('[role="timer"]')
85+
await expect(timerDisplay).toContainText('00:03')
86+
87+
// Set up sound request monitoring
88+
const soundRequestPromise = page.waitForRequest(
89+
(request) => {
90+
const url = request.url()
91+
return url.includes('/sounds/') && url.endsWith('.mp3')
92+
},
93+
{ timeout: 8000 },
94+
)
95+
96+
// Start the timer
97+
const startButton = page.getByRole('button', { name: /start/i })
98+
await startButton.click()
99+
100+
// Verify timer started
101+
await expect(page.getByRole('button', { name: /pause/i })).toBeVisible()
102+
103+
// Wait 1 second
104+
await page.waitForTimeout(1000)
105+
106+
// Open new tab immediately (timer has 2 seconds remaining)
107+
const newPage = await context.newPage()
108+
await newPage.goto('/en')
109+
await newPage.waitForLoadState('domcontentloaded')
110+
111+
// Wait 3 seconds for timer to complete in background
112+
await newPage.waitForTimeout(3000)
113+
114+
// Sound should have been requested even though tab was inactive
115+
const soundRequest = await soundRequestPromise
116+
expect(soundRequest.url()).toMatch(
117+
/\/sounds\/(ascending-chime|bright-ding|alert-beep|service-bell)\.mp3/,
118+
)
119+
120+
// Close new tab and return to timer
121+
await newPage.close()
122+
123+
// Wait for visibility change
124+
await page.waitForTimeout(500)
125+
126+
// Timer should show 00:00 (completed)
127+
await expect(timerDisplay).toContainText('00:00')
128+
129+
// Timer should be stopped (start button visible)
130+
await expect(page.getByRole('button', { name: /start/i })).toBeVisible()
131+
})
132+
133+
test('paused timer does not advance when tab is hidden', async ({
134+
page,
135+
context,
136+
}) => {
137+
await page.goto('/en')
138+
await page.waitForLoadState('domcontentloaded')
139+
await page.waitForTimeout(1000)
140+
141+
// Set a 10 second timer
142+
const minutesInput = page.getByRole('spinbutton', { name: /minutes/i })
143+
const secondsInput = page.getByRole('spinbutton', { name: /seconds/i })
144+
145+
await minutesInput.fill('0')
146+
await secondsInput.fill('10')
147+
148+
const timerDisplay = page.locator('[role="timer"]')
149+
await expect(timerDisplay).toContainText('00:10')
150+
151+
// Start the timer
152+
const startButton = page.getByRole('button', { name: /start/i })
153+
await startButton.click()
154+
155+
// Wait 2 seconds
156+
await page.waitForTimeout(2000)
157+
158+
// Pause the timer (should show ~00:08)
159+
const pauseButton = page.getByRole('button', { name: /pause/i })
160+
await pauseButton.click()
161+
162+
// Verify paused
163+
await expect(page.getByRole('button', { name: /start/i })).toBeVisible()
164+
165+
// Get current time
166+
const pausedTime = await timerDisplay.textContent()
167+
expect(pausedTime).toMatch(/00:0[78]/) // Should be 00:08 or 00:07
168+
169+
// Open new tab and wait
170+
const newPage = await context.newPage()
171+
await newPage.goto('/en')
172+
await newPage.waitForLoadState('domcontentloaded')
173+
await newPage.waitForTimeout(3000)
174+
175+
// Close new tab
176+
await newPage.close()
177+
await page.waitForTimeout(500)
178+
179+
// Timer should still show same time (paused doesn't advance)
180+
const currentTime = await timerDisplay.textContent()
181+
expect(currentTime).toBe(pausedTime)
182+
})
183+
184+
test('resuming timer after tab switch continues accurately', async ({
185+
page,
186+
context,
187+
}) => {
188+
await page.goto('/en')
189+
await page.waitForLoadState('domcontentloaded')
190+
await page.waitForTimeout(1000)
191+
192+
// Set a 5 second timer
193+
const minutesInput = page.getByRole('spinbutton', { name: /minutes/i })
194+
const secondsInput = page.getByRole('spinbutton', { name: /seconds/i })
195+
196+
await minutesInput.fill('0')
197+
await secondsInput.fill('5')
198+
199+
const timerDisplay = page.locator('[role="timer"]')
200+
201+
// Start timer
202+
const startButton = page.getByRole('button', { name: /start/i })
203+
await startButton.click()
204+
205+
// Wait 1 second
206+
await page.waitForTimeout(1000)
207+
208+
// Pause (should show ~00:04)
209+
const pauseButton = page.getByRole('button', { name: /pause/i })
210+
await pauseButton.click()
211+
212+
// Open new tab
213+
const newPage = await context.newPage()
214+
await newPage.goto('/en')
215+
await newPage.waitForLoadState('domcontentloaded')
216+
217+
// Wait 2 seconds
218+
await newPage.waitForTimeout(2000)
219+
220+
// Close new tab and return
221+
await newPage.close()
222+
await page.waitForTimeout(500)
223+
224+
// Resume timer
225+
const resumeButton = page.getByRole('button', { name: /start/i })
226+
await resumeButton.click()
227+
228+
// Wait for completion (should take ~4 seconds)
229+
const soundRequestPromise = page.waitForRequest(
230+
(request) => {
231+
const url = request.url()
232+
return url.includes('/sounds/') && url.endsWith('.mp3')
233+
},
234+
{ timeout: 6000 },
235+
)
236+
237+
const soundRequest = await soundRequestPromise
238+
expect(soundRequest.url()).toMatch(/\/sounds\/.*\.mp3/)
239+
240+
// Verify completion
241+
await expect(timerDisplay).toContainText('00:00')
242+
})
243+
244+
test('rapid tab switching maintains timer accuracy', async ({
245+
page,
246+
context,
247+
}) => {
248+
await page.goto('/en')
249+
await page.waitForLoadState('domcontentloaded')
250+
await page.waitForTimeout(1000)
251+
252+
// Set a 6 second timer
253+
const minutesInput = page.getByRole('spinbutton', { name: /minutes/i })
254+
const secondsInput = page.getByRole('spinbutton', { name: /seconds/i })
255+
256+
await minutesInput.fill('0')
257+
await secondsInput.fill('6')
258+
259+
const timerDisplay = page.locator('[role="timer"]')
260+
261+
// Start timer
262+
const startButton = page.getByRole('button', { name: /start/i })
263+
await startButton.click()
264+
265+
// Create new tab
266+
const newPage = await context.newPage()
267+
await newPage.goto('/en')
268+
await newPage.waitForLoadState('domcontentloaded')
269+
270+
// Rapid switching simulation
271+
await newPage.waitForTimeout(1000)
272+
await newPage.bringToFront()
273+
await page.waitForTimeout(500)
274+
await page.bringToFront()
275+
await page.waitForTimeout(500)
276+
await newPage.bringToFront()
277+
await page.waitForTimeout(1000)
278+
await page.bringToFront()
279+
await page.waitForTimeout(500)
280+
281+
// Close new tab
282+
await newPage.close()
283+
284+
// Total elapsed: ~3.5 seconds, should show ~00:02 or 00:03
285+
await page.waitForTimeout(500)
286+
const displayText = await timerDisplay.textContent()
287+
expect(displayText).toMatch(/00:0[23]/)
288+
289+
// Wait for completion
290+
await page.waitForTimeout(3000)
291+
292+
// Should complete
293+
await expect(timerDisplay).toContainText('00:00')
294+
})
295+
296+
test('timer state persists in localStorage after page reload', async ({
297+
page,
298+
}) => {
299+
await page.goto('/en')
300+
await page.waitForLoadState('domcontentloaded')
301+
await page.waitForTimeout(1000)
302+
303+
// Set a 10 second timer
304+
const minutesInput = page.getByRole('spinbutton', { name: /minutes/i })
305+
const secondsInput = page.getByRole('spinbutton', { name: /seconds/i })
306+
307+
await minutesInput.fill('0')
308+
await secondsInput.fill('10')
309+
310+
const timerDisplay = page.locator('[role="timer"]')
311+
await expect(timerDisplay).toContainText('00:10')
312+
313+
// Reload the page (timer not started yet)
314+
await page.reload()
315+
await page.waitForLoadState('domcontentloaded')
316+
await page.waitForTimeout(1000)
317+
318+
// Timer should still show 00:10 after reload (localStorage persistence)
319+
await expect(timerDisplay).toContainText('00:10')
320+
321+
// Timer should be in idle state
322+
const startButton = page.getByRole('button', { name: /start/i })
323+
await expect(startButton).toBeVisible()
324+
325+
// Verify inputs also persisted
326+
await expect(minutesInput).toHaveValue('0')
327+
await expect(secondsInput).toHaveValue('10')
328+
})
329+
})

0 commit comments

Comments
 (0)