diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d99c18..0a25743 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,34 +7,54 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Breaking + +- Renames `toParts` to `msToParts` +- Renames `MICROSECONDS_IN_A_MILLISECOND` to `MICROSECONDS_IN_MILLISECOND` +- Renames `MILLISECONDS_IN_A_SECOND` to `MILLISECONDS_IN_SECOND` +- Renames `NANOSECONDS_IN_A_MICROSECOND` to `NANOSECONDS_IN_MICROSECOND` + +### Added + +- `msToClock` function to convert milliseconds to a clock string +- `clockToMs` function to convert a clock string to milliseconds +- `partsToMs` function to convert an object with time unit properties to milliseconds +- `Parts` interface to represent the parts of a duration +- `TimeUnit` type to represent the time unit strings used in the `parseDuration` function + +### Fixed + +- Incorrect/missing documentation +- Floating point conversion error + ## [1.2.0] - 2025-06-10 ### Added -- Code coverage check (thanks [@simmo]) -- Microseconds and nanoseconds support (thanks [@simmo]) +- Code coverage check +- Microseconds and nanoseconds support ## [1.1.2] - 2025-05-06 -### Changes +### Changed -- Workflow adjustments (thanks [@simmo]) +- Workflow adjustments ### Fixed -- Missing package files path (thanks [@simmo]) +- Missing package files path ## [1.1.1] - 2025-05-05 ### Added -- Contributing guide (thanks [@simmo]) -- Security policy (thanks [@simmo]) -- PR + issue templates (thanks [@simmo]) +- Contributing guide +- Security policy +- PR + issue templates ### Changed -- README adjustments (thanks [@simmo]) +- README adjustments ### Fixed @@ -44,53 +64,51 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- `weeks` unit converter (thanks [@simmo]) -- `WEEK` and `DAYS_IN_WEEK` constants (thanks [@simmo]) -- `weeks` to `toParts` function output (thanks [@simmo]) +- `weeks` unit converter +- `WEEK` and `DAYS_IN_WEEK` constants +- `weeks` to `toParts` function output ### Changed -- Updated dev dependencies (thanks [@simmo]) -- Output modules in distribution (thanks [@simmo]) +- Updated dev dependencies +- Output modules in distribution ### Fixes -- README typos (thanks [@simmo]) -- Clean up (thanks [@simmo]) -- Changelog URLs (thanks [@simmo]) +- README typos +- Clean up +- Changelog URLs ## [1.0.0] - 2025-05-05 ### Changed -- Update readme (thanks [@simmo]) +- Update readme ## [0.0.2] - 2025-05-05 ### Fixed -- Update incorrect documentation (thanks [@simmo]) -- Update README (thanks [@simmo]) +- Update incorrect documentation +- Update README ## [0.0.1] - 2025-05-04 ### Added -- Initial setup (thanks [@simmo]) -- Constants for time units (thanks [@simmo]) -- Unit converter functions for `days`, `hours`, `minutes`, `seconds` and `milliseconds` (thanks [@simmo]) -- `toParts` function to convert milliseconds to an object with properties for each time unit (thanks [@simmo]) -- `parseDuration` function to parse a duration string into milliseconds (thanks [@simmo]) +- Initial setup +- Constants for time units +- Unit converter functions for `days`, `hours`, `minutes`, `seconds` and `milliseconds` +- `toParts` function to convert milliseconds to an object with properties for each time unit +- `parseDuration` function to parse a duration string into milliseconds [#3]: https://github.com/simmo/niobe/pull/3 +[@spyros-uk]: https://github.com/spyros-uk [unreleased]: https://github.com/simmo/niobe/compare/1.2.0...HEAD [1.0.0]: https://github.com/simmo/niobe/compare/0.0.2...1.0.0 [0.0.2]: https://github.com/simmo/niobe/compare/0.0.1...0.0.2 [0.0.1]: https://github.com/simmo/niobe/compare/f3751e...0.0.1 [1.1.0]: https://github.com/simmo/niobe/compare/1.0.1-beta.1...1.1.0 -[@simmo]: https://github.com/simmo -[@spyros-uk]: https://github.com/spyros-uk [1.1.1]: https://github.com/simmo/niobe/compare/1.1.1-beta.2...1.1.1 [1.1.2]: https://github.com/simmo/niobe/compare/1.1.2-beta.0...1.1.2 - [1.2.0]: https://github.com/simmo/niobe/releases/tag/1.2.0 diff --git a/README.md b/README.md index 3d06244..84939ef 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,8 @@ setTimeout(() => /* Do something */}, minutes(2) + seconds(30)); Additionally, Niobe provides [utilities](#utilities) to parse durations, split them into their components and [convert](#conversion) between different time units. There is even a range of common [constants](#constants). +Take a look at the [FAQs](#faqs) for more information, the [CHANGELOG](./CHANGELOG.md) for the latest changes or the [contribution guide](./CONTRIBUTING.md) if you want to get involved. + --- ## Installation @@ -99,6 +101,25 @@ _[This seems pointless, why is it here?](#this-seems-pointless-why-is-it-here)_ ### Utilities +#### `clockToMs(clock: string): number` + +Converts a `hh:mm:ss.ms_μs_ns` string to milliseconds. + +- `hh` - Hours - Optional, optional leading zero +- `mm` - Minutes - Optional, optional leading zero +- `ss` - Seconds - Required, optional leading zero +- `ms` - Milliseconds - Optional, optional trailing zeros +- `μs` - Microseconds - Optional, optional trailing zeros +- `ns` - Nanoseconds - Optional, optional trailing zeros + +Milliseconds, microseconds, and nanoseconds can be optionally separated by underscores, if not, you must provide padding. + +#### `msToClock(milliseconds: number, options?: { separateDecimal = true }): string` + +Converts milliseconds to a string in the format 'hh:mm:ss' optionally suffixing '.ms_μs_ns' if there are any remaining milliseconds, microseconds or nanoseconds. + +`separateDecimal` - If true, milliseconds, microseconds, and nanoseconds will be separated by underscores. If false, they will be concatenated without separators. + #### `parseDuration(duration: string, strict: boolean = false): number` Parses a duration string, returning milliseconds. @@ -113,6 +134,9 @@ parseDuration('2m 1s'); parseDuration('1h 2m 3s'); // => 3_723_004 + +parseDuration('1d 2h 3m 4s 5ms 6μs 7ns'); +// => 93_784_005.006_007 ``` ###### Invalid format - Non-strict (Default) @@ -132,14 +156,54 @@ parseDuration('invalid', true); // => throws Error: "invalid" is not a valid duration ``` -#### `toParts(milliseconds: number): { days: number, hours: number, minutes: number, seconds: number, milliseconds: number }` +#### `msToParts(milliseconds: number): Parts` + +Converts a duration in milliseconds to a [Parts](#parts) object with properties for each time unit. + +#### `partsToMs(parts: Partial): number` + +Converts a [Parts](#parts) object to duration in milliseconds. + +### Types + +#### `TimeUnit` + +This type represents the time unit strings used in the [`parseDuration`](#parsedurationduration-string-strict-boolean--false-number) function. It can be one of the following: + +```ts +type TimeUnit = 'ns' | 'μs' | 'ms' | 's' | 'm' | 'h' | 'd' | 'w'; +``` + +#### `Parts` -Converts a duration in milliseconds to an object with properties for each time unit. +Used in the [`msToParts`](#mstopartsmilliseconds-number-parts) and [`partsToMs`](#partstomsparts-partialparts-number) functions. This interface represents the parts of a duration. + +```ts +interface Parts { + days: number; + hours: number; + isNegative: boolean; + nanoseconds: number; + microseconds: number; + milliseconds: number; + minutes: number; + seconds: number; + weeks: number; +} +``` ### Constants These constants are used to represent the number of milliseconds in each time unit. +#### `NANOSECOND` + +One nanosecond in milliseconds. + +#### `MICROSECOND` + +One microsecond in milliseconds. + #### `MILLISECOND` One millisecond. @@ -166,6 +230,14 @@ One day in milliseconds. One week in milliseconds. +#### `NANOSECONDS_IN_MICROSECOND` + +Number of nanoseconds in a second. + +#### `MICROSECONDS_IN_MILLISECOND` + +Number of microseconds in a second. + #### `MILLISECONDS_IN_SECOND` Number of milliseconds in a second. diff --git a/src/clockToMs.test.ts b/src/clockToMs.test.ts new file mode 100644 index 0000000..41940ea --- /dev/null +++ b/src/clockToMs.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest'; + +import { clockToMs } from './clockToMs.js'; + +const testCases = [ + [1_000, '00:00:01'], + [61_000, '00:01:01'], + [3_661_000, '01:01:01'], + [1_000, '1'], + [1_000, '01'], + [61_000, '1:1'], + [61_000, '1:01'], + [61_000, '01:1'], + [3_661_000, '1:1:1'], + [3_661_000, '1:1:01'], + [3_661_000, '1:01:01'], + [1_500.000_005, '00:00:01.500000005'], + [61_500.000_005, '00:01:01.500000005'], + [3_661_500.000_005, '01:01:01.500000005'], + [1_500.000_005, '1.500000005'], + [1_500.000_005, '01.500000005'], + [61_500.000_005, '1:1.500000005'], + [61_500.000_005, '1:01.500000005'], + [61_500.000_005, '01:1.500000005'], + [3_661_500.000_005, '1:1:1.500000005'], + [3_661_500.000_005, '1:1:01.500000005'], + [3_661_500.000_005, '1:01:01.500000005'], + [1_500.000_005, '00:00:01.500_000_005'], + [61_500.000_005, '00:01:01.500_000_005'], + [3_661_500.000_005, '01:01:01.500_000_005'], + [1_500.000_005, '1.500_000_005'], + [1_500.000_005, '01.500_000_005'], + [61_500.000_005, '1:1.500_000_005'], + [61_500.000_005, '1:01.500_000_005'], + [61_500.000_005, '01:1.500_000_005'], + [3_661_500.000_005, '1:1:1.500_000_005'], + [3_661_500.000_005, '1:1:01.500_000_005'], + [3_661_500.000_005, '1:01:01.500_000_005'], + [3_661_500.000_005, '1:01:01.500_000_005'], + [3_661_500.000_005, '1:01:01.5_0_005'], + [3_661_500.500_500, '1:01:01.5_5_5'], + [3_661_500.500_500, '01:01:01.500_500_500'], +] as const; + +describe('clockToMs()', () => { + describe.each([ + ['positive', false], + ['negative', true], + ])('%s time', (_, isNegative) => { + const [offset, sign] = isNegative ? [-1, '-'] : [1, '']; + + it.each(testCases)( + `returns ${sign}%s for ${sign}%s`, + (milliseconds, clock) => { + expect(clockToMs(`${sign}${clock}`)).toBe(milliseconds * offset); + }, + ); + }); +}); diff --git a/src/clockToMs.ts b/src/clockToMs.ts new file mode 100644 index 0000000..e3de2f7 --- /dev/null +++ b/src/clockToMs.ts @@ -0,0 +1,104 @@ +import { DECIMAL_SEPARATOR } from './constants/DecimalSeparator.js'; +import { DECIMAL_UNIT_SEPARATOR } from './constants/DecimalUnitSeparator.js'; +import { HMS_SEPARATOR } from './constants/HmsSeparator.js'; +import { + hours, + microseconds, + milliseconds, + minutes, + nanoseconds, + seconds, +} from './conversion.js'; + +/** + * Converts a `hh:mm:ss.ms_μs_ns` string to milliseconds. + * + * - `hh` - Hours - Optional, optional leading zero + * - `mm` - Minutes - Optional, optional leading zero + * - `ss` - Seconds - Required, optional leading zero + * - `ms` - Milliseconds - Optional, optional trailing zeros + * - `μs` - Microseconds - Optional, optional trailing zeros + * - `ns` - Nanoseconds - Optional, optional trailing zeros + * + * Milliseconds, microseconds, and nanoseconds can be optionally separated by underscores, if not, you must provide padding. + * + * @example Seconds + * ```ts + * clockToMs('01:10') + * // => 70_000 + * ``` + * + * @example Minutes and seconds + * ```ts + * clockToMs('01:10') + * // => 70_000 + * ``` + * + * @example Hours, minutes, seconds + * ```ts + * clockToMs('01:01:01') + * // => 3_661_000 + * ``` + * + * @example Decimal notation with underscores + * ```ts + * clockToMs('01:01:01.500_000_005') + * // => 3_661_500.000_005 + * ``` + +* @example Decimal notation without underscores + * ```ts + * clockToMs('01:01:01.500000005') + * // => 3_661_500.000_005 + * ``` + +* @example Negative time + * ```ts + * clockToMs('-01:01:01.500000005') + * // => -3_661_500.000_005 + * ``` + */ + +export const clockToMs = (clock: string): number => { + const [time, fraction] = clock.split(DECIMAL_SEPARATOR); + const [timeAbsolute, isNegative] = time.startsWith('-') + ? [time.slice(1), true] + : [time, false]; + const [s = 0, m = 0, h = 0] = timeAbsolute + .split(HMS_SEPARATOR) + .map(Number) + .reverse(); + + let millis = 0; + let micros = 0; + let nanos = 0; + + if (fraction) { + const parts = fraction.split(DECIMAL_UNIT_SEPARATOR); + + if (parts.length === 1) { + const fractionStr = (parts[0] + '000000000').slice(0, 9); + + millis = parseInt(fractionStr.slice(0, 3), 10); + micros = parseInt(fractionStr.slice(3, 6), 10); + nanos = parseInt(fractionStr.slice(6, 9), 10); + } else { + if (typeof parts[0] !== 'undefined') + millis = parseInt(parts[0].padEnd(3, '0'), 10); + if (typeof parts[1] !== 'undefined') + micros = parseInt(parts[1].padEnd(3, '0'), 10); + if (typeof parts[2] !== 'undefined') + nanos = parseInt(parts[2].padEnd(3, '0'), 10); + } + } + + return ( + (hours(h) + + minutes(m) + + seconds(s) + + milliseconds(millis) + + microseconds(micros) + + nanoseconds(nanos)) * + (isNegative ? -1 : 1) + ); +}; diff --git a/src/constant.test.ts b/src/constant.test.ts index 01ea857..e7d9aaa 100644 --- a/src/constant.test.ts +++ b/src/constant.test.ts @@ -10,13 +10,13 @@ const constantsAndValues: [ ['HOUR', 3_600_000], ['HOURS_IN_DAY', 24], ['MICROSECOND', 0.001], - ['MICROSECONDS_IN_A_MILLISECOND', 1_000], + ['MICROSECONDS_IN_MILLISECOND', 1_000], ['MILLISECOND', 1], - ['MILLISECONDS_IN_A_SECOND', 1_000], + ['MILLISECONDS_IN_SECOND', 1_000], ['MINUTE', 60_000], ['MINUTES_IN_HOUR', 60], ['NANOSECOND', 0.000_001], - ['NANOSECONDS_IN_A_MICROSECOND', 1_000], + ['NANOSECONDS_IN_MICROSECOND', 1_000], ['SECOND', 1_000], ['SECONDS_IN_MINUTE', 60], ['WEEK', 604_800_000], @@ -31,13 +31,13 @@ describe('constants', () => { "HOUR": 3600000, "HOURS_IN_DAY": 24, "MICROSECOND": 0.001, - "MICROSECONDS_IN_A_MILLISECOND": 1000, + "MICROSECONDS_IN_MILLISECOND": 1000, "MILLISECOND": 1, - "MILLISECONDS_IN_A_SECOND": 1000, + "MILLISECONDS_IN_SECOND": 1000, "MINUTE": 60000, "MINUTES_IN_HOUR": 60, "NANOSECOND": 0.000001, - "NANOSECONDS_IN_A_MICROSECOND": 1000, + "NANOSECONDS_IN_MICROSECOND": 1000, "SECOND": 1000, "SECONDS_IN_MINUTE": 60, "WEEK": 604800000, diff --git a/src/constant.ts b/src/constant.ts index 1109f04..3bcf9cc 100644 --- a/src/constant.ts +++ b/src/constant.ts @@ -50,19 +50,19 @@ export const WEEK = 604_800_000; * Number of nanoseconds in a microsecond. */ -export const NANOSECONDS_IN_A_MICROSECOND = 1_000; +export const NANOSECONDS_IN_MICROSECOND = 1_000; /** * Number of microseconds in a millisecond. */ -export const MICROSECONDS_IN_A_MILLISECOND = 1_000; +export const MICROSECONDS_IN_MILLISECOND = 1_000; /** * Number of milliseconds in a second. */ -export const MILLISECONDS_IN_A_SECOND = 1_000; +export const MILLISECONDS_IN_SECOND = 1_000; /** * Number of seconds in a minute. diff --git a/src/constants/DecimalSeparator.ts b/src/constants/DecimalSeparator.ts new file mode 100644 index 0000000..a2c0d5c --- /dev/null +++ b/src/constants/DecimalSeparator.ts @@ -0,0 +1 @@ +export const DECIMAL_SEPARATOR = '.'; diff --git a/src/constants/DecimalUnitSeparator.ts b/src/constants/DecimalUnitSeparator.ts new file mode 100644 index 0000000..44db997 --- /dev/null +++ b/src/constants/DecimalUnitSeparator.ts @@ -0,0 +1 @@ +export const DECIMAL_UNIT_SEPARATOR = '_'; diff --git a/src/constants/HmsSeparator.ts b/src/constants/HmsSeparator.ts new file mode 100644 index 0000000..98e1c7e --- /dev/null +++ b/src/constants/HmsSeparator.ts @@ -0,0 +1 @@ +export const HMS_SEPARATOR = ':'; diff --git a/src/conversion.test.ts b/src/conversion.test.ts index 53ff362..4f68966 100644 --- a/src/conversion.test.ts +++ b/src/conversion.test.ts @@ -9,31 +9,115 @@ import { seconds, weeks, } from './conversion.js'; -import { UnitConverter } from './utils/createUnitConverter.js'; +import { type UnitConverter } from './utils/createUnitConverter.js'; -const testCases: [unit: string, fn: UnitConverter, milliseconds: number][] = [ - ['nanoseconds', nanoseconds, 0.000_001], - ['microseconds', microseconds, 0.001], - ['milliseconds', milliseconds, 1], - ['seconds', seconds, 1_000], - ['minutes', minutes, 60_000], - ['hours', hours, 3_600_000], - ['days', days, 86_400_000], - ['weeks', weeks, 604_800_000], +const testCases: [ + unit: string, + fn: UnitConverter, + inputs: [amount: number, ms: number][], +][] = [ + [ + 'nanoseconds', + nanoseconds, + [ + [1, 0.000_001], + [2, 0.000_002], + [3, 0.000_003], + [5, 0.000_005], + [8, 0.000_008], + ], + ], + [ + 'microseconds', + microseconds, + [ + [1, 0.001], + [2, 0.002], + [3, 0.003], + [5, 0.005], + [8, 0.008], + ], + ], + [ + 'milliseconds', + milliseconds, + [ + [1, 1], + [2, 2], + [3, 3], + [5, 5], + [8, 8], + ], + ], + [ + 'seconds', + seconds, + [ + [1, 1_000], + [2, 2_000], + [3, 3_000], + [5, 5_000], + [8, 8_000], + ], + ], + [ + 'minutes', + minutes, + [ + [1, 60_000], + [2, 120_000], + [3, 180_000], + [5, 300_000], + [8, 480_000], + ], + ], + [ + 'hours', + hours, + [ + [1, 3_600_000], + [2, 7_200_000], + [3, 10_800_000], + [5, 18_000_000], + [8, 28_800_000], + ], + ], + [ + 'days', + days, + [ + [1, 86_400_000], + [2, 172_800_000], + [3, 259_200_000], + [5, 432_000_000], + [8, 691_200_000], + ], + ], + [ + 'weeks', + weeks, + [ + [1, 604_800_000], + [2, 1_209_600_000], + [3, 1_814_400_000], + [5, 3_024_000_000], + [8, 4_838_400_000], + ], + ], ]; -const testUnitAmounts = [1, 2, 3, 5, 8]; - describe('conversion', () => { - it.each(testCases)('returns milliseconds from %s', (_, fn, milliseconds) => { - testUnitAmounts.forEach(amount => { - expect(fn(amount)).toBe(amount * milliseconds); + it.each(testCases)('returns milliseconds from %s', (_, fn, inputs) => { + inputs.forEach(([amount, milliseconds]) => { + expect(fn(amount), `${amount} >>> ${milliseconds}ms`).toBe(milliseconds); }); }); - it.each(testCases)('returns %s from milliseconds', (_, fn, milliseconds) => { - testUnitAmounts.forEach(amount => { - expect(fn.from(amount * milliseconds)).toBe(amount); + it.each(testCases)('returns %s from milliseconds', (_, fn, inputs) => { + inputs.forEach(([amount, milliseconds]) => { + expect(fn.from(milliseconds), `${milliseconds}ms >>> ${amount}`).toBe( + amount, + ); }); }); }); diff --git a/src/index.test.ts b/src/index.test.ts index 098a690..841876d 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -10,25 +10,28 @@ describe('api', () => { "HOUR": 3600000, "HOURS_IN_DAY": 24, "MICROSECOND": 0.001, - "MICROSECONDS_IN_A_MILLISECOND": 1000, + "MICROSECONDS_IN_MILLISECOND": 1000, "MILLISECOND": 1, - "MILLISECONDS_IN_A_SECOND": 1000, + "MILLISECONDS_IN_SECOND": 1000, "MINUTE": 60000, "MINUTES_IN_HOUR": 60, "NANOSECOND": 0.000001, - "NANOSECONDS_IN_A_MICROSECOND": 1000, + "NANOSECONDS_IN_MICROSECOND": 1000, "SECOND": 1000, "SECONDS_IN_MINUTE": 60, "WEEK": 604800000, + "clockToMs": [Function], "days": [Function], "hours": [Function], "microseconds": [Function], "milliseconds": [Function], "minutes": [Function], + "msToClock": [Function], + "msToParts": [Function], "nanoseconds": [Function], "parseDuration": [Function], + "partsToMs": [Function], "seconds": [Function], - "toParts": [Function], "weeks": [Function], } `); diff --git a/src/index.ts b/src/index.ts index 0920d65..97f37ff 100644 --- a/src/index.ts +++ b/src/index.ts @@ -14,4 +14,15 @@ export * from './conversion.js'; * Utilities */ -export * from './utilities.js'; +export { clockToMs } from './clockToMs.js'; +export { msToClock } from './msToClock.js'; +export { msToParts } from './msToParts.js'; +export { parseDuration } from './parseDuration.js'; +export { partsToMs } from './partsToMs.js'; + +/** + * Types + */ + +export type { Parts } from './interfaces/Parts.js'; +export type { TimeUnit } from './types/TimeUnit.js'; diff --git a/src/interfaces/Parts.ts b/src/interfaces/Parts.ts new file mode 100644 index 0000000..e0e738c --- /dev/null +++ b/src/interfaces/Parts.ts @@ -0,0 +1,15 @@ +/** + * This interface represents the parts of a duration. + */ + +export interface Parts { + days: number; + hours: number; + isNegative: boolean; + nanoseconds: number; + microseconds: number; + milliseconds: number; + minutes: number; + seconds: number; + weeks: number; +} diff --git a/src/msToClock.test.ts b/src/msToClock.test.ts new file mode 100644 index 0000000..1d8bb25 --- /dev/null +++ b/src/msToClock.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest'; + +import { msToClock } from './msToClock.js'; +import { DECIMAL_UNIT_SEPARATOR } from './constants/DecimalUnitSeparator.js'; + +const testCases = [ + ['00:00:01', 1_000], + ['00:01:01', 61_000], + ['01:01:01', 3_661_000], + ['00:00:01.001', 1_001], + ['00:00:01.500', 1_500], + ['00:00:01.500_400', 1_500.4], + ['00:00:01.500_400_300', 1_500.4003], + ['00:00:01.000_400_300', 1_000.4003], + ['00:00:01.000_000_300', 1_000.0003], + ['00:00:01.001_002_003', 1_001.002003], + ['00:00:01.010_020_030', 1_010.02003], +] as const satisfies [clock: string, Parameters[0]][]; + +describe('msToClock()', () => { + describe.each([ + ['positive', false], + ['negative', true], + ])('%s time', (_, isNegative) => { + const [offset, sign] = isNegative ? [-1, '-'] : [1, '']; + + it.each(testCases)( + `returns ${sign}%s for ${sign}%s`, + (clock, milliseconds) => { + const output = msToClock(milliseconds * offset); + + expect(output).toBe(`${sign}${clock}`); + }, + ); + + it.each(testCases)( + `returns ${sign}%s for ${sign}%s without decimal separators`, + (clock, milliseconds) => { + const output = msToClock(milliseconds * offset, { + separateDecimal: false, + }); + + expect(output).toBe( + `${sign}${clock.replaceAll(DECIMAL_UNIT_SEPARATOR, '')}`, + ); + }, + ); + }); +}); diff --git a/src/msToClock.ts b/src/msToClock.ts new file mode 100644 index 0000000..8e5cb7c --- /dev/null +++ b/src/msToClock.ts @@ -0,0 +1,66 @@ +import { DECIMAL_SEPARATOR } from './constants/DecimalSeparator.js'; +import { DECIMAL_UNIT_SEPARATOR } from './constants/DecimalUnitSeparator.js'; +import { HMS_SEPARATOR } from './constants/HmsSeparator.js'; +import { msToParts } from './msToParts.js'; +import { padLeadingZero } from './utils/padLeadingZero.js'; + +interface Options { + separateDecimal?: boolean; +} + +/** + * Converts milliseconds to a string in the format 'hh:mm:ss' optionally suffixing '.ms_μs_ns' if there are any remaining milliseconds, microseconds or nanoseconds. + * + * @example + * ```ts + * msToClock(7_501_500); + * // => '02:05:01.500' + * ``` + * + * @example + * ```ts + * msToClock(-7_501_500); + * // => '-02:05:01.500' + * ``` + * + * @example + * ```ts + * msToClock(7_501_000); + * // => '02:05:01' + * ``` + */ + +export const msToClock = ( + ms: number, + { separateDecimal = true }: Options = {}, +): string => { + const { + hours, + isNegative, + nanoseconds, + microseconds, + milliseconds, + minutes, + seconds, + } = msToParts(ms); + const time = [hours, minutes, seconds] + .map(value => padLeadingZero(value, 2)) + .join(HMS_SEPARATOR); + + const decimal = [nanoseconds, microseconds, milliseconds].reduce( + (acc, value, index) => { + if (!acc && value === 0) return acc; + + return ( + padLeadingZero(value, 3) + + (separateDecimal && acc && index !== 0 ? DECIMAL_UNIT_SEPARATOR : '') + + acc + ); + }, + '', + ); + + return [`${isNegative ? '-' : ''}${time}`, decimal] + .filter(Boolean) + .join(DECIMAL_SEPARATOR); +}; diff --git a/src/toParts.test.ts b/src/msToParts.test.ts similarity index 81% rename from src/toParts.test.ts rename to src/msToParts.test.ts index 5d3c9b5..607e6d6 100644 --- a/src/toParts.test.ts +++ b/src/msToParts.test.ts @@ -1,9 +1,9 @@ import { describe, expect, it } from 'vitest'; -import { toParts } from './toParts.js'; +import { msToParts } from './msToParts.js'; -describe('toParts()', () => { +describe('msToParts()', () => { it('returns the correct parts for a positive duration', () => { - const result = toParts(766_606_500); + const result = msToParts(766_606_500); expect(result).toEqual({ days: 1, @@ -19,7 +19,7 @@ describe('toParts()', () => { }); it('returns the correct parts for a negative duration', () => { - const result = toParts(-766_606_500); + const result = msToParts(-766_606_500); expect(result).toEqual({ days: 1, @@ -35,7 +35,7 @@ describe('toParts()', () => { }); it('returns the correct parts for a positive duration with decimal places', () => { - const result = toParts(766_606_500.003002); + const result = msToParts(766_606_500.003002); expect(result).toEqual({ days: 1, @@ -51,7 +51,7 @@ describe('toParts()', () => { }); it('returns the correct parts for a negative duration with decimal places', () => { - const result = toParts(-766_606_500.003002); + const result = msToParts(-766_606_500.003002); expect(result).toEqual({ days: 1, diff --git a/src/toParts.ts b/src/msToParts.ts similarity index 59% rename from src/toParts.ts rename to src/msToParts.ts index 1319902..5c685a3 100644 --- a/src/toParts.ts +++ b/src/msToParts.ts @@ -1,10 +1,10 @@ import { DAYS_IN_WEEK, HOURS_IN_DAY, - MICROSECONDS_IN_A_MILLISECOND, - MILLISECONDS_IN_A_SECOND, + MICROSECONDS_IN_MILLISECOND, + MILLISECONDS_IN_SECOND, MINUTES_IN_HOUR, - NANOSECONDS_IN_A_MICROSECOND, + NANOSECONDS_IN_MICROSECOND, SECONDS_IN_MINUTE, } from './constant.js'; import { @@ -16,18 +16,7 @@ import { seconds, weeks, } from './conversion.js'; - -export interface Parts { - days: number; - hours: number; - isNegative: boolean; - nanoseconds: number; - microseconds: number; - milliseconds: number; - minutes: number; - seconds: number; - weeks: number; -} +import { type Parts } from './interfaces/Parts.js'; /** * Converts a duration in milliseconds to an object with properties for each time unit. @@ -36,7 +25,7 @@ export interface Parts { * @returns An object with properties for each time unit */ -export const toParts = (ms: number): Parts => { +export const msToParts = (ms: number): Parts => { const absoluteMs = Math.abs(ms); return { @@ -44,12 +33,12 @@ export const toParts = (ms: number): Parts => { hours: Math.floor(hours.from(absoluteMs) % HOURS_IN_DAY), isNegative: ms < 0, nanoseconds: Math.round( - nanoseconds.from(absoluteMs) % NANOSECONDS_IN_A_MICROSECOND, + nanoseconds.from(absoluteMs) % NANOSECONDS_IN_MICROSECOND, ), - microseconds: Math.round( - microseconds.from(absoluteMs) % MICROSECONDS_IN_A_MILLISECOND, + microseconds: Math.floor( + microseconds.from(absoluteMs) % MICROSECONDS_IN_MILLISECOND, ), - milliseconds: Math.round(absoluteMs % MILLISECONDS_IN_A_SECOND), + milliseconds: Math.floor(absoluteMs % MILLISECONDS_IN_SECOND), minutes: Math.floor(minutes.from(absoluteMs) % MINUTES_IN_HOUR), seconds: Math.floor(seconds.from(absoluteMs) % SECONDS_IN_MINUTE), weeks: Math.floor(weeks.from(absoluteMs)), diff --git a/src/parseDuration.ts b/src/parseDuration.ts index c941961..c019205 100644 --- a/src/parseDuration.ts +++ b/src/parseDuration.ts @@ -1,24 +1,5 @@ -import { - days, - hours, - microseconds, - minutes, - nanoseconds, - seconds, -} from './conversion.js'; -import { UnitConverter } from './utils/createUnitConverter.js'; - -type TimeUnit = 'ns' | 'μs' | 'ms' | 's' | 'm' | 'h' | 'd'; - -const unitToConverterMap = { - ns: nanoseconds, - μs: microseconds, - ms: null, - s: seconds, - m: minutes, - h: hours, - d: days, -} satisfies Record; +import { type TimeUnit } from './types/TimeUnit.js'; +import { unitToConverterMap } from './utils/unitToConverterMap.js'; const regex = new RegExp( `(\\d+(?:\\.\\d+)?)(${Object.keys(unitToConverterMap).join('|')})`, @@ -67,10 +48,8 @@ export const parseDuration = ( const fn = unitToConverterMap[unit as TimeUnit]; if (fn !== undefined) { - const value = parseFloat(num); - total ??= 0; - total += fn === null ? value : fn(value); + total += fn(parseFloat(num)); } } diff --git a/src/partsToMs.test.ts b/src/partsToMs.test.ts new file mode 100644 index 0000000..2b1187e --- /dev/null +++ b/src/partsToMs.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from 'vitest'; +import { partsToMs } from './partsToMs.js'; + +describe('partsToMs()', () => { + it('returns the correct parts for a positive duration', () => { + const result = partsToMs({ + days: 1, + hours: 20, + milliseconds: 500, + minutes: 56, + seconds: 46, + weeks: 1, + }); + + expect(result).toEqual(766_606_500); + }); + + it('returns the correct parts for a negative duration', () => { + const result = partsToMs({ + days: 1, + hours: 20, + isNegative: true, + milliseconds: 500, + minutes: 56, + seconds: 46, + weeks: 1, + }); + + expect(result).toEqual(-766_606_500); + }); + + it('returns the correct parts for a positive duration with decimal places', () => { + const result = partsToMs({ + days: 1, + hours: 20, + microseconds: 3, + milliseconds: 500, + minutes: 56, + nanoseconds: 2, + seconds: 46, + weeks: 1, + }); + + expect(result).toEqual(766_606_500.003002); + }); + + it('returns the correct parts for a negative duration with decimal places', () => { + const result = partsToMs({ + days: 1, + hours: 20, + isNegative: true, + microseconds: 3, + milliseconds: 500, + minutes: 56, + nanoseconds: 2, + seconds: 46, + weeks: 1, + }); + + expect(result).toEqual(-766_606_500.003002); + }); +}); diff --git a/src/partsToMs.ts b/src/partsToMs.ts new file mode 100644 index 0000000..595f64b --- /dev/null +++ b/src/partsToMs.ts @@ -0,0 +1,37 @@ +import { type Parts } from './interfaces/Parts.js'; +import { UnitConverter } from './utils/createUnitConverter.js'; +import { floatOperation } from './utils/floatOperation.js'; +import { partToConverterMap } from './utils/partToConverterMap.js'; + +/** + * Converts a Parts object to duration in milliseconds. + * + * @param parts A partial object with properties for each time unit + * @returns The duration in milliseconds + */ + +export const partsToMs = ({ + isNegative = false, + ...parts +}: Partial): number => { + const convertedValues = Object.entries(parts).reduce( + (acc, [key, value]) => { + if (typeof value === 'number' && !isNaN(value) && value >= 0) { + const unitConverter: UnitConverter | undefined = + partToConverterMap[key as keyof typeof partToConverterMap]; + + if (unitConverter) acc.push(unitConverter(value)); + } + + return acc; + }, + [], + ); + + const result = floatOperation( + convertedValues, + (values, factor) => values.reduce((sum, value) => sum + value, 0) / factor, + ); + + return isNegative ? -result : result; +}; diff --git a/src/types/TimeUnit.ts b/src/types/TimeUnit.ts new file mode 100644 index 0000000..ab9659e --- /dev/null +++ b/src/types/TimeUnit.ts @@ -0,0 +1,14 @@ +/** + * Represents all possible time units. + * + * - `ns` - Nanoseconds + * - `μs` - Microseconds + * - `ms` - Milliseconds + * - `s` - Seconds + * - `m` - Minutes + * - `h` - Hours + * - `d` - Days + * - `w` - Weeks + */ + +export type TimeUnit = 'ns' | 'μs' | 'ms' | 's' | 'm' | 'h' | 'd' | 'w'; diff --git a/src/utilities.ts b/src/utilities.ts deleted file mode 100644 index a43a039..0000000 --- a/src/utilities.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { parseDuration } from './parseDuration.js'; -export { toParts, type Parts } from './toParts.js'; diff --git a/src/utils/createUnitConverter.test.ts b/src/utils/createUnitConverter.test.ts index df6d660..7091f3e 100644 --- a/src/utils/createUnitConverter.test.ts +++ b/src/utils/createUnitConverter.test.ts @@ -17,4 +17,24 @@ describe('createUnitConverter()', () => { expect(fn.from(2_000)).toBe(2); expect(fn.from(3_000)).toBe(3); }); + + it('returns a function that provides the number of milliseconds of a given input with decimals', () => { + const fn = createUnitConverter(0.000_001); + + expect(fn(1)).toBe(0.000_001); + expect(fn(2)).toBe(0.000_002); + expect(fn(3)).toBe(0.000_003); + expect(fn(5)).toBe(0.000_005); + expect(fn(8)).toBe(0.000_008); + }); + + it('returns a method that provides the number of units from a given input with decimals places', () => { + const fn = createUnitConverter(0.000_001); + + expect(fn.from(0.000_001)).toBe(1); + expect(fn.from(0.000_002)).toBe(2); + expect(fn.from(0.000_003)).toBe(3); + expect(fn.from(0.000_005)).toBe(5); + expect(fn.from(0.000_008)).toBe(8); + }); }); diff --git a/src/utils/createUnitConverter.ts b/src/utils/createUnitConverter.ts index 5233b9b..6156998 100644 --- a/src/utils/createUnitConverter.ts +++ b/src/utils/createUnitConverter.ts @@ -1,3 +1,5 @@ +import { floatOperation } from './floatOperation.js'; + export type UnitConverter = { /** * Converts the number of units to milliseconds. @@ -27,9 +29,17 @@ export type UnitConverter = { */ export const createUnitConverter = (unit: number): UnitConverter => { - const fn = (amount: number) => amount * unit; + const fn = (amount: number) => + floatOperation( + [unit], + ([normalisedUnit], factor) => (amount * normalisedUnit) / factor, + ); - fn.from = (ms: number) => ms / unit; + fn.from = (ms: number) => + floatOperation( + [ms, unit], + ([normalisedMs, normalisedUnit]) => normalisedMs / normalisedUnit, + ); return fn; }; diff --git a/src/utils/floatOperation.test.ts b/src/utils/floatOperation.test.ts new file mode 100644 index 0000000..a7618ee --- /dev/null +++ b/src/utils/floatOperation.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from 'vitest'; +import { floatOperation } from './floatOperation.js'; + +describe('floatOperation()', () => { + it('return normalised values and factor from input floats', () => { + const value = floatOperation([0.000_005, 0.000_001], (values, factor) => { + expect(values).toStrictEqual([5, 1]); + expect(factor).toBe(1_000_000); + + return values[0] / values[1]; + }); + + expect(value).toStrictEqual(5); + }); +}); diff --git a/src/utils/floatOperation.ts b/src/utils/floatOperation.ts new file mode 100644 index 0000000..6a74311 --- /dev/null +++ b/src/utils/floatOperation.ts @@ -0,0 +1,14 @@ +import { getDecimalPlaces } from './getDecimalPlaces.js'; + +export const floatOperation = ( + floats: number[], + operation: (normalised: number[], factor: number) => number, +) => { + const maxDecimals = Math.max(...floats.map(getDecimalPlaces)); + const factor = Math.pow(10, maxDecimals); + + return operation( + floats.map(value => Math.round(value * factor)), + factor, + ); +}; diff --git a/src/utils/getDecimalPlaces.test.ts b/src/utils/getDecimalPlaces.test.ts new file mode 100644 index 0000000..93696b4 --- /dev/null +++ b/src/utils/getDecimalPlaces.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from 'vitest'; +import { getDecimalPlaces } from './getDecimalPlaces.js'; + +describe('getDecimalPlaces()', () => { + it('returns the number of decimal places in a number', () => { + expect(getDecimalPlaces(1.23)).toBe(2); + expect(getDecimalPlaces(1.2345)).toBe(4); + expect(getDecimalPlaces(1)).toBe(0); + expect(getDecimalPlaces(0.001)).toBe(3); + expect(getDecimalPlaces(1e-7)).toBe(7); + expect(getDecimalPlaces(1234567890.123456789)).toBe(7); + }); +}); diff --git a/src/utils/getDecimalPlaces.ts b/src/utils/getDecimalPlaces.ts new file mode 100644 index 0000000..241ff77 --- /dev/null +++ b/src/utils/getDecimalPlaces.ts @@ -0,0 +1,12 @@ +import { DECIMAL_SEPARATOR } from '../constants/DecimalSeparator.js'; + +export const getDecimalPlaces = (number: number): number => { + const str = number.toExponential(); // handles cases like 1e-7 + const match = str.match(/e-(\d+)/); + const decimalPart = + number.toString().split(DECIMAL_SEPARATOR).at(1)?.length || 0; + + if (match) return Math.max(parseInt(match[1], 10), decimalPart); + + return Math.min(7, decimalPart); +}; diff --git a/src/utils/padLeadingZero.test.ts b/src/utils/padLeadingZero.test.ts new file mode 100644 index 0000000..c4e319c --- /dev/null +++ b/src/utils/padLeadingZero.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from 'vitest'; +import { padLeadingZero } from './padLeadingZero.js'; + +describe('padLeadingZero()', () => { + it.each([ + ['01', 1, 2], + ['10', 10, 2], + ['100', 100, 2], + ['001', 1, 3], + ['010', 10, 3], + ['100', 100, 3], + ])('returns %o when padding %s to length %s', (expected, value, length) => { + expect(padLeadingZero(value, length)).toBe(expected); + }); +}); diff --git a/src/utils/padLeadingZero.ts b/src/utils/padLeadingZero.ts new file mode 100644 index 0000000..c9b2bb4 --- /dev/null +++ b/src/utils/padLeadingZero.ts @@ -0,0 +1,2 @@ +export const padLeadingZero = (value: number, length: number): string => + String(value).padStart(length, '0'); diff --git a/src/utils/partToConverterMap.ts b/src/utils/partToConverterMap.ts new file mode 100644 index 0000000..8af78cf --- /dev/null +++ b/src/utils/partToConverterMap.ts @@ -0,0 +1,25 @@ +import { type UnitConverter } from './createUnitConverter.js'; +import { + days, + hours, + microseconds, + milliseconds, + minutes, + nanoseconds, + seconds, + weeks, +} from '../conversion.js'; +import { type Parts } from '../interfaces/Parts.js'; + +export const partToConverterMap = { + nanoseconds, + microseconds, + milliseconds, + seconds, + minutes, + hours, + days, + weeks, +} as const satisfies { + [K in keyof Parts as Parts[K] extends number ? K : never]: UnitConverter; +}; diff --git a/src/utils/unitToConverterMap.ts b/src/utils/unitToConverterMap.ts new file mode 100644 index 0000000..2e8b8fc --- /dev/null +++ b/src/utils/unitToConverterMap.ts @@ -0,0 +1,23 @@ +import { + days, + hours, + microseconds, + milliseconds, + minutes, + nanoseconds, + seconds, + weeks, +} from '../conversion.js'; +import { type TimeUnit } from '../types/TimeUnit.js'; +import { type UnitConverter } from './createUnitConverter.js'; + +export const unitToConverterMap = { + ns: nanoseconds, + μs: microseconds, + ms: milliseconds, + s: seconds, + m: minutes, + h: hours, + d: days, + w: weeks, +} as const satisfies Record;