From 169e06d83cef5116f0e39c74d2c497f04ba6278d Mon Sep 17 00:00:00 2001 From: Bart Krakowski Date: Wed, 29 May 2024 16:55:35 +0200 Subject: [PATCH 01/15] refactor: update isValidDate return type for improved type safety --- packages/time/src/tests/isValidDate.test.ts | 38 +++++++++++++++------ 1 file changed, 27 insertions(+), 11 deletions(-) diff --git a/packages/time/src/tests/isValidDate.test.ts b/packages/time/src/tests/isValidDate.test.ts index 57a35567..188c31cb 100644 --- a/packages/time/src/tests/isValidDate.test.ts +++ b/packages/time/src/tests/isValidDate.test.ts @@ -1,16 +1,32 @@ -import {describe, expect, test} from 'vitest'; -import {isValidDate} from '../utils/isValidDate'; +import { describe, expect, test } from 'vitest' +import { isValidDate } from '../utils/isValidDate' describe('isValidDate', () => { test('should return true for a valid date', () => { - expect(isValidDate(new Date())).toBe(true); - }); + const date = new Date(); + expect(isValidDate(date)).toBe(true) + }) - test('should return false for an invalid date', () => { - expect(isValidDate(new Date("invalid"))).toBe(false); - }); + test.each([ + '2021-10-10', + new Date('invalid'), + {}, + undefined, + null, + NaN, + 0, + ])('should return false for invalid date %p', (date) => { + expect(isValidDate(date)).toBe(false) + }) - test("should return false for null", () => { - expect(isValidDate(null)).toBe(false); - }); -}); \ No newline at end of file + test('should assert type guards correctly', () => { + const notADate = 'not a date'; + if (isValidDate(notADate)) { + expect(notADate).toBeInstanceOf(Date) + notADate.getDate() + } else { + // @ts-expect-error + notADate.getTime() + } + }) +}) From 75cb7b331a5c2ee41263f45509378197e9645b60 Mon Sep 17 00:00:00 2001 From: Bart Krakowski Date: Wed, 29 May 2024 16:55:47 +0200 Subject: [PATCH 02/15] refactor: update isValidDate return type for improved type safety --- packages/time/src/utils/isValidDate.ts | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/packages/time/src/utils/isValidDate.ts b/packages/time/src/utils/isValidDate.ts index 987df31a..c0d15991 100644 --- a/packages/time/src/utils/isValidDate.ts +++ b/packages/time/src/utils/isValidDate.ts @@ -4,9 +4,6 @@ * @param date Date * @returns boolean */ -export function isValidDate(date: any): boolean { - if (Object.prototype.toString.call(date) !== '[object Date]') { - return false; - } - return date.getTime() === date.getTime(); -} \ No newline at end of file +export function isValidDate(date: unknown): date is Date { + return date instanceof Date && !isNaN(date.getTime()); +} From 5dca8d5e763a5e7cb2879ae836fec9cd16070a89 Mon Sep 17 00:00:00 2001 From: Bart Krakowski Date: Thu, 30 May 2024 17:52:05 +0200 Subject: [PATCH 03/15] refactor: update isValidDate return type for improved type safety --- packages/time/src/tests/isValidDate.test.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/time/src/tests/isValidDate.test.ts b/packages/time/src/tests/isValidDate.test.ts index 188c31cb..4d019705 100644 --- a/packages/time/src/tests/isValidDate.test.ts +++ b/packages/time/src/tests/isValidDate.test.ts @@ -25,8 +25,10 @@ describe('isValidDate', () => { expect(notADate).toBeInstanceOf(Date) notADate.getDate() } else { - // @ts-expect-error - notADate.getTime() + expect(() => { + // @ts-expect-error + notADate.getTime() + }).toThrowError() } }) }) From e9e4be9c36c35283dded374d5475822fd888d203 Mon Sep 17 00:00:00 2001 From: Bart Krakowski Date: Thu, 30 May 2024 22:29:49 +0200 Subject: [PATCH 04/15] revert: isValidDate util --- packages/time/src/tests/isValidDate.test.ts | 38 ++++++--------------- packages/time/src/utils/isValidDate.ts | 9 +++-- 2 files changed, 16 insertions(+), 31 deletions(-) diff --git a/packages/time/src/tests/isValidDate.test.ts b/packages/time/src/tests/isValidDate.test.ts index 4d019705..01afdc69 100644 --- a/packages/time/src/tests/isValidDate.test.ts +++ b/packages/time/src/tests/isValidDate.test.ts @@ -1,34 +1,16 @@ -import { describe, expect, test } from 'vitest' -import { isValidDate } from '../utils/isValidDate' +import {describe, expect, test} from 'vitest'; +import {isValidDate} from '../utils/isValidDate'; describe('isValidDate', () => { test('should return true for a valid date', () => { - const date = new Date(); - expect(isValidDate(date)).toBe(true) + expect(isValidDate(new Date())).toBe(true); }) - test.each([ - '2021-10-10', - new Date('invalid'), - {}, - undefined, - null, - NaN, - 0, - ])('should return false for invalid date %p', (date) => { - expect(isValidDate(date)).toBe(false) - }) + test('should return false for an invalid date', () => { + expect(isValidDate(new Date("invalid"))).toBe(false); + }); - test('should assert type guards correctly', () => { - const notADate = 'not a date'; - if (isValidDate(notADate)) { - expect(notADate).toBeInstanceOf(Date) - notADate.getDate() - } else { - expect(() => { - // @ts-expect-error - notADate.getTime() - }).toThrowError() - } - }) -}) + test("should return false for null", () => { + expect(isValidDate(null)).toBe(false); + }); +}); diff --git a/packages/time/src/utils/isValidDate.ts b/packages/time/src/utils/isValidDate.ts index c0d15991..987df31a 100644 --- a/packages/time/src/utils/isValidDate.ts +++ b/packages/time/src/utils/isValidDate.ts @@ -4,6 +4,9 @@ * @param date Date * @returns boolean */ -export function isValidDate(date: unknown): date is Date { - return date instanceof Date && !isNaN(date.getTime()); -} +export function isValidDate(date: any): boolean { + if (Object.prototype.toString.call(date) !== '[object Date]') { + return false; + } + return date.getTime() === date.getTime(); +} \ No newline at end of file From 9f5a430f5883b5accab02ee1f96ef1d404491463 Mon Sep 17 00:00:00 2001 From: Bart Krakowski Date: Tue, 25 Jun 2024 23:10:51 +0200 Subject: [PATCH 05/15] feat: time core --- packages/time/src/core/time.ts | 60 ++++++++++++++++++++++++++ packages/time/src/tests/time.test.ts | 64 ++++++++++++++++++++++++++++ 2 files changed, 124 insertions(+) create mode 100644 packages/time/src/core/time.ts create mode 100644 packages/time/src/tests/time.test.ts diff --git a/packages/time/src/core/time.ts b/packages/time/src/core/time.ts new file mode 100644 index 00000000..dc419a3c --- /dev/null +++ b/packages/time/src/core/time.ts @@ -0,0 +1,60 @@ +import { Temporal } from '@js-temporal/polyfill' +import { Store } from '@tanstack/store' +import { getDefaultTimeZone } from '../utils/dateDefaults' + +export interface TimeCoreOptions { + /** + * The time zone to use for the current time. + * @default Intl.DateTimeFormat().resolvedOptions().timeZone + */ + timeZone?: Temporal.TimeZoneLike +} + +interface TimeState { + /** + * The current time. + * @default Temporal.Now.zonedDateTimeISO() + * @readonly + * @type Temporal.ZonedDateTime + */ + currentTime: Temporal.ZonedDateTime +} + +export abstract class TimeCore { + protected store: Store + protected interval: NodeJS.Timeout | null = null + protected timeZone: Temporal.TimeZoneLike + + constructor(options: TimeCoreOptions = {}) { + const defaultTimeZone = getDefaultTimeZone() + this.timeZone = options.timeZone || defaultTimeZone + this.store = new Store({ + currentTime: Temporal.Now.zonedDateTimeISO(this.timeZone), + }) + this.updateCurrentTime() + } + + protected updateCurrentTime() { + this.store.setState((prev) => ({ + ...prev, + currentTime: Temporal.Now.zonedDateTimeISO(this.timeZone), + })) + } + + startUpdatingTime(intervalMs: number = 1000) { + if (!this.interval) { + this.interval = setInterval(() => this.updateCurrentTime(), intervalMs) + } + } + + stopUpdatingTime() { + if (this.interval) { + clearInterval(this.interval) + this.interval = null + } + } + + getCurrentTime(): Temporal.ZonedDateTime { + return this.store.state.currentTime + } +} diff --git a/packages/time/src/tests/time.test.ts b/packages/time/src/tests/time.test.ts new file mode 100644 index 00000000..29135d75 --- /dev/null +++ b/packages/time/src/tests/time.test.ts @@ -0,0 +1,64 @@ +import { Temporal } from '@js-temporal/polyfill' +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' +import { TimeCore } from '../core/time' + + +export class TestTimeCore extends TimeCore { + getCurrentTime(): Temporal.ZonedDateTime { + return super.getCurrentTime() + } + + startUpdatingTime(intervalMs: number = 1000) { + super.startUpdatingTime(intervalMs) + } + + stopUpdatingTime() { + super.stopUpdatingTime() + } +} + +describe('TimeCore', () => { +beforeEach(() => { + vi.useFakeTimers() + const mockNow = Temporal.PlainDateTime.from({ year: 2024, month: 1, day: 1, hour: 0, minute: 0, second: 0 }) + vi.setSystemTime(mockNow.toZonedDateTime('UTC').epochMilliseconds) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + test('should initialize with the current time in the default time zone', () => { + const timeCore = new TestTimeCore() + const currentTime = Temporal.Now.zonedDateTimeISO() + expect(timeCore.getCurrentTime().toString()).toBe(currentTime.toString()) + }) + + test('should initialize with the current time in the specified time zone', () => { + const timeZone = 'America/New_York' + const timeCore = new TestTimeCore({ timeZone }) + const currentTime = Temporal.Now.zonedDateTimeISO(timeZone) + expect(timeCore.getCurrentTime().toString()).toBe(currentTime.toString()) + }) + + test('should start updating the current time', () => { + const timeCore = new TestTimeCore() + timeCore.startUpdatingTime() + vi.advanceTimersByTime(1000) + const currentTime = Temporal.Now.zonedDateTimeISO() + expect(timeCore.getCurrentTime().epochMilliseconds).toBe(currentTime.epochMilliseconds) + }) + + test('should stop updating the current time', () => { + const timeCore = new TestTimeCore() + timeCore.startUpdatingTime() + + vi.advanceTimersByTime(1000) + timeCore.stopUpdatingTime() + const stoppedTime = timeCore.getCurrentTime() + + vi.advanceTimersByTime(1000) + const timeAfterStop = timeCore.getCurrentTime() + expect(timeAfterStop.epochMilliseconds).toBe(stoppedTime.epochMilliseconds) + }) +}) From 4bde275f4846587030cb4d39ea7bf55888c64ebe Mon Sep 17 00:00:00 2001 From: Bart Krakowski Date: Tue, 25 Jun 2024 23:19:14 +0200 Subject: [PATCH 06/15] feat: time core --- packages/time/package.json | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/time/package.json b/packages/time/package.json index 081903be..44f58127 100644 --- a/packages/time/package.json +++ b/packages/time/package.json @@ -55,5 +55,12 @@ "files": [ "dist", "src" - ] + ], + "dependencies": { + "@js-temporal/polyfill": "^0.4.4", + "@tanstack/store": "^0.4.1" + }, + "devDependencies": { + "csstype": "^3.1.3" + } } From b73f8f7920ecfc013e192584a1a2e3dd9adcbd3a Mon Sep 17 00:00:00 2001 From: Bart Krakowski Date: Tue, 25 Jun 2024 23:19:39 +0200 Subject: [PATCH 07/15] feat: time core --- pnpm-lock.yaml | 78 +++++++++++++++++++++++++++++++++----------------- 1 file changed, 51 insertions(+), 27 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 37c8fc72..b27a380c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -183,7 +183,18 @@ importers: specifier: ^2.10.1 version: 2.10.1(@testing-library/jest-dom@6.4.2)(solid-js@1.7.8)(vite@5.2.6) - packages/time: {} + packages/time: + dependencies: + '@js-temporal/polyfill': + specifier: ^0.4.4 + version: 0.4.4 + '@tanstack/store': + specifier: ^0.4.1 + version: 0.4.1 + devDependencies: + csstype: + specifier: ^3.1.3 + version: 3.1.3 packages/vue-time: dependencies: @@ -541,7 +552,7 @@ packages: dependencies: '@ampproject/remapping': 2.3.0 '@babel/code-frame': 7.24.2 - '@babel/generator': 7.23.6 + '@babel/generator': 7.24.1 '@babel/helper-compilation-targets': 7.23.6 '@babel/helper-module-transforms': 7.23.3(@babel/core@7.24.0) '@babel/helpers': 7.24.1 @@ -2619,6 +2630,14 @@ packages: '@jridgewell/sourcemap-codec': 1.4.15 dev: true + /@js-temporal/polyfill@0.4.4: + resolution: {integrity: sha512-2X6bvghJ/JAoZO52lbgyAPFj8uCflhTo2g7nkFzEQdXd/D8rEeD4HtmTEpmtGCva260fcd66YNXBOYdnmHqSOg==} + engines: {node: '>=12'} + dependencies: + jsbi: 4.3.0 + tslib: 2.6.2 + dev: false + /@leichtgewicht/ip-codec@2.0.5: resolution: {integrity: sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw==} dev: true @@ -3288,6 +3307,10 @@ packages: - vite dev: true + /@tanstack/store@0.4.1: + resolution: {integrity: sha512-NvW3MomYSTzQK61AWdtWNIhWgszXFZDRgCNlvSDw/DBaoLqJIlZ0/gKLsditA8un/BGU1NR06+j0a/UNLgXA+Q==} + dev: false + /@testing-library/dom@9.3.4: resolution: {integrity: sha512-FlS4ZWlp97iiNWig0Muq8p+3rVDjRiYE+YKGbAqXOu9nwJFFOdL00kFpz42M+4huzYi86vAK1sOOfyOG45muIQ==} engines: {node: '>=14'} @@ -5113,7 +5136,7 @@ packages: dom-serializer: 2.0.0 domhandler: 5.0.3 htmlparser2: 8.0.2 - postcss: 8.4.35 + postcss: 8.4.38 postcss-media-query-parser: 0.2.3 dev: true @@ -5151,12 +5174,12 @@ packages: webpack: optional: true dependencies: - icss-utils: 5.1.0(postcss@8.4.35) - postcss: 8.4.35 - postcss-modules-extract-imports: 3.0.0(postcss@8.4.35) - postcss-modules-local-by-default: 4.0.4(postcss@8.4.35) - postcss-modules-scope: 3.1.1(postcss@8.4.35) - postcss-modules-values: 4.0.0(postcss@8.4.35) + icss-utils: 5.1.0(postcss@8.4.38) + postcss: 8.4.38 + postcss-modules-extract-imports: 3.0.0(postcss@8.4.38) + postcss-modules-local-by-default: 4.0.4(postcss@8.4.38) + postcss-modules-scope: 3.1.1(postcss@8.4.38) + postcss-modules-values: 4.0.0(postcss@8.4.38) postcss-value-parser: 4.2.0 semver: 7.6.0 webpack: 5.90.3(esbuild@0.20.2) @@ -7006,13 +7029,13 @@ packages: safer-buffer: 2.1.2 dev: true - /icss-utils@5.1.0(postcss@8.4.35): + /icss-utils@5.1.0(postcss@8.4.38): resolution: {integrity: sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA==} engines: {node: ^10 || ^12 || >= 14} peerDependencies: postcss: ^8.1.0 dependencies: - postcss: 8.4.35 + postcss: 8.4.38 dev: true /identity-function@1.0.0: @@ -7495,7 +7518,7 @@ packages: resolution: {integrity: sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==} engines: {node: '>=8'} dependencies: - '@babel/core': 7.24.0 + '@babel/core': 7.24.3 '@babel/parser': 7.24.1 '@istanbuljs/schema': 0.1.3 istanbul-lib-coverage: 3.2.2 @@ -7641,6 +7664,10 @@ packages: argparse: 2.0.1 dev: true + /jsbi@4.3.0: + resolution: {integrity: sha512-SnZNcinB4RIcnEyZqFPdGPVgrg2AcnykiBy0sHVJQKHYeaLUvi3Exj+iaPpLnFVkDPZIV4U0yvgC9/R4uEAZ9g==} + dev: false + /jsdom@24.0.0: resolution: {integrity: sha512-UDS2NayCvmXSXVP6mpTj+73JnNQadZlr9N68189xib2tx5Mls7swlTNao26IoHv46BZJFvXygyRtyXd1feAk1A==} engines: {node: '>=18'} @@ -7891,8 +7918,6 @@ packages: peerDependenciesMeta: webpack: optional: true - webpack-sources: - optional: true dependencies: webpack: 5.90.3(esbuild@0.20.2) webpack-sources: 3.2.3 @@ -9113,45 +9138,45 @@ packages: resolution: {integrity: sha512-3sOlxmbKcSHMjlUXQZKQ06jOswE7oVkXPxmZdoB1r5l0q6gTFTQSHxNxOrCccElbW7dxNytifNEo8qidX2Vsig==} dev: true - /postcss-modules-extract-imports@3.0.0(postcss@8.4.35): + /postcss-modules-extract-imports@3.0.0(postcss@8.4.38): resolution: {integrity: sha512-bdHleFnP3kZ4NYDhuGlVK+CMrQ/pqUm8bx/oGL93K6gVwiclvX5x0n76fYMKuIGKzlABOy13zsvqjb0f92TEXw==} engines: {node: ^10 || ^12 || >= 14} peerDependencies: postcss: ^8.1.0 dependencies: - postcss: 8.4.35 + postcss: 8.4.38 dev: true - /postcss-modules-local-by-default@4.0.4(postcss@8.4.35): + /postcss-modules-local-by-default@4.0.4(postcss@8.4.38): resolution: {integrity: sha512-L4QzMnOdVwRm1Qb8m4x8jsZzKAaPAgrUF1r/hjDR2Xj7R+8Zsf97jAlSQzWtKx5YNiNGN8QxmPFIc/sh+RQl+Q==} engines: {node: ^10 || ^12 || >= 14} peerDependencies: postcss: ^8.1.0 dependencies: - icss-utils: 5.1.0(postcss@8.4.35) - postcss: 8.4.35 + icss-utils: 5.1.0(postcss@8.4.38) + postcss: 8.4.38 postcss-selector-parser: 6.0.16 postcss-value-parser: 4.2.0 dev: true - /postcss-modules-scope@3.1.1(postcss@8.4.35): + /postcss-modules-scope@3.1.1(postcss@8.4.38): resolution: {integrity: sha512-uZgqzdTleelWjzJY+Fhti6F3C9iF1JR/dODLs/JDefozYcKTBCdD8BIl6nNPbTbcLnGrk56hzwZC2DaGNvYjzA==} engines: {node: ^10 || ^12 || >= 14} peerDependencies: postcss: ^8.1.0 dependencies: - postcss: 8.4.35 + postcss: 8.4.38 postcss-selector-parser: 6.0.16 dev: true - /postcss-modules-values@4.0.0(postcss@8.4.35): + /postcss-modules-values@4.0.0(postcss@8.4.38): resolution: {integrity: sha512-RDxHkAiEGI78gS2ofyvCsu7iycRv7oqw5xMWn9iMoR0N/7mf9D50ecQqUo5BZ9Zh2vH4bCUR/ktCqbB9m8vJjQ==} engines: {node: ^10 || ^12 || >= 14} peerDependencies: postcss: ^8.1.0 dependencies: - icss-utils: 5.1.0(postcss@8.4.35) - postcss: 8.4.35 + icss-utils: 5.1.0(postcss@8.4.38) + postcss: 8.4.38 dev: true /postcss-selector-parser@6.0.16: @@ -9458,7 +9483,7 @@ packages: /regenerator-transform@0.15.2: resolution: {integrity: sha512-hfMp2BoF0qOk3uc5V20ALGDS2ddjQaLrdl7xrGXvAIow7qeWRM2VA2HuCHkUKk9slq3VwEwLNK3DFBqDfPGYtg==} dependencies: - '@babel/runtime': 7.24.0 + '@babel/runtime': 7.24.1 dev: true /regex-parser@2.3.0: @@ -9545,7 +9570,7 @@ packages: adjust-sourcemap-loader: 4.0.0 convert-source-map: 1.9.0 loader-utils: 2.0.4 - postcss: 8.4.35 + postcss: 8.4.38 source-map: 0.6.1 dev: true @@ -10741,7 +10766,6 @@ packages: /tslib@2.6.2: resolution: {integrity: sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==} - dev: true /type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} From 78176924e9463cdf7820aa183c420085e737f6a6 Mon Sep 17 00:00:00 2001 From: Bart Krakowski Date: Wed, 26 Jun 2024 23:13:25 +0200 Subject: [PATCH 08/15] feat: timer core --- packages/time/src/core/time.ts | 10 +-- packages/time/src/core/timer.ts | 105 ++++++++++++++++++++++++++++++++ 2 files changed, 110 insertions(+), 5 deletions(-) create mode 100644 packages/time/src/core/timer.ts diff --git a/packages/time/src/core/time.ts b/packages/time/src/core/time.ts index dc419a3c..5f877b59 100644 --- a/packages/time/src/core/time.ts +++ b/packages/time/src/core/time.ts @@ -10,7 +10,7 @@ export interface TimeCoreOptions { timeZone?: Temporal.TimeZoneLike } -interface TimeState { +export interface TimeState { /** * The current time. * @default Temporal.Now.zonedDateTimeISO() @@ -20,17 +20,17 @@ interface TimeState { currentTime: Temporal.ZonedDateTime } -export abstract class TimeCore { - protected store: Store +export abstract class TimeCore { + protected store: Store protected interval: NodeJS.Timeout | null = null protected timeZone: Temporal.TimeZoneLike constructor(options: TimeCoreOptions = {}) { const defaultTimeZone = getDefaultTimeZone() this.timeZone = options.timeZone || defaultTimeZone - this.store = new Store({ + this.store = new Store({ currentTime: Temporal.Now.zonedDateTimeISO(this.timeZone), - }) + } as TState) this.updateCurrentTime() } diff --git a/packages/time/src/core/timer.ts b/packages/time/src/core/timer.ts new file mode 100644 index 00000000..d6bb4b40 --- /dev/null +++ b/packages/time/src/core/timer.ts @@ -0,0 +1,105 @@ +import { Store } from '@tanstack/store' +import { TimeCore } from './time' +import type { TimeCoreOptions, TimeState } from './time' + +interface TimerOptions extends TimeCoreOptions { + /** + * The initial time for the timer. + * @default 0 + */ + initialTime: number + /** + * A callback that is called when the timer finishes. + */ + onFinished?: () => void +} + +interface TimerState extends TimeState { + /** + * The remaining time for the timer. + * @default 0 + * @readonly + * @type number + */ + remainingTime: number + /** + * Whether the timer is running. + * @default false + * @readonly + * @type boolean + */ + isRunning: boolean +} + +export interface TimerActions { + /** + * Start the timer. + */ + start: () => void + /** + * Stop the timer. + */ + stop: () => void + /** + * Reset the timer. + */ + reset: () => void +} + +export interface TimerApi extends TimerActions, TimerState {} + +export class Timer extends TimeCore implements TimerActions { + private options: TimerOptions + + constructor(options: TimerOptions) { + super(options) + this.options = options + this.store = new Store({ + remainingTime: options.initialTime, + isRunning: false, + currentTime: this.store.state.currentTime, + }) + } + + start() { + if (!this.store.state.isRunning) { + this.store.setState((prev) => ({ + ...prev, + isRunning: true, + })) + this.startUpdatingTime(1000) + } + } + + stop() { + if (this.store.state.isRunning) { + this.store.setState((prev) => ({ + ...prev, + isRunning: false, + })) + this.stopUpdatingTime() + } + } + + reset() { + this.stop() + this.store.setState((prev) => ({ + ...prev, + remainingTime: this.options.initialTime, + })) + } + + protected updateCurrentTime() { + super.updateCurrentTime() + if (this.store.state.isRunning && this.store.state.remainingTime > 0) { + this.store.setState((prev) => ({ + ...prev, + remainingTime: prev.remainingTime - 1, + })) + if (this.store.state.remainingTime <= 0) { + this.stop() + this.options.onFinished?.() + } + } + } +} From f0c902dc4ec55f92cb96746a846f96f17dc2fa80 Mon Sep 17 00:00:00 2001 From: Bart Krakowski Date: Wed, 26 Jun 2024 23:45:58 +0200 Subject: [PATCH 09/15] docs: timer core --- docs/reference/timer.md | 58 +++++++++++++++++++++++++++++++++ packages/time/src/core/timer.ts | 19 +++++++++-- 2 files changed, 75 insertions(+), 2 deletions(-) create mode 100644 docs/reference/timer.md diff --git a/docs/reference/timer.md b/docs/reference/timer.md new file mode 100644 index 00000000..3002222b --- /dev/null +++ b/docs/reference/timer.md @@ -0,0 +1,58 @@ +--- +title: Timer +id: timer +--- + +# Timer + +```ts +export class Timer extends TimeCore implements TimerActions { + constructor(options: TimerOptions); +} +``` + +The Timer class provides functionality for managing a countdown timer, including starting, stopping, and resetting the timer. It also includes the ability to trigger a callback when the timer finishes. + + +## Parameters + +- `initialTime: number` +The initial time for the timer, specified in seconds. +- `onFinished?: () => void` +An optional callback function that is called when the timer finishes. +- `timeZone?: Temporal.TimeZoneLike` +Optional time zone specification for the timer. Defaults to the system's time zone. +- `onStart?: () => void` +Optional callback function that is called when the timer starts. +- `onStop?: () => void` +Optional callback function that is called when the timer stops. +- `onReset?: () => void` +Optional callback function that is called when the timer resets. + + +## Methods + +- `start(): void` +Starts the timer. +- `stop(): void` +Stops the timer. +- `reset(): void` +Resets the timer to the initial time. + + +## Example Usage + +```ts +import { Timer } from '@tanstack/time'; + +const timer = new Timer({ + initialTime: 60, // 60 seconds + onFinished: () => { + console.log('Timer finished!'); + }, + timeZone: 'America/New_York', +}); + +// Start the timer +timer.start(); +``` \ No newline at end of file diff --git a/packages/time/src/core/timer.ts b/packages/time/src/core/timer.ts index d6bb4b40..e8beed54 100644 --- a/packages/time/src/core/timer.ts +++ b/packages/time/src/core/timer.ts @@ -11,7 +11,19 @@ interface TimerOptions extends TimeCoreOptions { /** * A callback that is called when the timer finishes. */ - onFinished?: () => void + onFinish?: () => void + /** + * A callback that is called when the timer finishes. + */ + onStart?: () => void + /** + * A callback that is called when the timer stops. + */ + onStop?: () => void + /** + * A callback that is called when the timer resets. + */ + onReset?: () => void } interface TimerState extends TimeState { @@ -68,6 +80,7 @@ export class Timer extends TimeCore implements TimerActions { isRunning: true, })) this.startUpdatingTime(1000) + this.options.onStart?.() } } @@ -78,6 +91,7 @@ export class Timer extends TimeCore implements TimerActions { isRunning: false, })) this.stopUpdatingTime() + this.options.onStop?.() } } @@ -87,6 +101,7 @@ export class Timer extends TimeCore implements TimerActions { ...prev, remainingTime: this.options.initialTime, })) + this.options.onReset?.() } protected updateCurrentTime() { @@ -98,7 +113,7 @@ export class Timer extends TimeCore implements TimerActions { })) if (this.store.state.remainingTime <= 0) { this.stop() - this.options.onFinished?.() + this.options.onFinish?.() } } } From 4b6bd79065d3be8da81b3320fd98301d1395e555 Mon Sep 17 00:00:00 2001 From: Bart Krakowski Date: Thu, 27 Jun 2024 00:03:07 +0200 Subject: [PATCH 10/15] test: timer core --- packages/time/src/core/time.ts | 6 +-- packages/time/src/core/timer.ts | 2 +- packages/time/src/tests/timer.test.ts | 67 +++++++++++++++++++++++++++ 3 files changed, 71 insertions(+), 4 deletions(-) create mode 100644 packages/time/src/tests/timer.test.ts diff --git a/packages/time/src/core/time.ts b/packages/time/src/core/time.ts index 5f877b59..75ee64bb 100644 --- a/packages/time/src/core/time.ts +++ b/packages/time/src/core/time.ts @@ -21,9 +21,9 @@ export interface TimeState { } export abstract class TimeCore { - protected store: Store - protected interval: NodeJS.Timeout | null = null - protected timeZone: Temporal.TimeZoneLike + store: Store + interval: NodeJS.Timeout | null = null + timeZone: Temporal.TimeZoneLike constructor(options: TimeCoreOptions = {}) { const defaultTimeZone = getDefaultTimeZone() diff --git a/packages/time/src/core/timer.ts b/packages/time/src/core/timer.ts index e8beed54..e2dcf246 100644 --- a/packages/time/src/core/timer.ts +++ b/packages/time/src/core/timer.ts @@ -2,7 +2,7 @@ import { Store } from '@tanstack/store' import { TimeCore } from './time' import type { TimeCoreOptions, TimeState } from './time' -interface TimerOptions extends TimeCoreOptions { +export interface TimerOptions extends TimeCoreOptions { /** * The initial time for the timer. * @default 0 diff --git a/packages/time/src/tests/timer.test.ts b/packages/time/src/tests/timer.test.ts new file mode 100644 index 00000000..4663c4a5 --- /dev/null +++ b/packages/time/src/tests/timer.test.ts @@ -0,0 +1,67 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { Timer } from '../core/timer'; +import type { TimerOptions } from '../core/timer'; + +describe('Timer', () => { + let timer: Timer; + const onFinish = vi.fn(); + const onStart = vi.fn(); + const onStop = vi.fn(); + const onReset = vi.fn(); + const initialTime = 5; + + beforeEach(() => { + vi.useFakeTimers(); + + const options: TimerOptions = { + initialTime, + onFinish, + onStart, + onStop, + onReset, + timeZone: 'America/New_York', + }; + + timer = new Timer(options); + }); + + it('should initialize with the correct remaining time and isRunning state', () => { + expect(timer.store.state.remainingTime).toBe(initialTime); + expect(timer.store.state.isRunning).toBe(false); + }); + + it('should start the timer', () => { + timer.start(); + expect(timer.store.state.isRunning).toBe(true); + expect(onStart).toHaveBeenCalled(); + }); + + it('should stop the timer', () => { + timer.start(); + timer.stop(); + expect(timer.store.state.isRunning).toBe(false); + expect(onStop).toHaveBeenCalled(); + }); + + it('should reset the timer', () => { + timer.start(); + timer.reset(); + expect(timer.store.state.isRunning).toBe(false); + expect(timer.store.state.remainingTime).toBe(initialTime); + expect(onReset).toHaveBeenCalled(); + }); + + it('should call onFinish when the timer reaches zero', () => { + timer.start(); + vi.advanceTimersByTime(initialTime * 1000); + expect(timer.store.state.remainingTime).toBe(0); + expect(onFinish).toHaveBeenCalled(); + expect(timer.store.state.isRunning).toBe(false); + }); + + it('should decrement the remaining time every second when running', () => { + timer.start(); + vi.advanceTimersByTime(3000); + expect(timer.store.state.remainingTime).toBe(2); + }); +}); From f23fb69ed3c0b4963fc89bb5ee9aa83b6dc34a20 Mon Sep 17 00:00:00 2001 From: Bart Krakowski Date: Thu, 27 Jun 2024 23:01:52 +0200 Subject: [PATCH 11/15] feat: useTimer --- packages/react-time/package.json | 1 + .../react-time/src/tests/useTimer.test.ts | 0 packages/react-time/src/useTimer.ts | 23 +++++++++++++++++++ 3 files changed, 24 insertions(+) create mode 100644 packages/react-time/src/tests/useTimer.test.ts create mode 100644 packages/react-time/src/useTimer.ts diff --git a/packages/react-time/package.json b/packages/react-time/package.json index c3f9d136..32a3f06b 100644 --- a/packages/react-time/package.json +++ b/packages/react-time/package.json @@ -62,6 +62,7 @@ "react-dom": "^17.0.0 || ^18.0.0" }, "dependencies": { + "@tanstack/react-store": "^0.5.2", "@tanstack/time": "workspace:*", "use-sync-external-store": "^1.2.0" }, diff --git a/packages/react-time/src/tests/useTimer.test.ts b/packages/react-time/src/tests/useTimer.test.ts new file mode 100644 index 00000000..e69de29b diff --git a/packages/react-time/src/useTimer.ts b/packages/react-time/src/useTimer.ts new file mode 100644 index 00000000..3099a025 --- /dev/null +++ b/packages/react-time/src/useTimer.ts @@ -0,0 +1,23 @@ +import { useStore } from '@tanstack/react-store' +import { Timer, type TimerApi, type TimerOptions } from '@tanstack/time' +import { useCallback, useState } from 'react' + + +export const useTimer = (options: TimerOptions): TimerApi => { + const [timer] = useState(() => new Timer(options)) + const state = useStore(timer.store) + + const start = useCallback(() => { + timer.start() + }, [timer]) + + const stop = useCallback(() => { + timer.stop() + }, [timer]) + + const reset = useCallback(() => { + timer.reset() + }, [timer]) + + return { ...state, start, stop, reset } +} \ No newline at end of file From d97fe40a0612fe85a0c0bd07383ad071eedd3d76 Mon Sep 17 00:00:00 2001 From: Bart Krakowski Date: Thu, 27 Jun 2024 23:02:00 +0200 Subject: [PATCH 12/15] feat: useTimer --- packages/time/src/core/index.ts | 1 + packages/time/src/index.ts | 3 ++- pnpm-lock.yaml | 19 +++++++++++++++++++ 3 files changed, 22 insertions(+), 1 deletion(-) create mode 100644 packages/time/src/core/index.ts diff --git a/packages/time/src/core/index.ts b/packages/time/src/core/index.ts new file mode 100644 index 00000000..46213912 --- /dev/null +++ b/packages/time/src/core/index.ts @@ -0,0 +1 @@ +export * from './timer' diff --git a/packages/time/src/index.ts b/packages/time/src/index.ts index 12981a25..3e0cfa01 100644 --- a/packages/time/src/index.ts +++ b/packages/time/src/index.ts @@ -1,4 +1,5 @@ /** * TanStack Time */ -export * from './utils/parse'; \ No newline at end of file +export * from './utils/parse' +export * from './core' diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b27a380c..b206e85c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -150,6 +150,9 @@ importers: packages/react-time: dependencies: + '@tanstack/react-store': + specifier: ^0.5.2 + version: 0.5.2(react-dom@18.2.0)(react@18.2.0) '@tanstack/time': specifier: workspace:* version: link:../time @@ -3307,10 +3310,26 @@ packages: - vite dev: true + /@tanstack/react-store@0.5.2(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-qzYy3ov/U/QZV8MX4zpStlp0Wwj91dmz7faVQwyLZLHiZp1VbOC5WfbUfbkKz9waL6MecZLKvosYVbgxz8Ty7Q==} + peerDependencies: + react: ^17.0.0 || ^18.0.0 + react-dom: ^17.0.0 || ^18.0.0 + dependencies: + '@tanstack/store': 0.5.2 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + use-sync-external-store: 1.2.0(react@18.2.0) + dev: false + /@tanstack/store@0.4.1: resolution: {integrity: sha512-NvW3MomYSTzQK61AWdtWNIhWgszXFZDRgCNlvSDw/DBaoLqJIlZ0/gKLsditA8un/BGU1NR06+j0a/UNLgXA+Q==} dev: false + /@tanstack/store@0.5.2: + resolution: {integrity: sha512-t3vR/nzKnixSmJcSjAULL4mlK6hApsC/pFNjwhLTgJJuWzGQaEjcaQvWfyD3LTVm4wljIL0gWW9cZ7Zrqb1bPQ==} + dev: false + /@testing-library/dom@9.3.4: resolution: {integrity: sha512-FlS4ZWlp97iiNWig0Muq8p+3rVDjRiYE+YKGbAqXOu9nwJFFOdL00kFpz42M+4huzYi86vAK1sOOfyOG45muIQ==} engines: {node: '>=14'} From 1af881ecc0a499d6e229be56546ecccc180fc86b Mon Sep 17 00:00:00 2001 From: Bart Krakowski Date: Thu, 27 Jun 2024 23:24:53 +0200 Subject: [PATCH 13/15] test: useTimer --- .../react-time/src/tests/useTimer.test.ts | 99 +++++++++++++++++++ packages/react-time/src/useTimer.ts | 3 +- packages/time/src/core/timer.ts | 6 +- 3 files changed, 103 insertions(+), 5 deletions(-) diff --git a/packages/react-time/src/tests/useTimer.test.ts b/packages/react-time/src/tests/useTimer.test.ts index e69de29b..e8649f90 100644 --- a/packages/react-time/src/tests/useTimer.test.ts +++ b/packages/react-time/src/tests/useTimer.test.ts @@ -0,0 +1,99 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest' +import { act, renderHook } from '@testing-library/react' +import { useTimer } from '../useTimer' + +describe('useTimer', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + test('should start the timer', () => { + const { result } = renderHook(() => useTimer({ initialTime: 5 })) + act(() => { + result.current.start() + }) + expect(result.current.isRunning).toBe(true) + }) + + test('should stop the timer', () => { + const { result } = renderHook(() => useTimer({ initialTime: 5 })) + act(() => { + result.current.start() + }) + act(() => { + result.current.stop() + }) + expect(result.current.isRunning).toBe(false) + }) + + test('should reset the timer', () => { + const { result } = renderHook(() => useTimer({ initialTime: 5 })) + act(() => { + result.current.start() + }) + act(() => { + result.current.stop() + }) + expect(result.current.isRunning).toBe(false) + expect(result.current.remainingTime).toBe(5) + }) + + test('should update the remaining time', () => { + const { result } = renderHook(() => useTimer({ initialTime: 5 })) + act(() => { + result.current.start() + }) + act(() => { + vi.advanceTimersByTime(1000) + }) + expect(result.current.remainingTime).toBe(4) + }) + + test('should call onStart callback', () => { + const onStart = vi.fn() + const { result } = renderHook(() => useTimer({ initialTime: 5, onStart })) + act(() => { + result.current.start() + }) + expect(onStart).toHaveBeenCalledTimes(1) + }) + + test('should call onStop callback', () => { + const onStop = vi.fn() + const { result } = renderHook(() => useTimer({ initialTime: 5, onStop })) + act(() => { + result.current.start() + }) + act(() => { + result.current.stop() + }) + expect(onStop).toHaveBeenCalledTimes(1) + }) + + test('should call onReset callback', () => { + const onReset = vi.fn() + const { result } = renderHook(() => useTimer({ initialTime: 5, onReset })) + act(() => { + result.current.start() + }) + act(() => { + result.current.stop() + }) + act(() => { + result.current.reset() + }) + expect(onReset).toHaveBeenCalledTimes(1) + }) + + test('should call onFinish callback', () => { + const onFinish = vi.fn() + const { result } = renderHook(() => useTimer({ initialTime: 5, onFinish })) + act(() => { + result.current.start() + }) + act(() => { + vi.advanceTimersByTime(5000) + }) + expect(onFinish).toHaveBeenCalledTimes(1) + }) +}) diff --git a/packages/react-time/src/useTimer.ts b/packages/react-time/src/useTimer.ts index 3099a025..71ba97c8 100644 --- a/packages/react-time/src/useTimer.ts +++ b/packages/react-time/src/useTimer.ts @@ -2,7 +2,6 @@ import { useStore } from '@tanstack/react-store' import { Timer, type TimerApi, type TimerOptions } from '@tanstack/time' import { useCallback, useState } from 'react' - export const useTimer = (options: TimerOptions): TimerApi => { const [timer] = useState(() => new Timer(options)) const state = useStore(timer.store) @@ -20,4 +19,4 @@ export const useTimer = (options: TimerOptions): TimerApi => { }, [timer]) return { ...state, start, stop, reset } -} \ No newline at end of file +} diff --git a/packages/time/src/core/timer.ts b/packages/time/src/core/timer.ts index e2dcf246..ece14dc5 100644 --- a/packages/time/src/core/timer.ts +++ b/packages/time/src/core/timer.ts @@ -7,7 +7,7 @@ export interface TimerOptions extends TimeCoreOptions { * The initial time for the timer. * @default 0 */ - initialTime: number + initialTime?: number /** * A callback that is called when the timer finishes. */ @@ -67,7 +67,7 @@ export class Timer extends TimeCore implements TimerActions { super(options) this.options = options this.store = new Store({ - remainingTime: options.initialTime, + remainingTime: options.initialTime || 0, isRunning: false, currentTime: this.store.state.currentTime, }) @@ -99,7 +99,7 @@ export class Timer extends TimeCore implements TimerActions { this.stop() this.store.setState((prev) => ({ ...prev, - remainingTime: this.options.initialTime, + remainingTime: this.options.initialTime || 0, })) this.options.onReset?.() } From 3e305e16ab2fe5e29decb679a5666a06d42b30af Mon Sep 17 00:00:00 2001 From: Bart Krakowski Date: Thu, 27 Jun 2024 23:41:43 +0200 Subject: [PATCH 14/15] docs: useTimer --- docs/framework/react/reference/useTimer.md | 76 ++++++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 docs/framework/react/reference/useTimer.md diff --git a/docs/framework/react/reference/useTimer.md b/docs/framework/react/reference/useTimer.md new file mode 100644 index 00000000..d78becbb --- /dev/null +++ b/docs/framework/react/reference/useTimer.md @@ -0,0 +1,76 @@ +--- +title: Use Timer +id: useTimer +--- + +### useTimer + +```ts +export function useTimer({ + initialTime: number, + onFinished?: () => void, + timeZone?: Temporal.TimeZoneLike, + onStart?: () => void, + onStop?: () => void, + onReset?: () => void, +}): TimerApi; +``` + +`useTimer` is a hook that provides a comprehensive set of functionalities for managing a countdown timer, including starting, stopping, and resetting the timer. It also includes the ability to trigger a callback when the timer finishes, starts, stops, or resets. + + +#### Parameters + +- `initialTime: number` +The initial time for the timer, specified in seconds. +- `timeZone?: Temporal.TimeZoneLike` +Optional time zone specification for the timer. Defaults to the system's time zone. +- `onFinished?: () => void` +An optional callback function that is called when the timer finishes. +- `onStart?: () => void` +Optional callback function that is called when the timer starts. +- `onStop?: () => void` +Optional callback function that is called when the timer stops. +- `onReset?: () => void` +Optional callback function that is called when the timer resets. + + +#### Returns + +- `remainingTime: number` +This value represents the remaining time of the timer in seconds. +- `isRunning: boolean` +This value represents whether the timer is currently running. +- `start: () => void` +This function starts the timer. +- `stop: () => void` +This function stops the timer. +- `reset: () => void` +This function resets the timer to the initial time. + + +#### Example Usage + +```ts +import { useTimer } from '@tanstack/react-time'; + +const TimerComponent = () => { + const { remainingTime, isRunning, start, stop, reset } = useTimer({ + initialTime: 60, + onFinished: () => { + console.log('Timer finished!'); + }, + timeZone: 'America/New_York', + }); + + return ( +
+
Remaining Time: {remainingTime}
+
Is Running: {isRunning ? 'Yes' : 'No'}
+ + + +
+ ); +}; +``` From 568ef3aafc59bc55da3c51dacb29a8c42f19603f Mon Sep 17 00:00:00 2001 From: Bart Krakowski Date: Thu, 27 Jun 2024 23:43:00 +0200 Subject: [PATCH 15/15] docs: useTimer --- packages/time/src/core/timer.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/time/src/core/timer.ts b/packages/time/src/core/timer.ts index ece14dc5..b1f72af7 100644 --- a/packages/time/src/core/timer.ts +++ b/packages/time/src/core/timer.ts @@ -5,9 +5,8 @@ import type { TimeCoreOptions, TimeState } from './time' export interface TimerOptions extends TimeCoreOptions { /** * The initial time for the timer. - * @default 0 */ - initialTime?: number + initialTime: number /** * A callback that is called when the timer finishes. */ @@ -67,7 +66,7 @@ export class Timer extends TimeCore implements TimerActions { super(options) this.options = options this.store = new Store({ - remainingTime: options.initialTime || 0, + remainingTime: options.initialTime, isRunning: false, currentTime: this.store.state.currentTime, }) @@ -99,7 +98,7 @@ export class Timer extends TimeCore implements TimerActions { this.stop() this.store.setState((prev) => ({ ...prev, - remainingTime: this.options.initialTime || 0, + remainingTime: this.options.initialTime, })) this.options.onReset?.() }