@magmacomputing/tempo 1.0.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.
Files changed (61) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +61 -0
  3. package/dist/array.library.d.ts +25 -0
  4. package/dist/array.library.js +78 -0
  5. package/dist/buffer.library.d.ts +6 -0
  6. package/dist/buffer.library.js +192 -0
  7. package/dist/cipher.class.d.ts +18 -0
  8. package/dist/cipher.class.js +65 -0
  9. package/dist/class.library.d.ts +10 -0
  10. package/dist/class.library.js +57 -0
  11. package/dist/coercion.library.d.ts +14 -0
  12. package/dist/coercion.library.js +71 -0
  13. package/dist/enumerate.library.d.ts +64 -0
  14. package/dist/enumerate.library.js +54 -0
  15. package/dist/function.library.d.ts +9 -0
  16. package/dist/function.library.js +49 -0
  17. package/dist/index.d.ts +1 -0
  18. package/dist/index.js +3 -0
  19. package/dist/logify.class.d.ts +33 -0
  20. package/dist/logify.class.js +53 -0
  21. package/dist/number.library.d.ts +16 -0
  22. package/dist/number.library.js +36 -0
  23. package/dist/object.library.d.ts +37 -0
  24. package/dist/object.library.js +94 -0
  25. package/dist/pledge.class.d.ts +54 -0
  26. package/dist/pledge.class.js +131 -0
  27. package/dist/prototype.library.d.ts +33 -0
  28. package/dist/prototype.library.js +51 -0
  29. package/dist/reflection.library.d.ts +74 -0
  30. package/dist/reflection.library.js +97 -0
  31. package/dist/serialize.library.d.ts +25 -0
  32. package/dist/serialize.library.js +266 -0
  33. package/dist/storage.library.d.ts +8 -0
  34. package/dist/storage.library.js +57 -0
  35. package/dist/string.library.d.ts +37 -0
  36. package/dist/string.library.js +93 -0
  37. package/dist/tempo.class.d.ts +556 -0
  38. package/dist/tempo.class.js +1412 -0
  39. package/dist/tempo.config/plugins/term.import.d.ts +42 -0
  40. package/dist/tempo.config/plugins/term.import.js +44 -0
  41. package/dist/tempo.config/plugins/term.quarter.d.ts +7 -0
  42. package/dist/tempo.config/plugins/term.quarter.js +28 -0
  43. package/dist/tempo.config/plugins/term.season.d.ts +7 -0
  44. package/dist/tempo.config/plugins/term.season.js +36 -0
  45. package/dist/tempo.config/plugins/term.timeline.d.ts +7 -0
  46. package/dist/tempo.config/plugins/term.timeline.js +19 -0
  47. package/dist/tempo.config/plugins/term.utils.d.ts +17 -0
  48. package/dist/tempo.config/plugins/term.utils.js +38 -0
  49. package/dist/tempo.config/plugins/term.zodiac.d.ts +7 -0
  50. package/dist/tempo.config/plugins/term.zodiac.js +62 -0
  51. package/dist/tempo.config/tempo.default.d.ts +169 -0
  52. package/dist/tempo.config/tempo.default.js +158 -0
  53. package/dist/tempo.config/tempo.enum.d.ts +99 -0
  54. package/dist/tempo.config/tempo.enum.js +78 -0
  55. package/dist/temporal.polyfill.d.ts +9 -0
  56. package/dist/temporal.polyfill.js +18 -0
  57. package/dist/type.library.d.ts +296 -0
  58. package/dist/type.library.js +80 -0
  59. package/dist/utility.library.d.ts +32 -0
  60. package/dist/utility.library.js +54 -0
  61. package/package.json +54 -0
