Skip to content

Commit e87b53e

Browse files
authored
Feat: Add toDuration Coercion (#345)
1 parent ebd2c72 commit e87b53e

8 files changed

Lines changed: 326 additions & 5 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,8 @@ xx.xx.xxxx
115115
* **Breaking** - `kebabToCamel` and `camelToKebab` now use lossless encoding — digit-leading segments are preserved with `_` (e.g. `grid-2x2``grid_2x2`), and every uppercase letter gets its own hyphen (e.g. `arrowDownAZ``arrow-down-a-z`). Both accept a `separator` option to customize the digit-boundary character. `camelToKebab` now normalizes leading uppercase by default for DOM-safe output (e.g. `FooBar``foo-bar`); pass `{ lossless: true }` to preserve it for exact round-trips.
116116
* **Feature** - Added [`humanize`](https://next.semantic-ui.com/docs/api/utils/strings#humanize) — turns a machine identifier (`snake_case`, `kebab-case`, `camelCase`, `PascalCase`) into human-readable label text, keeping acronym runs whole including plurals (`getURLsFromPage``Get URLs from page`), dropping a trailing `id` (`user_id``User`), and sentence-casing by default. `titleCase` switches to header casing, `constantCase` sentence-cases shouting enums (`IN_PROGRESS``In progress`). The display-side inverse of `tokenize`
117117
* **Enhancement** - `humanize` rescues common lowercase acronyms out of the box (`api_url``API URL`) through a `humanize.config` vocabulary seeded with `id`/`url`/`api`. Extend it once at app boot and every call inherits it, or pass a per-call `terms` map that layers over the global. `humanize.config` also carries the default `titleCase`/`dropId`/`constantCase`
118-
* **Feature** - Added a coercion suite — [`toBoolean`](https://next.semantic-ui.com/docs/api/utils/coercion#toboolean), [`toNumber`](https://next.semantic-ui.com/docs/api/utils/coercion#tonumber), [`toInteger`](https://next.semantic-ui.com/docs/api/utils/coercion#tointeger), [`toDate`](https://next.semantic-ui.com/docs/api/utils/coercion#todate), [`toString`](https://next.semantic-ui.com/docs/api/utils/coercion#tostring), with `coerceX` aliases — best-effort conversion of loose input to a target type. Each returns the type or `null` when there is no clean reading, never a poison value, so results compose with `??`. `toBoolean` reads a generous set of true/false spellings, `toNumber` never yields `NaN` or `Infinity`, `toDate` takes ISO strings and epoch-ms numbers only (ambiguous formats like `01/15/2024` return null rather than a guessed Date), and `toString` returns null for objects instead of `"[object Object]"`. `toBoolean` and `toString` take `{ loose: true }` to fall back to native truthiness or JSON rendering. Every helper takes `{ onInvalid: 'passthrough' }` to return the original value on a failed coercion instead of `null`, so a schema or validator sees the bad input rather than an erasing `null` (the passthrough return widens the TypeScript type to include the input). `toBoolean` reads its vocabulary and defaults from an editable `toBoolean.config` (mirroring `humanize.config`), so a locale or domain teaches it new true/false spellings once at boot instead of on every call
118+
* **Feature** - Added a coercion suite — [`toBoolean`](https://next.semantic-ui.com/docs/api/utils/coercion#toboolean), [`toNumber`](https://next.semantic-ui.com/docs/api/utils/coercion#tonumber), [`toInteger`](https://next.semantic-ui.com/docs/api/utils/coercion#tointeger), [`toDate`](https://next.semantic-ui.com/docs/api/utils/coercion#todate), [`toDuration`](https://next.semantic-ui.com/docs/api/utils/coercion#toduration), [`toString`](https://next.semantic-ui.com/docs/api/utils/coercion#tostring), with `coerceX` aliases — best-effort conversion of loose input to a target type. Each returns the type or `null` when there is no clean reading, never a poison value, so results compose with `??`. `toBoolean` reads a generous set of true/false spellings, `toNumber` never yields `NaN` or `Infinity`, `toDate` takes ISO strings and epoch-ms numbers only (ambiguous formats like `01/15/2024` return null rather than a guessed Date), and `toString` returns null for objects instead of `"[object Object]"`. `toBoolean` and `toString` take `{ loose: true }` to fall back to native truthiness or JSON rendering. Every helper takes `{ onInvalid: 'passthrough' }` to return the original value on a failed coercion instead of `null`, so a schema or validator sees the bad input rather than an erasing `null` (the passthrough return widens the TypeScript type to include the input). `toBoolean` reads its vocabulary and defaults from an editable `toBoolean.config` (mirroring `humanize.config`), so a locale or domain teaches it new true/false spellings once at boot instead of on every call
119+
* **Feature** - Added [`toDuration`](https://next.semantic-ui.com/docs/api/utils/coercion#toduration) — reads a duration expression as milliseconds, so `'5s'`, `'1.5h'`, and `'300msecs'` all become numbers a timer or TTL takes directly. One number and one optional unit, case-insensitive, with the space optional, and unit words, abbreviations, and plurals all read (`'10 minutes'`, `'2hrs'`, `'.5d'`). A unitless string reads as milliseconds so `'1500'` and `1500` agree, the sign is kept, and a compound form like `'1h 30m'` returns `null` rather than a partial reading. Milliseconds through weeks are built in because each is a fixed span. Years and months are not: a year is a judgment call (the `ms` package reads it as 365.25 days) and a month has no length without a calendar date, so `toDuration.config.units` names your own value once at boot rather than inheriting someone else's guess
119120
* **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` or typed array 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
120121
* **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
121122
* **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' }`

ai/skills/authoring/utility-functions.md

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -577,10 +577,10 @@ suggest('stirng', ['strong', 'string'], { count: 3 }); // ['string', 'strong'] b
577577

578578
## Coercion Utilities (coercion.js)
579579

580-
Best-effort conversion of loose input (attribute strings, query params, JSON) to a target type. Each returns the type or `null` when there is no clean reading, so results compose with `??`. Also exported as `coerceBoolean`/`coerceNumber`/`coerceInteger`/`coerceDate`/`coerceString`.
580+
Best-effort conversion of loose input (attribute strings, query params, JSON) to a target type. Each returns the type or `null` when there is no clean reading, so results compose with `??`. Also exported as `coerceBoolean`/`coerceNumber`/`coerceInteger`/`coerceDate`/`coerceDuration`/`coerceString`.
581581

582582
```javascript
583-
import { toBoolean, toNumber, toInteger, toDate, toString } from '@semantic-ui/utils';
583+
import { toBoolean, toNumber, toInteger, toDate, toDuration, toString } from '@semantic-ui/utils';
584584

585585
toBoolean('yes'); // true (generous: true/t/yes/y/on/enabled, false/f/no/n/off/disabled, numeric)
586586
toBoolean('banana'); // null (unrecognized, composes with ??)
@@ -602,6 +602,13 @@ toDate('2024-01-01'); // Date (ISO strings, epoch-ms numbers,
602602
toDate(1700000000, { epoch: 'seconds' }); // Date from a unix-second timestamp (a JWT exp)
603603
toDate('01/15/2024'); // null (ambiguous format, never a guessed date)
604604

605+
toDuration('5s'); // 5000 (ms/s/m/h/d/w, plus word and abbreviation spellings)
606+
toDuration('10 minutes'); // 600000 (case-insensitive, space optional)
607+
toDuration('1500'); // 1500 (no unit reads as milliseconds, same as the number)
608+
toDuration('1h 30m'); // null (compound expressions are a different grammar)
609+
// years and months have no fixed length, so neither is built in. name your own value once at boot
610+
toDuration.config.units.y = 365 * 24 * 60 * 60 * 1000;
611+
605612
toString(42); // '42'
606613
toString({ a: 1 }); // null (never "[object Object]")
607614
toString({ a: 1 }, { loose: true }); // '{"a":1}' (render objects for display)
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
title: 'toDuration'
3+
id: 'utils-toduration'
4+
sortIndex: 5
5+
exampleType: 'log'
6+
category: 'Utils'
7+
subcategory: 'Coercion'
8+
description: 'Coerce a duration expression to milliseconds, or null when it reads as no duration'
9+
tags: ['utils', 'coercion', 'toduration', 'duration', 'time', 'parse']
10+
selectedFile: 'index.js'
11+
tip: 'Unit words and abbreviations both read, so 10 minutes, 2hrs, and 300msecs all work. Years and months have no fixed length, so name your own value with toDuration.config.units'
12+
---
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { toDuration } from '@semantic-ui/utils';
2+
3+
console.log(toDuration('5s'));
4+
console.log(toDuration('1.5h'));
5+
console.log(toDuration('10 minutes'));
6+
console.log(toDuration('300msecs'));
7+
console.log(toDuration(1500));
8+
console.log(toDuration('1h 30m'));
9+
console.log(toDuration('soon') ?? 1000);

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

Lines changed: 64 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ layout: '@layouts/Guide.astro'
33
pageType: 'API Reference'
44
title: Coercion Utilities
55
package: "@semantic-ui/utils"
6-
methods: [toBoolean, toNumber, toInteger, toDate, toString]
6+
methods: [toBoolean, toNumber, toInteger, toDate, toDuration, toString]
77
icon: replace
88
description: API reference for best-effort type coercion functions
99
---
@@ -15,7 +15,7 @@ const page = toNumber(params.get('page')) ?? 1;
1515
const when = toDate(input.value) ?? new Date();
1616
```
1717
18-
Each helper is also exported as `coerceBoolean`, `coerceNumber`, `coerceInteger`, `coerceDate`, and `coerceString` for callers who think in coercion terms.
18+
Each helper is also exported as `coerceBoolean`, `coerceNumber`, `coerceInteger`, `coerceDate`, `coerceDuration`, and `coerceString` for callers who think in coercion terms.
1919
2020
Every helper accepts an `onInvalid` setting. It defaults to `'null'` (the failed coercion returns `null`, composing with `??`). Pass `{ onInvalid: 'passthrough' }` to return the *original* value instead, so a schema or validator can flag the bad input rather than see an erasing `null` — and the TypeScript return widens to include the input type (`number | string` for `toNumber(str, { onInvalid: 'passthrough' })`).
2121
@@ -192,6 +192,68 @@ console.log(toDate('banana')); // null (never an Invalid Date)
192192
const when = toDate(userInput) ?? new Date();
193193
```
194194
195+
### toDuration
196+
197+
```javascript
198+
function toDuration(value, { onInvalid = 'null' } = {})
199+
```
200+
201+
Coerces a duration expression to milliseconds, or `null` when it reads as no duration at all. A number is already milliseconds, and a string takes one number followed by one optional unit, case-insensitively, with an optional space between the two: `'5s'`, `'1.5h'`, `'10 minutes'`, `'2hrs'`, `'300msecs'`, `'.5d'`. Leave the unit off and the number reads as milliseconds, so `'1500'` and `1500` agree.
202+
203+
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.
204+
205+
#### Units
206+
207+
| Unit | Accepted spellings |
208+
|------|--------------------|
209+
| Milliseconds | `ms`, `msec`, `msecs`, `millisecond`, `milliseconds` |
210+
| Seconds | `s`, `sec`, `secs`, `second`, `seconds` |
211+
| Minutes | `m`, `min`, `mins`, `minute`, `minutes` |
212+
| Hours | `h`, `hr`, `hrs`, `hour`, `hours` |
213+
| Days | `d`, `day`, `days` |
214+
| Weeks | `w`, `week`, `weeks` |
215+
216+
Every unit here is a fixed span. Years and months are absent because neither has one: a year is a judgment call (the `ms` package reads it as 365.25 days) and a month has no length without a calendar date to anchor it. Name your own value in `toDuration.config.units` rather than inheriting someone else's guess.
217+
218+
#### Parameters
219+
220+
| Name | Type | Description |
221+
|----------|---------|-------------|
222+
| value | unknown | The value to coerce |
223+
| settings | object | Optional configuration (`onInvalid`, see above) |
224+
225+
#### Returns
226+
227+
The duration in milliseconds, or `null` if unreadable.
228+
229+
#### Example
230+
231+
```javascript
232+
import { toDuration } from '@semantic-ui/utils';
233+
234+
console.log(toDuration('5s')); // 5000
235+
console.log(toDuration('1.5h')); // 5400000
236+
console.log(toDuration('10 minutes')); // 600000
237+
console.log(toDuration(1500)); // 1500 (already milliseconds)
238+
console.log(toDuration('1500')); // 1500 (no unit reads as milliseconds)
239+
console.log(toDuration('1h 30m')); // null (compound expressions are a different grammar)
240+
241+
setTimeout(retry, toDuration(config.retryAfter) ?? 1000);
242+
```
243+
244+
#### toDuration.config
245+
246+
`toDuration.config.units` holds the milliseconds per unit, keyed by the lowercase spelling accepted after the number. Edit it once at app boot to teach the grammar a unit your domain uses, and every call inherits it.
247+
248+
```javascript
249+
import { toDuration } from '@semantic-ui/utils';
250+
251+
toDuration.config.units.y = 365 * 24 * 60 * 60 * 1000;
252+
toDuration.config.units.years = toDuration.config.units.y;
253+
254+
console.log(toDuration('1y')); // 31536000000
255+
```
256+
195257
### toString
196258
197259
```javascript

packages/utils/src/coercion.js

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,16 @@ import { isArray, isBoolean, isDate, isNumber, isObject, isString } from './type
1010
// client), returned as a UTC instant, matching what native new Date does with an <input type=datetime-local>
1111
const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}([T ]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?)?$/;
1212

13+
// one number, an optional space, one optional unit, mirroring the ecosystem's ms(). a compound form
14+
// like '1h 30m' is a different grammar and reads as junk here
15+
const DURATION_RE = /^([+-]?(?:\d+(?:\.\d+)?|\.\d+))\s*([a-z]*)$/i;
16+
17+
const SECOND = 1000;
18+
const MINUTE = 60 * SECOND;
19+
const HOUR = 60 * MINUTE;
20+
const DAY = 24 * HOUR;
21+
const WEEK = 7 * DAY;
22+
1323
// fold -0 to 0 so a coerced value never trips Object.is or 1/x downstream
1424
const normalizeZero = (number) => (number === 0 ? 0 : number);
1525

@@ -112,6 +122,60 @@ export const toDate = (value, { onInvalid = 'null', epoch = 'milliseconds' } = {
112122
return onInvalidResult(value, onInvalid);
113123
};
114124

125+
// only fixed-length spans are listed. a week is always 7 days, but a year is a pick (ms() reads it
126+
// as 365.25 days) and a month has no length at all, so neither is guessed here. add one at boot with
127+
// toDuration.config.units.y = 365 * 24 * 60 * 60 * 1000, where keys are matched lowercase
128+
export const toDuration = /* @__PURE__ */ configured(
129+
(value, { onInvalid = 'null' } = {}) => {
130+
// a number is already milliseconds, the unit taken by timers, TTLs, and animation APIs
131+
if (isNumber(value)) { return Number.isFinite(value) ? normalizeZero(value) : onInvalidResult(value, onInvalid); }
132+
if (isString(value)) {
133+
const match = DURATION_RE.exec(value.trim());
134+
if (match) {
135+
const units = toDuration.config.units;
136+
const unit = match[2].toLowerCase() || 'ms';
137+
// an inherited key ('5constructor') or a 300-digit overflow would otherwise produce NaN or
138+
// Infinity, which this family never returns
139+
if (Object.hasOwn(units, unit)) {
140+
const milliseconds = Number(match[1]) * units[unit];
141+
if (Number.isFinite(milliseconds)) { return normalizeZero(milliseconds); }
142+
}
143+
}
144+
}
145+
return onInvalidResult(value, onInvalid);
146+
},
147+
{
148+
units: {
149+
ms: 1,
150+
msec: 1,
151+
msecs: 1,
152+
millisecond: 1,
153+
milliseconds: 1,
154+
s: SECOND,
155+
sec: SECOND,
156+
secs: SECOND,
157+
second: SECOND,
158+
seconds: SECOND,
159+
m: MINUTE,
160+
min: MINUTE,
161+
mins: MINUTE,
162+
minute: MINUTE,
163+
minutes: MINUTE,
164+
h: HOUR,
165+
hr: HOUR,
166+
hrs: HOUR,
167+
hour: HOUR,
168+
hours: HOUR,
169+
d: DAY,
170+
day: DAY,
171+
days: DAY,
172+
w: WEEK,
173+
week: WEEK,
174+
weeks: WEEK,
175+
},
176+
},
177+
);
178+
115179
export const toString = (value, { loose = false, onInvalid = 'null' } = {}) => {
116180
if (isString(value)) { return value; }
117181
if (value == null) { return onInvalidResult(value, onInvalid); }
@@ -135,4 +199,5 @@ export const coerceBoolean = toBoolean;
135199
export const coerceNumber = toNumber;
136200
export const coerceInteger = toInteger;
137201
export const coerceDate = toDate;
202+
export const coerceDuration = toDuration;
138203
export const coerceString = toString;

0 commit comments

Comments
 (0)