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 */
88import { ServerMessage } from '../types/websocket'
99import { 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 )
0 commit comments