@@ -0,0 +1,1412 @@
1
+ // #region library modules~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
2
+ import { Logify } from './logify.class.js';
3
+ import { ifDefined } from './object.library.js';
4
+ import { secure } from './utility.library.js';
5
+ import { Immutable, Serializable } from './class.library.js';
6
+ import { Cipher } from './cipher.class.js';
7
+ import { asArray } from './array.library.js';
8
+ import { cleanify, stringify } from './serialize.library.js';
9
+ import { getStorage, setStorage } from './storage.library.js';
10
+ import { ownKeys, ownEntries, getAccessors } from './reflection.library.js';
11
+ import { getContext, CONTEXT } from './utility.library.js';
12
+ import { asInteger, isNumeric, ifNumeric } from './number.library.js';
13
+ import { pad, singular, toProperCase, trimAll } from './string.library.js';
14
+ import { getType, asType, isType, isEmpty, isNull, isNullish, isDefined, isUndefined, isString, isObject, isRegExp, isRegExpLike, isIntegerLike, isSymbol, isFunction } from './type.library.js';
15
+ import * as enums from './tempo.config/tempo.enum.js';
16
+ import terms from './tempo.config/plugins/term.import.js';
17
+ import { Match, Token, Snippet, Layout, Event, Period, Default, TimeZone } from './tempo.config/tempo.default.js';
18
+ import './prototype.library.js'; // patch prototypes
19
+ // #endregion
20
+ const STORAGEKEY = '$Tempo'; // for stash in persistent storage
21
+ const Context = getContext(); // get current execution context
22
+ // #endregion Const variables
23
+ /**
24
+ * # Tempo
25
+ * **Tempo** is a powerful wrapper around `Temporal.ZonedDateTime` designed for flexible parsing and intuitive manipulation of date-time objects.
26
+ *
27
+ * It bridges the gap between raw string/number inputs and the strict requirements of the ECMAScript Temporal API.
28
+ *
29
+ * ### Key Features
30
+ * - **Flexible Parsing**: Interprets strings, numbers, BigInts, and various Temporal objects.
31
+ * - **Static Utility**: Access to common enums like `WEEKDAY`, `MONTH` and `SEASON`.
32
+ * - **Fluent API**: Methods for adding, setting, formatting, and comparing date-times.
33
+ * - **Alias Parsing**: Define custom `events` (e.g., "xmas" → "25 Dec") and `periods` (e.g., "noon" → "12:00") for intuitive parsing.
34
+ * - **Plugin System**: Extensible via "terms" to provide contextual date calculations (e.g., quarters, seasons, zodiac signs, etc.).
35
+ *
36
+ * @example
37
+ * ```typescript
38
+ * // Standard parsing
39
+ * const t1 = new Tempo('2024-05-20');
40
+ *
41
+ * // Using an event alias (pre-defined or custom)
42
+ * const t2 = new Tempo('christmas'); // Dec 25th
43
+ *
44
+ * // Using a period alias
45
+ * const t3 = new Tempo('2024-05-20 midnight'); // 2024-05-20T00:00:00
46
+ *
47
+ * // Custom events and periods for this instance
48
+ * const t4 = new Tempo('birthday', {
49
+ * event: [['birthday', '20 May']],
50
+ * period: [['tea-time', '15:30']]
51
+ * })
52
+ * ```
53
+ */
54
+ @Serializable
55
+ @Immutable
56
+ export class Tempo {
57
+ // #region Static enum properties~~~~~~~~~~~~~~~~~~~~~~~~~
58
+ /** Weekday names (short-form) */ static get WEEKDAY() { return enums.WEEKDAY; }
59
+ /** Weekday names (long-form) */ static get WEEKDAYS() { return enums.WEEKDAYS; }
60
+ /** Month names (short-form) */ static get MONTH() { return enums.MONTH; }
61
+ /** Month names (long-form) */ static get MONTHS() { return enums.MONTHS; }
62
+ /** Time durations as seconds (singular) */ static get DURATION() { return enums.DURATION; }
63
+ /** Time durations as milliseconds (plural) */ static get DURATIONS() { return enums.DURATIONS; }
64
+ /** Quarterly Seasons */ static get SEASON() { return enums.SEASON; }
65
+ /** Compass cardinal points */ static get COMPASS() { return enums.COMPASS; }
66
+ /** Tempo to Temporal DateTime Units map */ static get ELEMENT() { return enums.ELEMENT; }
67
+ /** Pre-configured format {name -> string} pairs */ static get FORMAT() { return enums.FORMAT; }
68
+ /** some useful Dates */ static get LIMIT() { return enums.LIMIT; }
69
+ // #endregion
70
+ // #region Static private properties~~~~~~~~~~~~~~~~~~~~~~
71
+ static #dbg = new Logify('Tempo', {
72
+ debug: Default?.debug ?? false,
73
+ catch: Default?.catch ?? false
74
+ });
75
+ static #global = {};
76
+ static #pending = void 0; // collect the parse rule-match results
77
+ static #usrCount = 0; // cache for next-available 'usr' Token key
78
+ // #endregion
79
+ // #region Static private methods~~~~~~~~~~~~~~~~~~~~~~~~~
80
+ //** prototype handlers */
81
+ /** return the Prototype parent of an object */ static #proto(obj) { return Object.getPrototypeOf(obj); }
82
+ /** test object has own property with the given key */ static #hasOwn(obj, key) { return Object.hasOwn(obj, key); }
83
+ /** test object is Frozen */ static #isFrozen(obj) { return isDefined(obj) && Object.isFrozen(obj); }
84
+ /** return whether the shape is 'local' or 'global' */ static #isLocal(shape) { return shape.config.scope === 'local'; }
85
+ /** create an object based on a prototype */ static #create(obj, name) { return Object.create(Tempo.#proto(obj)[name]); }
86
+ /**
87
+ * {dt} is a layout that combines date-related {snippets} (e.g. dd, mm -or- evt) into a pattern against which a string can be tested.
88
+ * because it will also include a list of events (e.g. 'new_years' | 'xmas'), we need to rebuild {dt} if the user adds a new event
89
+ */
90
+ // TODO: check all Layouts which reference "{evt}" and update them
91
+ static #setEvents(shape) {
92
+ const events = ownEntries(shape.parse.event, true);
93
+ if (Tempo.#isLocal(shape) && !Tempo.#hasOwn(shape.parse, 'event') && !Tempo.#hasOwn(shape.parse, 'isMonthDay'))
94
+ return; // no local change needed
95
+ const src = shape.config.scope.substring(0, 1); // 'g'lobal or 'l'ocal
96
+ const groups = events
97
+ .map(([pat, _], idx) => `(?<${src}evt${idx}>${pat})`) // assign a number to the pattern
98
+ .join('|'); // make an 'Or' pattern for the event-keys
99
+ if (groups) {
100
+ const protoEvt = Tempo.#proto(shape.parse.snippet)[Token.evt]?.source;
101
+ if (!Tempo.#isLocal(shape) || groups !== protoEvt) {
102
+ if (Tempo.#isLocal(shape) && !Tempo.#hasOwn(shape.parse, 'snippet'))
103
+ shape.parse.snippet = Tempo.#create(shape.parse, 'snippet');
104
+ Object.defineProperty(shape.parse.snippet, Token.evt, {
105
+ value: new RegExp(groups),
106
+ enumerable: true,
107
+ writable: true,
108
+ configurable: true
109
+ });
110
+ }
111
+ }
112
+ if (shape.parse.isMonthDay) {
113
+ const protoDt = Tempo.#proto(shape.parse.layout)[Token.dt];
114
+ const localDt = '{mm}{sep}?{dd}({sep}?{yy})?|{mod}?({evt})';
115
+ if (!Tempo.#isLocal(shape) || localDt !== protoDt) {
116
+ if (Tempo.#isLocal(shape) && !Tempo.#hasOwn(shape.parse, 'layout'))
117
+ shape.parse.layout = Tempo.#create(shape.parse, 'layout');
118
+ Object.defineProperty(shape.parse.layout, Token.dt, {
119
+ value: localDt,
120
+ enumerable: true,
121
+ writable: true,
122
+ configurable: true
123
+ });
124
+ }
125
+ }
126
+ }
127
+ /**
128
+ * {tm} is a layout that combines time-related snippets (hh, mi, ss, ff, mer -or- per) into a pattern against which a string can be tested.
129
+ * because it will also include a list of periods (e.g. 'midnight' | 'afternoon' ), we need to rebuild {tm} if the user adds a new period
130
+ */
131
+ // TODO: check all Layouts which reference "{per}" and update them
132
+ static #setPeriods(shape) {
133
+ const periods = ownEntries(shape.parse.period, true);
134
+ if (Tempo.#isLocal(shape) && !Tempo.#hasOwn(shape.parse, 'period'))
135
+ return; // no local change needed
136
+ const src = shape.config.scope.substring(0, 1); // 'g'lobal or 'l'ocal
137
+ const groups = periods
138
+ .map(([pat, _], idx) => `(?<${src}per${idx}>${pat})`) // {pattern} is the 1st element of the tuple
139
+ .join('|'); // make an 'or' pattern for the period-keys
140
+ if (groups) {
141
+ const protoPer = Tempo.#proto(shape.parse.snippet)[Token.per]?.source;
142
+ if (!Tempo.#isLocal(shape) || groups !== protoPer) {
143
+ if (Tempo.#isLocal(shape) && !Tempo.#hasOwn(shape.parse, 'snippet'))
144
+ shape.parse.snippet = Tempo.#create(shape.parse, 'snippet');
145
+ Object.defineProperty(shape.parse.snippet, Token.per, {
146
+ value: new RegExp(groups),
147
+ enumerable: true,
148
+ writable: true,
149
+ configurable: true
150
+ });
151
+ }
152
+ }
153
+ }
154
+ /** try to infer hemisphere using the timezone's daylight-savings setting */
155
+ static #setSphere = (shape, options) => {
156
+ if (isUndefined(shape.config.timeZone) || Tempo.#hasOwn(options, 'sphere'))
157
+ return shape.config.sphere; // already specified or no timeZone to calculate from
158
+ const zdt = Temporal.Now.zonedDateTimeISO(shape.config.timeZone);
159
+ const jan = zdt.with({ day: 1, month: 1 }).offsetNanoseconds;
160
+ const jun = zdt.with({ day: 1, month: 6 }).offsetNanoseconds;
161
+ const dst = Math.sign(jan - jun); // timeZone offset difference between Jan and Jun
162
+ switch (dst) {
163
+ case -1:
164
+ return Tempo.COMPASS.North; // clock moves backward in Northern hemisphere
165
+ case 1:
166
+ return Tempo.COMPASS.South; // clock moves forward in Southern hemisphere
167
+ case 0:
168
+ return void 0; // timeZone does not observe DST
169
+ default:
170
+ return Default.sphere ?? this.COMPASS.North; // timeZone does not observe DST
171
+ }
172
+ };
173
+ /** determine if we have a {timeZone} which prefers {mdy} date-order */
174
+ static #isMonthDay(shape) {
175
+ const monthDay = [...asArray(Tempo.#global.parse.mdyLocales)];
176
+ if (Tempo.#isLocal(shape) && Tempo.#hasOwn(shape.parse, 'mdyLocales'))
177
+ monthDay.push(...shape.parse.mdyLocales); // append local mdyLocales (not overwrite global)
178
+ return monthDay.some(mdy => mdy.timeZones?.includes(shape.config.timeZone));
179
+ }
180
+ /**
181
+ * swap parsing-order of layouts to suit different timeZones
182
+ * this allows the parser to try to interpret '04012023' as Apr-01-2023 before trying 04-Jan-2023
183
+ */
184
+ static #swapLayout(shape) {
185
+ const layouts = ownEntries(shape.parse.layout); // get entries of Layout Record
186
+ const swap = shape.parse.mdyLayouts; // get the swap-tuple
187
+ let chg = false; // no need to rebuild, if no change
188
+ swap
189
+ .forEach(([dmy, mdy]) => {
190
+ const idx1 = layouts.findIndex(([key]) => key.description === dmy); // 1st swap element exists in {layouts}
191
+ const idx2 = layouts.findIndex(([key]) => key.description === mdy); // 2nd swap element exists in {layouts}
192
+ if (idx1 === -1 || idx2 === -1)
193
+ return; // no pair to swap
194
+ const swap1 = (idx1 < idx2) && shape.parse.isMonthDay; // we prefer {mdy} and the 1st tuple was found earlier than the 2nd
195
+ const swap2 = (idx1 > idx2) && !shape.parse.isMonthDay; // we dont prefer {mdy} and the 1st tuple was found later than the 2nd
196
+ if (swap1 || swap2) { // since {layouts} is an array, ok to swap by-reference
197
+ [layouts[idx1], layouts[idx2]] = [layouts[idx2], layouts[idx1]];
198
+ chg = true;
199
+ }
200
+ });
201
+ if (chg)
202
+ shape.parse.layout = Object.fromEntries(layouts); // rebuild Layout in new parse order
203
+ }
204
+ /** properCase week-day / calendar-month */
205
+ static #prefix = (str) => toProperCase(str.substring(0, 3));
206
+ /** get first Canonical name of a supplied locale */
207
+ static #locale = (locale) => {
208
+ let language;
209
+ try { // lookup locale
210
+ language = Intl.getCanonicalLocales(locale.replace('_', '-'))[0];
211
+ }
212
+ catch (error) { } // catch unknown locale
213
+ const global = Context.global;
214
+ return language ??
215
+ global?.navigator?.languages?.[0] ?? // fallback to current first navigator.languages[]
216
+ global?.navigator?.language ?? // else navigator.language
217
+ Default.locale ?? // else default locale
218
+ locale; // cannot determine locale
219
+ };
220
+ /**
221
+ * conform input of Snippet / Layout / Event / Period options
222
+ * This is needed because we allow the user to flexibly provide detail as {[key]:val} or {[key]:val}[] or [key,val][]
223
+ * for example:
224
+ ```
225
+ Tempo.init({ snippet: {'yy': /20\d{2}/, 'mm': /[0-9|1|2]\d/ } })
226
+ Tempo.init({ layout: {'ddmm': '{dd}{sep}?{mm}'} })
227
+ Tempo.init({ layout: '{wkd}' }) (can be a single string)
228
+ Tempo.init({ layout: '({wkd})? {tm}' }) (or a string combination of snippets)
229
+ Tempo.init({ layout: new Map([['name1', '{wkd} {yy}']], ['name2', '{mm}{sep}{dd}']]]) })
230
+ Tempo.init({ layout: [ {name1: '{wkd} {yy}'}, {name2: '{mm}{sep}{dd}'} ]
231
+
232
+ Tempo.init({event: {'canada ?day': '01-Jun', 'aus(tralia)? ?day': '26-Jan'} })
233
+ Tempo.init({period: [{'morning tea': '09:30' }, {'elevenses': '11:00' }]})
234
+ new Tempo('birthday', {event: [["birth(day)?", "20-May"], ["anniversary", "01-Jul"] ]})
235
+ ```
236
+ */
237
+ static #setConfig(shape, ...options) {
238
+ /** helper to normalize snippet/layout Options into the target Config */
239
+ const collect = (target, value, convert) => {
240
+ const itm = asType(value);
241
+ target ??= {};
242
+ switch (itm.type) {
243
+ case 'Object':
244
+ ownEntries(itm.value)
245
+ .forEach(([k, v]) => target[Tempo.getSymbol(k)] = convert(v));
246
+ break;
247
+ case 'String':
248
+ case 'RegExp':
249
+ target[Tempo.getSymbol()] = convert(itm.value);
250
+ break;
251
+ case 'Array':
252
+ itm.value.forEach(elm => collect(target, elm, convert));
253
+ break;
254
+ }
255
+ };
256
+ const mergedOptions = Object.assign({}, ...options);
257
+ ownEntries(mergedOptions)
258
+ .forEach(([optKey, optVal]) => {
259
+ if (isUndefined(optVal))
260
+ return; // skip undefined values
261
+ const arg = asType(optVal);
262
+ switch (optKey) {
263
+ case 'snippet':
264
+ case 'layout':
265
+ case 'event':
266
+ case 'period':
267
+ // lazy-shadowing: only create local object if it doesn't already exist on local shape
268
+ if (!Tempo.#hasOwn(shape.parse, optKey))
269
+ shape.parse[optKey] = Tempo.#create(shape.parse, optKey);
270
+ const rule = shape.parse[optKey];
271
+ if (['snippet', 'layout'].includes(optKey)) {
272
+ collect(rule, arg.value, v => optKey === 'snippet'
273
+ ? isRegExp(v) ? v : new RegExp(v)
274
+ : isRegExp(v) ? v.source : v);
275
+ }
276
+ else {
277
+ asArray(arg.value)
278
+ .forEach(elm => ownEntries(elm).forEach(([key, val]) => rule[key] = val));
279
+ }
280
+ break;
281
+ case 'mdyLocales':
282
+ shape.parse[optKey] = Tempo.#mdyLocales(arg.value);
283
+ break;
284
+ case 'mdyLayouts': // these are the 'layouts' that need to swap parse-order
285
+ shape.parse[optKey] = asArray(arg.value);
286
+ break;
287
+ case 'timeZone':
288
+ const zone = String(arg.value).toLowerCase();
289
+ shape.config.timeZone = TimeZone[zone] ?? arg.value;
290
+ break;
291
+ default: // else just add to config
292
+ Object.assign(shape.config, { [optKey]: optVal });
293
+ break;
294
+ }
295
+ });
296
+ const isMonthDay = Tempo.#isMonthDay(shape);
297
+ if (isMonthDay !== Tempo.#proto(shape.parse).isMonthDay) // this will always set on 'global', conditionally on 'local'
298
+ shape.parse.isMonthDay = isMonthDay;
299
+ shape.config.sphere = Tempo.#setSphere(shape, mergedOptions);
300
+ if (isDefined(shape.parse.mdyLayouts))
301
+ Tempo.#swapLayout(shape);
302
+ if (isDefined(shape.parse.event))
303
+ Tempo.#setEvents(shape);
304
+ if (isDefined(shape.parse.period))
305
+ Tempo.#setPeriods(shape);
306
+ Tempo.#setPatterns(shape); // setup Regex DateTime patterns
307
+ }
308
+ /** setup mdy TimeZones, using Intl.Locale */
309
+ // The google-apps-script types package provides its own Intl.Locale interface that doesn't include getTimeZones(),
310
+ // and it takes priority over the ESNext.Intl augmentation in tsconfig.
311
+ // The "(mdy as any).getTimeZones?.()" can be replaced with "mdy.getTimeZones()" after google-apps-script is corrected
312
+ static #mdyLocales(value) {
313
+ return asArray(value)
314
+ .map(mdy => new Intl.Locale(mdy))
315
+ .map(mdy => ({ locale: mdy.baseName, timeZones: mdy.getTimeZones?.() ?? [] }));
316
+ }
317
+ /** build RegExp patterns */
318
+ static #setPatterns(shape) {
319
+ const snippet = shape.parse.snippet;
320
+ // if local and no snippet or layout overrides, we can just use the prototype's patterns
321
+ if (Tempo.#isLocal(shape) && !Tempo.#hasOwn(shape.parse, 'snippet') && !Tempo.#hasOwn(shape.parse, 'layout'))
322
+ return;
323
+ // ensure we have our own Map to mutate (shadow if local)
324
+ if (!Tempo.#hasOwn(shape.parse, 'pattern'))
325
+ shape.parse.pattern = new Map();
326
+ shape.parse.pattern.clear(); // reset {pattern} Map
327
+ for (const [sym, layout] of ownEntries(shape.parse.layout, true))
328
+ shape.parse.pattern.set(sym, Tempo.regexp(layout, snippet));
329
+ }
330
+ // #endregion Static private methods
331
+ // #region Static public methods~~~~~~~~~~~~~~~~~~~~~~~~~~
332
+ /**
333
+ * Initializes the global default configuration for all subsequent `Tempo` instances.
334
+ *
335
+ * Settings are inherited in this priority:
336
+ * 1. Reasonable library defaults (defined in tempo.config.js)
337
+ * 2. Persistent storage (e.g. localStorage), if available.
338
+ * 3. `options` provided to this method.
339
+ *
340
+ * @param options - Configuration overrides to apply globally.
341
+ * @returns The resolved global configuration.
342
+ */
343
+ static init(options = {}) {
344
+ if (isEmpty(options)) { // if no options supplied, reset all
345
+ Tempo.#global.config = {}; // remove previous config
346
+ Tempo.#global.parse = {
347
+ snippet: { ...Snippet },
348
+ layout: { ...Layout },
349
+ event: { ...Event },
350
+ period: { ...Period },
351
+ mdyLocales: Tempo.#mdyLocales(Default.mdyLocales),
352
+ mdyLayouts: asArray(Default.mdyLayouts),
353
+ }; // remove previous parsing rules
354
+ for (const key of Object.keys(Token)) // purge user-allocated Tokens
355
+ if (key.startsWith('usr.')) // only remove 'usr.' prefixed keys
356
+ delete Token[key];
357
+ Tempo.#usrCount = 0; // reset user-key counter
358
+ const dateTime = Intl.DateTimeFormat().resolvedOptions();
359
+ Object.assign(Tempo.#global.config, {
360
+ calendar: dateTime.calendar,
361
+ timeZone: dateTime.timeZone,
362
+ locale: Tempo.#locale(), // get from browser, if possible
363
+ });
364
+ Tempo.#setConfig(Tempo.#global, { store: STORAGEKEY, scope: 'global' }, Default, // set Tempo defaults
365
+ Tempo.readStore());
366
+ }
367
+ else {
368
+ // if (options.store !== STORAGEKEY)
369
+ // Tempo.#setConfig(Tempo.#global, Tempo.readStore(options.store)); // user-defined local storage
370
+ Tempo.#setConfig(Tempo.#global, options); // overload with init() argument (options)
371
+ }
372
+ if (Context.type === CONTEXT.Browser || options.debug === true)
373
+ Tempo.#dbg.info(Tempo.config, 'Tempo:', Tempo.#global.config);
374
+ return Tempo.#global.config;
375
+ }
376
+ /**
377
+ * Reads `Tempo` options from persistent storage (e.g., localStorage).
378
+ * @returns The stored configuration or an empty object.
379
+ */
380
+ static readStore(key = Tempo.#global.config.store) {
381
+ return getStorage(key, {});
382
+ }
383
+ /**
384
+ * Writes the provided configuration into persistent storage.
385
+ * @param config - The options to save.
386
+ */
387
+ static writeStore(config, key = Tempo.#global.config.store) {
388
+ return setStorage(key, config);
389
+ }
390
+ /**
391
+ * looks-up or registers a new `Symbol` for a given key.
392
+ * auto-maintains the `Token` registry for internal consistency.
393
+ *
394
+ * @param key - The description for which to retrieve/create a Symbol.
395
+ */
396
+ static getSymbol(key) {
397
+ if (isUndefined(key)) {
398
+ const usr = `usr.${++Tempo.#usrCount}`; // allocate a prefixed 'user' key
399
+ return Token[usr] = Symbol(usr); // add to Symbol register
400
+ }
401
+ if (isSymbol(key)) {
402
+ const name = key.description ?? Cipher.randomKey(); // get Symbol description, else allocate random string
403
+ return Token[name] ??= key;
404
+ }
405
+ if (isDefined(Token[key])) // already registered (internal)
406
+ return Token[key]; // return existing Symbol
407
+ const usr = `usr.${key}`;
408
+ if (isDefined(Token[usr])) // already registered (user)
409
+ return Token[usr]; // return existing Symbol
410
+ const description = key
411
+ .split(Match.separator)
412
+ .filter(s => !isEmpty(s)).pop() || key;
413
+ return Token[usr] = Symbol(description); // add to Symbol register
414
+ }
415
+ /**
416
+ * translates a {layout} string into an anchored, case-insensitive regular expression.
417
+ * supports placeholder expansion using the {snippet} registry (e.g., `{yy}`, `{mm}`).
418
+ */
419
+ static regexp(layout, snippet) {
420
+ // helper function to replace {name} placeholders with their corresponding snippets
421
+ function matcher(str) {
422
+ let source = isRegExp(str) ? str.source : str;
423
+ if (isRegExpLike(source)) // string that looks like a RegExp
424
+ source = source.substring(1, source.length - 1); // remove the leading/trailing "/"
425
+ if (source.startsWith('^') && source.endsWith('$'))
426
+ source = source.substring(1, source.length - 1); // remove the leading/trailing anchors (^ $)
427
+ return source.replace(Match.braces, (match, name) => {
428
+ const token = Tempo.getSymbol(name); // get the symbol for this {name}
429
+ let snip = snippet?.[token]?.source // get the snippet source (custom)
430
+ ?? Snippet[token]?.source // else get the snippet source (global)
431
+ ?? Layout[token]; // else get the layout source
432
+ if (isNullish(snip) && name.includes('.')) { // if no definition found, try fallback
433
+ const prefix = name.split('.')[0]; // get the base token name
434
+ const pToken = Tempo.getSymbol(prefix);
435
+ snip = snippet?.[pToken]?.source
436
+ ?? Snippet[pToken]?.source
437
+ ?? Layout[pToken];
438
+ if (snip) {
439
+ const safeName = name.replace(/\./g, '_'); // e.g. aaa.bbb -> aaa_bbb
440
+ snip = `(?<${safeName}>${snip.replace(Match.captures, '(?:$2)')})`;
441
+ }
442
+ }
443
+ return (isNullish(snip) || snip === match) // if no definition found,
444
+ ? match // return the original match
445
+ : matcher(snip); // else recurse to see if snippet contains embedded "{}" pairs
446
+ });
447
+ }
448
+ // helper to check for duplicate named capture-groups
449
+ function checker(source) {
450
+ names.clear(); // clear the set of names
451
+ return source.replace(Match.captures, (match, name) => {
452
+ if (names.has(name))
453
+ return `(\\k<${name}>)`; // replace with a back-reference to the {name}
454
+ names.add(name); // add {name} to the set of names
455
+ return match;
456
+ });
457
+ }
458
+ const names = new Set(); // track the regex named capture-groups
459
+ layout = matcher(layout); // initiate the layout-parse
460
+ layout = checker(layout); // check for named capture-groups
461
+ return new RegExp(`^(${layout})$`, 'i'); // translate the source into a regex
462
+ }
463
+ /**
464
+ * Compares two `Tempo` instances or date-time values.
465
+ * Useful for sorting or determining chronological order.
466
+ *
467
+ * @param tempo1 - The first value to compare.
468
+ * @param tempo2 - The second value to compare (defaults to 'now').
469
+ * @returns `-1` if `tempo1 < tempo2`, `0` if tempo1 == tempo2, `1` if `tempo1 > tempo2`.
470
+ *
471
+ * @example
472
+ * ```typescript
473
+ * const sorted = [t1, t2].sort(Tempo.compare);
474
+ * ```
475
+ */
476
+ static compare(tempo1, tempo2) {
477
+ const one = new Tempo(tempo1), two = new Tempo(tempo2);
478
+ return Number((one.nano > two.nano) || -(one.nano < two.nano)) + 0;
479
+ }
480
+ static from(tempo, options) { return new Tempo(tempo, options); }
481
+ /** Returns current time as epoch nanoseconds (BigInt). */
482
+ static now() { return Temporal.Now.instant().epochNanoseconds; }
483
+ /** static Tempo.terms getter */
484
+ static get terms() {
485
+ return secure(terms
486
+ .map(({ define, ...rest }) => rest)); // omit the 'define' method
487
+ }
488
+ /** static Tempo properties getter */
489
+ static get properties() {
490
+ return secure(getAccessors(Tempo)
491
+ .filter(acc => getType(acc) !== 'Symbol')); // omit any Symbol properties
492
+ }
493
+ /** Tempo global config settings */
494
+ static get config() {
495
+ return secure({ ...Tempo.#global.config });
496
+ }
497
+ /** Tempo initial default settings */
498
+ static get default() {
499
+ return secure(Default);
500
+ }
501
+ /**
502
+ * configuration governing the static 'rules' used when parsing Tempo.DateTime argument
503
+ */
504
+ static get parse() {
505
+ const parse = Tempo.#global.parse;
506
+ return secure({
507
+ ...parse,
508
+ snippet: { ...parse.snippet },
509
+ layout: { ...parse.layout },
510
+ event: { ...parse.event },
511
+ period: { ...parse.period },
512
+ // token: { ...parse.token }, // I don't believe the Token needs to be exposed
513
+ });
514
+ }
515
+ /** iterate over Tempo properties */
516
+ static [Symbol.iterator]() {
517
+ return Tempo.properties[Symbol.iterator](); // static Iterator over array of 'getters'
518
+ }
519
+ // #endregion Static public methods
520
+ // #region Instance symbols~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
521
+ /** allow for auto-convert of Tempo to BigInt */
522
+ [Symbol.toPrimitive](hint) {
523
+ Tempo.#dbg.info(this.config, getType(this), '.hint: ', hint);
524
+ return this.nano;
525
+ }
526
+ /** iterate over instance formats */
527
+ [Symbol.iterator]() {
528
+ return ownEntries(this.#fmt)[Symbol.iterator](); // instance Iterator over tuple of FormatType[]
529
+ }
530
+ get [Symbol.toStringTag]() {
531
+ return 'Tempo'; // hard-coded to avoid minification mangling
532
+ }
533
+ // #endregion Instance symbols
534
+ // #region Instance properties~~~~~~~~~~~~~~~~~~~~~~~~~~~~
535
+ /** constructor tempo */ #tempo;
536
+ /** constructor options */ #options = {};
537
+ /** instantiation Temporal Instant */ #now;
538
+ /** underlying Temporal ZonedDateTime */ #zdt;
539
+ /** prebuilt formats, for convenience */ #fmt = {};
540
+ /** instance term plugins */ #term = {};
541
+ /** instance values to complement static values */ #local = {
542
+ /** instance configuration */ config: {},
543
+ /** instance parse rules (only populated when local overrides exist) */ parse: {}
544
+ };
545
+ constructor(tempo, options = {}) {
546
+ this.#now = Temporal.Now.instant(); // stash current Instant
547
+ // swap arguments around, if arg1=Options or Temporal-like
548
+ [this.#tempo, this.#options] = (this.#isOptions(tempo) || this.#isZonedDateTimeLike(tempo))
549
+ ? [tempo?.value, tempo]
550
+ : [tempo, { ...options }];
551
+ // parse the local options looking for overrides to Tempo.#global.config
552
+ this.#setLocal(this.#options);
553
+ // we now have all the info we need to instantiate a new Tempo
554
+ try {
555
+ this.#zdt = this.#parse(this.#tempo); // attempt to interpret the DateTime arg
556
+ if (['iso8601', 'gregory'].includes(this.#local.config['calendar'])) {
557
+ const formats = ownEntries(Tempo.FORMAT);
558
+ for (const [key, val] of formats)
559
+ this.#fmt[key] = this.format(val);
560
+ }
561
+ terms // add the plug-in getters for the pre-defined Terms to the instance
562
+ .forEach(({ key, scope, define }) => {
563
+ this.#setTerm(this, key, define, true); // add a getter which returns the key-field only
564
+ this.#setTerm(this, scope, define, false); // add a getter which returns a range-object
565
+ });
566
+ if (isDefined(Tempo.#pending)) { // are we mutating with 'set()' ?
567
+ this.#local.parse.result = Tempo.#pending; // stash collected parse-matches
568
+ Tempo.#pending = void 0; // and reset mutating-flag
569
+ }
570
+ secure(this.#fmt); // prevent mutations
571
+ secure(this.#local.config);
572
+ secure(this.#local.parse);
573
+ }
574
+ catch (err) {
575
+ Tempo.#dbg.catch(this.config, `Cannot create Tempo: ${err.message}\n${err.stack}`);
576
+ return {}; // return empty Object
577
+ }
578
+ }
579
+ // This function has be defined within the Tempo class (and not imported from another module) because it references a private-variable
580
+ /** this will add the self-updating {getter} on the Tempo.term object */
581
+ #setTerm(self, name, define, isKeyOnly) {
582
+ if (isDefined(name) && isDefined(define)) {
583
+ Object.defineProperty(self.#term, name, {
584
+ configurable: false,
585
+ enumerable: false,
586
+ get: function () {
587
+ const props = Object.getOwnPropertyDescriptors(self.#term);
588
+ self.#term = {}; // wipe down the 'term'
589
+ ownEntries(props)
590
+ .forEach(([prop, desc]) => {
591
+ if (prop !== name) // except the current one
592
+ Object.defineProperty(self.#term, prop, desc);
593
+ });
594
+ const value = define.call(self, isKeyOnly); // evaluate the term range-lookup
595
+ Object.defineProperty(self.#term, name, {
596
+ value,
597
+ configurable: false,
598
+ writable: false,
599
+ enumerable: true,
600
+ });
601
+ secure(self.#term);
602
+ return secure(value);
603
+ }
604
+ });
605
+ }
606
+ }
607
+ // #endregion Constructor
608
+ // #region Instance public accessors~~~~~~~~~~~~~~~~~~~~~~
609
+ /** 4-digit year (e.g., 2024) */ get yy() { return this.#zdt.year; }
610
+ /** Month number: Jan=1, Dec=12 */ get mm() { return this.#zdt.month; }
611
+ /** ISO week number of the year */ get ww() { return this.#zdt.weekOfYear; }
612
+ /** Day of the month (1-31) */ get dd() { return this.#zdt.day; }
613
+ /** Day of the month (alias for `dd`) */ get day() { return this.#zdt.day; }
614
+ /** Hour of the day (0-23) */ get hh() { return this.#zdt.hour; }
615
+ /** Minutes of the hour (0-59) */ get mi() { return this.#zdt.minute; }
616
+ /** Seconds of the minute (0-59) */ get ss() { return this.#zdt.second; }
617
+ /** Milliseconds of the second (0-999) */ get ms() { return this.#zdt.millisecond; }
618
+ /** Microseconds of the millisecond (0-999) */ get us() { return this.#zdt.microsecond; }
619
+ /** Nanoseconds of the microsecond (0-999) */ get ns() { return this.#zdt.nanosecond; }
620
+ /** Fractional seconds (e.g., 0.123456789) */ get ff() { return +(`0.${pad(this.ms, 3)}${pad(this.us, 3)}${pad(this.ns, 3)}`); }
621
+ /** IANA Time Zone ID (e.g., 'Australia/Sydney') */ get tz() { return this.#zdt.timeZoneId; }
622
+ /** Unix timestamp (defaults to milliseconds) */ get ts() { return this.epoch[this.#local.config['timeStamp']]; }
623
+ /** Short month name (e.g., 'Jan') */ get mmm() { return Tempo.MONTH.keyOf(this.#zdt.month); }
624
+ /** Full month name (e.g., 'January') */ get mon() { return Tempo.MONTHS.keyOf(this.#zdt.month); }
625
+ /** Short weekday name (e.g., 'Mon') */ get www() { return Tempo.WEEKDAY.keyOf(this.#zdt.dayOfWeek); }
626
+ /** Full weekday name (e.g., 'Monday') */ get wkd() { return Tempo.WEEKDAYS.keyOf(this.#zdt.dayOfWeek); }
627
+ /** ISO weekday number: Mon=1, Sun=7 */ get dow() { return this.#zdt.dayOfWeek; }
628
+ /** Nanoseconds since Unix epoch (BigInt) */ get nano() { return this.#zdt.epochNanoseconds; }
629
+ /** Instance-specific configuration settings */ get config() { return this.#local.config; }
630
+ /** Instance-specific parse rules (merged with global) */ get parse() { return this.#local.parse; }
631
+ /** Object containing results from all term plugins */ get term() { return this.#term; }
632
+ /** Formatted results for all pre-defined format codes */ get fmt() { return this.#fmt; }
633
+ /** units since epoch */ get epoch() {
634
+ return secure({
635
+ /** seconds since epoch */ ss: Math.trunc(this.#zdt.epochMilliseconds / 1_000),
636
+ /** milliseconds since epoch */ ms: this.#zdt.epochMilliseconds,
637
+ /** microseconds since epoch */ us: Number(this.#zdt.epochNanoseconds / BigInt(1_000)),
638
+ /** nanoseconds since epoch */ ns: this.#zdt.epochNanoseconds,
639
+ });
640
+ }
641
+ /** time duration until another date-time */ until(optsOrDate, optsOrUntil) { return this.#until(optsOrDate, optsOrUntil); }
642
+ /** time elapsed since another date-time */ since(optsOrDate, optsOrUntil) { return this.#since(optsOrDate, optsOrUntil); }
643
+ /** applies a format to the current `Tempo` instance. */ format(fmt) { return this.#format(fmt); }
644
+ /** returns a new `Tempo` with specific duration added. */ add(mutate) { return this.#add(mutate); }
645
+ /** returns a new `Tempo` with specific offsets. */ set(offset) { return this.#set(offset); }
646
+ /** `true` if the underlying date-time is valid. */ isValid() { return !isEmpty(this); }
647
+ /** the underlying `Temporal.ZonedDateTime` object. */ toDateTime() { return this.#zdt; }
648
+ /** the date-time as a `Temporal.Instant`. */ toInstant() { return this.#now; }
649
+ /** the date-time as a standard `Date` object. */ toDate() { return new Date(this.#zdt.round({ smallestUnit: 'millisecond' }).epochMilliseconds); }
650
+ /** the ISO8601 string representation of the date-time. */ toString() { return this.#zdt.toString(); }
651
+ /** Custom JSON serialization for `JSON.stringify`. */ toJSON() { return { ...this.#local.config, value: this.toString() }; }
652
+ // #endregion Instance public methods
653
+ // #region Instance private methods~~~~~~~~~~~~~~~~~~~~~~~
654
+ /**
655
+ * setup local 'config' and 'parse' rules (with prototype set to global)
656
+ * we copy down the current global config to the local instance, then apply any options provided.
657
+ * in this way, we preserve immutability of this instance, in case the user later changes the global config.
658
+ *
659
+ * we do not copy down the current global parse rules, but instead create a new parse object
660
+ * that prototypes the global parse object. this way, we can add new parse rules to the local
661
+ * parse object without affecting the global parse object.
662
+ */
663
+ #setLocal(options) {
664
+ // copy down current global config to local instance
665
+ this.#local.config = Object.create(Tempo.#global.config); // set prototype-;ink to global config
666
+ const { mdyLocales, mdyLayouts, ...config } = Tempo.#global.config;
667
+ Object.assign(this.#local.config, config, { level: 'local' });
668
+ // setup effective parse rules for this instance (prototype-link to global)
669
+ this.#local.parse = Object.create(Tempo.#global.parse); // set prototype to global parse
670
+ this.#local.parse.result = []; // start with empty result
671
+ Tempo.#setConfig(this.#local, options); // set #local config
672
+ }
673
+ /** parse DateTime input */
674
+ #parse(tempo, dateTime) {
675
+ const timeZone = this.#local.config['timeZone'];
676
+ const calendar = this.#local.config['calendar'];
677
+ const today = dateTime ?? this.#now // use provided ZonedDateTime, else cast instantiation to current timeZone
678
+ .toZonedDateTimeISO(timeZone);
679
+ const { type, value } = this.#conform(tempo, today); // if String or Number, conform the input against known patterns
680
+ if (isEmpty(this.#local.parse.result)) // #conform() didn't find any matches
681
+ this.#local.parse.result = [{ type, value }];
682
+ Tempo.#dbg.info(this.#local.config, 'parse', `{type: ${type}, value: ${value}}`); // show what we're parsing
683
+ switch (type) {
684
+ case 'Null':
685
+ case 'Void':
686
+ case 'Empty':
687
+ case 'Undefined':
688
+ return today;
689
+ case 'String': // String which didn't conform to a Tempo.pattern
690
+ case 'Temporal.ZonedDateTime':
691
+ try {
692
+ return Temporal.ZonedDateTime.from(value); // see if Temporal can parse value
693
+ }
694
+ catch { // else see if Date.parse can parse value
695
+ const fallback = { match: 'Date.parse' };
696
+ this.#result({ type, value }, fallback);
697
+ Tempo.#dbg.warn(this.#local.config, 'Cannot detect DateTime; fallback to Date.parse');
698
+ return Temporal.ZonedDateTime
699
+ .from(`${new Date(value.toString()).toISOString()}[${timeZone}]`)
700
+ .withCalendar(calendar);
701
+ }
702
+ case 'Temporal.PlainDate':
703
+ case 'Temporal.PlainDateTime':
704
+ return value
705
+ .toZonedDateTime(timeZone)
706
+ .withCalendar(calendar);
707
+ case 'Temporal.PlainTime':
708
+ return today.withPlainTime(value);
709
+ case 'Temporal.PlainYearMonth': // assume current day, else end-of-month
710
+ return value
711
+ .toPlainDate({ day: Math.min(today.day, value.daysInMonth) })
712
+ .toZonedDateTime(timeZone)
713
+ .withCalendar(calendar);
714
+ case 'Temporal.PlainMonthDay': // assume current year
715
+ return value
716
+ .toPlainDate({ year: today.year })
717
+ .toZonedDateTime(timeZone)
718
+ .withCalendar(calendar);
719
+ case 'Temporal.Instant':
720
+ return value
721
+ .toZonedDateTimeISO(timeZone)
722
+ .withCalendar(calendar);
723
+ case 'Tempo':
724
+ return value
725
+ .toDateTime(); // clone provided Tempo
726
+ case 'Date':
727
+ return new Temporal.ZonedDateTime(BigInt(value.getTime() * 1_000_000), timeZone, calendar);
728
+ case 'Number': // Number which didn't conform to a Tempo.pattern
729
+ const [seconds = BigInt(0), suffix = BigInt(0)] = value.toString().split('.').map(BigInt);
730
+ const nano = BigInt(suffix.toString().substring(0, 9).padEnd(9, '0'));
731
+ return new Temporal.ZonedDateTime(seconds * BigInt(1_000_000_000) + nano, timeZone, calendar);
732
+ case 'BigInt': // BigInt is not conformed against a Tempo.pattern
733
+ return new Temporal.ZonedDateTime(value, timeZone, calendar);
734
+ default:
735
+ Tempo.#dbg.catch(this.#local.config, `Unexpected Tempo parameter type: ${type}, ${String(value)}`);
736
+ return today;
737
+ }
738
+ }
739
+ /** check if we've been given a Tempo Options object */
740
+ #isOptions(arg) {
741
+ return isObject(arg) && ownKeys(arg)
742
+ .some(key => ['snippet', 'layout', 'event', 'period', 'mdyLocales', 'mdyLayouts', 'debug', 'catch', 'store', 'pivot'].includes(key));
743
+ }
744
+ /** check if we've been given a ZonedDateTimeLike object */
745
+ #isZonedDateTimeLike(tempo) {
746
+ if (!isObject(tempo) || isEmpty(tempo))
747
+ return false;
748
+ // if it contains any 'options' keys, it's not a ZonedDateTime
749
+ const keys = ownKeys(tempo);
750
+ if (keys.some(key => ['snippet', 'layout', 'event', 'period', 'mdyLocales', 'mdyLayouts', 'debug', 'catch', 'store', 'pivot'].includes(key)))
751
+ return false;
752
+ // we include {value} to allow for Tempo instances
753
+ return keys
754
+ .filter(isString)
755
+ .every(key => ['value', 'timeZoneId', 'calendarId', 'year', 'month', 'monthCode', 'day', 'hour', 'minute', 'second', 'millisecond', 'microsecond', 'nanosecond', 'offset', 'timeZone'].includes(key)); // if every key in tempo-object is included in this array
756
+ }
757
+ /** trace the initial instance pattern-match */
758
+ #result(base, ...rest) {
759
+ (Tempo.#pending ?? this.#local.parse.result)
760
+ .push(Object.assign({}, base, ...rest));
761
+ }
762
+ /** evaluate 'string | number' input against known patterns */
763
+ #conform(tempo, dateTime) {
764
+ const arg = asType(tempo);
765
+ if (this.#isZonedDateTimeLike(tempo)) { // tempo is ZonedDateTime-ish object (throw away 'value' property)
766
+ const { timeZone, calendar, value, ...options } = tempo;
767
+ let zdt = !isEmpty(options)
768
+ ? dateTime.with({ ...options })
769
+ : dateTime;
770
+ if (timeZone)
771
+ zdt = zdt.withTimeZone(timeZone); // optionally set timeZone
772
+ if (calendar)
773
+ zdt = zdt.withCalendar(calendar); // optionally set calendar
774
+ this.#result({ type: 'Temporal.ZonedDateTimeLike', value: zdt, match: 'Temporal.ZonedDateTimeLike' });
775
+ return Object.assign(arg, {
776
+ type: 'Temporal.ZonedDateTime', // override {arg.type}
777
+ value: zdt,
778
+ });
779
+ }
780
+ if (!isType(arg.value, 'String', 'Number'))
781
+ return arg; // only conform String or Number (not BigInt, etc) against known patterns
782
+ const value = trimAll(arg.value, Match.strips); // cast as String, remove \( \) and control-characters
783
+ if (isString(arg.value)) { // if original value is String
784
+ if (isEmpty(value)) { // don't conform empty string
785
+ this.#result(arg, { match: 'Empty' });
786
+ return Object.assign(arg, { type: 'Empty' });
787
+ }
788
+ if (isIntegerLike(value)) { // if string representation of BigInt literal
789
+ this.#result(arg, { match: 'BigInt' });
790
+ return Object.assign(arg, { type: 'BigInt', value: asInteger(value) });
791
+ }
792
+ }
793
+ else { // else it is a Number
794
+ if (value.length <= 7) { // cannot reliably interpret small numbers: might be {ss} or {yymmdd} or {dmmyyyy}
795
+ Tempo.#dbg.catch(this.#local.config, 'Cannot safely interpret number with less than 8-digits: use string instead');
796
+ return arg;
797
+ }
798
+ }
799
+ if (isUndefined(this.#zdt)) // if first pass
800
+ dateTime = dateTime.withPlainTime('00:00:00'); // strip out all time-components
801
+ const map = this.#local.parse.pattern;
802
+ for (const [sym, pat] of map) {
803
+ const groups = this.#parseMatch(pat, value); // determine pattern-match groups
804
+ if (isEmpty(groups))
805
+ continue; // no match, so skip this iteration
806
+ this.#result(arg, { match: sym.description, groups: cleanify(groups) }); // stash the {key} of the pattern that was matched
807
+ this.#parseGroups(groups); // mutate the {groups} object
808
+ dateTime = this.#parseWeekday(groups, dateTime); // if {weekDay} pattern, calculate a calendar value
809
+ dateTime = this.#parseDate(groups, dateTime); // if {calendar}|{event} pattern, translate to date value
810
+ dateTime = this.#parseTime(groups, dateTime); // if {clock}|{period} pattern, translate to a time value
811
+ /**
812
+ * finished analyzing a matched pattern.
813
+ * rebuild {arg.value} into a ZonedDateTime
814
+ */
815
+ Object.assign(arg, { type: 'Temporal.ZonedDateTime', value: dateTime });
816
+ Tempo.#dbg.info(this.#local.config, 'groups', groups); // show the resolved date-time elements
817
+ Tempo.#dbg.info(this.#local.config, 'pattern', sym.description); // show the pattern that was matched
818
+ break; // stop checking patterns
819
+ }
820
+ return arg;
821
+ }
822
+ /** apply a regex-match against a value, and clean the result */
823
+ #parseMatch(pat, value) {
824
+ const groups = value.toString().match(pat)?.groups || {};
825
+ ownEntries(groups) // remove undefined, NaN, null and empty values
826
+ .forEach(([key, val]) => isEmpty(val) && delete groups[key]);
827
+ return groups;
828
+ }
829
+ /**
830
+ * resolve any {event} | {period} to their date | time values,
831
+ * intercept any {month} string,
832
+ * set default {nbr} if {mod} present,
833
+ * Note: this will mutate the {groups} object
834
+ */
835
+ #parseGroups(groups) {
836
+ // fix {event}
837
+ const event = ownKeys(groups).find(key => key.match(Match.event));
838
+ if (event) {
839
+ const idx = +event.substring(4); // number index of the {event}
840
+ const src = event.startsWith('g') ? Tempo.#global.parse.event : this.#local.parse.event;
841
+ const [_key, evt] = ownEntries(src, true)[idx]; // fetch the indexed tuple's value
842
+ Object.assign(groups, this.#parseEvent(evt)); // determine the date-values for the {event}
843
+ delete groups[event];
844
+ const { yy, mm, dd } = groups;
845
+ if (isEmpty(yy) && isEmpty(mm) && isEmpty(dd))
846
+ return Tempo.#dbg.catch(this.#local.config, `Cannot determine a {date} or {event} from "${evt}"`);
847
+ }
848
+ // fix {period}
849
+ const period = ownKeys(groups).find(key => key.match(Match.period));
850
+ if (period) {
851
+ const idx = +period.substring(4); // number index of the {period}
852
+ const src = period.startsWith('g') ? Tempo.#global.parse.period : this.#local.parse.period;
853
+ const [_key, per] = ownEntries(src, true)[idx]; // fetch the indexed tuple's value
854
+ Object.assign(groups, this.#parsePeriod(per)); // determine the time-values for the {period}
855
+ delete groups[period];
856
+ if (isEmpty(groups["hh"])) // must have at-least {hh} time-component
857
+ return Tempo.#dbg.catch(this.#local.config, `Cannot determine a {time} or {period} from "${per}"`);
858
+ }
859
+ // fix {mm}
860
+ if (isDefined(groups["mm"]) && !isNumeric(groups["mm"])) {
861
+ const mm = Tempo.#prefix(groups["mm"]); // conform month-name
862
+ groups["mm"] = Tempo.MONTH.keys()
863
+ .findIndex(el => el === mm) // resolve month-name into a month-number
864
+ .toString() // (some browsers do not allow month-names when parsing a Date)
865
+ .padStart(2, '0');
866
+ }
867
+ // fix {rdt}
868
+ if (isDefined(groups["rdt"])) {
869
+ const idx = ['yesterday', 'tomorrow', 'today'].indexOf(groups["rdt"]);
870
+ const val = [Event['yesterday'], Event['tomorrow'], Event['today']][idx];
871
+ const zdt = val.bind(this)();
872
+ Object.assign(groups, {
873
+ yy: zdt.year.toString(),
874
+ mm: zdt.month.toString().padStart(2, '0'),
875
+ dd: zdt.day.toString().padStart(2, '0'),
876
+ });
877
+ delete groups["rdt"];
878
+ }
879
+ return groups;
880
+ }
881
+ /**
882
+ * We expect similar offset-logic to apply to 'modifiers' when parsing a string DateTime.
883
+ * returns {adjust} to make, based on {modifier}, {offset}, and {period}
884
+ * - previous period
885
+ * + next period
886
+ * -3 three periods ago
887
+ * < prior to base-date (asIs)
888
+ * <= prior to base-date (plus one)
889
+ */
890
+ #parseModifier({ mod, adjust, offset, period }) {
891
+ adjust = Math.abs(adjust);
892
+ switch (mod) {
893
+ case void 0: // no adjustment
894
+ case '=':
895
+ case 'this': // current period
896
+ return 0;
897
+ case '+': // next period
898
+ case 'next':
899
+ return adjust;
900
+ case '-': // previous period
901
+ case 'prev':
902
+ case 'last':
903
+ return -adjust;
904
+ case '<': // period before base-date
905
+ case 'ago':
906
+ return (period <= offset)
907
+ ? -adjust
908
+ : -(adjust - 1);
909
+ case '<=': // period before or including base-date
910
+ return (period < offset)
911
+ ? -adjust
912
+ : -(adjust - 1);
913
+ case '>': // period after base-date
914
+ case 'hence':
915
+ return (period > offset)
916
+ ? adjust
917
+ : (adjust - 1);
918
+ case '>=': // period after or including base-date
919
+ case '+=':
920
+ return (period >= offset)
921
+ ? adjust
922
+ : (adjust - 1);
923
+ default: // unexpected modifier
924
+ return 0;
925
+ }
926
+ }
927
+ /**
928
+ * if named-group 'wkd' detected (with optional 'mod', 'nbr', or time-units), then calc relative weekday offset
929
+ * | Example | Result | Note |
930
+ * | :--- | :---- | :---- |
931
+ * | `Wed` | Wed this week | might be earlier or later or equal to current day |
932
+ * | `-Wed` | Wed last week | same as new Tempo('Wed').add({ weeks: -1 }) |
933
+ * | `+Wed` | Wed next week | same as new Tempo('Wed').add({ weeks: 1 }) |
934
+ * | `-3Wed` | Wed three weeks ago | same as new Tempo('Wed').add({ weeks: -3 }) |
935
+ * | `<Wed` | Wed prior to today | might be current or previous week |
936
+ * | `<=Wed` | Wed prior to tomorrow | might be current or previous week |
937
+ * | `Wed noon` | Wed this week at 12:00pm | even though time-periods may be present, ignore them in this method |
938
+ *
939
+ * @returns ZonedDateTime with computed date-offset
940
+ */
941
+ #parseWeekday(groups, dateTime) {
942
+ const { wkd, mod, nbr = '1', sfx, ...rest } = groups;
943
+ if (isUndefined(wkd)) // this is not a true {weekDay} pattern match
944
+ return dateTime;
945
+ /**
946
+ * the {weekDay} pattern should only have keys of {wkd}, {mod}, {nbr}, {sfx} (and optionally time-units)
947
+ * for example: {wkd: 'Wed', mod: '>', hh: '10', mer: 'pm'}
948
+ * we early-exit if we find anything other than time-units
949
+ */
950
+ const time = ['hh', 'mi', 'ss', 'ms', 'us', 'ns', 'ff', 'mer'];
951
+ if (!ownKeys(rest)
952
+ .every(key => time.includes(key))) // non 'time-unit' keys detected
953
+ return dateTime; // this is not a true {weekDay} pattern, so early-exit
954
+ if (!isEmpty(mod) && !isEmpty(sfx)) {
955
+ Tempo.#dbg.warn(`Cannot provide both a modifier '${mod}' and suffix '${sfx}'`);
956
+ return dateTime; // cannot provide both 'modifier' and 'suffix'
957
+ }
958
+ const weekday = Tempo.#prefix(wkd); // conform weekday-name
959
+ const adjust = +nbr; // how many weeks to adjust
960
+ const offset = Tempo.WEEKDAY.keys() // how far weekday is from today
961
+ .findIndex(el => el === weekday);
962
+ const days = offset - dateTime.dayOfWeek // number of days to offset from dateTime
963
+ + (this.#parseModifier({ mod: mod ?? sfx, adjust, offset, period: dateTime.dayOfWeek }) * dateTime.daysInWeek);
964
+ delete groups["wkd"];
965
+ delete groups["mod"];
966
+ delete groups["nbr"];
967
+ delete groups["sfx"];
968
+ return dateTime
969
+ .add({ days }); // set new {day}
970
+ }
971
+ /**
972
+ * match input against date patterns
973
+ * @returns adjusted ZonedDateTime with resolved time-components
974
+ */
975
+ #parseDate(groups, dateTime) {
976
+ const { mod, nbr = '1', afx, unt, yy, mm, dd } = groups;
977
+ if (isEmpty(yy) && isEmpty(mm) && isEmpty(dd) && isUndefined(unt))
978
+ return dateTime; // return default
979
+ if (!isEmpty(mod) && !isEmpty(afx)) {
980
+ Tempo.#dbg.warn(`Cannot provide both a modifier '${mod}' and suffix '${afx}'`);
981
+ return dateTime;
982
+ }
983
+ let { year, month, day } = this.#num({
984
+ year: yy ?? dateTime.year, // supplied year, else current year
985
+ month: mm ?? dateTime.month, // supplied month, else current month
986
+ day: dd ?? dateTime.day, // supplied day, else current day
987
+ });
988
+ // handle {unt} relative offset (e.g. '2 days ago')
989
+ if (unt) {
990
+ const adjust = +nbr;
991
+ const direction = (mod === '<' || mod === '-' || afx === 'ago') ? -1 : 1;
992
+ const plural = singular(unt) + 's';
993
+ dateTime = dateTime.add({ [plural]: adjust * direction });
994
+ delete groups["unt"];
995
+ delete groups["nbr"];
996
+ delete groups["afx"];
997
+ delete groups["mod"];
998
+ return dateTime;
999
+ }
1000
+ /**
1001
+ * change two-digit year into four-digits using 'pivot-year' (defaulted to '75' years) to determine century
1002
+ * pivot = (currYear - Tempo.pivot) % 100 // for example: Rem((2024 - 75) / 100) => 49
1003
+ * century = Int(currYear / 100) // for example: Int(2024 / 100) => 20
1004
+ * 22 => 2022 // 22 is less than pivot, so use {century}
1005
+ * 57 => 1957 // 57 is more than pivot, so use {century - 1}
1006
+ */
1007
+ if (year.toString().match(Match.twoDigit)) { // if {year} match just-two digits
1008
+ const pivot = dateTime
1009
+ .subtract({ years: this.#local.config['pivot'] }) // pivot cutoff to determine century
1010
+ .year % 100; // remainder
1011
+ const century = Math.trunc(dateTime.year / 100); // current century
1012
+ year += (century - Number(year >= pivot)) * 100; // now a four-digit year
1013
+ }
1014
+ // adjust the {year} if a Modifier is present
1015
+ const adjust = +nbr; // how many years to adjust
1016
+ const offset = Number(pad(month) + '.' + pad(day)); // the event month.day
1017
+ const period = Number(pad(dateTime.month) + '.' + pad(dateTime.day + 1));
1018
+ year += this.#parseModifier({ mod: mod ?? afx, adjust, offset, period });
1019
+ Object.assign(groups, { yy: year, mm: month, dd: day });
1020
+ delete groups["mod"];
1021
+ delete groups["nbr"];
1022
+ delete groups["afx"];
1023
+ // all date-components are now set; check for overflow in case past end-of-month
1024
+ return Temporal.PlainDate.from({ year, month, day }, { overflow: 'constrain' })
1025
+ .toZonedDateTime(dateTime.timeZoneId) // adjust to constrained date
1026
+ .withPlainTime(dateTime.toPlainTime()); // restore the time
1027
+ }
1028
+ /**
1029
+ * match input against 'tm' pattern.
1030
+ * {groups} is expected to contain time-components (like {hh:'3', mi:'30', mer:'pm'}).
1031
+ * returns an adjusted ZonedDateTime
1032
+ */
1033
+ #parseTime(groups = {}, dateTime) {
1034
+ if (isUndefined(groups["hh"])) // must contain 'time' with at least {hh}
1035
+ return dateTime;
1036
+ let { hh = 0, mi = 0, ss = 0, ms = 0, us = 0, ns = 0 } = this.#num(groups);
1037
+ if (hh >= 24) {
1038
+ dateTime = dateTime.add({ days: Math.trunc(hh / 24) }); // move the date forward number of days to offset
1039
+ hh %= 24; // midnight is '00:00' on the next-day
1040
+ }
1041
+ if (isDefined(groups["ff"])) { // {ff} is fractional seconds and overrides {ms|us|ns}
1042
+ const ff = groups["ff"].substring(0, 9).padEnd(9, '0');
1043
+ ms = +ff.substring(0, 3);
1044
+ us = +ff.substring(3, 6);
1045
+ ns = +ff.substring(6, 9);
1046
+ }
1047
+ if (groups["mer"]?.toLowerCase() === 'pm' && hh < 12 && (hh + mi + ss + ms + us + ns) > 0)
1048
+ hh += 12; // anything after midnight and before midday
1049
+ if (groups["mer"]?.toLowerCase() === 'am' && hh >= 12)
1050
+ hh -= 12; // anything after midday
1051
+ return dateTime // return the computed time-values
1052
+ .withPlainTime({ hour: hh, minute: mi, second: ss, millisecond: ms, microsecond: us, nanosecond: ns });
1053
+ }
1054
+ /**
1055
+ * match an {event} string against a date pattern
1056
+ * if {evt} is a function, it is bound to the current instance (to allow access to properties/methods)
1057
+ */
1058
+ #parseEvent(evt) {
1059
+ const groups = {};
1060
+ const val = isFunction(evt)
1061
+ ? evt.bind(this)().toString()
1062
+ : evt;
1063
+ const pats = this.#local.parse.isMonthDay // first find out if we have a US-format timeZone
1064
+ ? ['mdy', 'dmy', 'ymd'] // try {mdy} before {dmy} if US-format
1065
+ : ['dmy', 'mdy', 'ymd']; // else try {dmy} before {mdy}
1066
+ for (const pat of pats) {
1067
+ const reg = this.#local.parse.pattern.get(Tempo.getSymbol(pat)); // get the RegExp for the date-pattern
1068
+ if (isUndefined(reg)) {
1069
+ Tempo.#dbg.catch(this.#local.config, `Cannot find pattern: "${pat}"`);
1070
+ }
1071
+ else {
1072
+ const match = this.#parseMatch(reg, val);
1073
+ if (!isEmpty(match))
1074
+ this.#result({ type: 'Event', value: val, match: pat, groups: cleanify(match) });
1075
+ Object.assign(groups, match);
1076
+ }
1077
+ if (!isEmpty(groups))
1078
+ break; // return on the first matched pattern
1079
+ }
1080
+ return groups; // overlay the match date-components
1081
+ }
1082
+ /**
1083
+ * match a {period} string against a time pattern
1084
+ * if {per} is a function, it is bound to the current instance (to allow access to properties/methods)
1085
+ */
1086
+ #parsePeriod(per) {
1087
+ const groups = {};
1088
+ const tm = this.#local.parse.pattern.get(Tempo.getSymbol('tm')); // get the RegExp for the time-pattern
1089
+ if (isUndefined(tm)) {
1090
+ Tempo.#dbg.catch(this.#local.config, `Cannot find pattern "tm"`);
1091
+ return;
1092
+ }
1093
+ const val = isFunction(per)
1094
+ ? per.bind(this)().toString()
1095
+ : per;
1096
+ const match = this.#parseMatch(tm, val);
1097
+ if (!isEmpty(match))
1098
+ this.#result({ type: 'Period', value: val, match: 'tm', groups: cleanify(match) });
1099
+ Object.assign(groups, match);
1100
+ return groups;
1101
+ }
1102
+ /** return a new object, with only numeric values */
1103
+ #num = (groups) => {
1104
+ return ownEntries(groups)
1105
+ .reduce((acc, [key, val]) => {
1106
+ if (isNumeric(val))
1107
+ acc[key] = ifNumeric(val);
1108
+ return acc;
1109
+ }, {});
1110
+ };
1111
+ /** create new Tempo with {offset} property */
1112
+ #add = (arg) => {
1113
+ Tempo.#pending ??= [...this.#local.parse.result]; // collected parse-results so-far
1114
+ const mutate = 'add';
1115
+ const zdt = ownEntries(arg) // loop through each mutation
1116
+ .reduce((zdt, [unit, offset]) => {
1117
+ const single = singular(unit);
1118
+ const plural = single + 's';
1119
+ switch (`${mutate}.${single}`) {
1120
+ case 'add.year':
1121
+ case 'add.month':
1122
+ case 'add.week':
1123
+ case 'add.day':
1124
+ case 'add.hour':
1125
+ case 'add.minute':
1126
+ case 'add.second':
1127
+ case 'add.millisecond':
1128
+ case 'add.microsecond':
1129
+ case 'add.nanosecond':
1130
+ return zdt
1131
+ .add({ [plural]: offset });
1132
+ default:
1133
+ Tempo.#dbg.catch(this.#local.config, `Unexpected method(${mutate}), unit(${unit}) and offset(${offset})`);
1134
+ return zdt;
1135
+ }
1136
+ }, this.#zdt);
1137
+ return new Tempo(zdt, this.#options);
1138
+ };
1139
+ /** create a new Tempo with {adjust} property */
1140
+ #set = (args) => {
1141
+ Tempo.#pending ??= [...this.#local.parse.result]; // collected parse-results so-far
1142
+ const zdt = ownEntries(args) // loop through each mutation
1143
+ .reduce((zdt, [key, adjust]) => {
1144
+ const { mutate, offset, single } = ((key) => {
1145
+ switch (key) {
1146
+ case 'start':
1147
+ case 'mid':
1148
+ case 'end':
1149
+ return { mutate: key, offset: 0, single: singular(adjust?.toString() ?? '') };
1150
+ default:
1151
+ return { mutate: 'set', offset: adjust, single: singular(key) };
1152
+ }
1153
+ })(key); // IIFE to analyze arguments
1154
+ switch (`${mutate}.${single}`) {
1155
+ case 'set.period':
1156
+ case 'set.time':
1157
+ case 'set.date':
1158
+ case 'set.event':
1159
+ case 'set.dow': // set day-of-week by number
1160
+ case 'set.wkd': // set day-of-week by name
1161
+ return this.#parse(offset, zdt);
1162
+ case 'set.year':
1163
+ case 'set.month':
1164
+ // case 'set.week': // not defined
1165
+ case 'set.day':
1166
+ case 'set.hour':
1167
+ case 'set.minute':
1168
+ case 'set.second':
1169
+ case 'set.millisecond':
1170
+ case 'set.microsecond':
1171
+ case 'set.nanosecond':
1172
+ return zdt
1173
+ .with({ [single]: offset });
1174
+ case 'set.yy':
1175
+ case 'set.mm':
1176
+ // case 'set.ww': // not defined
1177
+ case 'set.dd':
1178
+ case 'set.hh':
1179
+ case 'set.mi':
1180
+ case 'set.ss':
1181
+ case 'set.ms':
1182
+ case 'set.us':
1183
+ case 'set.ns':
1184
+ const value = Tempo.ELEMENT[single];
1185
+ return zdt
1186
+ .with({ [value]: offset });
1187
+ case 'start.year':
1188
+ return zdt
1189
+ .with({ month: Tempo.MONTH.Jan, day: 1 })
1190
+ .startOfDay();
1191
+ case 'start.term': // TODO
1192
+ return zdt;
1193
+ case 'start.month':
1194
+ return zdt
1195
+ .with({ day: 1 })
1196
+ .startOfDay();
1197
+ case 'start.week':
1198
+ return zdt
1199
+ .add({ days: -(this.dow - Tempo.WEEKDAY.Mon) })
1200
+ .startOfDay();
1201
+ case 'start.day':
1202
+ return zdt
1203
+ .startOfDay();
1204
+ case 'start.hour':
1205
+ case 'start.minute':
1206
+ case 'start.second':
1207
+ return zdt
1208
+ .round({ smallestUnit: offset, roundingMode: 'trunc' });
1209
+ // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
1210
+ case 'mid.year':
1211
+ return zdt
1212
+ .with({ month: Tempo.MONTH.Jul, day: 1 })
1213
+ .startOfDay();
1214
+ case 'mid.term': // TODO: relevant?
1215
+ return zdt;
1216
+ case 'mid.month':
1217
+ return zdt
1218
+ .with({ day: Math.trunc(zdt.daysInMonth / 2) })
1219
+ .startOfDay();
1220
+ case 'mid.week':
1221
+ return zdt
1222
+ .add({ days: -(this.dow - Tempo.WEEKDAY.Thu) })
1223
+ .startOfDay();
1224
+ case 'mid.day':
1225
+ return zdt
1226
+ .round({ smallestUnit: 'day', roundingMode: 'trunc' })
1227
+ .add({ hours: 12 });
1228
+ case 'mid.hour':
1229
+ return zdt
1230
+ .round({ smallestUnit: 'hour', roundingMode: 'trunc' })
1231
+ .add({ minutes: 30 });
1232
+ case 'mid.minute':
1233
+ return zdt
1234
+ .round({ smallestUnit: 'minute', roundingMode: 'trunc' })
1235
+ .add({ seconds: 30 });
1236
+ case 'mid.second':
1237
+ return zdt
1238
+ .round({ smallestUnit: 'second', roundingMode: 'trunc' })
1239
+ .add({ milliseconds: 500 });
1240
+ // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
1241
+ case 'end.year':
1242
+ return zdt
1243
+ .add({ years: 1 })
1244
+ .with({ month: Tempo.MONTH.Jan, day: 1 })
1245
+ .startOfDay()
1246
+ .subtract({ nanoseconds: 1 });
1247
+ case 'end.term': // TODO
1248
+ return zdt
1249
+ .subtract({ nanoseconds: 1 });
1250
+ case 'end.month':
1251
+ return zdt
1252
+ .add({ months: 1 })
1253
+ .with({ day: 1 })
1254
+ .startOfDay()
1255
+ .subtract({ nanoseconds: 1 });
1256
+ case 'end.week':
1257
+ return zdt
1258
+ .add({ days: (Tempo.WEEKDAY.Sun - this.dow) + 1 })
1259
+ .startOfDay()
1260
+ .subtract({ nanoseconds: 1 });
1261
+ case 'end.day':
1262
+ case 'end.hour':
1263
+ case 'end.minute':
1264
+ case 'end.second':
1265
+ return zdt
1266
+ .round({ smallestUnit: offset, roundingMode: 'ceil' })
1267
+ .subtract({ nanoseconds: 1 });
1268
+ default:
1269
+ Tempo.#dbg.catch(this.#local.config, `Unexpected method(${mutate}), unit(${adjust}) and offset(${single})`);
1270
+ return zdt;
1271
+ }
1272
+ }, this.#zdt); // start reduce with the Tempo zonedDateTime
1273
+ return new Tempo(zdt, this.#options);
1274
+ };
1275
+ #format = (fmt) => {
1276
+ if (isNull(this.#tempo))
1277
+ return void 0; // don't format <null> dates
1278
+ const obj = Tempo.FORMAT;
1279
+ const template = isString(fmt) && Tempo.#hasOwn(obj, fmt)
1280
+ ? obj[fmt]
1281
+ : fmt;
1282
+ const sTemplate = String(template);
1283
+ switch (sTemplate) {
1284
+ case obj.yearWeek:
1285
+ const offset = this.ww === 1 && this.mm === Tempo.MONTH.Dec; // if late-Dec, add 1 to yy
1286
+ return +`${this.yy + +offset}${pad(this.ww)}`;
1287
+ case obj.yearMonth:
1288
+ return +`${this.yy}${pad(this.mm)}`;
1289
+ case obj.yearMonthDay:
1290
+ return +`${this.yy}${pad(this.mm)}${pad(this.dd)}`;
1291
+ default:
1292
+ return sTemplate.replace(Match.braces, (_match, token) => {
1293
+ switch (token) {
1294
+ case 'yyyy': return pad(this.yy, 4);
1295
+ case 'yy': return pad(this.yy % 100);
1296
+ case 'mon': return this.mon;
1297
+ case 'mmm': return this.mmm;
1298
+ case 'mm': return pad(this.mm);
1299
+ case 'dd': return pad(this.dd);
1300
+ case 'day': return this.day.toString();
1301
+ case 'dow': return this.dow.toString();
1302
+ case 'wkd': return this.wkd;
1303
+ case 'www': return this.www;
1304
+ case 'ww': return pad(this.ww);
1305
+ case 'hh': return pad(this.hh);
1306
+ case 'HH': return pad(this.hh > 12 ? this.hh % 12 : this.hh || 12);
1307
+ case 'mer': return this.hh >= 12 ? 'pm' : 'am';
1308
+ case 'MER': return this.hh >= 12 ? 'PM' : 'AM';
1309
+ case 'mi': return pad(this.mi);
1310
+ case 'ss': return pad(this.ss);
1311
+ case 'ms': return pad(this.ms, 3);
1312
+ case 'us': return pad(this.us, 3);
1313
+ case 'ns': return pad(this.ns, 3);
1314
+ case 'ff': return `${pad(this.ms, 3)}${pad(this.us, 3)}${pad(this.ns, 3)}`;
1315
+ case 'hhmiss': return pad(this.hh) + pad(this.mi) + pad(this.ss);
1316
+ case 'ts': return this.ts.toString();
1317
+ case 'nano': return this.nano.toString();
1318
+ case 'tz': return this.tz;
1319
+ default: {
1320
+ return token.startsWith('term.')
1321
+ ? stringify(this.term[token.slice(5)])
1322
+ : `{${token}}`; // unknown format-code, return as-is
1323
+ }
1324
+ }
1325
+ });
1326
+ }
1327
+ };
1328
+ #until(arg, until = {}, since = false) {
1329
+ let value, opts = {}, unit = void 0;
1330
+ switch (true) {
1331
+ case isString(arg) && Tempo.ELEMENT.includes(singular(arg)):
1332
+ unit = arg; // e.g. tempo.until('hours')
1333
+ ({ value, ...opts } = until);
1334
+ break;
1335
+ case isString(arg): // assume 'arg' is a dateTime string
1336
+ value = arg; // e.g. tempo.until('20-May-1957', {unit: 'years'})
1337
+ if (isObject(until))
1338
+ ({ unit, ...opts } = until);
1339
+ else
1340
+ unit = until; // assume the 'until' arg is a 'unit' string
1341
+ break;
1342
+ case isObject(arg) && isString(until): // assume 'until' is a Unit
1343
+ unit = until; // e.g. tempo.until({value:'20-May-1957}, 'years'})
1344
+ ({ value, ...opts } = arg);
1345
+ break;
1346
+ case isObject(arg) && isObject(until): // assume combination of Tempo.Options and Tempo.Until
1347
+ ({ value, unit, ...opts } = Object.assign({}, arg, until));
1348
+ break;
1349
+ case isString(until):
1350
+ unit = until;
1351
+ value = arg;
1352
+ break;
1353
+ case isObject(until):
1354
+ unit = until.unit;
1355
+ value = arg;
1356
+ break;
1357
+ default:
1358
+ value = arg; // assume 'arg' is a DateTime
1359
+ }
1360
+ const offset = new Tempo(value, opts); // create the offset Tempo
1361
+ const diffZone = this.#zdt.timeZoneId !== offset.#zdt.timeZoneId;
1362
+ const duration = this.#zdt.until(offset.#zdt, { largestUnit: diffZone ? 'hours' : (unit ?? 'years') });
1363
+ if (isDefined(unit))
1364
+ unit = `${singular(unit)}s`; // coerce to plural
1365
+ return (isUndefined(unit) || since) // if no 'unit' provided, or if called via #since()
1366
+ ? getAccessors(duration) // return an Object with all the duration accessors
1367
+ .reduce((acc, dur) => Object.assign(acc, { [dur]: duration[dur] }), ifDefined({ iso: duration.toString(), unit }))
1368
+ : duration.total({ relativeTo: this.#zdt, unit }); // sum-up the duration components
1369
+ }
1370
+ /** format the elapsed time between two Tempos (to nanosecond) */
1371
+ #since(arg, until = {}) {
1372
+ const dur = this.#until(arg, until, true); // get a Tempo.Duration object
1373
+ const date = [dur.years, dur.months, dur.days];
1374
+ const time = [dur.hours, dur.minutes, dur.seconds];
1375
+ const fraction = [dur.milliseconds, dur.microseconds, dur.nanoseconds]
1376
+ .map(nbr => nbr.toString().padStart(3, '0'))
1377
+ .join('');
1378
+ const rtf = new Intl.RelativeTimeFormat(this.#local.config['locale'], { style: 'narrow' });
1379
+ switch (dur.unit) {
1380
+ case void 0:
1381
+ return `${date.join('.')}T${time.join(':')}.${fraction}`;
1382
+ case 'years':
1383
+ return rtf.format(date[0], 'years');
1384
+ case 'months':
1385
+ return rtf.format(date[1], 'months');
1386
+ case 'weeks':
1387
+ return rtf.format(date[1], 'weeks');
1388
+ case 'days':
1389
+ return rtf.format(date[2], 'days');
1390
+ case 'hours':
1391
+ return rtf.format(time[0], 'hours');
1392
+ case 'minutes':
1393
+ return rtf.format(time[1], 'minutes');
1394
+ case 'seconds':
1395
+ return rtf.format(time[2], 'seconds');
1396
+ case 'milliseconds':
1397
+ case 'microseconds':
1398
+ case 'nanoseconds':
1399
+ return `${fraction}`;
1400
+ default:
1401
+ return dur.iso;
1402
+ }
1403
+ }
1404
+ }
1405
+ // #endregion Namespace
1406
+ // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
1407
+ Tempo.init(); // initialize default global configuration
1408
+ // shortcut functions to common Tempo properties / methods
1409
+ /** check valid Tempo */ export const isTempo = (tempo) => isType(tempo, 'Tempo');
1410
+ /** current timestamp (ts) */ export const getStamp = ((tempo, options) => new Tempo(tempo, options).ts);
1411
+ /** create new Tempo */ export const getTempo = ((tempo, options) => new Tempo(tempo, options));
1412
+ /** format a Tempo */ export const fmtTempo = ((fmt, tempo, options) => new Tempo(tempo, options).format(fmt));