Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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' |

#### <a id="formatduration"></a>`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
Expand Down
107 changes: 107 additions & 0 deletions src/formatting/duration.ts
Original file line number Diff line number Diff line change
@@ -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<string, [string, string]> = {
'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}`;
}
}
3 changes: 3 additions & 0 deletions src/formatting/index.ts
Original file line number Diff line number Diff line change
@@ -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,
};
72 changes: 72 additions & 0 deletions src/tests/formatting/duration.test.ts
Original file line number Diff line number Diff line change
@@ -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'
});
});
});
});