diff --git a/apps/storybook/stories/essentials/Timer.stories.tsx b/apps/storybook/stories/essentials/Timer.stories.tsx index d4c5a8cf..2aaa9813 100644 --- a/apps/storybook/stories/essentials/Timer.stories.tsx +++ b/apps/storybook/stories/essentials/Timer.stories.tsx @@ -1,4 +1,4 @@ -import { Timer } from "@codedazur/essentials"; +import { Timer, TimerEvent } from "@codedazur/essentials"; import { Button, Center, Column, Row } from "@codedazur/react-components"; import { action } from "@storybook/addon-actions"; import type { Meta, StoryObj } from "@storybook/react"; @@ -17,12 +17,11 @@ export default meta; const timer = new Timer(action("callback"), 3000); -timer.addEventListener("start", action("start")); -timer.addEventListener("stop", action("stop")); -timer.addEventListener("pause", action("pause")); -timer.addEventListener("resume", action("resume")); -timer.addEventListener("extend", action("extend")); -timer.addEventListener("end", action("end")); +timer.addEventListener(TimerEvent.start, action("start")); +timer.addEventListener(TimerEvent.stop, action("stop")); +timer.addEventListener(TimerEvent.pause, action("pause")); +timer.addEventListener(TimerEvent.resume, action("resume")); +timer.addEventListener(TimerEvent.end, action("end")); type Story = StoryObj; diff --git a/apps/storybook/stories/react-essentials/useTimer.stories.tsx b/apps/storybook/stories/react-essentials/useTimer.stories.tsx index 17aa1ee7..24e76725 100644 --- a/apps/storybook/stories/react-essentials/useTimer.stories.tsx +++ b/apps/storybook/stories/react-essentials/useTimer.stories.tsx @@ -99,27 +99,35 @@ interface TimerControlsProps extends ReturnType {} const TimerControls = ({ status, + start, resume, pause, stop, - isStopped, end, -}: TimerControlsProps) => ( - - {status === TimerStatus.running ? ( - - +}: TimerControlsProps) => { + const isStopped = status === TimerStatus.stopped; + const isRunning = status === TimerStatus.running; + + return ( + + {isRunning ? ( + + + + ) : ( + + + + )} + start + resume + stop + + - ) : ( - - + + - )} - - - - - - - -); + + ); +}; diff --git a/packages/essentials/utilities/timing/Timer.test.ts b/packages/essentials/utilities/timing/Timer.test.ts index 02adcfff..3e18e94d 100644 --- a/packages/essentials/utilities/timing/Timer.test.ts +++ b/packages/essentials/utilities/timing/Timer.test.ts @@ -20,6 +20,19 @@ describe("Timer", () => { }); describe("Initialization", () => { + it("should throw an error when duration is less then 0", () => { + expect(() => new Timer(callback, -2000)).toThrow( + new Error("Duration cannot be less then 0.") + ); + }); + + it("should initialize with the correct status and remaining time when duration is 0", () => { + const timer = new Timer(callback, 0); + expect(timer.duration).toBe(0); + expect(timer.remaining).toBe(0); + expect(timer.status).toBe(TimerStatus.stopped); + }); + it("should initialize with the correct duration and remaining time", () => { expect(timer.duration).toBe(5000); expect(timer.remaining).toBe(5000); @@ -45,7 +58,7 @@ describe("Timer", () => { vi.advanceTimersByTime(5000); expect(callback).toHaveBeenCalled(); - expect(timer.status).toBe(TimerStatus.stopped); + expect(timer.status).toBe(TimerStatus.completed); }); it("should emit the 'start' event when the timer starts", () => { @@ -69,17 +82,14 @@ describe("Timer", () => { expect(timer.isPaused).toBe(false); }); - /** - * @todo This test needs to be fixed and enabled. - */ - // it("should emit the 'stop' event when the timer stops", () => { - // const eventHandler = vi.fn(); - // timer.addEventListener(TimerEvent.stop, eventHandler); - // - // timer.stop(); - // - // expect(eventHandler).toHaveBeenCalled(); - // }); + it("should emit the 'stop' event when the timer stops", () => { + const eventHandler = vi.fn(); + timer.addEventListener(TimerEvent.stop, eventHandler); + timer.start(); + timer.stop(); + + expect(eventHandler).toHaveBeenCalled(); + }); }); describe("Pause", () => { @@ -125,7 +135,7 @@ describe("Timer", () => { vi.advanceTimersByTime(2000); expect(callback).toHaveBeenCalled(); - expect(timer.status).toBe(TimerStatus.stopped); + expect(timer.status).toBe(TimerStatus.completed); }); it("should start the timer if it's not running or paused", () => { @@ -150,6 +160,146 @@ describe("Timer", () => { }); }); + describe("Set duration", () => { + it("should change the timer duration and update the remaining time", () => { + timer.start(); + timer.setDuration(2000); + + expect(timer.progress).toBe(0); + expect(timer.duration).toBe(2000); + expect(timer.remaining).toBe(2000); + expect(timer.status).toBe(TimerStatus.running); + expect(callback).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(2000); + + expect(timer.status).toBe(TimerStatus.completed); + expect(callback).toHaveBeenCalled(); + }); + + it("should update the timer duration and update the remaining time when paused", () => { + timer.start(); + timer.pause(); + timer.setDuration(7000); + + expect(timer.duration).toBe(7000); + expect(timer.remaining).toBe(7000); + }); + + it("should extend a completed timer duration and show correct status", () => { + timer.start(); + vi.advanceTimersByTime(5000); + + expect(timer.status).toBe(TimerStatus.completed); + + timer.setDuration(8000); + + expect(timer.status).toBe(TimerStatus.paused); + + vi.advanceTimersByTime(8000); + + expect(timer.status).toBe(TimerStatus.paused); + + timer.resume(); + + expect(timer.status).toBe(TimerStatus.running); + + vi.advanceTimersByTime(5000); + + expect(timer.status).toBe(TimerStatus.completed); + }); + + it("should extend a completed timer duration and show correct data", () => { + const eventHandler = vi.fn(); + timer.addEventListener(TimerEvent.end, eventHandler); + timer.start(); + vi.advanceTimersByTime(5000); + timer.setDuration(8000); + + expect(timer.remaining).toBe(3000); + expect(timer.progress).toBe(0.625); + expect(timer.elapsed).toBe(5000); + + vi.advanceTimersByTime(8000); + + expect(eventHandler).toHaveBeenCalledTimes(1); + + timer.resume(); + + vi.advanceTimersByTime(5000); + expect(eventHandler).toHaveBeenCalledTimes(2); + }); + + it("should emit the 'changeDuration' event when the timer duration is changed", () => { + const eventHandler = vi.fn(); + timer.addEventListener(TimerEvent.changeDuration, eventHandler); + + timer.setDuration(7000); + + expect(eventHandler).toHaveBeenCalled(); + }); + + it("should complete a running timer when duration is set to 0", () => { + timer.setDuration(0); + + expect(timer.progress).toBe(0); + expect(timer.duration).toBe(0); + expect(timer.remaining).toBe(0); + expect(timer.status).toBe(TimerStatus.stopped); + expect(callback).not.toHaveBeenCalled(); + + timer.start(); + vi.advanceTimersByTime(0); + + expect(timer.progress).toBe(1); + expect(timer.duration).toBe(0); + expect(timer.remaining).toBe(0); + expect(timer.status).toBe(TimerStatus.completed); + expect(callback).toHaveBeenCalled(); + }); + + it("should throw an error when duration is less then 0", () => { + expect(() => timer.setDuration(-1000)).toThrow( + new Error("Duration cannot be less then 0.") + ); + }); + + it("should complete a running timer when duration is set to less then the remaining time", () => { + timer.start(); + vi.advanceTimersByTime(2000); + timer.setDuration(2000); + + expect(timer.status).toBe(TimerStatus.completed); + }); + + it("should not complete a paused timer when duration is set to less then the remaining time", () => { + timer.start(); + vi.advanceTimersByTime(2000); + timer.pause(); + + expect(timer.status).toBe(TimerStatus.paused); + + timer.setDuration(2000); + + expect(timer.status).toBe(TimerStatus.paused); + expect(callback).not.toHaveBeenCalled(); + }); + + it("should not change a timer when duration is set to the initial duration", () => { + timer.start(); + vi.advanceTimersByTime(2500); + timer.setDuration(5000); + + expect(timer.status).toBe(TimerStatus.running); + + vi.advanceTimersByTime(2500); + + expect(timer.remaining).toBe(0); + expect(timer.status).toBe(TimerStatus.completed); + expect(callback).toHaveBeenCalled(); + }); + }); + describe("Extend", () => { it("should extend the timer duration and update the remaining time", () => { timer.start(); @@ -164,7 +314,7 @@ describe("Timer", () => { expect(callback).not.toHaveBeenCalled(); }); - it("should extend the timer duration abd update the remaining time when paused", () => { + it("should extend the timer duration and update the remaining time when paused", () => { timer.start(); timer.pause(); timer.extend(2000); @@ -173,9 +323,9 @@ describe("Timer", () => { expect(timer.remaining).toBe(7000); }); - it("should emit the 'extend' event when the timer is extended", () => { + it("should emit the 'changeDuration' event when the timer is extended", () => { const eventHandler = vi.fn(); - timer.addEventListener(TimerEvent.extend, eventHandler); + timer.addEventListener(TimerEvent.changeDuration, eventHandler); timer.extend(2000); diff --git a/packages/essentials/utilities/timing/Timer.ts b/packages/essentials/utilities/timing/Timer.ts index 5911752c..095eb11b 100644 --- a/packages/essentials/utilities/timing/Timer.ts +++ b/packages/essentials/utilities/timing/Timer.ts @@ -5,7 +5,7 @@ export enum TimerEvent { stop = "stop", pause = "pause", resume = "resume", - extend = "extend", + changeDuration = "changeDuration", end = "end", } @@ -13,6 +13,7 @@ export enum TimerStatus { stopped = "stopped", running = "running", paused = "paused", + completed = "completed", } export class Timer { @@ -23,6 +24,7 @@ export class Timer { private _shiftedStartedAt?: number; private _timeoutStartedAt?: number; private _pausedAt?: number; + private _completedAt?: number; private _remaining: number; private _eventListeners: Record void>> = { @@ -30,11 +32,15 @@ export class Timer { stop: [], pause: [], resume: [], - extend: [], + changeDuration: [], end: [], }; constructor(callback: () => void, duration: number) { + if (duration < 0) { + throw new Error("Duration cannot be less then 0."); + } + this._callback = callback; this._duration = duration; this._remaining = duration; @@ -45,11 +51,15 @@ export class Timer { } public get status(): TimerStatus { - return this._hasTimeout - ? TimerStatus.running - : this._remaining < this._duration - ? TimerStatus.paused - : TimerStatus.stopped; + if (this._hasTimeout) { + return TimerStatus.running; + } else if (this._remaining === 0 && this._completedAt) { + return TimerStatus.completed; + } else if (this._remaining < this._duration) { + return TimerStatus.paused; + } else { + return TimerStatus.stopped; + } } public get isRunning(): boolean { @@ -64,6 +74,10 @@ export class Timer { return this.status === TimerStatus.stopped; } + public get isCompleted(): boolean { + return this.status === TimerStatus.completed; + } + public get duration(): number { return this._duration; } @@ -73,13 +87,19 @@ export class Timer { } public get progress(): number { - return clamp( + if (this.isCompleted) { + return 1; + } + + const progress = clamp( this.isRunning ? (Date.now() - this._shiftedStartedAt!) / this._duration : 1 - this._remaining / this._duration, 0, 1 ); + + return progress || 0; } public get elapsed(): number { @@ -87,7 +107,7 @@ export class Timer { } public get remaining(): number { - return this.duration - this.elapsed; + return this._duration - this.elapsed; } private _clearTimeout = (): void => { @@ -116,6 +136,7 @@ export class Timer { this._startedAt = this._shiftedStartedAt = undefined; this._pausedAt = undefined; + this._completedAt = undefined; this._remaining = this._duration; }; @@ -137,10 +158,6 @@ export class Timer { }; public start = (): void => { - if (this.isRunning) { - return; - } - this._reset(); this._startedAt = this._shiftedStartedAt = Date.now(); this._setTimeout(); @@ -170,7 +187,7 @@ export class Timer { }; public resume = (): void => { - if (this.isRunning) { + if (this.isRunning || this.isCompleted) { return; } @@ -186,24 +203,50 @@ export class Timer { this._runEventListeners(TimerEvent.resume); }; - public extend = (by: number): void => { + public setDuration = (duration: number): void => { const wasRunning = this.isRunning; - this.pause(); - this._duration += by; - this._remaining += by; + if (duration < 0) { + throw new Error("Duration cannot be less then 0."); + } + + if (duration === this.duration) { + return; + } + + if (wasRunning && this.elapsed >= duration) { + this.end(); + return; + } + + if (this.isCompleted) { + this._pausedAt = this._completedAt; + this._completedAt = undefined; + } + + this._clearTimeout(); + this._remaining = duration - (this.elapsed || 0); + this._duration = duration; if (wasRunning) { - this.resume(); + this._setTimeout(); } - this._runEventListeners(TimerEvent.extend); + this._runEventListeners(TimerEvent.changeDuration); + }; + + public extend = (by: number): void => { + this.setDuration(this.duration + by); }; public end = (): void => { - this._reset(); - this._runEventListeners(TimerEvent.end); + this._clearTimeout(); + + this._completedAt = Date.now(); + this._pausedAt = undefined; + this._remaining = 0; + this._runEventListeners(TimerEvent.end); this._callback(); }; } diff --git a/packages/react-essentials/hooks/useTimer.ts b/packages/react-essentials/hooks/useTimer.ts index 9157113c..51847389 100644 --- a/packages/react-essentials/hooks/useTimer.ts +++ b/packages/react-essentials/hooks/useTimer.ts @@ -1,6 +1,6 @@ import { Timer, TimerEvent, TimerStatus } from "@codedazur/essentials"; import { useCallback, useEffect, useRef, useState } from "react"; -import { useDelta } from "./useDelta"; + import { useSynchronizedRef } from "./useSynchronizedRef"; import { useUpdateLoop } from "./useUpdateLoop"; @@ -18,8 +18,6 @@ export function useTimer(callback: () => void, duration: number) { const [_status, setStatus] = useState(TimerStatus.stopped); const [_duration, setDuration] = useState(duration); - const deltaDuration = useDelta(duration); - useEffect(() => { const timer = timerRef.current; @@ -31,7 +29,8 @@ export function useTimer(callback: () => void, duration: number) { timer.addEventListener(TimerEvent.pause, reflectStatus); timer.addEventListener(TimerEvent.resume, reflectStatus); timer.addEventListener(TimerEvent.end, reflectStatus); - timer.addEventListener(TimerEvent.extend, reflectDuration); + timer.addEventListener(TimerEvent.changeDuration, reflectDuration); + timer.addEventListener(TimerEvent.changeDuration, reflectStatus); return () => { timer.removeEventListener(TimerEvent.start, reflectStatus); @@ -39,15 +38,14 @@ export function useTimer(callback: () => void, duration: number) { timer.removeEventListener(TimerEvent.pause, reflectStatus); timer.removeEventListener(TimerEvent.resume, reflectStatus); timer.removeEventListener(TimerEvent.end, reflectStatus); - timer.removeEventListener(TimerEvent.extend, reflectDuration); + timer.removeEventListener(TimerEvent.changeDuration, reflectDuration); + timer.removeEventListener(TimerEvent.changeDuration, reflectStatus); }; }, [timerRef]); useEffect(() => { - if (deltaDuration > 0) { - timerRef.current.extend(deltaDuration); - } - }, [deltaDuration]); + timerRef.current.setDuration(duration); + }, [duration]); const useProgress = useCallback(function useProgress({ targetFps, @@ -65,6 +63,7 @@ export function useTimer(callback: () => void, duration: number) { pause: timerRef.current.pause, resume: timerRef.current.resume, extend: timerRef.current.extend, + setDuration: timerRef.current.setDuration, end: timerRef.current.end, useProgress, }; @@ -88,7 +87,7 @@ export function useTimerProgress( timer.addEventListener(TimerEvent.pause, stop); timer.addEventListener(TimerEvent.stop, stop); timer.addEventListener(TimerEvent.stop, onUpdate); - timer.addEventListener(TimerEvent.extend, onUpdate); + timer.addEventListener(TimerEvent.changeDuration, onUpdate); timer.addEventListener(TimerEvent.end, onUpdate); return () => { @@ -97,7 +96,7 @@ export function useTimerProgress( timer.removeEventListener(TimerEvent.pause, stop); timer.removeEventListener(TimerEvent.stop, stop); timer.removeEventListener(TimerEvent.stop, onUpdate); - timer.removeEventListener(TimerEvent.extend, onUpdate); + timer.removeEventListener(TimerEvent.changeDuration, onUpdate); timer.removeEventListener(TimerEvent.end, onUpdate); }; }, [onUpdate, start, stop, timer]);