Skip to content

Commit f113438

Browse files
authored
Feat: Add formatDuration (#348)
1 parent e241b2d commit f113438

9 files changed

Lines changed: 411 additions & 5 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,7 @@ xx.xx.xxxx
120120
* **Feature** - Added a bytes module — [`toBase64`](https://next.semantic-ui.com/docs/api/utils/bytes#tobase64) and [`fromBase64`](https://next.semantic-ui.com/docs/api/utils/bytes#frombase64) — unicode-safe base64 encode/decode, the pair `btoa`/`atob` never were. A string round-trips through its UTF-8 bytes (emoji and accents survive), `toBase64` also takes an `ArrayBuffer`, typed array, or array of byte values and a `{ urlSafe }` option, and `fromBase64` decodes both alphabets, strips MIME/PEM line wrapping, and returns a string or `Uint8Array` — or `null` on malformed input, never a throw
121121
* **Feature** - Added [`toByteSize`](https://next.semantic-ui.com/docs/api/utils/coercion#tobytesize) and [`toBytes`](https://next.semantic-ui.com/docs/api/utils/coercion#tobytes) to the coercion suite, with `coerceByteSize`/`coerceBytes` aliases. `toByteSize` reads a size expression as a whole number of bytes, so `'10mb'`, `'1.5 KB'`, and `'2 gigabytes'` all become the number an upload limit or quota compares against. Same grammar as `toDuration` (one number, one optional unit, case-insensitive, space optional), abbreviations only like the ecosystem's `bytes()` where `toDuration` reads words like `ms()`, since every importer pays per spelling and a word is one `toByteSize.config.units` line. `b`, `kb` through `pb` scale by 1024, what every config that accepts `'10mb'` already means, or by 1000 per call with `{ base: 1000 }` or once at boot via `toByteSize.config.base`. The IEC spellings `kib` through `pib` are 1024 by definition and never move. Bits, compound forms, and durations return `null`. `toBytes` is the `Uint8Array` coercion: a string reads as its UTF-8 text (never as an encoding, that is `fromBase64`'s job), a buffer, typed array, or `DataView` becomes a view over the same memory with no copy, an array of byte values is copied, and a bare number or an array holding a non-byte returns `null` rather than reading as a length or wrapping
122122
* **Feature** - Added [`byteLength`](https://next.semantic-ui.com/docs/api/utils/bytes#bytelength) and [`formatByteSize`](https://next.semantic-ui.com/docs/api/utils/bytes#formatbytesize) to the bytes module. `byteLength` counts the bytes a value holds (a string by its UTF-8 bytes, not its characters), `null` when it holds none. `formatByteSize` prints a count in the largest unit it fills (`'1.5 KB'`, `'10 MB'`), accepts anything `toByteSize` reads at the same base so `'10mb'` prints back as `'10 MB'`, and takes `decimals` (a maximum, trailing zeros drop), `unit` to hold one unit down a column, `iec` for `KiB`/`MiB` labels, `base: 1000` for SI, and `locale`. Labels and defaults are editable at boot via `formatByteSize.config`
123+
* **Feature** - Added [`formatDuration`](https://next.semantic-ui.com/docs/api/utils/dates#formatduration), the inverse of `toDuration`. Prints milliseconds in the largest unit filled (`300000` as `'5m'`, `90000` as `'1.5m'`), reads anything `toDuration` reads, and every string it prints reads back through `toDuration`, so a config can take `'5m'` on the way in and show `'5m'` on the way out. `decimals` is a maximum, `unit` holds one unit down a column, `separator` goes between the number and the unit (`'1.5 minutes'`), and `lossless` picks the largest unit that reads back to exactly the same value (`100000` is `'100s'`, not `'1.7m'`), the print a config or debug view wants. The unit ladder is editable at boot via `formatDuration.config.units`, spelled in `toDuration`'s vocabulary
123124
* **Feature** - Added [`configured`](https://next.semantic-ui.com/docs/api/utils/functions#configured) — attaches an editable `fn.config` to a function as one tree-shakable expression. All configured utilities (`humanize`, `toTitleCase`, `getArticle`, `toBoolean`, `formatDate`) use it, so a bundle only pays for the vocabularies it imports — the attribute-codec path that pulls in string casing no longer carries any of them
124125
* **Feature** - Arbitrary vocabularies now live in editable configs, set once at app boot: [`toTitleCase.config.stopWords`](https://next.semantic-ui.com/docs/api/utils/strings#totitlecase) (style guides disagree on the list, `humanize`'s `titleCase` reads the same one), [`getArticle.config.exceptions`](https://next.semantic-ui.com/docs/api/utils/strings#getarticle) for sound-contradicts-spelling words (`an hour`, `a university` now correct out of the box), and [`formatDate.config.timezones`](https://next.semantic-ui.com/docs/api/utils/dates#formatdate) for remapping an ambiguous shorthand like `IST`. `toDate` reads unix-second timestamps (a JWT `exp`) via `{ epoch: 'seconds' }`
125126
* **Feature** - Added `unescapeHTML()` for converting HTML entities back to characters — the inverse of `escapeHTML`

ai/skills/authoring/utility-functions.md

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
---
22
title: Utility Functions Reference
33
description: Complete reference for @semantic-ui/utils — a standalone utility library providing functions for arrays, objects, strings, type checking, colors, dates, and more. Use this before reimplementing common operations.
4-
keywords: [utilities, arrays, objects, strings, type checking, functions, debounce, throttle, memoize, clone, equality, formatDate, each, range, sequence, remove, noop, isDate, isRegExp]
4+
keywords: [utilities, arrays, objects, strings, type checking, functions, debounce, throttle, memoize, clone, equality, formatDate, formatDuration, each, range, sequence, remove, noop, isDate, isRegExp]
55
audience: authoring
66
skill: utility-functions
77
type: skill
@@ -911,6 +911,31 @@ formatDate.config.timezones.IST = 'Asia/Jerusalem'; // shorthand aliases are edi
911911
912912
**Preset formats:** `LT`, `LTS`, `L`, `l`, `LL`, `ll`, `LLL`, `lll`, `LLLL`, `llll`
913913
914+
```javascript
915+
import { formatDuration, toDuration } from '@semantic-ui/utils';
916+
917+
// formatDuration is the inverse of toDuration: one grammar, both directions.
918+
// it prints one quantity in the largest unit filled, never a compound '1h 30m'
919+
formatDuration(300000); // '5m'
920+
formatDuration(90000); // '1.5m' (decimals is a maximum, never '5.0m')
921+
formatDuration(3598200); // '1h' (a value that rounds up to a whole unit promotes)
922+
formatDuration(-90000); // '-1.5m'
923+
formatDuration('90s'); // '1.5m' (reads anything toDuration reads)
924+
formatDuration(90000, { unit: 's' }); // '90s' (hold one unit down a column, printed as spelled)
925+
formatDuration(90000, { unit: 'minutes', separator: ' ' }); // '1.5 minutes' (a space reads back, toDuration allows one there)
926+
formatDuration('banana'); // null
927+
928+
// the default rounds, so 100000 prints '1.7m' and reads back as 102000. lossless picks the
929+
// largest unit that reads back exactly, the print a config or debug view wants
930+
formatDuration(100000); // '1.7m'
931+
formatDuration(100000, { lossless: true }); // '100s'
932+
toDuration(formatDuration(100000, { lossless: true })); // 100000
933+
934+
// the ladder is spelled in toDuration's vocabulary, so a unit is added to both
935+
toDuration.config.units.y = 365.25 * 86400000;
936+
formatDuration.config.units.unshift('y');
937+
```
938+
914939
---
915940
916941
## Number Utilities (numbers.js)
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
title: 'formatDuration'
3+
id: 'utils-formatduration'
4+
exampleType: 'log'
5+
category: 'Utils'
6+
subcategory: 'Dates'
7+
description: 'Format milliseconds for display in the largest unit filled, like 5m or 1.5h, the inverse of toDuration'
8+
tags: ['utils', 'dates', 'formatduration', 'duration', 'time', 'format', 'display', 'config']
9+
selectedFile: 'index.js'
10+
tip: 'Every string it prints reads back through toDuration. The default rounds, so 100000 is 1.7m. Pass lossless to pick the largest unit that reads back exactly (100s), the print a config view wants'
11+
---
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { formatDuration, toDuration } from '@semantic-ui/utils';
2+
3+
console.log(formatDuration(500));
4+
console.log(formatDuration(5000));
5+
console.log(formatDuration(300000));
6+
console.log(formatDuration(90000));
7+
console.log(formatDuration(5400000));
8+
console.log(formatDuration(-90000));
9+
10+
// decimals is a maximum, a value that rounds up to a whole unit promotes
11+
console.log(formatDuration(1234567, { decimals: 3 }));
12+
console.log(formatDuration(3598200));
13+
14+
// hold one unit down a column, printed as spelled
15+
console.log(formatDuration(90000, { unit: 's' }));
16+
console.log(formatDuration(90000, { unit: 'minutes', separator: ' ' }));
17+
18+
// reads anything toDuration reads, and everything it prints reads back
19+
console.log(formatDuration('90s'));
20+
console.log(toDuration(formatDuration(300000)));
21+
22+
// the default rounds. lossless picks the largest unit that reads back exactly
23+
console.log(formatDuration(100000));
24+
console.log(formatDuration(100000, { lossless: true }));
25+
console.log(toDuration(formatDuration(100000, { lossless: true })));
26+
27+
console.log(formatDuration('banana'));

docs/src/pages/docs/api/utils/coercion.mdx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,8 @@ Coerces a duration expression to milliseconds, or `null` when it reads as no dur
202202

203203
The sign is kept, so `'-1.5h'` is `-5400000`. Compound expressions like `'1h 30m'` are a different grammar and return `null` rather than a partial reading, as do unknown units, bare numbers with trailing junk, and exponent notation.
204204

205+
[`formatDuration`](/docs/api/utils/dates#formatduration) is the inverse, printing milliseconds back in this grammar (`300000` as `'5m'`), so a config can read `'5m'` on the way in and show `'5m'` on the way out.
206+
205207
#### Units
206208

207209
| Unit | Accepted spellings |

docs/src/pages/docs/api/utils/dates.mdx

Lines changed: 75 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,12 @@ layout: '@layouts/Guide.astro'
33
pageType: 'API Reference'
44
title: Date Utilities
55
package: "@semantic-ui/utils"
6-
methods: [formatDate]
6+
methods: [formatDate, formatDuration]
77
icon: calendar
8-
description: API reference for date manipulation and formatting functions
8+
description: API reference for date and duration formatting functions
99
---
1010

11-
The Date utilities provide functions for formatting and manipulating dates in JavaScript.
11+
The Date utilities format dates and durations for display. `formatDate` prints a `Date` through a token string, `formatDuration` prints a millisecond count in the largest unit it fills, the inverse of [`toDuration`](/docs/api/utils/coercion#toduration).
1212

1313
## Functions
1414

@@ -148,3 +148,75 @@ console.log(formatDate(date, 'hh:mm a', { hour12: false })); // "14:30 "
148148
// Escaped literal text
149149
console.log(formatDate(date, '[Today is] dddd, MMMM Do, YYYY')); // "Today is Monday, May 15th, 2023"
150150
```
151+
152+
### formatDuration
153+
154+
```javascript
155+
function formatDuration(value, { decimals, unit, lossless, separator } = {})
156+
```
157+
158+
Formats a duration for display in the largest unit it fills, or `null` when there is no duration to format. `300000` prints as `'5m'`, `90000` as `'1.5m'`, `500` as `'500ms'`, and the sign is kept. Accepts anything `toDuration` reads, so `'90s'` prints as `'1.5m'` with no conversion in between.
159+
160+
This is the inverse of `toDuration` and the two share one grammar: every string `formatDuration` prints reads back through `toDuration`, and the unit ladder is spelled in `toDuration`'s vocabulary. It prints one quantity, never a compound `'1h 30m'`, because `toDuration` reads one quantity.
161+
162+
`decimals` is a maximum, so `'5m'` never prints as `'5.0m'`, and a value that rounds up to a whole unit promotes (`3598200` is `'1h'` at one decimal, not `'60m'`). `unit` holds one unit so a column reads down in the same scale, printed as spelled. `separator` goes between the number and the unit, so `{ unit: 'minutes', separator: ' ' }` gives `'1.5 minutes'`. A space is the one separator `toDuration` reads back, since its grammar allows one there and nothing else.
163+
164+
`lossless` is for a config view. The default picks the largest unit the value fills and rounds, so `100000` prints as `'1.7m'`, which reads back as `102000`. Under `lossless` the walk continues to the largest unit whose print reads back to exactly the same value, `'100s'`, where the check is the very product `toDuration` computes. A value with no short exact form prints in a small unit (`93784000` is `'93784s'`), which is the honest answer for a value nobody configured by hand.
165+
166+
#### Parameters
167+
168+
| Name | Type | Description |
169+
|----------|---------|-------------|
170+
| value | unknown | The milliseconds, or a duration expression |
171+
| settings | object | Optional configuration |
172+
173+
##### Options
174+
175+
| Name | Type | Default | Description |
176+
|----------|---------|---------|-------------|
177+
| decimals | number | `formatDuration.config.decimals` (1) | Maximum decimal places, trailing zeros drop |
178+
| unit | string | largest unit filled | Hold one unit, printed as spelled, any spelling `toDuration` reads (`'s'`, `'minutes'`, `'hr'`) |
179+
| lossless | boolean | `formatDuration.config.lossless` (false) | Pick the largest unit that reads back through `toDuration` to the same value |
180+
| separator | string | `formatDuration.config.separator` (`''`) | Text between the number and the unit, `' '` for `'1.5 minutes'` |
181+
182+
#### Returns
183+
184+
The formatted duration, or `null` if there is no duration to format.
185+
186+
#### Example
187+
188+
```javascript
189+
import { formatDuration, toDuration } from '@semantic-ui/utils';
190+
191+
console.log(formatDuration(500)); // '500ms'
192+
console.log(formatDuration(300000)); // '5m'
193+
console.log(formatDuration(90000)); // '1.5m'
194+
console.log(formatDuration(5400000)); // '1.5h'
195+
console.log(formatDuration(-90000)); // '-1.5m'
196+
console.log(formatDuration(1234567, { decimals: 3 })); // '20.576m'
197+
console.log(formatDuration(3598200)); // '1h'
198+
console.log(formatDuration(90000, { unit: 's' })); // '90s'
199+
console.log(formatDuration(90000, { unit: 'minutes', separator: ' ' })); // '1.5 minutes'
200+
console.log(formatDuration('90s')); // '1.5m'
201+
console.log(formatDuration(100000)); // '1.7m'
202+
console.log(formatDuration(100000, { lossless: true })); // '100s'
203+
console.log(formatDuration('banana')); // null
204+
205+
// a config accepts '5m' on the way in and shows '5m' on the way out
206+
const reuseAfter = toDuration(config.reuseAfter) ?? 300000;
207+
console.log(formatDuration(reuseAfter, { lossless: true })); // '5m'
208+
```
209+
210+
#### formatDuration.config
211+
212+
`formatDuration.config` holds the defaults for `decimals`, `lossless`, and `separator`, plus `units`, the ladder walked largest first. Each entry is a spelling `toDuration.config.units` holds, which is where the span comes from, so a unit is added to both. Edit it once at app boot and every call inherits it. Per-call settings still win.
213+
214+
```javascript
215+
import { formatDuration, toDuration } from '@semantic-ui/utils';
216+
217+
toDuration.config.units.y = 365.25 * 24 * 60 * 60 * 1000;
218+
formatDuration.config.units.unshift('y');
219+
220+
console.log(formatDuration(63115200000)); // '2y'
221+
console.log(toDuration('2y')); // 63115200000
222+
```

packages/utils/src/dates.js

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1+
import { toDuration } from './coercion.js';
12
import { configured } from './functions.js';
3+
import { roundDecimal } from './numbers.js';
24

35
/*-------------------
46
Dates
@@ -150,3 +152,49 @@ export const formatDate = /* @__PURE__ */ configured((date, format = 'LLL', {
150152
SGT: 'Asia/Singapore',
151153
},
152154
});
155+
156+
// the ladder is spelled in toDuration's vocabulary and reads its spans from there, so every string
157+
// this prints reads back through toDuration. a unit is added to both: toDuration.config.units.y for
158+
// the span, formatDuration.config.units.unshift('y') for its place in the walk
159+
export const formatDuration = /* @__PURE__ */ configured(
160+
(value, options = {}) => {
161+
const config = formatDuration.config;
162+
const decimals = options.decimals ?? config.decimals;
163+
const lossless = options.lossless ?? config.lossless;
164+
const separator = options.separator ?? config.separator;
165+
const spans = toDuration.config.units;
166+
const ms = toDuration(value);
167+
if (ms === null) { return null; }
168+
const magnitude = Math.abs(ms);
169+
let unit;
170+
if (options.unit != null) {
171+
unit = String(options.unit).toLowerCase();
172+
if (!Object.hasOwn(spans, unit)) { return null; }
173+
}
174+
else {
175+
const ladder = config.units;
176+
let index = 0;
177+
let span = spans[ladder[0]];
178+
// walk largest first to the first unit the value fills. lossless walks on to the first one
179+
// whose rounded print reads back to the same value, the product toDuration itself computes
180+
while (
181+
index < ladder.length - 1
182+
&& (magnitude < span || (lossless && roundDecimal(magnitude / span, decimals) * span !== magnitude))
183+
) {
184+
span = spans[ladder[++index]];
185+
}
186+
// 59.97m rounds to 60m, which is a whole hour. a lossless pick is exact and never promotes
187+
if (
188+
!lossless && index > 0 && roundDecimal(magnitude / span, decimals) * span >= spans[ladder[index - 1]]
189+
) { index--; }
190+
unit = ladder[index];
191+
}
192+
return (ms < 0 ? '-' : '') + roundDecimal(magnitude / spans[unit], decimals) + separator + unit;
193+
},
194+
{
195+
decimals: 1,
196+
lossless: false,
197+
separator: '',
198+
units: ['w', 'd', 'h', 'm', 's', 'ms'],
199+
},
200+
);

0 commit comments

Comments
 (0)