diff --git a/.gitignore b/.gitignore index 50770c2..9da6b38 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,7 @@ node_modules/ dist/ +.claude + *.*~ *.*swp diff --git a/test/timer.test.js b/test/timer.test.js index f7a1607..4f128b2 100644 --- a/test/timer.test.js +++ b/test/timer.test.js @@ -1,4 +1,4 @@ -import { describe, it, expect } from 'vitest' +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' import { TimerService } from '../utils/timer/timer.js' describe('TimerService.pretty', () => { @@ -31,3 +31,65 @@ describe('TimerService.clean', () => { expect(timer.clean('05', ':')).toBe('05:') }) }) + +describe('TimerService rendering', () => { + let display + + beforeEach(() => { + vi.useFakeTimers() + display = document.createElement('span') + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('shows 0 before the timer starts', () => { + const timer = new TimerService() + timer.initialize(display) + expect(display.innerHTML).toBe('0') + }) + + it('drives updates with requestAnimationFrame, not a 1ms interval', () => { + const raf = vi.spyOn(window, 'requestAnimationFrame') + const interval = vi.spyOn(window, 'setInterval') + + const timer = new TimerService() + timer.initialize(display) + timer.start() + + expect(raf).toHaveBeenCalled() + expect(interval).not.toHaveBeenCalled() + }) + + it('only touches the DOM when the displayed value changes', () => { + const timer = new TimerService() + timer.initialize(display) + timer.start() + + let writes = 0 + const span = { set innerHTML(_v) { writes++ } } + timer.display = span + + // Same tenth-of-a-second value across frames -> a single DOM write. + timer.time = 1500 + timer.render() + timer.render() + timer.render() + expect(writes).toBe(1) + + // A new value -> one more write. + timer.time = 1600 + timer.render() + expect(writes).toBe(2) + }) + + it('returns the elapsed time in milliseconds from stop()', () => { + const timer = new TimerService() + timer.initialize(display) + timer.start() + vi.advanceTimersByTime(2500) + timer.tick() + expect(timer.stop()).toBe(2500) + }) +}) diff --git a/utils/timer/timer.js b/utils/timer/timer.js index 4bf644a..3db2ed4 100644 --- a/utils/timer/timer.js +++ b/utils/timer/timer.js @@ -1,11 +1,11 @@ import { LoggerService } from '../logger/logger' -const INTERVAL = 1 - export class TimerService { constructor() { this.loggerService = new LoggerService() + this.time = 0 + this.rendered = undefined } initialize(el) { @@ -13,33 +13,58 @@ export class TimerService { this.display = el this.startTime = undefined - if (this.id) { + if (this.id !== undefined) { this.stop() } - this.updateDisplay() + this.time = 0 + this.render() } start() { if (this.running || !this.display) return this.running = true - this.startTime = new Date().getTime() - this.id = window.setInterval(() => this.updateDisplay(), INTERVAL) - this.loggerService.debug(`started timer id: ${this.id}`) + this.startTime = Date.now() + this.tick() + this.loggerService.debug('started timer') } stop() { this.running = false - clearInterval(this.id) - this.loggerService.debug(`stopped timer id: ${this.id}`) + if (this.id !== undefined) { + window.cancelAnimationFrame(this.id) + } this.id = undefined + this.loggerService.debug('stopped timer') return this.time } - updateDisplay() { - let currentTime = new Date().getTime() - this.startTime - this.time = Math.floor(currentTime / INTERVAL) - this.display.innerHTML = this.pretty(this.time) || '0' + /** + * Recompute the elapsed time and schedule the next frame. + * Driven by requestAnimationFrame so it aligns with the browser's paint + * cadence and pauses automatically when the tab is hidden — instead of the + * old fixed 1ms interval that fired ~1000 times a second. + */ + tick() { + this.time = Date.now() - this.startTime + this.render() + if (this.running) { + this.id = window.requestAnimationFrame(() => this.tick()) + } + } + + /** + * Write to the DOM only when the visible value actually changes. The display + * has 100ms (tenths-of-a-second) resolution, so most frames are a no-op and + * cost no reflow. + */ + render() { + if (!this.display) return + const text = this.pretty(this.time) || '0' + if (text !== this.rendered) { + this.display.innerHTML = text + this.rendered = text + } } pretty(duration) { @@ -60,4 +85,3 @@ export class TimerService { return (str === '00') ? '' : `${str}${separator}` } } -