@jsenv/humanize 1.7.8 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,3 +1,5 @@
1
+ import { parseDuration } from "@jsenv/validity";
2
+
1
3
  const createDetailedMessage = (message, details = {}) => {
2
4
  let text = `${message}`;
3
5
  const namedSectionsText = renderNamedSections(details);
@@ -233,7 +235,7 @@ const inspectNumber = (value, { numericSeparator }) => {
233
235
  } = numberString.match(
234
236
  /^(?<number>.*?)(?:(?<mark>e)(?<sign>[+-])?(?<power>\d+))?$/i,
235
237
  ).groups;
236
- const numberWithSeparators = formatNumber(number);
238
+ const numberWithSeparators = formatNumber$1(number);
237
239
  const powerWithSeparators = addSeparator(power, {
238
240
  minimumDigits: 5,
239
241
  groupLength: 3,
@@ -248,7 +250,7 @@ const isNegativeZero = (value) => {
248
250
  return value === 0 && 1 / value === -Infinity;
249
251
  };
250
252
 
251
- const formatNumber = (numberString) => {
253
+ const formatNumber$1 = (numberString) => {
252
254
  const parts = numberString.split(".");
253
255
  const [integer, fractional] = parts;
254
256
 
@@ -988,8 +990,8 @@ const UNIT_MS = {
988
990
  minute: 60_000,
989
991
  second: 1000,
990
992
  };
991
- const UNIT_KEYS = Object.keys(UNIT_MS);
992
- const SMALLEST_UNIT_NAME = UNIT_KEYS[UNIT_KEYS.length - 1];
993
+ const UNIT_KEYS$1 = Object.keys(UNIT_MS);
994
+ const SMALLEST_UNIT_NAME = UNIT_KEYS$1[UNIT_KEYS$1.length - 1];
993
995
  const TIME_DICTIONARY_EN = {
994
996
  year: { long: "year", plural: "years", short: "y" },
995
997
  month: { long: "month", plural: "months", short: "m" },
@@ -1078,8 +1080,8 @@ const humanizeDuration = (
1078
1080
  }
1079
1081
  const { primary, remaining } = parseMs(ms);
1080
1082
  if (!remaining) {
1081
- const primaryUnitIndex = UNIT_KEYS.indexOf(primary.name);
1082
- const nextUnitName = UNIT_KEYS[primaryUnitIndex - 1];
1083
+ const primaryUnitIndex = UNIT_KEYS$1.indexOf(primary.name);
1084
+ const nextUnitName = UNIT_KEYS$1[primaryUnitIndex - 1];
1083
1085
  const maxCount = nextUnitName
1084
1086
  ? UNIT_MS[nextUnitName] / UNIT_MS[primary.name]
1085
1087
  : null;
@@ -1137,7 +1139,7 @@ const humanizeDurationUnit = (
1137
1139
  const parseMs = (ms) => {
1138
1140
  let firstUnitName = SMALLEST_UNIT_NAME;
1139
1141
  let firstUnitCount = ms / UNIT_MS[SMALLEST_UNIT_NAME];
1140
- const firstUnitIndex = UNIT_KEYS.findIndex((unitName) => {
1142
+ const firstUnitIndex = UNIT_KEYS$1.findIndex((unitName) => {
1141
1143
  if (unitName === SMALLEST_UNIT_NAME) {
1142
1144
  return false;
1143
1145
  }
@@ -1159,7 +1161,7 @@ const parseMs = (ms) => {
1159
1161
  };
1160
1162
  }
1161
1163
  const remainingMs = ms - firstUnitCount * UNIT_MS[firstUnitName];
1162
- const remainingUnitName = UNIT_KEYS[firstUnitIndex + 1];
1164
+ const remainingUnitName = UNIT_KEYS$1[firstUnitIndex + 1];
1163
1165
  const remainingUnitCount = remainingMs / UNIT_MS[remainingUnitName];
1164
1166
  // - 1 year and 1 second is too much information
1165
1167
  // so we don't check the remaining units
@@ -1240,6 +1242,1894 @@ const inspectBytes = (
1240
1242
 
1241
1243
  const BYTE_UNITS = ["B", "kB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];
1242
1244
 
1245
+ // The JSX half of interpolation (VNode detection, fragment assembly) is
1246
+ // installed by the frontend using it (@jsenv/navi's interpolate.jsx) rather
1247
+ // than imported: this module sits under createI18n and the formatters, which
1248
+ // must stay importable where preact is not installed. Until installed, a VNode
1249
+ // replacement is neither detected nor assembled — values are joined as
1250
+ // strings — which is only reachable by passing a VNode without going through
1251
+ // <Interpolate>.
1252
+ let jsx = null;
1253
+ const installInterpolateJsx = (runtime) => {
1254
+ jsx = runtime;
1255
+ };
1256
+
1257
+ /**
1258
+ * Interpolates a template string, replacing `[key]` placeholders with values.
1259
+ *
1260
+ * Usable on its own — no i18n instance required — whenever a sentence should
1261
+ * stay readable as one string instead of being cut into JSX expressions or
1262
+ * concatenations. `<Interpolate>` is the JSX form of this function, and
1263
+ * `createI18n` runs every translation through it. See @jsenv/navi's
1264
+ * `docs/i18n.md`.
1265
+ *
1266
+ * `[]` was chosen as the placeholder delimiter (rather than `{}` or `{{}}`)
1267
+ * because it does not conflict with JSX syntax, JavaScript template literals,
1268
+ * or common punctuation in translated strings.
1269
+ *
1270
+ * @param {string} template
1271
+ * e.g. `"Hello [name], you have [count] messages"`. A non-string is returned
1272
+ * untouched, as is any template when `replacements` is missing.
1273
+ * @param {object} [replacements]
1274
+ * Values keyed by placeholder name. A key can be:
1275
+ * - a direct name — `[name]` ← `{ name: "Alice" }`
1276
+ * - a dot-path — `[item.label]` ← `{ item: { label: "Book" } }` (a literal
1277
+ * `"item.label"` key wins over the path)
1278
+ *
1279
+ * A value that is a function is called at that point, so an expensive or
1280
+ * lazily-known replacement is only computed when the placeholder is actually
1281
+ * present in this language's template.
1282
+ *
1283
+ * A placeholder with no matching value is left in the output as-is
1284
+ * (`"[name]"`), making the gap visible rather than silently empty.
1285
+ * @param {object} [options]
1286
+ * @param {boolean} [options.allowJsx=false]
1287
+ * Allow VNode replacements (what `<Interpolate>` passes). Without it, a VNode
1288
+ * value warns and is coerced to a string.
1289
+ * @returns {string|import("preact").VNode}
1290
+ * A plain string when every replacement is a string, a Preact fragment when
1291
+ * at least one VNode was interpolated with `allowJsx`.
1292
+ */
1293
+ const interpolateText = (
1294
+ template,
1295
+ replacements,
1296
+ { allowJsx = false } = {},
1297
+ ) => {
1298
+ if (!replacements || typeof template !== "string") {
1299
+ return template;
1300
+ }
1301
+ const parts = template.split(/(\[[^\]]+\])/);
1302
+ let hasVnode = false;
1303
+ const resolved = [];
1304
+ for (const part of parts) {
1305
+ const match = part.match(/^\[([^\]]+)\]$/);
1306
+ if (!match) {
1307
+ resolved.push(part);
1308
+ continue;
1309
+ }
1310
+ const key = match[1];
1311
+ let value = resolveValue(replacements, key, part);
1312
+ if (typeof value === "function") {
1313
+ value = value();
1314
+ }
1315
+ if (jsx && jsx.isValidElement(value)) {
1316
+ if (allowJsx) {
1317
+ hasVnode = true;
1318
+ } else {
1319
+ console.warn(
1320
+ `interpolateText: VNode passed for placeholder [${match[1]}] but allowJsx is false — value coerced to string`,
1321
+ );
1322
+ }
1323
+ }
1324
+ resolved.push(value);
1325
+ }
1326
+ if (!hasVnode) {
1327
+ return resolved.join("");
1328
+ }
1329
+ return jsx.createFragment(resolved);
1330
+ };
1331
+
1332
+ // Resolves a placeholder key against the replacements object.
1333
+ // 1. Direct lookup: replacements["item.name"]
1334
+ // 2. Dot-path lookup: replacements["item"]["name"]
1335
+ // 3. Fallback: the original placeholder string (e.g. "[item.name]")
1336
+ const resolveValue = (replacements, key, fallback) => {
1337
+ if (key in replacements) {
1338
+ return replacements[key];
1339
+ }
1340
+ const dotIndex = key.indexOf(".");
1341
+ if (dotIndex !== -1) {
1342
+ const head = key.slice(0, dotIndex);
1343
+ const tail = key.slice(dotIndex + 1);
1344
+ const parent = replacements[head];
1345
+ if (parent && typeof parent === "object") {
1346
+ const nested = parent[tail];
1347
+ if (nested !== undefined) {
1348
+ return nested;
1349
+ }
1350
+ }
1351
+ }
1352
+ return fallback;
1353
+ };
1354
+
1355
+ /**
1356
+ * The language every formatter/i18n call falls back to when it is given no
1357
+ * `lang` — an injectable source, deliberately free of any import.
1358
+ *
1359
+ * This module is the seam that keeps text formatting importable outside the
1360
+ * browser (a backend wording a date, say): by default the source is the
1361
+ * runtime's own locale, exactly what Intl itself would pick. A frontend swaps
1362
+ * the source for something live — @jsenv/navi points it at its
1363
+ * `languagesSignal`, so the fallback follows the user's language preference,
1364
+ * and because the source is read fresh on every call, reading it during a
1365
+ * component render subscribes the component the same way reading the signal
1366
+ * directly would.
1367
+ */
1368
+
1369
+ let systemLocale;
1370
+ let runtimeLangSource = () => {
1371
+ systemLocale ??= new Intl.DateTimeFormat().resolvedOptions().locale;
1372
+ return systemLocale;
1373
+ };
1374
+
1375
+ const getRuntimeLang = () => runtimeLangSource();
1376
+
1377
+ /**
1378
+ * @param {() => string|string[]} source - Returns the language (BCP 47 tag,
1379
+ * or an ordered preference array) to use when a call passes no `lang`.
1380
+ */
1381
+ const setRuntimeLangSource = (source) => {
1382
+ runtimeLangSource = source;
1383
+ };
1384
+
1385
+ /**
1386
+ * Creates a lightweight i18n instance: a central place where an app declares
1387
+ * its texts once and reads them back translated into the active language.
1388
+ *
1389
+ * Worth using even in a single-language app — one registry beats strings
1390
+ * scattered across components, and adding a second language later becomes a
1391
+ * data change instead of a refactor. See @jsenv/navi's `docs/i18n.md` for how
1392
+ * to choose between the two key styles below and how this relates to
1393
+ * `humanizeI18n`, the registry the built-in texts live in.
1394
+ *
1395
+ * @param {object} [options]
1396
+ * @param {string} [options.keyLang]
1397
+ * When set, each key also serves as its own translation for `keyLang`.
1398
+ * This allows writing keys directly in that language (typically the language
1399
+ * the app is written in) so only *other* languages need registering:
1400
+ *
1401
+ * ```js
1402
+ * const i18n = createI18n({ keyLang: "en" });
1403
+ * i18n.add("Hello [name]!", { fr: "Bonjour [name] !" });
1404
+ * i18n("Hello [name]!", { name: "Alice" }, { lang: "en" }); // "Hello Alice!"
1405
+ * i18n("Hello [name]!", { name: "Alice" }, { lang: "fr" }); // "Bonjour Alice !"
1406
+ * ```
1407
+ *
1408
+ * `keyLang` only applies to keys passed to `add()`/`addAll()`; a key never
1409
+ * registered stays opaque and comes back as-is.
1410
+ *
1411
+ * Without `keyLang`, keys are opaque identifiers and every language
1412
+ * (including the one the app was written in) must be registered explicitly:
1413
+ *
1414
+ * ```js
1415
+ * const i18n = createI18n();
1416
+ * i18n.add("greeting", { en: "Hello [name]!", fr: "Bonjour [name] !" });
1417
+ * i18n("greeting", { name: "Alice" }, { lang: "en" }); // "Hello Alice!"
1418
+ * ```
1419
+ *
1420
+ * @param {string} [options.fallbackLang]
1421
+ * Language consulted when the active language has no translation for a key
1422
+ * — per key, not per language: a partially translated language falls through
1423
+ * to `fallbackLang` only for the keys it is missing. Without it, a missing
1424
+ * translation returns the key itself.
1425
+ *
1426
+ * @param {string|string[]} [options.runtimeLang]
1427
+ * The active language (BCP 47 tag or ordered array of tags) — named
1428
+ * "runtime" rather than "system" because there is no actual access to the
1429
+ * OS/user's system language from a browser, only `navigator.languages` (or
1430
+ * an explicit override) at runtime. Defaults to the shared runtime language
1431
+ * source (see runtime_lang.js) — the runtime's own locale, or whatever a
1432
+ * frontend installed in its place — read fresh on every `format()`/`has()`
1433
+ * call (not frozen at creation time), so overriding the language app-wide
1434
+ * is picked up here too.
1435
+ * Passing an explicit `runtimeLang` opts out of that and stays fixed for
1436
+ * this instance's whole lifetime.
1437
+ *
1438
+ * ---
1439
+ *
1440
+ * ## Registration
1441
+ *
1442
+ * **`i18n.add(key, { lang: "translation" })`** — one key, multiple languages.
1443
+ *
1444
+ * **`i18n.addAll({ key: { lang: "translation" }, ... })`** — multiple keys at once.
1445
+ *
1446
+ * **`i18n.addLangKeys(lang, { key: "translation", ... })`** — full language pack
1447
+ * (useful when loading a JSON translation file).
1448
+ *
1449
+ * All three accumulate: registering a key that already exists overwrites that
1450
+ * one key and leaves the rest of the language untouched. This is what lets an
1451
+ * app override a single built-in text without redeclaring the others.
1452
+ *
1453
+ * A regional variant (e.g. `"fr-CA"`) automatically inherits all keys from its
1454
+ * parent (`"fr"`) that it does not explicitly override:
1455
+ * ```js
1456
+ * i18n.addLangKeys("fr", { hello: "Bonjour !" });
1457
+ * i18n.addLangKeys("fr-CA", { hello: "Allo !" }); // other "fr" keys inherited
1458
+ * ```
1459
+ * Inheritance is resolved at registration time, so register the parent first.
1460
+ *
1461
+ * ---
1462
+ *
1463
+ * ## Reading
1464
+ *
1465
+ * **`i18n(key, values?, { lang? })`** — the translation for `key`, with
1466
+ * `[placeholder]` occurrences replaced from `values` (see `interpolateText`).
1467
+ * Returns `key` itself when nothing matches, so an untranslated string still
1468
+ * renders something readable. `i18n.format` is an alias of this call.
1469
+ *
1470
+ * **`i18n.has(key, { lang? })`** — whether a translation genuinely exists,
1471
+ * i.e. how to tell "no translation" apart from "translation equal to the key".
1472
+ *
1473
+ * @returns {Function & { add, addAll, addLangKeys, has, format, languageMap }}
1474
+ */
1475
+ const createI18n = ({ keyLang, fallbackLang, runtimeLang } = {}) => {
1476
+ const languageMap = new Map();
1477
+ // Bumped by addLangKeys — the only thing besides the active lang itself
1478
+ // that could change what getActiveLang()/getResolvedFallbackLang() below
1479
+ // resolve to, so it's what invalidates their own small caches.
1480
+ let languageMapVersion = 0;
1481
+
1482
+ // Without an explicit runtimeLang, the runtime language source is re-read
1483
+ // fresh on every call rather than frozen here — freezing it would silently
1484
+ // ignore an app-wide language change (see runtime_lang.js) for the rest of
1485
+ // this instance's life.
1486
+ const hasExplicitRuntimeLang = runtimeLang !== undefined;
1487
+
1488
+ // matchBestLang does real work (a Map lookup per candidate, a possible
1489
+ // "fr-CA" → "fr" split-and-retry loop) — worth skipping on every single
1490
+ // format()/has() call in the common case, since what it resolves to only
1491
+ // ever changes when languageMap itself changes (addLangKeys) or, for the
1492
+ // non-explicit case, when the runtime lang itself changes (see
1493
+ // runtime_lang.js; an installed source is expected to keep its reference
1494
+ // stable while nothing changed, and the default one caches its string) —
1495
+ // comparing those two cheaply (===) is enough to know the cached result
1496
+ // below is still valid.
1497
+ let cachedActiveLang;
1498
+ let cachedActiveLangRuntimeLang;
1499
+ let cachedActiveLangVersion = -1;
1500
+ const getActiveLang = () => {
1501
+ const currentRuntimeLang = hasExplicitRuntimeLang
1502
+ ? runtimeLang
1503
+ : getRuntimeLang();
1504
+ if (
1505
+ cachedActiveLangVersion === languageMapVersion &&
1506
+ cachedActiveLangRuntimeLang === currentRuntimeLang
1507
+ ) {
1508
+ return cachedActiveLang;
1509
+ }
1510
+ cachedActiveLang = matchBestLang(currentRuntimeLang, languageMap);
1511
+ cachedActiveLangVersion = languageMapVersion;
1512
+ cachedActiveLangRuntimeLang = currentRuntimeLang;
1513
+ return cachedActiveLang;
1514
+ };
1515
+
1516
+ // fallbackLang is a plain, never-reactive option set once at creation —
1517
+ // its own resolution only ever needs recomputing when languageMap does.
1518
+ let cachedResolvedFallbackLang;
1519
+ let cachedResolvedFallbackLangVersion = -1;
1520
+ const getResolvedFallbackLang = () => {
1521
+ if (!fallbackLang) {
1522
+ return null;
1523
+ }
1524
+ if (cachedResolvedFallbackLangVersion === languageMapVersion) {
1525
+ return cachedResolvedFallbackLang;
1526
+ }
1527
+ cachedResolvedFallbackLang = matchBestLang(fallbackLang, languageMap);
1528
+ cachedResolvedFallbackLangVersion = languageMapVersion;
1529
+ return cachedResolvedFallbackLang;
1530
+ };
1531
+
1532
+ const addLangKeys = (lang, translations) => {
1533
+ // Accumulate: merge with any existing translations for this lang
1534
+ const existing = languageMap.get(lang);
1535
+ if (existing) {
1536
+ translations = { ...existing, ...translations };
1537
+ }
1538
+ // A regional variant inherits all keys not explicitly overridden
1539
+ // e.g. "fr-CA" inherits from "fr"
1540
+ const dashIndex = lang.indexOf("-");
1541
+ if (dashIndex !== -1) {
1542
+ const parentLang = lang.slice(0, dashIndex);
1543
+ const parentTranslations = languageMap.get(parentLang);
1544
+ if (parentTranslations) {
1545
+ translations = { ...parentTranslations, ...translations };
1546
+ }
1547
+ }
1548
+ languageMap.set(lang, translations);
1549
+ languageMapVersion++;
1550
+ };
1551
+
1552
+ const add = (key, langTranslations) => {
1553
+ if (keyLang && !(keyLang in langTranslations)) {
1554
+ // Auto-register the key itself as the translation for keyLang
1555
+ addLangKeys(keyLang, { [key]: key });
1556
+ }
1557
+ for (const [lang, value] of Object.entries(langTranslations)) {
1558
+ addLangKeys(lang, { [key]: value });
1559
+ }
1560
+ };
1561
+
1562
+ const addAll = (keyMap) => {
1563
+ for (const [key, langTranslations] of Object.entries(keyMap)) {
1564
+ add(key, langTranslations);
1565
+ }
1566
+ };
1567
+
1568
+ const _getTemplate = (key, lang) => {
1569
+ // matchBestLang, not matchLang directly: lang can be an ordered array of
1570
+ // preferences, and matchLang alone assumes a plain string, throwing on
1571
+ // .split() otherwise.
1572
+ const resolvedLang = lang ? matchBestLang(lang, languageMap) : null;
1573
+ if (resolvedLang) {
1574
+ const translations = languageMap.get(resolvedLang);
1575
+ const translated = translations[key];
1576
+ if (translated !== undefined) {
1577
+ return translated;
1578
+ }
1579
+ }
1580
+ const resolvedFallbackLang = getResolvedFallbackLang();
1581
+ if (resolvedFallbackLang) {
1582
+ const fallbackTranslations = languageMap.get(resolvedFallbackLang);
1583
+ const fallbackTranslated = fallbackTranslations[key];
1584
+ if (fallbackTranslated !== undefined) {
1585
+ return fallbackTranslated;
1586
+ }
1587
+ }
1588
+ // No translation found — return key as-is (opaque fallback)
1589
+ return key;
1590
+ };
1591
+
1592
+ const format = (key, values, { lang = getActiveLang() } = {}) => {
1593
+ const template = _getTemplate(key, lang);
1594
+ return interpolateText(template, values);
1595
+ };
1596
+
1597
+ const has = (key, { lang = getActiveLang() } = {}) => {
1598
+ const resolvedLang = lang ? matchBestLang(lang, languageMap) : null;
1599
+ if (resolvedLang) {
1600
+ const translations = languageMap.get(resolvedLang);
1601
+ if (translations && key in translations) {
1602
+ return true;
1603
+ }
1604
+ }
1605
+ const resolvedFallbackLang = getResolvedFallbackLang();
1606
+ if (resolvedFallbackLang) {
1607
+ const fallbackTranslations = languageMap.get(resolvedFallbackLang);
1608
+ if (fallbackTranslations && key in fallbackTranslations) {
1609
+ return true;
1610
+ }
1611
+ }
1612
+ return false;
1613
+ };
1614
+
1615
+ // The i18n instance is itself a callable function
1616
+ const i18n = (key, values, opts) => format(key, values, opts);
1617
+ i18n.add = add;
1618
+ i18n.addAll = addAll;
1619
+ i18n.addLangKeys = addLangKeys;
1620
+ i18n.has = has;
1621
+ i18n.format = format;
1622
+ i18n.languageMap = languageMap;
1623
+
1624
+ return i18n;
1625
+ };
1626
+
1627
+ // Walk "fr-CA-variant" → "fr-CA" → "fr" until a registered lang is found
1628
+ const matchLang = (lang, languageMap) => {
1629
+ if (languageMap.has(lang)) {
1630
+ return lang;
1631
+ }
1632
+ const parts = lang.split("-");
1633
+ while (parts.length > 1) {
1634
+ parts.pop();
1635
+ const candidate = parts.join("-");
1636
+ if (languageMap.has(candidate)) {
1637
+ return candidate;
1638
+ }
1639
+ }
1640
+ return null;
1641
+ };
1642
+
1643
+ // lang can be a string or an ordered array of preference strings
1644
+ const matchBestLang = (lang, languageMap) => {
1645
+ if (!lang) {
1646
+ return null;
1647
+ }
1648
+ const candidates = Array.isArray(lang) ? lang : [lang];
1649
+ for (const candidate of candidates) {
1650
+ const match = matchLang(candidate, languageMap);
1651
+ if (match) {
1652
+ return match;
1653
+ }
1654
+ }
1655
+ return null;
1656
+ };
1657
+
1658
+ /**
1659
+ * The shared registry holding the texts jsenv libraries display on their own:
1660
+ * the words this package's formatters need — relative time wording, duration
1661
+ * unit symbols, date field placeholders — and, when @jsenv/navi is installed,
1662
+ * everything its components say (button labels, validation messages,
1663
+ * empty-list messages…).
1664
+ *
1665
+ * One instance for all of them rather than one per package: a text belongs to
1666
+ * whoever displays it, but an app overriding one wants a single handle to do
1667
+ * it through. navi registers its own keys here and re-exports this very
1668
+ * object as `naviI18n`, so a `time.*` key registered below is overridable
1669
+ * from either name.
1670
+ *
1671
+ * Keys are opaque identifiers (`"time.ongoing"`), never the English sentence
1672
+ * — the opposite of what an app is advised to do for its own texts; navi's
1673
+ * `docs/i18n.md` explains why.
1674
+ *
1675
+ * @example
1676
+ * import { humanizeI18n } from "@jsenv/humanize";
1677
+ *
1678
+ * // Override a built-in text:
1679
+ * humanizeI18n.add("time.ongoing", { fr: "En cours…" });
1680
+ *
1681
+ * // Teach a language that is not shipped:
1682
+ * humanizeI18n.addLangKeys("ja", { "time.midnight": "真夜中" });
1683
+ */
1684
+ const humanizeI18n = createI18n();
1685
+
1686
+ // What the time formatters in ../time/format_time.js write in words:
1687
+ // relative wording, the midnight word, the mark between the two bounds of a
1688
+ // span, and the compact duration unit symbols.
1689
+ humanizeI18n.addAll({
1690
+ "time.less_than_minute": {
1691
+ en: "in less than a minute",
1692
+ fr: "dans moins d'une minute",
1693
+ de: "in weniger als einer Minute",
1694
+ es: "en menos de un minuto",
1695
+ it: "in meno di un minuto",
1696
+ pt: "em menos de um minuto",
1697
+ nl: "over minder dan een minuut",
1698
+ },
1699
+ "time.ongoing": {
1700
+ en: "Ongoing",
1701
+ fr: "En cours",
1702
+ de: "Laufend",
1703
+ es: "En curso",
1704
+ it: "In corso",
1705
+ pt: "Em andamento",
1706
+ nl: "Bezig",
1707
+ },
1708
+ // [day] and [time] are replaced at runtime with the localized day/time strings
1709
+ "time.tomorrow_at": {
1710
+ en: "[day] at [time]",
1711
+ fr: "[day] à [time]",
1712
+ de: "[day] um [time]",
1713
+ es: "[day] a las [time]",
1714
+ it: "[day] alle [time]",
1715
+ pt: "[day] às [time]",
1716
+ nl: "[day] om [time]",
1717
+ },
1718
+ // [duration] is replaced at runtime with the formatted duration string (e.g. "1h30", "45 min")
1719
+ "time.in_duration": {
1720
+ en: "in [duration]",
1721
+ fr: "dans [duration]",
1722
+ de: "in [duration]",
1723
+ es: "en [duration]",
1724
+ it: "tra [duration]",
1725
+ pt: "em [duration]",
1726
+ nl: "over [duration]",
1727
+ },
1728
+ // The word formatTimeOfDay splices in place of the "0 heure(s)" part of a
1729
+ // spelled-out time of day — see its own comment for why hour 0 needs a word
1730
+ // of its own, and how the swap keeps the rest of the sentence in this
1731
+ // language's grammar. A language with no entry here keeps its literal
1732
+ // "0 heure(s)" wording rather than this key.
1733
+ "time.midnight": {
1734
+ en: "midnight",
1735
+ fr: "minuit",
1736
+ de: "Mitternacht",
1737
+ es: "medianoche",
1738
+ it: "mezzanotte",
1739
+ pt: "meia-noite",
1740
+ nl: "middernacht",
1741
+ },
1742
+ // What formatTimeRange writes between the two bounds of a span — "8h–10h",
1743
+ // "11 mai – 14 mai". An en dash, the mark for a span, not a hyphen.
1744
+ "time.range_separator": {
1745
+ en: "–",
1746
+ fr: "–",
1747
+ de: "–",
1748
+ es: "–",
1749
+ it: "–",
1750
+ pt: "–",
1751
+ nl: "–",
1752
+ },
1753
+ // Compact duration unit symbols used in "1h30", "45min", "2d", etc.
1754
+ "time.duration.year_symbol": {
1755
+ en: "y",
1756
+ fr: "a",
1757
+ de: "J",
1758
+ es: "a",
1759
+ it: "a",
1760
+ pt: "a",
1761
+ nl: "j",
1762
+ ja: "年",
1763
+ zh: "年",
1764
+ ko: "년",
1765
+ },
1766
+ "time.duration.month_symbol": {
1767
+ en: "mo",
1768
+ fr: "mo",
1769
+ de: "Mo",
1770
+ es: "mo",
1771
+ it: "mo",
1772
+ pt: "mo",
1773
+ nl: "mo",
1774
+ ja: "月",
1775
+ zh: "月",
1776
+ ko: "월",
1777
+ },
1778
+ "time.duration.week_symbol": {
1779
+ en: "w",
1780
+ fr: "sem",
1781
+ de: "W",
1782
+ es: "sem",
1783
+ it: "sett",
1784
+ pt: "sem",
1785
+ nl: "w",
1786
+ ja: "週",
1787
+ zh: "周",
1788
+ ko: "주",
1789
+ },
1790
+ "time.duration.day_symbol": {
1791
+ en: "d",
1792
+ fr: "j",
1793
+ de: "T",
1794
+ es: "d",
1795
+ it: "g",
1796
+ pt: "d",
1797
+ nl: "d",
1798
+ ja: "日",
1799
+ zh: "天",
1800
+ ko: "일",
1801
+ },
1802
+ "time.duration.hour_symbol": {
1803
+ en: "h",
1804
+ fr: "h",
1805
+ de: "h",
1806
+ es: "h",
1807
+ it: "h",
1808
+ pt: "h",
1809
+ nl: "u",
1810
+ ja: "時間",
1811
+ zh: "小时",
1812
+ ko: "시간",
1813
+ },
1814
+ "time.duration.minute_symbol": {
1815
+ en: "min",
1816
+ fr: "min",
1817
+ de: "min",
1818
+ es: "min",
1819
+ it: "min",
1820
+ pt: "min",
1821
+ nl: "min",
1822
+ ja: "分",
1823
+ zh: "分",
1824
+ ko: "분",
1825
+ },
1826
+ "time.duration.second_symbol": {
1827
+ en: "s",
1828
+ fr: "s",
1829
+ de: "s",
1830
+ es: "s",
1831
+ it: "s",
1832
+ pt: "s",
1833
+ nl: "s",
1834
+ ja: "秒",
1835
+ zh: "秒",
1836
+ ko: "초",
1837
+ },
1838
+ "time.duration.millisecond_symbol": {
1839
+ en: "ms",
1840
+ fr: "ms",
1841
+ de: "ms",
1842
+ es: "ms",
1843
+ it: "ms",
1844
+ pt: "ms",
1845
+ nl: "ms",
1846
+ ja: "ms",
1847
+ zh: "ms",
1848
+ ko: "ms",
1849
+ },
1850
+ });
1851
+
1852
+ // Date/time placeholder tokens — shown when no value is selected
1853
+ // Override any key to adapt to your language conventions
1854
+ humanizeI18n.addAll({
1855
+ "time.placeholder.day": {
1856
+ fr: "jj",
1857
+ en: "dd",
1858
+ de: "TT",
1859
+ es: "dd",
1860
+ it: "gg",
1861
+ pt: "dd",
1862
+ nl: "dd",
1863
+ },
1864
+ "time.placeholder.month": {
1865
+ fr: "mm",
1866
+ en: "mm",
1867
+ de: "MM",
1868
+ es: "mm",
1869
+ it: "mm",
1870
+ pt: "mm",
1871
+ nl: "mm",
1872
+ },
1873
+ "time.placeholder.year": {
1874
+ fr: "aaaa",
1875
+ en: "yyyy",
1876
+ de: "JJJJ",
1877
+ es: "aaaa",
1878
+ it: "aaaa",
1879
+ pt: "aaaa",
1880
+ nl: "jjjj",
1881
+ },
1882
+ "time.placeholder.hour": {
1883
+ fr: "hh",
1884
+ en: "hh",
1885
+ de: "hh",
1886
+ es: "hh",
1887
+ it: "hh",
1888
+ pt: "hh",
1889
+ nl: "uu",
1890
+ },
1891
+ "time.placeholder.minute": {
1892
+ fr: "mm",
1893
+ en: "mm",
1894
+ de: "mm",
1895
+ es: "mm",
1896
+ it: "mm",
1897
+ pt: "mm",
1898
+ nl: "mm",
1899
+ },
1900
+ "time.placeholder.week": {
1901
+ fr: "sem.",
1902
+ en: "wk",
1903
+ de: "KW",
1904
+ es: "sem.",
1905
+ it: "sett.",
1906
+ pt: "sem.",
1907
+ nl: "wk",
1908
+ },
1909
+ });
1910
+
1911
+ const formatNumber = (value, { lang = getRuntimeLang() } = {}) => {
1912
+ return new Intl.NumberFormat(lang).format(value);
1913
+ };
1914
+
1915
+ /**
1916
+ * Locale-aware time formatting: days, months, times of day, spans and
1917
+ * durations, worded the way a reader of that language expects them.
1918
+ *
1919
+ * It lives in a package with no frontend of its own so that a server and a
1920
+ * browser word the same instant identically — a notification sentence and the
1921
+ * card it points at must read the same date the same way. Nothing here touches
1922
+ * the DOM, and nothing it imports may.
1923
+ *
1924
+ * `lang` defaults to the runtime language source (see ../i18n/runtime_lang.js):
1925
+ * the runtime's own locale, or whatever a browser bundle installs in its place
1926
+ * (@jsenv/navi points it at the user's live language preference, so reading it
1927
+ * during a render subscribes the component). The words around the numbers come
1928
+ * from humanizeI18n, their order and shape from Intl.
1929
+ *
1930
+ * Its neighbour ./time.js writes durations too, in English, for CLI
1931
+ * output where readability beats precision — these write for the reader of an
1932
+ * app, in their language.
1933
+ *
1934
+ * All functions accept an optional `{ now }` parameter for testability.
1935
+ */
1936
+
1937
+
1938
+ // Constructing an Intl formatter dominates the cost of a call (~19µs vs
1939
+ // ~0.4µs to format with a kept instance, node 26 on an M-series Mac) and
1940
+ // these formatters run in render loops — a card easily writes half a dozen
1941
+ // per render — so every instance is memoized by (constructor, lang,
1942
+ // options). lang and each option value come from small closed sets, so the
1943
+ // cache stays bounded.
1944
+ const intlCache = new Map();
1945
+ const memoIntl = (constructorName, lang, options) => {
1946
+ let key = `${constructorName}|${Array.isArray(lang) ? lang.join() : lang}`;
1947
+ if (options) {
1948
+ for (const optionName of Object.keys(options)) {
1949
+ key += `|${optionName}:${options[optionName]}`;
1950
+ }
1951
+ }
1952
+ const cached = intlCache.get(key);
1953
+ if (cached) {
1954
+ return cached;
1955
+ }
1956
+ const formatter = new Intl[constructorName](lang, options);
1957
+ intlCache.set(key, formatter);
1958
+ return formatter;
1959
+ };
1960
+
1961
+ // Our own compact/custom duration notation interpolates raw numbers
1962
+ // directly (unlike Intl.DurationFormat, which groups thousands on its own,
1963
+ // e.g. "5 400 secondes") — this keeps that consistent without reimplementing
1964
+ // locale-aware grouping. Falls back to the raw value as-is for a
1965
+ // non-numeric mid-edit value (e.g. "2a"), which Intl.NumberFormat can't
1966
+ // format anyway.
1967
+ const formatCompactNumber = (value, lang) => {
1968
+ const n = Number(value);
1969
+ return Number.isFinite(n) ? memoIntl("NumberFormat", lang).format(n) : value;
1970
+ };
1971
+
1972
+ /**
1973
+ * Formats a date as a human-readable day string.
1974
+ *
1975
+ * @param {Date} date
1976
+ * @param {{ lang?: string, format?: "long"|"short"|"narrow"|"numeric"|{ weekday?: "long"|"short"|"narrow"|false, day?: boolean, month?: "long"|"short"|"narrow"|"numeric"|false }, year?: boolean|"auto", now?: Date, timeZone?: string }} [options]
1977
+ * A string spells the weekday and the month the same way. An object spells
1978
+ * them apart, each key defaulting to `"long"`: a narrow card usually wants
1979
+ * the weekday whole (it is the reading anchor) and the month abbreviated (it
1980
+ * is where the characters are — "septembre" is 9 of them, "sept." reads the
1981
+ * same). `"numeric"` stays a string-only spelling: it drops the weekday and
1982
+ * writes the whole date in digits.
1983
+ *
1984
+ * In the object form, `false` drops a part: `{ day: false, month: false }`
1985
+ * writes the weekday alone ("mardi"), `{ month: false }` the weekday and
1986
+ * day-of-month ("mardi 18"), `{ weekday: false }` the date without its
1987
+ * anchor ("18 juillet"). At least one part must stay — with all three
1988
+ * dropped, Intl falls back to its own default date spelling.
1989
+ * @param {boolean|"auto"} [options.year=true]
1990
+ * Whether the `"numeric"` spelling writes the year: `false` drops it
1991
+ * ("30/07", the day/month order still following the locale), `"auto"` drops
1992
+ * it only when the date is in the current year (`now`'s year). The spelled
1993
+ * formats never write the year, so they ignore it.
1994
+ * @param {string} [options.timeZone]
1995
+ * IANA zone the instant is worded in ("Europe/Paris"); defaults to the
1996
+ * runtime's own zone. The case is a server wording an instant for readers
1997
+ * in a known zone — a game at 00:30 Paris must not be dated the previous
1998
+ * day just because the process clock runs on UTC. `year: "auto"` reads both
1999
+ * years in that zone too.
2000
+ *
2001
+ * @example
2002
+ * formatDay(new Date(), { lang: "fr" }) // "lundi 11 mai" (long, default)
2003
+ * formatDay(new Date(), { lang: "fr", format: "short" }) // "lun. 11 mai"
2004
+ * formatDay(new Date(), { lang: "fr", format: "narrow" }) // "lu. 11 mai"
2005
+ * formatDay(new Date(), { lang: "fr", format: "numeric" }) // "11/05/2026"
2006
+ * formatDay(new Date(), { lang: "fr", format: "numeric", year: false }) // "11/05"
2007
+ * formatDay(new Date(), { lang: "fr", format: { weekday: "long", month: "short" } }) // "mercredi 2 sept."
2008
+ * formatDay(new Date(), { lang: "fr", format: { day: false, month: false } }) // "mercredi"
2009
+ * formatDay(new Date(), { lang: "fr", format: { month: false } }) // "mercredi 2"
2010
+ */
2011
+ const formatDay = (
2012
+ date,
2013
+ {
2014
+ lang = getRuntimeLang(),
2015
+ format = "long",
2016
+ year = true,
2017
+ now = new Date(),
2018
+ timeZone,
2019
+ } = {},
2020
+ ) => {
2021
+ if (format === "numeric") {
2022
+ const yearWritten =
2023
+ year === "auto"
2024
+ ? readYear(date, timeZone) !== readYear(now, timeZone)
2025
+ : year !== false;
2026
+ return memoIntl("DateTimeFormat", lang, {
2027
+ day: "2-digit",
2028
+ month: "2-digit",
2029
+ ...(yearWritten ? { year: "numeric" } : {}),
2030
+ timeZone,
2031
+ }).format(date);
2032
+ }
2033
+ const {
2034
+ weekday = "long",
2035
+ day = true,
2036
+ month = "long",
2037
+ } = typeof format === "string" ? { weekday: format, month: format } : format;
2038
+ // a `false` part is omitted, not passed: Intl rejects false as a value
2039
+ return memoIntl("DateTimeFormat", lang, {
2040
+ ...(weekday === false ? {} : { weekday }),
2041
+ ...(day === false ? {} : { day: "numeric" }),
2042
+ ...(month === false ? {} : { month }),
2043
+ timeZone,
2044
+ }).format(date);
2045
+ };
2046
+
2047
+ /**
2048
+ * Returns the day offset relative to now: -1 (yesterday), 0 (today), 1 (tomorrow), or the
2049
+ * actual number of days difference for any other date.
2050
+ */
2051
+ const getRelativeDay = (date, { now = new Date() } = {}) => {
2052
+ const dateKey = toLocalDayKey(date);
2053
+
2054
+ const yesterdayDate = new Date(now);
2055
+ yesterdayDate.setDate(yesterdayDate.getDate() - 1);
2056
+ if (dateKey === toLocalDayKey(yesterdayDate)) {
2057
+ return -1;
2058
+ }
2059
+
2060
+ if (dateKey === toLocalDayKey(now)) {
2061
+ return 0;
2062
+ }
2063
+
2064
+ const tomorrowDate = new Date(now);
2065
+ tomorrowDate.setDate(tomorrowDate.getDate() + 1);
2066
+ if (dateKey === toLocalDayKey(tomorrowDate)) {
2067
+ return 1;
2068
+ }
2069
+
2070
+ const nowMidnight = new Date(now);
2071
+ nowMidnight.setHours(0, 0, 0, 0);
2072
+ const dateMidnight = new Date(date);
2073
+ dateMidnight.setHours(0, 0, 0, 0);
2074
+ return Math.round((dateMidnight - nowMidnight) / DAY);
2075
+ };
2076
+
2077
+ /**
2078
+ * Formats a relative day offset (-1/0/1) as a locale-aware label: "hier", "aujourd'hui", "demain".
2079
+ */
2080
+ // ── Placeholder helpers ────────────────────────────────────────────────────
2081
+ // Derive locale-aware format placeholders from Intl.DateTimeFormat.formatToParts
2082
+ // using a sentinel date whose parts are unambiguous (day=28, month=11, year=9999).
2083
+ // Per-language token tables cover the most common locales; unknown langs fall
2084
+ // back to "dd/mm/yyyy".
2085
+
2086
+ const SENTINEL_DATE = new Date(9999, 10, 28); // 28 Nov 9999 — day≠month, both 2-digit
2087
+
2088
+ const getToken = (key, lang) =>
2089
+ humanizeI18n(`time.placeholder.${key}`, undefined, { lang });
2090
+
2091
+ const formatDatePlaceholder = ({ lang = getRuntimeLang() } = {}) => {
2092
+ const parts = memoIntl("DateTimeFormat", lang, {
2093
+ day: "numeric",
2094
+ month: "numeric",
2095
+ year: "numeric",
2096
+ }).formatToParts(SENTINEL_DATE);
2097
+ return parts
2098
+ .map((p) => {
2099
+ if (p.type === "day") {
2100
+ return getToken("day", lang);
2101
+ }
2102
+ if (p.type === "month") {
2103
+ return getToken("month", lang);
2104
+ }
2105
+ if (p.type === "year") {
2106
+ return getToken("year", lang);
2107
+ }
2108
+ return p.value;
2109
+ })
2110
+ .join("");
2111
+ };
2112
+
2113
+ const formatMonthPlaceholder = ({
2114
+ lang = getRuntimeLang(),
2115
+ format = "long",
2116
+ } = {}) => {
2117
+ const parts = memoIntl("DateTimeFormat", lang, {
2118
+ month: format,
2119
+ year: "numeric",
2120
+ }).formatToParts(SENTINEL_DATE);
2121
+ return parts
2122
+ .map((p) => {
2123
+ if (p.type === "month") {
2124
+ // Text month formats (long/short/narrow) → dash; numeric → token
2125
+ return format === "numeric" ? "–" : getToken("month", lang);
2126
+ }
2127
+ if (p.type === "year") {
2128
+ return getToken("year", lang);
2129
+ }
2130
+ return p.value;
2131
+ })
2132
+ .join("");
2133
+ };
2134
+
2135
+ const formatWeekPlaceholder = ({ lang = getRuntimeLang() } = {}) => {
2136
+ return `${getToken("week", lang)} xx / ${getToken(lang)}`;
2137
+ };
2138
+
2139
+ const formatDatetimePlaceholder = ({
2140
+ lang = getRuntimeLang(),
2141
+ format = "long",
2142
+ } = {}) => {
2143
+ const intlOptions =
2144
+ format === "long"
2145
+ ? {
2146
+ weekday: "short",
2147
+ day: "numeric",
2148
+ month: "long",
2149
+ hour: "2-digit",
2150
+ minute: "2-digit",
2151
+ }
2152
+ : format === "narrow"
2153
+ ? {
2154
+ day: "2-digit",
2155
+ month: "2-digit",
2156
+ hour: "2-digit",
2157
+ minute: "2-digit",
2158
+ }
2159
+ : {
2160
+ day: "numeric",
2161
+ month: "short",
2162
+ hour: "2-digit",
2163
+ minute: "2-digit",
2164
+ };
2165
+ const parts = memoIntl("DateTimeFormat", lang, intlOptions).formatToParts(
2166
+ SENTINEL_DATE,
2167
+ );
2168
+ let skipNext = false;
2169
+ return parts
2170
+ .map((p) => {
2171
+ if (p.type === "weekday") {
2172
+ skipNext = true;
2173
+ return "";
2174
+ }
2175
+ if (p.type === "literal" && skipNext) {
2176
+ skipNext = false;
2177
+ return "";
2178
+ }
2179
+ skipNext = false;
2180
+ if (p.type === "day") {
2181
+ return getToken("day", lang);
2182
+ }
2183
+ if (p.type === "month") {
2184
+ return getToken("month", lang);
2185
+ }
2186
+ if (p.type === "hour") {
2187
+ return getToken("hour", lang);
2188
+ }
2189
+ if (p.type === "minute") {
2190
+ return getToken("minute", lang);
2191
+ }
2192
+ return p.value;
2193
+ })
2194
+ .join("")
2195
+ .trim();
2196
+ };
2197
+
2198
+ // ── End placeholder helpers ────────────────────────────────────────────────
2199
+
2200
+ const formatDayRelative = (offset, { lang = getRuntimeLang() } = {}) => {
2201
+ return memoIntl("RelativeTimeFormat", lang, {
2202
+ numeric: "auto",
2203
+ }).format(offset, "day");
2204
+ };
2205
+
2206
+ const formatMonth = (
2207
+ date,
2208
+ { lang = getRuntimeLang(), format = "long", timeZone } = {},
2209
+ ) => {
2210
+ return memoIntl("DateTimeFormat", lang, {
2211
+ month: format, // "long", "short", or "narrow"
2212
+ year: "numeric",
2213
+ timeZone,
2214
+ }).format(date);
2215
+ };
2216
+
2217
+ /**
2218
+ * Formats a date as "lun. 11 mai, 14:30" (long), "11 mai, 14:30" (short), "11/05, 14:30" (narrow).
2219
+ * `timeZone` words the instant in that IANA zone instead of the runtime's own.
2220
+ */
2221
+ const formatDatetime = (
2222
+ date,
2223
+ { lang = getRuntimeLang(), format = "long", timeZone } = {},
2224
+ ) => {
2225
+ if (format === "long") {
2226
+ return memoIntl("DateTimeFormat", lang, {
2227
+ weekday: "short",
2228
+ day: "numeric",
2229
+ month: "long",
2230
+ hour: "2-digit",
2231
+ minute: "2-digit",
2232
+ timeZone,
2233
+ }).format(date);
2234
+ }
2235
+ if (format === "narrow") {
2236
+ return memoIntl("DateTimeFormat", lang, {
2237
+ day: "2-digit",
2238
+ month: "2-digit",
2239
+ hour: "2-digit",
2240
+ minute: "2-digit",
2241
+ timeZone,
2242
+ }).format(date);
2243
+ }
2244
+ // "short": no weekday
2245
+ return memoIntl("DateTimeFormat", lang, {
2246
+ day: "numeric",
2247
+ month: "short",
2248
+ hour: "2-digit",
2249
+ minute: "2-digit",
2250
+ timeZone,
2251
+ }).format(date);
2252
+ };
2253
+
2254
+ /**
2255
+ * Formats a date as "14:30".
2256
+ * `timeZone` words the instant in that IANA zone instead of the runtime's own.
2257
+ */
2258
+ const formatTime = (
2259
+ date,
2260
+ { lang = getRuntimeLang(), timeZone } = {},
2261
+ ) => {
2262
+ return memoIntl("DateTimeFormat", lang, {
2263
+ hour: "2-digit",
2264
+ minute: "2-digit",
2265
+ timeZone,
2266
+ }).format(date);
2267
+ };
2268
+
2269
+ /**
2270
+ * Formats a time-of-day the way `<Time type="time">` writes it, as a plain
2271
+ * string — for the places a component cannot go (a `title` attribute, a push
2272
+ * notification).
2273
+ *
2274
+ * @param {Date|number|string} value
2275
+ * A Date, a ms timestamp, or an "HH:MM"/"HH:MM:SS" string. Only the clock
2276
+ * time is read. A nullish value renders the "--:--" placeholder; an
2277
+ * unparseable one is returned as-is, stringified.
2278
+ * @param {{ lang?: string, format?: "long"|"short"|"narrow"|"compact"|"timestring", pad?: boolean, precision?: "hour"|"minute", timeZone?: string }} [options]
2279
+ * The options `<Time type="time">` takes: `"timestring"` is the clock
2280
+ * "14:30"; the other formats write the time as a duration-shaped phrase —
2281
+ * see {@link formatMinuteDuration}'s `clockStyle` for what `pad` and
2282
+ * `precision` shape in `format="compact"`.
2283
+ * @param {string} [options.timeZone]
2284
+ * IANA zone the instant's clock is read in ("Europe/Paris"); defaults to
2285
+ * the runtime's own zone. Only applies to a Date/timestamp — an
2286
+ * "HH:MM" string is already a wall-clock reading, there is nothing to
2287
+ * move to another zone, so it ignores this.
2288
+ *
2289
+ * @example
2290
+ * formatTimeOfDay(date, { lang: "fr" }) // "14 heures 30" (long, default)
2291
+ * formatTimeOfDay(date, { lang: "fr", format: "timestring" }) // "14:30"
2292
+ * formatTimeOfDay(date, { lang: "fr", format: "compact" }) // "14h30"
2293
+ * formatTimeOfDay(date, { lang: "fr", format: "compact", pad: false }) // "8h30", "8h"
2294
+ */
2295
+ const formatTimeOfDay = (
2296
+ value,
2297
+ {
2298
+ lang = getRuntimeLang(),
2299
+ format = "long",
2300
+ pad = true,
2301
+ precision = pad ? "minute" : "hour",
2302
+ timeZone,
2303
+ } = {},
2304
+ ) => {
2305
+ if (value === undefined || value === null) {
2306
+ return "--:--";
2307
+ }
2308
+ const date = toTimeOfDay(value);
2309
+ // toDate turns a non-finite number into an Invalid Date, which is an object
2310
+ if (!date || isNaN(date.getTime())) {
2311
+ return String(value);
2312
+ }
2313
+ // An "HH:MM" string is a wall-clock reading, not an instant — re-reading
2314
+ // it in another zone would shift what the caller already spelled out.
2315
+ const zone = typeof value === "string" ? undefined : timeZone;
2316
+ if (format === "timestring") {
2317
+ return formatTime(date, { lang, timeZone: zone });
2318
+ }
2319
+ const { hours, minutes } = readClock(date, zone);
2320
+ const totalMinutes = hours * 60 + minutes;
2321
+ // clockStyle: this is always a time-of-day here, never a duration — keeps
2322
+ // a zero hour instead of dropping it (midnight would otherwise be
2323
+ // indistinguishable from an actual 5-minute duration), and in
2324
+ // format="compact" also zero-pads a single-digit hour so "5h30"/"0h05"
2325
+ // read as "05h30"/"00h05", closer to a "HH:MM" clock.
2326
+ if (hours !== 0 || format !== "long") {
2327
+ // At midnight, short/narrow/compact keep the "0 h"/"0h" hour part —
2328
+ // "0 h et 5 min"/"0h 5min"/"00h05" — rather than substituting a
2329
+ // translated "midnight" word, which would look out of place squeezed
2330
+ // into these otherwise terse, symbol-based formats.
2331
+ return formatMinuteDuration(totalMinutes, {
2332
+ lang,
2333
+ format,
2334
+ clockStyle: true,
2335
+ pad,
2336
+ precision,
2337
+ });
2338
+ }
2339
+ // Midnight (hour 0) at format="long" can't go through
2340
+ // formatMinuteDuration's own default zero-hour handling: it drops a
2341
+ // zero-valued unit entirely (by design — a real 5-minute duration should
2342
+ // print as "5 minutes", not "0 hours 5 minutes"), so "00:05" would
2343
+ // otherwise render identically to an actual 5-minute duration, silently
2344
+ // losing the fact that it's midnight. Every other hour keeps at least its
2345
+ // own "N hour(s)" wording as a hint that this is a time-of-day — only
2346
+ // hour 0 loses that hint entirely.
2347
+ const midnightWord = humanizeI18n("time.midnight", undefined, { lang });
2348
+ if (midnightWord === "time.midnight") {
2349
+ // No "midnight" translation registered for this language — fall back
2350
+ // to this language's own literal "0 heure(s)" wording instead (still
2351
+ // better than leaking the untranslated key, or substituting an
2352
+ // English word that wouldn't grammatically match the rest of the
2353
+ // sentence in whatever language this actually is).
2354
+ return formatMinuteDuration(totalMinutes, {
2355
+ lang,
2356
+ format,
2357
+ clockStyle: true,
2358
+ });
2359
+ }
2360
+ // Swap just the "0 heure(s)" part of the Intl-generated duration
2361
+ // string for the translated "midnight" word, keeping everything else
2362
+ // (the conjunction, the minutes part) exactly as Intl would produce
2363
+ // for this locale — formatToParts tags each token with the unit it
2364
+ // belongs to, so the swap doesn't need to know the locale's own
2365
+ // grammar/word order. Only ever one hour-tagged group per call
2366
+ // (hours is always 0 or absent here), but guarded anyway in case a
2367
+ // future Intl implementation ever splits it into more parts.
2368
+ const parts = memoIntl("DurationFormat", lang, {
2369
+ style: "long",
2370
+ hoursDisplay: "always",
2371
+ }).formatToParts({ hours: 0, minutes });
2372
+ let hourGroupReplaced = false;
2373
+ return parts
2374
+ .map((part) => {
2375
+ if (part.unit !== "hour") {
2376
+ return part.value;
2377
+ }
2378
+ if (hourGroupReplaced) {
2379
+ return "";
2380
+ }
2381
+ hourGroupReplaced = true;
2382
+ return midnightWord;
2383
+ })
2384
+ .join("");
2385
+ };
2386
+
2387
+ /**
2388
+ * Formats a span between two times-of-day the way `<TimeRange>` writes it, as
2389
+ * a plain string — "8h–10h", "11h30–14h00", "14 heures 30 – 16 heures".
2390
+ *
2391
+ * Applies `<TimeRange>`'s shared-precision rule: the two bounds are written
2392
+ * to the same precision, decided by the pair — any bound with minutes gives
2393
+ * minutes to both, zero included ("11h30–14h00", never "11h30–14h").
2394
+ *
2395
+ * @param {Date|number|string} from
2396
+ * @param {Date|number|string} to
2397
+ * Each bound accepts what {@link formatTimeOfDay} accepts; a nullish bound
2398
+ * renders its "--:--" placeholder.
2399
+ * @param {{ lang?: string, format?: "long"|"short"|"narrow"|"compact"|"timestring", pad?: boolean, precision?: "hour"|"minute", separator?: string, timeZone?: string }} [options]
2400
+ * `precision` writes both bounds at this precision instead of the one the
2401
+ * pair calls for. `separator` defaults to the `"time.range_separator"`
2402
+ * registered text (an en dash), tightened against both bounds in
2403
+ * `format="compact"` —
2404
+ * where the span is one short token — and spaced out otherwise. `timeZone`
2405
+ * reads both bounds' clocks in that IANA zone — see {@link formatTimeOfDay}.
2406
+ *
2407
+ * @example
2408
+ * formatTimeRange("08:00", "10:00", { lang: "fr", format: "compact", pad: false }) // "8h–10h"
2409
+ * formatTimeRange("11:30", "14:00", { lang: "fr", format: "compact", pad: false }) // "11h30–14h00"
2410
+ */
2411
+ const formatTimeRange = (
2412
+ from,
2413
+ to,
2414
+ {
2415
+ lang = getRuntimeLang(),
2416
+ format = "long",
2417
+ pad = true,
2418
+ timeZone,
2419
+ precision = resolveTimeRangePrecision(from, to, { format, pad, timeZone }),
2420
+ separator = humanizeI18n("time.range_separator", undefined, { lang }),
2421
+ } = {},
2422
+ ) => {
2423
+ const boundOptions = { lang, format, pad, precision, timeZone };
2424
+ const fromText = formatTimeOfDay(from, boundOptions);
2425
+ const toText = formatTimeOfDay(to, boundOptions);
2426
+ if (format === "compact") {
2427
+ return `${fromText}${separator}${toText}`;
2428
+ }
2429
+ return `${fromText} ${separator} ${toText}`;
2430
+ };
2431
+
2432
+ // The two bounds of a span are written to the same precision, decided by the
2433
+ // pair: "8h–10h" as long as neither has minutes, "11h30–14h00" as soon as one
2434
+ // of them does. Only ever a question for the unpadded compact clock — the
2435
+ // padded one always writes "08h00", and the spelled-out formats name their
2436
+ // units, leaving no shape for the eye to trip on.
2437
+ const resolveTimeRangePrecision = (
2438
+ from,
2439
+ to,
2440
+ { format, pad, timeZone },
2441
+ ) => {
2442
+ if (pad || format !== "compact") {
2443
+ return "minute";
2444
+ }
2445
+ const hasMinutes = (value) => {
2446
+ const date = toTimeOfDay(value);
2447
+ if (!date || isNaN(date.getTime())) {
2448
+ return false;
2449
+ }
2450
+ // Same rule as formatTimeOfDay: a string is a wall-clock reading, only
2451
+ // an instant is re-read in `timeZone`.
2452
+ const zone = typeof value === "string" ? undefined : timeZone;
2453
+ return readClock(date, zone).minutes !== 0;
2454
+ };
2455
+ return hasMinutes(from) || hasMinutes(to) ? "minute" : "hour";
2456
+ };
2457
+
2458
+ /**
2459
+ * Formats a duration expressed in minutes as a human-readable string.
2460
+ * "long", "short", "narrow" delegate to Intl.DurationFormat.
2461
+ * "compact" uses our own notation that omits the minute symbol when hours are present.
2462
+ *
2463
+ * @param {number} minutes
2464
+ * @param {{ lang?: string, format?: "long"|"short"|"narrow"|"compact", clockStyle?: boolean, pad?: boolean, precision?: "hour"|"minute", forceUnit?: boolean }} [options]
2465
+ * @param {boolean} [options.forceUnit=false] - Keep the value in minutes
2466
+ * however big it gets ("2160 minutes" instead of "1 jour et 12 heures").
2467
+ * Past 24 hours the default promotes to days, which reads better but hides
2468
+ * the unit the caller works in.
2469
+ * @param {boolean} [options.clockStyle=false] - Set this when `minutes`
2470
+ * represents a time-of-day rather than a real duration (used by
2471
+ * `<Time type="time">`, see time.jsx's own TimeTime). A clock's "0" is a
2472
+ * meaningful hour rather than "no hours": a zero-hours component is
2473
+ * normally dropped entirely (a real 5-minute duration should print as
2474
+ * "5 minutes", not "0 hours 5 minutes"); this keeps it instead (e.g.
2475
+ * "0 h et 5 min"/"0h 5min"/"00h05") so midnight doesn't collapse to
2476
+ * something indistinguishable from an actual 5-minute duration.
2477
+ * Must not be set for plain duration formatting.
2478
+ * @param {boolean} [options.pad=true] - Zero-pad the hour to 2 digits
2479
+ * ("08h30" rather than "8h30"). `clockStyle` + `format: "compact"` only.
2480
+ * @param {"hour"|"minute"} [options.precision="minute"] - Whether a zero
2481
+ * minute is written: `"minute"` keeps it ("10h00"), `"hour"` drops it
2482
+ * ("10h"). `clockStyle` + `format: "compact"` only.
2483
+ *
2484
+ * These last two are the clock's two independent shape choices, and only
2485
+ * `format: "compact"` has to make them — the spelled-out formats put their
2486
+ * units in words, so "10 heures"/"10 h"/"10h" already reads as a time of
2487
+ * day whatever the padding, and they always write the hour bare and drop a
2488
+ * zero minute. Padded + minute ("08h00") is the column shape, where every
2489
+ * row occupies the same width; bare + hour ("8h", "8h30") is the shape a
2490
+ * person speaks. Bare + minute ("8h00") only ever makes sense next to a
2491
+ * partner that has minutes of its own — see `<TimeRange>`, which is the
2492
+ * only thing that asks for it.
2493
+ *
2494
+ * @example
2495
+ * formatMinuteDuration(90, { lang: "fr" }) // "1 heure 30 minutes" (long, default)
2496
+ * formatMinuteDuration(90, { lang: "fr", format: "short" }) // "1 h et 30 min" (Intl short)
2497
+ * formatMinuteDuration(90, { lang: "fr", format: "narrow" }) // "1h 30min" (Intl narrow)
2498
+ * formatMinuteDuration(90, { lang: "fr", format: "compact" }) // "1h30" (custom, no minute symbol)
2499
+ * formatMinuteDuration(45, { lang: "en", format: "compact" }) // "45min"
2500
+ * formatMinuteDuration(5, { lang: "fr", format: "narrow", clockStyle: true }) // "0h 5min"
2501
+ * formatMinuteDuration(330, { lang: "fr", format: "compact", clockStyle: true }) // "05h30"
2502
+ * formatMinuteDuration(600, { lang: "fr", format: "compact", clockStyle: true }) // "10h00"
2503
+ * formatMinuteDuration(600, { lang: "fr", format: "compact", clockStyle: true, pad: false }) // "10h00"
2504
+ * formatMinuteDuration(600, { lang: "fr", format: "compact", clockStyle: true, pad: false, precision: "hour" }) // "10h"
2505
+ * formatMinuteDuration(510, { lang: "fr", format: "compact", clockStyle: true, pad: false, precision: "hour" }) // "8h30"
2506
+ * formatMinuteDuration(2160, { lang: "fr" }) // "1 jour et 12 heures"
2507
+ * formatMinuteDuration(2160, { lang: "fr", forceUnit: true }) // "2 160 minutes"
2508
+ */
2509
+ const formatMinuteDuration = (
2510
+ minutes,
2511
+ {
2512
+ lang = getRuntimeLang(),
2513
+ format = "long",
2514
+ clockStyle = false,
2515
+ pad = true,
2516
+ precision = "minute",
2517
+ forceUnit = false,
2518
+ } = {},
2519
+ ) => {
2520
+ if (minutes < 0) {
2521
+ // the d/h/m split below only holds for a positive value; formatting the
2522
+ // magnitude and putting the sign back is the only reading that works
2523
+ return `-${formatMinuteDuration(-minutes, { lang, format, clockStyle, pad, precision, forceUnit })}`;
2524
+ }
2525
+ if (forceUnit || (minutes === 0 && !clockStyle)) {
2526
+ // a zero has nothing to promote to, and rendering it as an empty string
2527
+ // would be indistinguishable from a missing value
2528
+ return formatSingleUnit(minutes, "minute", { lang, format });
2529
+ }
2530
+ const totalHours = Math.floor(minutes / 60);
2531
+ const m = minutes % 60;
2532
+ // a time of day never goes past 24h, and its hour part is the clock hour
2533
+ const d = clockStyle ? 0 : Math.floor(totalHours / 24);
2534
+ const h = clockStyle ? totalHours : totalHours % 24;
2535
+ if (format !== "compact" && typeof Intl.DurationFormat !== "undefined") {
2536
+ const fmt = memoIntl("DurationFormat", lang, {
2537
+ style: format, // "long", "short", or "narrow"
2538
+ ...(clockStyle ? { hoursDisplay: "always" } : {}),
2539
+ });
2540
+ const duration = {};
2541
+ if (d > 0) {
2542
+ duration.days = d;
2543
+ }
2544
+ if (h > 0 || clockStyle || d > 0) {
2545
+ duration.hours = h;
2546
+ }
2547
+ if (m > 0 || (d === 0 && h === 0)) {
2548
+ duration.minutes = m;
2549
+ }
2550
+ return fmt.format(duration);
2551
+ }
2552
+ // format="compact": "1j12h", "1h30", "45min", "2h" — no minute symbol when hours are present
2553
+ const dSym = humanizeI18n("time.duration.day_symbol", undefined, { lang });
2554
+ const hSym = humanizeI18n("time.duration.hour_symbol", undefined, { lang });
2555
+ const mSym = humanizeI18n("time.duration.minute_symbol", undefined, { lang });
2556
+ const dStr = d > 0 ? `${formatCompactNumber(d, lang)}${dSym}` : "";
2557
+ const hStr =
2558
+ clockStyle && pad
2559
+ ? String(h).padStart(2, "0")
2560
+ : formatCompactNumber(h, lang);
2561
+ if (d === 0 && h === 0 && !clockStyle) {
2562
+ return `${m}${mSym}`;
2563
+ }
2564
+ if (m === 0) {
2565
+ if (clockStyle) {
2566
+ // "10h00" on a clock, "2h" for a real 2 hours duration — except at
2567
+ // precision "hour", where a clock drops the zero minute too ("10h"),
2568
+ // the way one says it out loud
2569
+ return precision === "minute" ? `${hStr}${hSym}00` : `${hStr}${hSym}`;
2570
+ }
2571
+ return h === 0 ? dStr : `${dStr}${hStr}${hSym}`;
2572
+ }
2573
+ return `${dStr}${hStr}${hSym}${String(m).padStart(2, "0")}`;
2574
+ };
2575
+
2576
+ // "forceUnit": stay in the unit the value is expressed in, however big it gets
2577
+ const formatSingleUnit = (value, unit, { lang, format }) => {
2578
+ if (format !== "compact" && typeof Intl.DurationFormat !== "undefined") {
2579
+ return memoIntl("DurationFormat", lang, {
2580
+ style: format,
2581
+ // Intl drops a zero-valued unit, and "0 minute" is the whole point here
2582
+ [`${unit}sDisplay`]: "always",
2583
+ }).format({
2584
+ [`${unit}s`]: value,
2585
+ });
2586
+ }
2587
+ const symbol = humanizeI18n(`time.duration.${unit}_symbol`, undefined, {
2588
+ lang,
2589
+ });
2590
+ return `${formatCompactNumber(value, lang)}${symbol}`;
2591
+ };
2592
+
2593
+ /**
2594
+ * Formats a duration expressed in hours (possibly fractional) as a human-readable string.
2595
+ * Delegates to {@link formatMinuteDuration} after converting hours to minutes.
2596
+ *
2597
+ * @param {number} hours
2598
+ * @param {{ lang?: string, format?: "long"|"short"|"narrow"|"compact", forceUnit?: boolean }} [options]
2599
+ * @param {boolean} [options.forceUnit=false] - Keep the value in hours however
2600
+ * big it gets ("36 heures" instead of "1 jour et 12 heures"). Ignored for a
2601
+ * fractional value, which has no single-unit spelling.
2602
+ *
2603
+ * @example
2604
+ * formatHourDuration(1.5, { lang: "fr" }) // "1 heure 30 minutes" (long, default)
2605
+ * formatHourDuration(1.5, { lang: "fr", format: "compact" }) // "1h30"
2606
+ * formatHourDuration(2, { lang: "en", format: "compact" }) // "2h"
2607
+ * formatHourDuration(36, { lang: "fr" }) // "1 jour et 12 heures"
2608
+ * formatHourDuration(36, { lang: "fr", forceUnit: true }) // "36 heures"
2609
+ */
2610
+ const formatHourDuration = (hours, options = {}) => {
2611
+ const { lang = getRuntimeLang(), format = "long", forceUnit } = options;
2612
+ if (hours === 0 || (forceUnit && Number.isInteger(hours))) {
2613
+ return formatSingleUnit(hours, "hour", { lang, format });
2614
+ }
2615
+ // a fractional value has no single-unit spelling, it needs its minutes
2616
+ const totalMinutes = Math.round(hours * 60);
2617
+ return formatMinuteDuration(totalMinutes, { ...options, forceUnit: false });
2618
+ };
2619
+
2620
+ /**
2621
+ * Formats a duration expressed in seconds as a human-readable string.
2622
+ * "long", "short", "narrow" delegate to Intl.DurationFormat.
2623
+ * "compact" uses our own symbol-based notation.
2624
+ *
2625
+ * @param {number} seconds
2626
+ * @param {{ lang?: string, format?: "long"|"short"|"narrow"|"compact", forceUnit?: boolean }} [options]
2627
+ * @param {boolean} [options.forceUnit=false] - Keep the value in seconds
2628
+ * however big it gets ("90 000 secondes" instead of "1 jour et 1 heure").
2629
+ *
2630
+ * @example
2631
+ * formatSecondDuration(90, { lang: "fr" }) // "1 minute 30 secondes" (long, default)
2632
+ * formatSecondDuration(90, { lang: "fr", format: "short" }) // "1 min. et 30 s." (Intl short)
2633
+ * formatSecondDuration(90, { lang: "fr", format: "narrow" }) // "1min 30s" (Intl narrow)
2634
+ * formatSecondDuration(90, { lang: "fr", format: "compact" }) // "1m30s" (custom)
2635
+ * formatSecondDuration(45, { lang: "en", format: "compact" }) // "45s"
2636
+ */
2637
+ const formatSecondDuration = (
2638
+ seconds,
2639
+ { lang = getRuntimeLang(), format = "long", forceUnit = false } = {},
2640
+ ) => {
2641
+ if (seconds < 0) {
2642
+ // the d/h/m/s split below only holds for a positive value; formatting the
2643
+ // magnitude and putting the sign back is the only reading that works
2644
+ return `-${formatSecondDuration(-seconds, { lang, format, forceUnit })}`;
2645
+ }
2646
+ if (forceUnit || seconds === 0) {
2647
+ return formatSingleUnit(seconds, "second", { lang, format });
2648
+ }
2649
+ const totalHours = Math.floor(seconds / 3600);
2650
+ const d = Math.floor(totalHours / 24);
2651
+ const h = totalHours % 24;
2652
+ const m = Math.floor((seconds % 3600) / 60);
2653
+ const s = seconds % 60;
2654
+ if (format !== "compact" && typeof Intl.DurationFormat !== "undefined") {
2655
+ const fmt = memoIntl("DurationFormat", lang, { style: format });
2656
+ const duration = {};
2657
+ if (d > 0) duration.days = d;
2658
+ if (h > 0) duration.hours = h;
2659
+ if (m > 0) duration.minutes = m;
2660
+ if (s > 0 || (d === 0 && h === 0 && m === 0)) duration.seconds = s;
2661
+ return fmt.format(duration);
2662
+ }
2663
+ // compact: "1d1h30m45s", "1h30m45s", "1m30s", "45s"
2664
+ const dSym = humanizeI18n("time.duration.day_symbol", undefined, { lang });
2665
+ const hSym = humanizeI18n("time.duration.hour_symbol", undefined, { lang });
2666
+ const mSym = humanizeI18n("time.duration.minute_symbol", undefined, { lang });
2667
+ const sSym = humanizeI18n("time.duration.second_symbol", undefined, { lang });
2668
+ const parts = [];
2669
+ // h/m/s are bounded by construction (never need grouping); d can be
2670
+ // arbitrarily large for a long duration.
2671
+ if (d > 0) parts.push(`${formatCompactNumber(d, lang)}${dSym}`);
2672
+ if (h > 0) parts.push(`${h}${hSym}`);
2673
+ if (m > 0) parts.push(`${m}${mSym}`);
2674
+ if (s > 0 || parts.length === 0) parts.push(`${s}${sSym}`);
2675
+ return parts.join("");
2676
+ };
2677
+
2678
+ /**
2679
+ * Formats a duration object as a human-readable string.
2680
+ * Reads the parts directly — no conversion to seconds — so years/months/days
2681
+ * are preserved as-is and non-numeric mid-edit values (e.g. "2a") are rendered
2682
+ * with their unit symbol rather than being stringified.
2683
+ *
2684
+ * @param {string|number|{ years?: any, months?: any, weeks?: any, days?: any,
2685
+ * hours?: any, minutes?: any, seconds?: any, milliseconds?: any }} duration -
2686
+ * A string goes through {@link parseDuration} ("PT1H30M", "1h30"), a number
2687
+ * is read as seconds. Each unit is written with the value it carries: 90
2688
+ * minutes reads "90 minutes", never "1 heure 30" — the variants that
2689
+ * promote a count into bigger units are {@link formatMinuteDuration},
2690
+ * {@link formatHourDuration} and {@link formatSecondDuration}.
2691
+ * @param {{ lang?: string, format?: "long"|"short"|"narrow"|"compact" }} [options]
2692
+ *
2693
+ * @example
2694
+ * formatDuration({ hours: 2, minutes: 15 }, { lang: "fr" }) // "2 heures 15 minutes" (long, default)
2695
+ * formatDuration({ hours: 2, minutes: 15 }, { lang: "fr", format: "short" }) // "2 h et 15 min" (Intl short)
2696
+ * formatDuration({ hours: 2, minutes: 15 }, { lang: "fr", format: "narrow" }) // "2h 15min" (Intl narrow)
2697
+ * formatDuration({ hours: 2, minutes: 15 }, { lang: "fr", format: "compact" }) // "2h15" (custom, no minute symbol)
2698
+ * formatDuration({ minutes: 45 }, { lang: "fr", format: "compact" }) // "45min"
2699
+ * formatDuration({ hours: 0, minutes: 0 }, { lang: "fr" }) // "0 minute"
2700
+ * formatDuration({ hours: "2a", minutes: "15" }, { lang: "fr", format: "compact" }) // "2ah15"
2701
+ */
2702
+ const formatDuration = (
2703
+ duration,
2704
+ { lang = getRuntimeLang(), format = "long" } = {},
2705
+ ) => {
2706
+ if (typeof duration === "string") {
2707
+ duration = parseDuration(duration) ?? {};
2708
+ } else if (typeof duration === "number") {
2709
+ duration = { seconds: duration };
2710
+ }
2711
+ const has = (key) => duration[key] !== undefined && duration[key] !== null;
2712
+
2713
+ // "long" and "narrow" delegate to Intl.DurationFormat when available and all values are numeric.
2714
+ //
2715
+ // "short" always uses our own compact symbols ("2h15", "45min") because:
2716
+ // 1. We omit the minute symbol when hours are also present ("2h15" not "2h 15 min"),
2717
+ // which Intl.DurationFormat style:"narrow" does not do.
2718
+ // 2. Non-numeric mid-edit values (e.g. { hours: "2a" }) must render as-is with their
2719
+ // unit symbol — Intl.DurationFormat only accepts integers.
2720
+ if (format !== "compact" && typeof Intl.DurationFormat !== "undefined") {
2721
+ const intlDuration = {};
2722
+ let allNumeric = true;
2723
+ let hasNegative = false;
2724
+ let hasPositive = false;
2725
+ for (const key of [
2726
+ "years",
2727
+ "months",
2728
+ "weeks",
2729
+ "days",
2730
+ "hours",
2731
+ "minutes",
2732
+ "seconds",
2733
+ "milliseconds",
2734
+ ]) {
2735
+ if (!has(key)) {
2736
+ continue;
2737
+ }
2738
+ const n = Number(duration[key]);
2739
+ if (!isFinite(n)) {
2740
+ allNumeric = false;
2741
+ break;
2742
+ }
2743
+ if (n < 0) {
2744
+ hasNegative = true;
2745
+ } else if (n > 0) {
2746
+ hasPositive = true;
2747
+ }
2748
+ intlDuration[key] = n;
2749
+ }
2750
+ // Temporal requires all components to share the same sign.
2751
+ // Mixed-sign values (e.g. { hours: -1, minutes: 15 }) throw a RangeError.
2752
+ if (
2753
+ allNumeric &&
2754
+ Object.keys(intlDuration).length > 0 &&
2755
+ !(hasNegative && hasPositive)
2756
+ ) {
2757
+ if (!hasNegative && !hasPositive) {
2758
+ return formatSingleUnit(0, smallestUnitOf(intlDuration), {
2759
+ lang,
2760
+ format,
2761
+ });
2762
+ }
2763
+ return memoIntl("DurationFormat", lang, { style: format }).format(
2764
+ intlDuration,
2765
+ );
2766
+ }
2767
+ // Fall through to compact notation when values are non-numeric or mixed-sign
2768
+ }
2769
+
2770
+ // A component explicitly present but numerically zero (e.g. the demo's own
2771
+ // { hours: 0, minutes: 5 }) conveys no information for a genuine duration
2772
+ // — same convention formatMinuteDuration/formatSecondDuration already
2773
+ // follow (checking h > 0/m > 0, not merely "was a value passed") — so
2774
+ // it's dropped here too, regardless of whether the caller included the
2775
+ // key at all. Non-numeric mid-edit values (e.g. "2a") still count as
2776
+ // present — Number("2a") is NaN, never === 0 — so those keep rendering
2777
+ // as-is with their own unit symbol. When every component is zero there is
2778
+ // nothing left to drop, so the zero itself is rendered — see below.
2779
+ const hasNonZero = (key) => has(key) && Number(duration[key]) !== 0;
2780
+
2781
+ const sym = (key) =>
2782
+ humanizeI18n(`time.duration.${key}_symbol`, undefined, { lang });
2783
+ const parts = [];
2784
+
2785
+ if (hasNonZero("years")) {
2786
+ parts.push(`${formatCompactNumber(duration.years, lang)}${sym("year")}`);
2787
+ }
2788
+ if (hasNonZero("months")) {
2789
+ parts.push(`${formatCompactNumber(duration.months, lang)}${sym("month")}`);
2790
+ }
2791
+ if (hasNonZero("weeks")) {
2792
+ parts.push(`${formatCompactNumber(duration.weeks, lang)}${sym("week")}`);
2793
+ }
2794
+ if (hasNonZero("days")) {
2795
+ parts.push(`${formatCompactNumber(duration.days, lang)}${sym("day")}`);
2796
+ }
2797
+
2798
+ // Hours + minutes: when both present, pad minutes to 2 digits after the h
2799
+ // symbol — minutes stays a plain 2-digit pad (it's always 0-59 by
2800
+ // convention), only hours goes through grouping.
2801
+ const hSym = sym("hour");
2802
+ const mSym = sym("minute");
2803
+ if (hasNonZero("hours") && hasNonZero("minutes")) {
2804
+ parts.push(
2805
+ `${formatCompactNumber(duration.hours, lang)}${hSym}${String(duration.minutes).padStart(2, "0")}`,
2806
+ );
2807
+ } else if (hasNonZero("hours")) {
2808
+ parts.push(`${formatCompactNumber(duration.hours, lang)}${hSym}`);
2809
+ } else if (hasNonZero("minutes")) {
2810
+ parts.push(`${formatCompactNumber(duration.minutes, lang)}${mSym}`);
2811
+ }
2812
+
2813
+ if (hasNonZero("seconds")) {
2814
+ parts.push(
2815
+ `${formatCompactNumber(duration.seconds, lang)}${sym("second")}`,
2816
+ );
2817
+ }
2818
+ if (hasNonZero("milliseconds")) {
2819
+ parts.push(
2820
+ `${formatCompactNumber(duration.milliseconds, lang)}${sym("millisecond")}`,
2821
+ );
2822
+ }
2823
+ if (parts.length > 0) {
2824
+ return parts.join("");
2825
+ }
2826
+ // everything was zero: say so in the smallest unit the caller mentioned,
2827
+ // rather than a bare "0" whose unit the reader has to guess
2828
+ const smallestUnit = smallestUnitOf(duration);
2829
+ return smallestUnit ? `0${sym(smallestUnit)}` : "0";
2830
+ };
2831
+
2832
+ const UNIT_KEYS = [
2833
+ "years",
2834
+ "months",
2835
+ "weeks",
2836
+ "days",
2837
+ "hours",
2838
+ "minutes",
2839
+ "seconds",
2840
+ "milliseconds",
2841
+ ];
2842
+ const smallestUnitOf = (duration) => {
2843
+ for (const key of [...UNIT_KEYS].reverse()) {
2844
+ if (duration[key] !== undefined && duration[key] !== null) {
2845
+ return key.slice(0, -1); // "seconds" -> "second"
2846
+ }
2847
+ }
2848
+ return null;
2849
+ };
2850
+
2851
+ /**
2852
+ * Formats a date relative to now: "il y a 3 jours", "dans 2 heures", etc.
2853
+ */
2854
+ const formatTimeAgo = (
2855
+ date,
2856
+ { lang = getRuntimeLang(), now = new Date(), bare, format = "long" } = {},
2857
+ ) => {
2858
+ const rtf = memoIntl("RelativeTimeFormat", lang, {
2859
+ numeric: "auto",
2860
+ style: format,
2861
+ });
2862
+ const nowMs = now instanceof Date ? now.getTime() : now;
2863
+ const diff = date.getTime() - nowMs;
2864
+ const absDiff = Math.abs(diff);
2865
+
2866
+ let value;
2867
+ let unit;
2868
+ if (absDiff < MINUTE) {
2869
+ value = Math.round(diff / 1000);
2870
+ unit = "second";
2871
+ } else if (absDiff < HOUR) {
2872
+ value = Math.round(diff / MINUTE);
2873
+ unit = "minute";
2874
+ } else if (absDiff < DAY) {
2875
+ value = Math.round(diff / HOUR);
2876
+ unit = "hour";
2877
+ } else if (absDiff < 7 * DAY) {
2878
+ value = Math.round(diff / DAY);
2879
+ unit = "day";
2880
+ } else if (absDiff < 30 * DAY) {
2881
+ value = Math.round(diff / (7 * DAY));
2882
+ unit = "week";
2883
+ } else if (absDiff < YEAR) {
2884
+ value = Math.round(diff / (30 * DAY));
2885
+ unit = "month";
2886
+ } else {
2887
+ value = Math.round(diff / YEAR);
2888
+ unit = "year";
2889
+ }
2890
+
2891
+ if (!bare || value >= 0) {
2892
+ return rtf.format(value, unit);
2893
+ }
2894
+ // Drop the leading past-tense literal ("il y a ", "ago ") — keep only integer + unit.
2895
+ const parts = rtf.formatToParts(value, unit);
2896
+ const integerIndex = parts.findIndex((p) => p.type === "integer");
2897
+ return parts
2898
+ .slice(integerIndex)
2899
+ .map((p) => p.value)
2900
+ .join("")
2901
+ .trim();
2902
+ };
2903
+
2904
+ /**
2905
+ * Formats a timed event with an optional duration window.
2906
+ *
2907
+ * States:
2908
+ * - Future (now < start) → "dans 1 heure et 30 minutes", "demain à 15 h", …
2909
+ * - Ongoing (start ≤ now < start+dur) → "En cours"
2910
+ * - Past (now ≥ start+dur) → relative ("il y a 2 heures", …)
2911
+ *
2912
+ * @param {Date|number} start Start of the event (Date or ms timestamp)
2913
+ * @param {number} durationMs Duration in milliseconds (0 = instant event)
2914
+ * @param {{ lang?: string, now?: Date|number, bare?: boolean, format?: "long"|"short"|"narrow" }} options
2915
+ *
2916
+ * @example
2917
+ * // 90 min from now
2918
+ * formatTimeRelative(Date.now() + 90 * 60_000, 0, { lang: "fr" }) // "dans 1 heure et 30 minutes"
2919
+ * // currently happening (30 min window)
2920
+ * formatTimeRelative(Date.now() - 5 * 60_000, 30 * 60_000, { lang: "fr" }) // "En cours"
2921
+ * // ended 2 hours ago
2922
+ * formatTimeRelative(Date.now() - 3 * 3_600_000, 3_600_000, { lang: "fr" }) // "il y a 2 heures"
2923
+ * // short format
2924
+ * formatTimeRelative(Date.now() - 3 * 3_600_000, 0, { lang: "fr", format: "short" }) // "il y a 3 h"
2925
+ */
2926
+ const formatTimeRelative = (
2927
+ start,
2928
+ durationMs = 0,
2929
+ { lang = getRuntimeLang(), now = new Date(), bare, format = "long" } = {},
2930
+ ) => {
2931
+ const startMs = start instanceof Date ? start.getTime() : Number(start);
2932
+ const endMs = startMs + durationMs;
2933
+ const nowMs = now instanceof Date ? now.getTime() : Number(now);
2934
+
2935
+ if (nowMs >= startMs && nowMs < endMs) {
2936
+ return getOngoingText(lang);
2937
+ }
2938
+ if (nowMs >= endMs) {
2939
+ const refDate = endMs > startMs ? new Date(endMs) : new Date(startMs);
2940
+ return formatTimeAgo(refDate, { lang, now, bare, format });
2941
+ }
2942
+
2943
+ const diff = startMs - nowMs;
2944
+ return formatFuture(new Date(startMs), diff, { lang, now, format });
2945
+ };
2946
+
2947
+ const formatFuture = (date, diff, { lang, now, format = "long" }) => {
2948
+ const rtf = memoIntl("RelativeTimeFormat", lang, {
2949
+ numeric: "auto",
2950
+ style: format,
2951
+ });
2952
+ const nowDate = now instanceof Date ? now : new Date(now);
2953
+
2954
+ // < 1 min
2955
+ if (diff < MINUTE) {
2956
+ return getLessThanMinuteText(lang);
2957
+ }
2958
+
2959
+ // < 1 hour → "dans X minutes"
2960
+ if (diff < HOUR) {
2961
+ return rtf.format(Math.ceil(diff / MINUTE), "minute");
2962
+ }
2963
+
2964
+ // 1h to 2h → "dans 1 heure 30"
2965
+ if (diff < 2 * HOUR) {
2966
+ const hours = Math.floor(diff / HOUR);
2967
+ const minutes = Math.round((diff % HOUR) / MINUTE);
2968
+ if (minutes === 0) {
2969
+ return rtf.format(hours, "hour");
2970
+ }
2971
+ const duration = formatMinuteDuration(hours * 60 + minutes, {
2972
+ lang,
2973
+ format,
2974
+ });
2975
+ const template = humanizeI18n("time.in_duration", undefined, { lang });
2976
+ if (template !== "time.in_duration") {
2977
+ return template.replace("[duration]", duration);
2978
+ }
2979
+ return `in ${duration}`;
2980
+ }
2981
+
2982
+ // < 6h → "dans X heures" (precise enough, skip tomorrow label)
2983
+ if (diff < 6 * HOUR) {
2984
+ return rtf.format(Math.round(diff / HOUR), "hour");
2985
+ }
2986
+
2987
+ // Tomorrow (calendar day) and within ~30h → "demain à 15h"
2988
+ const tomorrowDate = new Date(nowDate);
2989
+ tomorrowDate.setDate(tomorrowDate.getDate() + 1);
2990
+ if (diff < 30 * HOUR && toLocalDayKey(date) === toLocalDayKey(tomorrowDate)) {
2991
+ return formatTomorrowAt(date, lang);
2992
+ }
2993
+
2994
+ // < 24h → "dans X heures"
2995
+ if (diff < DAY) {
2996
+ return rtf.format(Math.round(diff / HOUR), "hour");
2997
+ }
2998
+
2999
+ // < 7 days → "dans X jours"
3000
+ if (diff < 7 * DAY) {
3001
+ return rtf.format(Math.round(diff / DAY), "day");
3002
+ }
3003
+
3004
+ // < 30 days → "dans X semaines"
3005
+ if (diff < 30 * DAY) {
3006
+ return rtf.format(Math.round(diff / (7 * DAY)), "week");
3007
+ }
3008
+
3009
+ // months (Intl handles "le mois prochain" when value = 1)
3010
+ if (diff < YEAR) {
3011
+ return rtf.format(Math.round(diff / (30 * DAY)), "month");
3012
+ }
3013
+
3014
+ return rtf.format(Math.round(diff / YEAR), "year");
3015
+ };
3016
+
3017
+ const formatTomorrowAt = (date, lang) => {
3018
+ const dayLabel = memoIntl("RelativeTimeFormat", lang, {
3019
+ numeric: "auto",
3020
+ }).format(1, "day");
3021
+ const hasMinutes = date.getMinutes() !== 0;
3022
+ const timeLabel = memoIntl("DateTimeFormat", lang, {
3023
+ hour: "numeric",
3024
+ ...(hasMinutes ? { minute: "2-digit" } : {}),
3025
+ }).format(date);
3026
+ const atTemplate = humanizeI18n("time.tomorrow_at", undefined, {
3027
+ lang,
3028
+ });
3029
+ // atTemplate is e.g. "[day] à [time]" — replace placeholders
3030
+ if (atTemplate !== "time.tomorrow_at") {
3031
+ return atTemplate.replace("[day]", dayLabel).replace("[time]", timeLabel);
3032
+ }
3033
+ // fallback: concatenate with a space
3034
+ return `${dayLabel} ${timeLabel}`;
3035
+ };
3036
+
3037
+ const getLessThanMinuteText = (lang) => {
3038
+ return humanizeI18n("time.less_than_minute", undefined, { lang });
3039
+ };
3040
+
3041
+ const getOngoingText = (lang) => {
3042
+ return humanizeI18n("time.ongoing", undefined, { lang });
3043
+ };
3044
+
3045
+ const MINUTE = 60_000;
3046
+ const HOUR = 60 * MINUTE;
3047
+ const DAY = 24 * HOUR;
3048
+ const YEAR = 365 * DAY;
3049
+
3050
+ // Compares calendar days in local time (ignores the clock time)
3051
+ const toLocalDayKey = (date) => {
3052
+ return `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}`;
3053
+ };
3054
+
3055
+ /**
3056
+ * Coerces what `<Time>` accepts as a value — a Date, a ms timestamp, a
3057
+ * parseable string — into a Date, or null when it cannot. `parseString`
3058
+ * lets a caller claim the string forms it recognizes ("HH:MM" for a
3059
+ * time-of-day, "YYYY-MM" for a month…) before the generic ones apply.
3060
+ */
3061
+ const toDate = (value, parseString) => {
3062
+ if (value instanceof Date) {
3063
+ return value;
3064
+ }
3065
+ if (typeof value === "number") {
3066
+ return new Date(value);
3067
+ }
3068
+ if (typeof value === "string") {
3069
+ if (parseString) {
3070
+ return parseString(value);
3071
+ }
3072
+ // "YYYY-MM-DD" — use local midnight to avoid UTC shift
3073
+ if (/^\d{4}-\d{2}-\d{2}$/.test(value)) {
3074
+ const d = new Date(`${value}T00:00:00`);
3075
+ return isNaN(d.getTime()) ? null : d;
3076
+ }
3077
+ // ISO / other parseable strings
3078
+ const d = new Date(value);
3079
+ return isNaN(d.getTime()) ? null : d;
3080
+ }
3081
+ return null;
3082
+ };
3083
+
3084
+ const toTimeOfDay = (value) => {
3085
+ return toDate(value, (string) => {
3086
+ if (/^\d{2}:\d{2}(?::\d{2})?$/.test(string)) {
3087
+ const d = new Date(`1970-01-01T${string}`);
3088
+ return isNaN(d.getTime()) ? null : d;
3089
+ }
3090
+ return null;
3091
+ });
3092
+ };
3093
+
3094
+ // Reads the wall-clock hour/minute of an instant — in the runtime's own zone
3095
+ // by default, in `timeZone` when given. A Date only carries local getters, so
3096
+ // another zone's clock has to come out of Intl parts (hourCycle h23 so
3097
+ // midnight reads hour 0, never 24).
3098
+ const readClock = (date, timeZone) => {
3099
+ if (!timeZone) {
3100
+ return { hours: date.getHours(), minutes: date.getMinutes() };
3101
+ }
3102
+ const parts = memoIntl("DateTimeFormat", "en", {
3103
+ timeZone,
3104
+ hour: "numeric",
3105
+ minute: "numeric",
3106
+ hourCycle: "h23",
3107
+ }).formatToParts(date);
3108
+ let hours = 0;
3109
+ let minutes = 0;
3110
+ for (const part of parts) {
3111
+ if (part.type === "hour") {
3112
+ hours = Number(part.value);
3113
+ } else if (part.type === "minute") {
3114
+ minutes = Number(part.value);
3115
+ }
3116
+ }
3117
+ return { hours, minutes };
3118
+ };
3119
+
3120
+ // Reads the calendar year of an instant in `timeZone` (the runtime's own zone
3121
+ // when not given) — what formatDay's `year: "auto"` compares.
3122
+ const readYear = (date, timeZone) => {
3123
+ if (!timeZone) {
3124
+ return date.getFullYear();
3125
+ }
3126
+ return Number(
3127
+ memoIntl("DateTimeFormat", "en", { timeZone, year: "numeric" }).format(
3128
+ date,
3129
+ ),
3130
+ );
3131
+ };
3132
+
1243
3133
  const distributePercentages = (
1244
3134
  namedNumbers,
1245
3135
  { maxPrecisionHint = 2 } = {},
@@ -1528,5 +3418,5 @@ const escapeHtml = (string) => {
1528
3418
  .replace(/'/g, "&#039;");
1529
3419
  };
1530
3420
 
1531
- export { ANSI, UNICODE, createCallOrderer, createDetailedMessage, distributePercentages, errorToHTML, errorToMarkdown, generateContentFrame, humanize, humanizeDuration, humanizeEllapsedTime, humanizeFileSize, humanizeMemory, humanizeMethodSymbol, preNewLineAndIndentation, prefixFirstAndIndentRemainingLines, wrapNewLineAndIndentation };
3421
+ export { ANSI, UNICODE, createCallOrderer, createDetailedMessage, createI18n, distributePercentages, errorToHTML, errorToMarkdown, formatDatePlaceholder, formatDatetime, formatDatetimePlaceholder, formatDay, formatDayRelative, formatDuration, formatHourDuration, formatMinuteDuration, formatMonth, formatMonthPlaceholder, formatNumber, formatSecondDuration, formatTime, formatTimeOfDay, formatTimeRange, formatTimeRelative, formatWeekPlaceholder, generateContentFrame, getRelativeDay, getRuntimeLang, humanize, humanizeDuration, humanizeEllapsedTime, humanizeFileSize, humanizeI18n, humanizeMemory, humanizeMethodSymbol, installInterpolateJsx, interpolateText, preNewLineAndIndentation, prefixFirstAndIndentRemainingLines, resolveTimeRangePrecision, setRuntimeLangSource, toDate, toTimeOfDay, wrapNewLineAndIndentation };
1532
3422
  //# sourceMappingURL=jsenv_humanize_browser.js.map