@jsenv/humanize 1.7.7 → 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.
- package/README.md +113 -0
- package/dist/browser/jsenv_humanize_browser.js +1960 -42
- package/dist/browser/jsenv_humanize_browser.js.map +19 -4
- package/dist/node/jsenv_humanize_node.js +1959 -42
- package/dist/node/jsenv_humanize_node.js.map +21 -7
- package/dist/node/jsenv_humanize_node_modules.js.map +13 -6
- package/package.json +4 -2
|
@@ -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
|
|
|
@@ -660,10 +662,13 @@ const derivedErrorNameArray = [
|
|
|
660
662
|
];
|
|
661
663
|
|
|
662
664
|
const inspectDate = (value, { nestedHumanize, useNew, parenthesis }) => {
|
|
663
|
-
const dateSource = nestedHumanize(value.
|
|
665
|
+
const dateSource = nestedHumanize(value.toISOString(), {
|
|
664
666
|
numericSeparator: false,
|
|
665
667
|
});
|
|
666
|
-
return inspectConstructor(`Date(${dateSource})`, {
|
|
668
|
+
return inspectConstructor(`Date(${dateSource})`, {
|
|
669
|
+
useNew,
|
|
670
|
+
parenthesis,
|
|
671
|
+
});
|
|
667
672
|
};
|
|
668
673
|
|
|
669
674
|
const inspectFunction = (
|
|
@@ -985,8 +990,8 @@ const UNIT_MS = {
|
|
|
985
990
|
minute: 60_000,
|
|
986
991
|
second: 1000,
|
|
987
992
|
};
|
|
988
|
-
const UNIT_KEYS = Object.keys(UNIT_MS);
|
|
989
|
-
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];
|
|
990
995
|
const TIME_DICTIONARY_EN = {
|
|
991
996
|
year: { long: "year", plural: "years", short: "y" },
|
|
992
997
|
month: { long: "month", plural: "months", short: "m" },
|
|
@@ -997,29 +1002,16 @@ const TIME_DICTIONARY_EN = {
|
|
|
997
1002
|
second: { long: "second", plural: "seconds", short: "s" },
|
|
998
1003
|
joinDuration: (primary, remaining) => `${primary} and ${remaining}`,
|
|
999
1004
|
};
|
|
1000
|
-
const TIME_DICTIONARY_FR = {
|
|
1001
|
-
year: { long: "an", plural: "ans", short: "a" },
|
|
1002
|
-
month: { long: "mois", plural: "mois", short: "m" },
|
|
1003
|
-
week: { long: "semaine", plural: "semaines", short: "s" },
|
|
1004
|
-
day: { long: "jour", plural: "jours", short: "j" },
|
|
1005
|
-
hour: { long: "heure", plural: "heures", short: "h" },
|
|
1006
|
-
minute: { long: "minute", plural: "minutes", short: "m" },
|
|
1007
|
-
second: { long: "seconde", plural: "secondes", short: "s" },
|
|
1008
|
-
joinDuration: (primary, remaining) => `${primary} et ${remaining}`,
|
|
1009
|
-
};
|
|
1010
1005
|
|
|
1011
1006
|
const humanizeEllapsedTime = (
|
|
1012
1007
|
ms,
|
|
1013
|
-
{
|
|
1014
|
-
short,
|
|
1015
|
-
lang = "en",
|
|
1016
|
-
timeDictionnary = lang === "fr" ? TIME_DICTIONARY_FR : TIME_DICTIONARY_EN,
|
|
1017
|
-
} = {},
|
|
1008
|
+
{ short, timeDictionnary = TIME_DICTIONARY_EN } = {},
|
|
1018
1009
|
) => {
|
|
1019
1010
|
if (ms < 1000) {
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1011
|
+
if (short) {
|
|
1012
|
+
return `0${timeDictionnary.second.short}`;
|
|
1013
|
+
}
|
|
1014
|
+
return `0 ${timeDictionnary.second.long}`;
|
|
1023
1015
|
}
|
|
1024
1016
|
const { primary, remaining } = parseMs(ms);
|
|
1025
1017
|
if (!remaining) {
|
|
@@ -1051,33 +1043,52 @@ const inspectEllapsedUnit = (unit, { short, timeDictionnary }) => {
|
|
|
1051
1043
|
return `${count} ${unitText}`;
|
|
1052
1044
|
};
|
|
1053
1045
|
|
|
1046
|
+
/**
|
|
1047
|
+
* Converts a duration in milliseconds into a human-readable string intended for display in
|
|
1048
|
+
* CLI output — where readability matters more than precision.
|
|
1049
|
+
*
|
|
1050
|
+
* - Values below 1ms are displayed as "0 second". Sub-millisecond durations are not
|
|
1051
|
+
* meaningful at human scale, and showing "0.0001 second" (or switching to a "millisecond"
|
|
1052
|
+
* unit) would hurt readability. The chosen trade-off is to always use "second" as the
|
|
1053
|
+
* smallest unit and accept the loss of precision for very small values.
|
|
1054
|
+
* - Values below 1s are displayed in fractional seconds (e.g. "0.05 second").
|
|
1055
|
+
* - Values are expressed using the two most significant units (e.g. "1 hour and 23 minutes").
|
|
1056
|
+
* - Rounding never causes a value to display as the next unit boundary
|
|
1057
|
+
* (e.g. 59_999ms → "59.9 seconds", never "60 seconds").
|
|
1058
|
+
*
|
|
1059
|
+
* @param {number} ms - Duration in milliseconds.
|
|
1060
|
+
* @param {object} [options]
|
|
1061
|
+
* @param {boolean} [options.short=false] - Use compact unit symbols (e.g. "1h and 23m").
|
|
1062
|
+
* @param {boolean} [options.rounded=true] - Round the last displayed digit. When false, truncates instead.
|
|
1063
|
+
* @param {number} [options.decimals] - Override the number of decimal places shown.
|
|
1064
|
+
* @returns {string}
|
|
1065
|
+
*/
|
|
1054
1066
|
const humanizeDuration = (
|
|
1055
1067
|
ms,
|
|
1056
1068
|
{
|
|
1057
1069
|
short,
|
|
1058
1070
|
rounded = true,
|
|
1059
1071
|
decimals,
|
|
1060
|
-
|
|
1061
|
-
timeDictionnary = lang === "fr" ? TIME_DICTIONARY_FR : TIME_DICTIONARY_EN,
|
|
1072
|
+
timeDictionnary = TIME_DICTIONARY_EN,
|
|
1062
1073
|
} = {},
|
|
1063
1074
|
) => {
|
|
1064
|
-
// ignore ms below meaningfulMs so that:
|
|
1065
|
-
// humanizeDuration(0.5) -> "0 second"
|
|
1066
|
-
// humanizeDuration(1.1) -> "0.001 second" (and not "0.0011 second")
|
|
1067
|
-
// This tool is meant to be read by humans and it would be barely readable to see
|
|
1068
|
-
// "0.0001 second" (stands for 0.1 millisecond)
|
|
1069
|
-
// yes we could return "0.1 millisecond" but we choosed consistency over precision
|
|
1070
|
-
// so that the prefered unit is "second" (and does not become millisecond when ms is super small)
|
|
1071
1075
|
if (ms < 1) {
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1076
|
+
if (short) {
|
|
1077
|
+
return `0${timeDictionnary.second.short}`;
|
|
1078
|
+
}
|
|
1079
|
+
return `0 ${timeDictionnary.second.long}`;
|
|
1075
1080
|
}
|
|
1076
1081
|
const { primary, remaining } = parseMs(ms);
|
|
1077
1082
|
if (!remaining) {
|
|
1083
|
+
const primaryUnitIndex = UNIT_KEYS$1.indexOf(primary.name);
|
|
1084
|
+
const nextUnitName = UNIT_KEYS$1[primaryUnitIndex - 1];
|
|
1085
|
+
const maxCount = nextUnitName
|
|
1086
|
+
? UNIT_MS[nextUnitName] / UNIT_MS[primary.name]
|
|
1087
|
+
: null;
|
|
1078
1088
|
return humanizeDurationUnit(primary, {
|
|
1079
1089
|
decimals:
|
|
1080
1090
|
decimals === undefined ? (primary.name === "second" ? 1 : 0) : decimals,
|
|
1091
|
+
maxCount,
|
|
1081
1092
|
short,
|
|
1082
1093
|
rounded,
|
|
1083
1094
|
timeDictionnary,
|
|
@@ -1095,15 +1106,23 @@ const humanizeDuration = (
|
|
|
1095
1106
|
rounded,
|
|
1096
1107
|
timeDictionnary,
|
|
1097
1108
|
});
|
|
1109
|
+
if (short) {
|
|
1110
|
+
return `${primaryText}${remainingText}`;
|
|
1111
|
+
}
|
|
1098
1112
|
return timeDictionnary.joinDuration(primaryText, remainingText);
|
|
1099
1113
|
};
|
|
1100
1114
|
const humanizeDurationUnit = (
|
|
1101
1115
|
unit,
|
|
1102
|
-
{ decimals, short, rounded, timeDictionnary },
|
|
1116
|
+
{ decimals, maxCount, short, rounded, timeDictionnary },
|
|
1103
1117
|
) => {
|
|
1104
|
-
|
|
1118
|
+
let count = rounded
|
|
1105
1119
|
? setRoundedPrecision(unit.count, { decimals })
|
|
1106
1120
|
: setPrecision(unit.count, { decimals });
|
|
1121
|
+
if (maxCount !== null && maxCount !== undefined && count >= maxCount) {
|
|
1122
|
+
// Prevent rounding up to the next unit boundary (e.g. 59.999s → 60s → cap to 59.9s)
|
|
1123
|
+
const factor = Math.pow(10, decimals ?? 0);
|
|
1124
|
+
count = Math.floor(unit.count * factor) / factor;
|
|
1125
|
+
}
|
|
1107
1126
|
const name = unit.name;
|
|
1108
1127
|
if (short) {
|
|
1109
1128
|
const unitText = timeDictionnary[name].short;
|
|
@@ -1120,7 +1139,7 @@ const humanizeDurationUnit = (
|
|
|
1120
1139
|
const parseMs = (ms) => {
|
|
1121
1140
|
let firstUnitName = SMALLEST_UNIT_NAME;
|
|
1122
1141
|
let firstUnitCount = ms / UNIT_MS[SMALLEST_UNIT_NAME];
|
|
1123
|
-
const firstUnitIndex = UNIT_KEYS.findIndex((unitName) => {
|
|
1142
|
+
const firstUnitIndex = UNIT_KEYS$1.findIndex((unitName) => {
|
|
1124
1143
|
if (unitName === SMALLEST_UNIT_NAME) {
|
|
1125
1144
|
return false;
|
|
1126
1145
|
}
|
|
@@ -1142,7 +1161,7 @@ const parseMs = (ms) => {
|
|
|
1142
1161
|
};
|
|
1143
1162
|
}
|
|
1144
1163
|
const remainingMs = ms - firstUnitCount * UNIT_MS[firstUnitName];
|
|
1145
|
-
const remainingUnitName = UNIT_KEYS[firstUnitIndex + 1];
|
|
1164
|
+
const remainingUnitName = UNIT_KEYS$1[firstUnitIndex + 1];
|
|
1146
1165
|
const remainingUnitCount = remainingMs / UNIT_MS[remainingUnitName];
|
|
1147
1166
|
// - 1 year and 1 second is too much information
|
|
1148
1167
|
// so we don't check the remaining units
|
|
@@ -1156,6 +1175,17 @@ const parseMs = (ms) => {
|
|
|
1156
1175
|
},
|
|
1157
1176
|
};
|
|
1158
1177
|
}
|
|
1178
|
+
// When remaining rounds up to a full next-unit (e.g. 59.999s rounds to 60s = 1min),
|
|
1179
|
+
// drop the remaining to avoid displaying "59 minutes and 60 seconds".
|
|
1180
|
+
const remainingUnitMs = UNIT_MS[remainingUnitName];
|
|
1181
|
+
const nextUnitMs = UNIT_MS[firstUnitName];
|
|
1182
|
+
const maxRemainingCount = nextUnitMs / remainingUnitMs; // e.g. 60 for seconds-in-a-minute
|
|
1183
|
+
// Cap remaining so it never rounds up to the next unit boundary
|
|
1184
|
+
// (e.g. 59.5s stays as 59s instead of rounding to 60s = 1min)
|
|
1185
|
+
const cappedRemainingCount =
|
|
1186
|
+
remainingUnitCount >= maxRemainingCount - 1
|
|
1187
|
+
? maxRemainingCount - 1
|
|
1188
|
+
: remainingUnitCount;
|
|
1159
1189
|
// - 1 year and 1 month is great
|
|
1160
1190
|
return {
|
|
1161
1191
|
primary: {
|
|
@@ -1164,7 +1194,7 @@ const parseMs = (ms) => {
|
|
|
1164
1194
|
},
|
|
1165
1195
|
remaining: {
|
|
1166
1196
|
name: remainingUnitName,
|
|
1167
|
-
count:
|
|
1197
|
+
count: cappedRemainingCount,
|
|
1168
1198
|
},
|
|
1169
1199
|
};
|
|
1170
1200
|
};
|
|
@@ -1212,6 +1242,1894 @@ const inspectBytes = (
|
|
|
1212
1242
|
|
|
1213
1243
|
const BYTE_UNITS = ["B", "kB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];
|
|
1214
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
|
+
|
|
1215
3133
|
const distributePercentages = (
|
|
1216
3134
|
namedNumbers,
|
|
1217
3135
|
{ maxPrecisionHint = 2 } = {},
|
|
@@ -1500,5 +3418,5 @@ const escapeHtml = (string) => {
|
|
|
1500
3418
|
.replace(/'/g, "'");
|
|
1501
3419
|
};
|
|
1502
3420
|
|
|
1503
|
-
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 };
|
|
1504
3422
|
//# sourceMappingURL=jsenv_humanize_browser.js.map
|