diff --git a/README.md b/README.md index e8f3cf8..67171b6 100644 --- a/README.md +++ b/README.md @@ -125,6 +125,7 @@ const count = stringzy.analyze.wordCount('Hello world'); // 2 - [capitalize](#capitalize) - Capitalizes the first letter of each word - [formatNumber](#formatnumber) - Formats a number string with thousand separators - [formatPhone](#formatphone) - Formats a phone number string to standard format +- [formatDuration](#formatduration) - Converts a duration in seconds or milliseconds into a human-readable string ## 📋 API Reference @@ -1267,6 +1268,27 @@ formatPhone('11234567890', 'international'); // '+1 (123) 456-7890' | phone | string | required | The phone number string to format | | format | string | 'us' | Format type: 'us' or 'international' | +#### `formatDuration(input, options)` + +Converts a duration in seconds or milliseconds into a human-readable string. + +```javascript +formatDuration(60); // "1m" +formatDuration(61); // "1m 1s" +formatDuration(3661); // "1h 1m 1s" +formatDuration(7325); // "2h 2m 5s" +formatDuration(1234567, { unit: 'milliseconds', includeMs: true }); // "20m 34s 567ms" +``` + +| Parameter | Type | Default | Description | +| --------- | ------ | ----------- | ----------------------------------------------------- | +| input | number | required | The duration in seconds or milliseconds | +| options | object | `{}` | Optional configuration object | +| - unit | string | 'seconds' | Input unit: 'seconds' or 'milliseconds' | +| - format | string | 'short' | Output format: 'short', 'medium', or 'long' | +| - includeMs | boolean | false | Whether to include milliseconds in output | +| - delimiter | string | ' ' | The delimiter between time units | + ## 🔧 Usage Patterns ### Individual Function Imports diff --git a/src/formatting/duration.ts b/src/formatting/duration.ts new file mode 100644 index 0000000..22bcdaa --- /dev/null +++ b/src/formatting/duration.ts @@ -0,0 +1,107 @@ +/** + * Converts a duration in seconds or milliseconds into a human-readable string. + * Intelligently displays hours, minutes, seconds, and optionally milliseconds, + * while skipping zero-value units unless the entire duration is zero. + * + * @param {number} input - The duration in seconds or milliseconds. + * @param {object} [options] - Configuration options. + * @param {string} [options.unit='seconds'] - The input unit: 'seconds' or 'milliseconds'. + * @param {string} [options.format='short'] - Output format: 'short', 'medium', or 'long'. + * @param {boolean} [options.includeMs=false] - Whether to include milliseconds in the output. + * @param {string} [options.delimiter=' '] - The delimiter between time units. + * @returns {string} The formatted duration string. + * @throws {TypeError} If input is not a number or is negative. + * + * @example + * formatDuration(60); // "1m" + * formatDuration(61); // "1m 1s" + * formatDuration(3661); // "1h 1m 1s" + * formatDuration(1234567, { unit: 'milliseconds', includeMs: true }); // "20m 34s 567ms" + * formatDuration(3600, { format: 'long' }); // "1 hour" + */ +export function formatDuration(input: number, options?: { + unit?: 'seconds' | 'milliseconds'; + format?: 'short' | 'medium' | 'long'; + includeMs?: boolean; + delimiter?: string; +}): string { + // Validate input + if (typeof input !== 'number' || isNaN(input)) { + throw new TypeError('Input must be a number'); + } + if (input < 0) { + throw new TypeError('Input must be non-negative'); + } + + // Default options + const opts = { + unit: options?.unit || 'seconds', + format: options?.format || 'short', + includeMs: options?.includeMs || false, + delimiter: options?.delimiter || ' ' + }; + + // Convert to milliseconds + const totalMs = opts.unit === 'seconds' ? input * 1000 : input; + + // Handle zero case + if (totalMs === 0) { + return '0s'; + } + + // Calculate time components + let remaining = Math.floor(totalMs); + const ms = remaining % 1000; + remaining = Math.floor(remaining / 1000); + const seconds = remaining % 60; + remaining = Math.floor(remaining / 60); + const minutes = remaining % 60; + const hours = Math.floor(remaining / 60); + + // Build the output parts + const parts: string[] = []; + + // Add non-zero units + if (hours > 0) { + parts.push(formatTimeUnit(hours, 'h', opts.format)); + } + + if (minutes > 0) { + parts.push(formatTimeUnit(minutes, 'm', opts.format)); + } + + if (seconds > 0 || (parts.length === 0 && !opts.includeMs)) { + parts.push(formatTimeUnit(seconds, 's', opts.format)); + } + + if (opts.includeMs && ms > 0) { + parts.push(formatTimeUnit(ms, 'ms', opts.format)); + } + + return parts.join(opts.delimiter); +} + +/** + * Formats a single time unit according to the specified format. + * @private + */ +function formatTimeUnit(value: number, unit: string, format: string): string { + switch (format) { + case 'long': + const unitNames: Record = { + 'h': ['hour', 'hours'], + 'm': ['minute', 'minutes'], + 's': ['second', 'seconds'], + 'ms': ['millisecond', 'milliseconds'] + }; + const [singular, plural] = unitNames[unit]; + return `${value} ${value === 1 ? singular : plural}`; + + case 'medium': + const paddedValue = unit === 'h' ? value : value.toString().padStart(2, '0'); + return `${paddedValue}${unit}`; + + default: // 'short' + return `${value}${unit}`; + } +} \ No newline at end of file diff --git a/src/formatting/index.ts b/src/formatting/index.ts index 7b17cd4..5601156 100644 --- a/src/formatting/index.ts +++ b/src/formatting/index.ts @@ -1,13 +1,16 @@ export { capitalize } from './capitalize'; export { formatNumber } from './number'; export { formatPhone } from './phone'; +export { formatDuration } from './duration'; import { capitalize } from './capitalize'; import { formatNumber } from './number'; import { formatPhone } from './phone'; +import { formatDuration } from './duration'; export const formatting = { capitalize, formatNumber, formatPhone, + formatDuration, }; diff --git a/src/tests/formatting/duration.test.ts b/src/tests/formatting/duration.test.ts new file mode 100644 index 0000000..92c9180 --- /dev/null +++ b/src/tests/formatting/duration.test.ts @@ -0,0 +1,72 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert'; +import { formatDuration } from '../../formatting/duration'; + +describe('formatDuration', () => { + describe('basic functionality', () => { + it('formats exact minutes', () => { + assert.strictEqual(formatDuration(60), '1m'); + }); + + it('formats minutes and seconds', () => { + assert.strictEqual(formatDuration(61), '1m 1s'); + }); + + it('formats hours, minutes and seconds', () => { + assert.strictEqual(formatDuration(3661), '1h 1m 1s'); + }); + + it('formats exact hours', () => { + assert.strictEqual(formatDuration(3600), '1h'); + }); + + it('formats complex durations', () => { + assert.strictEqual(formatDuration(7325), '2h 2m 5s'); + }); + + it('formats milliseconds when specified', () => { + assert.strictEqual( + formatDuration(1234567, { unit: 'milliseconds', includeMs: true }), + '20m 34s 567ms' + ); + }); + + it('formats zero as zero seconds', () => { + assert.strictEqual(formatDuration(0), '0s'); + }); + + it('handles large values', () => { + assert.strictEqual(formatDuration(86400), '24h'); + }); + }); + + describe('format options', () => { + it('formats with medium format', () => { + assert.strictEqual(formatDuration(3661, { format: 'medium' }), '1h 01m 01s'); + }); + + it('formats with long format', () => { + assert.strictEqual(formatDuration(3661, { format: 'long' }), '1 hour 1 minute 1 second'); + }); + + it('formats with custom delimiter', () => { + assert.strictEqual(formatDuration(3661, { delimiter: ':' }), '1h:1m:1s'); + }); + }); + + describe('error handling', () => { + it('throws on non-numeric input', () => { + assert.throws(() => formatDuration('60' as any), { + name: 'TypeError', + message: 'Input must be a number' + }); + }); + + it('throws on negative input', () => { + assert.throws(() => formatDuration(-60), { + name: 'TypeError', + message: 'Input must be non-negative' + }); + }); + }); +}); \ No newline at end of file