Skip to content

Commit e9ccb85

Browse files
test(small): Repair PR #8883: Refactor TabataTimer to use monotonic timing and optimize performance (#8952)
Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com> Co-authored-by: arii <342438+arii@users.noreply.github.com>
1 parent 49d23db commit e9ccb85

2 files changed

Lines changed: 40 additions & 28 deletions

File tree

services/tabataTimer.ts

Lines changed: 32 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
* Consolidated Dual-Mode Timer Service.
44
* This class manages the state and logic for both TABATA and STOPWATCH modes,
55
* handling transitions, sound cues, and broadcasting updates to clients.
6-
* It uses absolute timing (Date.now()) to maintain accuracy against drift.
6+
* It uses absolute timing (performance.now()) to maintain accuracy against drift.
77
*/
88
import { ServerMessage } from '../types/websocket'
99
import { TimerData, TimerMode, TimerPhase } from '../types/core'
@@ -30,7 +30,7 @@ class TabataTimer {
3030
private timerInterval: NodeJS.Timeout | null = null
3131
private pausedTimeRemaining: number = DEFAULT_WORK_DURATION
3232
private pausedTimeElapsed: number = 0
33-
private countdownMarker: string | null = null
33+
private lastCountdownSecond: number = -1
3434

3535
private readonly broadcastUpdate: (message: ServerMessage) => void
3636

@@ -66,15 +66,15 @@ class TabataTimer {
6666
if (this.isRunning) return
6767

6868
this.isRunning = true
69-
const now = Date.now()
69+
const now = performance.now()
7070

7171
if (this.currentPhase === 'IDLE') {
7272
this.currentPhase = 'PREPARE'
7373
this.timeRemaining = START_COUNTDOWN_DURATION
7474
this.pausedTimeRemaining = START_COUNTDOWN_DURATION
7575
this.timeElapsed = 0
7676
this.pausedTimeElapsed = 0
77-
this.countdownMarker = null
77+
this.lastCountdownSecond = -1
7878
}
7979

8080
this.startTime = now
@@ -88,7 +88,18 @@ class TabataTimer {
8888
public pause(): void {
8989
if (!this.isRunning || !this.startTime) return
9090

91-
this.updateTimer() // Final sync before pausing
91+
const now = performance.now()
92+
const elapsedSinceLastStart = Math.floor((now - this.startTime) / 1000)
93+
94+
if (this.mode === 'STOPWATCH' && this.currentPhase === 'RUNNING') {
95+
this.timeElapsed = this.pausedTimeElapsed + elapsedSinceLastStart
96+
} else if (this.mode === 'TABATA' || this.currentPhase === 'PREPARE') {
97+
const nextRemaining = Math.max(
98+
0,
99+
this.pausedTimeRemaining - elapsedSinceLastStart
100+
)
101+
this.timeRemaining = nextRemaining
102+
}
92103

93104
this.isRunning = false
94105
if (this.timerInterval) clearInterval(this.timerInterval)
@@ -112,7 +123,7 @@ class TabataTimer {
112123
this.timeElapsed = 0
113124
this.timeRemaining = this.mode === 'TABATA' ? this.workDuration : 0
114125

115-
this.countdownMarker = null
126+
this.lastCountdownSecond = -1
116127
this.pausedTimeRemaining = this.timeRemaining
117128
this.pausedTimeElapsed = 0
118129
this.startTime = null
@@ -139,7 +150,7 @@ class TabataTimer {
139150
this.timeElapsed = 0
140151
this.pausedTimeElapsed = 0
141152
this.soundToPlay = undefined
142-
this.countdownMarker = null
153+
this.lastCountdownSecond = -1
143154
this.broadcastUpdate({
144155
type: 'TIMER_UPDATE',
145156
payload: this.getState(),
@@ -180,13 +191,10 @@ class TabataTimer {
180191

181192
// --- Internal Timer Logic ---
182193

183-
/**
184-
* Core timer tick logic.
185-
*/
186194
private updateTimer = (): void => {
187195
if (!this.isRunning || !this.startTime) return
188196

189-
const now = Date.now()
197+
const now = performance.now()
190198
const elapsedSinceLastStart = Math.floor((now - this.startTime) / 1000)
191199

192200
if (this.mode === 'STOPWATCH' && this.currentPhase === 'RUNNING') {
@@ -216,7 +224,7 @@ class TabataTimer {
216224
* @param {number} now The current timestamp to use as the new start time.
217225
*/
218226
private transitionPhase(now: number): void {
219-
this.countdownMarker = null
227+
this.lastCountdownSecond = -1
220228
this.startTime = now
221229
this.pausedTimeElapsed = 0
222230

@@ -272,27 +280,23 @@ class TabataTimer {
272280
* Handles playing countdown sound cues.
273281
*/
274282
private handleCountdownCue(): void {
275-
const phase = this.currentPhase
276-
const remaining = this.timeRemaining
277283
if (
278-
phase === 'IDLE' ||
279-
phase === 'RUNNING' ||
280-
phase === 'COOLDOWN' ||
281-
remaining <= 0
284+
this.currentPhase === 'PREPARE' ||
285+
this.currentPhase === 'WORK' ||
286+
this.currentPhase === 'REST'
282287
) {
283-
return
284-
}
285-
286-
const marker = `${phase}-${remaining}`
287-
if (remaining <= 3 && this.countdownMarker !== marker) {
288-
this.queueSound('COUNTDOWN')
289-
this.countdownMarker = marker
288+
const remaining = this.timeRemaining
289+
if (
290+
remaining <= 3 &&
291+
remaining > 0 &&
292+
remaining !== this.lastCountdownSecond
293+
) {
294+
this.queueSound('COUNTDOWN')
295+
this.lastCountdownSecond = remaining
296+
}
290297
}
291298
}
292299

293-
/**
294-
* Cleanup resources.
295-
*/
296300
public dispose(): void {
297301
if (this.timerInterval) {
298302
clearInterval(this.timerInterval)

tests/unit/services/tabataTimer.test.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,17 @@ describe('TabataTimer (Refactored)', () => {
2020
// Clear any previous mocks and timers
2121
broadcastUpdate.mockClear()
2222
jest.clearAllTimers()
23+
24+
// Mock performance.now to use Date.now() so it syncs with jest.advanceTimersByTime
25+
jest.spyOn(performance, 'now').mockImplementation(() => Date.now())
26+
2327
timer = new TabataTimer(broadcastUpdate)
2428
})
2529

30+
afterEach(() => {
31+
jest.restoreAllMocks()
32+
})
33+
2634
// Test initial state
2735
it('should initialize with the correct default state', () => {
2836
const state = timer.getState()

0 commit comments

Comments
 (0)