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