@jarenjs/core 0.9.2 → 0.34.2

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 (121) hide show
  1. package/ARCHITECTURE.md +229 -26
  2. package/README.md +64 -10
  3. package/dist/types/array.d.ts +13 -0
  4. package/dist/types/cache.d.ts +166 -0
  5. package/dist/types/chunk.d.ts +102 -0
  6. package/dist/types/color.d.ts +64 -0
  7. package/dist/types/convert/convert.d.ts +33 -0
  8. package/dist/types/convert/currency.d.ts +47 -0
  9. package/dist/types/convert/index.d.ts +9 -0
  10. package/dist/types/convert/registry.d.ts +55 -0
  11. package/dist/types/dates/civil.d.ts +146 -0
  12. package/dist/types/dates/duration.d.ts +83 -0
  13. package/dist/types/dates/format.d.ts +59 -0
  14. package/dist/types/dates/index.d.ts +4 -0
  15. package/dist/types/{dates.d.ts → dates/rfc3339.d.ts} +57 -3
  16. package/dist/types/errors.d.ts +77 -0
  17. package/dist/types/finance/amortization.d.ts +51 -0
  18. package/dist/types/finance/bond.d.ts +45 -0
  19. package/dist/types/finance/cashflow.d.ts +44 -0
  20. package/dist/types/finance/depreciation.d.ts +26 -0
  21. package/dist/types/finance/index.d.ts +16 -0
  22. package/dist/types/finance/indicators.d.ts +76 -0
  23. package/dist/types/finance/interest.d.ts +44 -0
  24. package/dist/types/finance/returns.d.ts +43 -0
  25. package/dist/types/finance/tvm.d.ts +50 -0
  26. package/dist/types/geo/angle.d.ts +4 -0
  27. package/dist/types/geo/bbox.d.ts +32 -0
  28. package/dist/types/geo/distance.d.ts +67 -0
  29. package/dist/types/geo/geohash.d.ts +63 -0
  30. package/dist/types/geo/geojson.d.ts +108 -0
  31. package/dist/types/geo/index-tree.d.ts +52 -0
  32. package/dist/types/geo/index.d.ts +11 -0
  33. package/dist/types/geo/mercator.d.ts +57 -0
  34. package/dist/types/geo/predicates.d.ts +44 -0
  35. package/dist/types/geo/ring.d.ts +80 -0
  36. package/dist/types/geo/simplify.d.ts +31 -0
  37. package/dist/types/geo/valid.d.ts +24 -0
  38. package/dist/types/geo/wkt.d.ts +16 -0
  39. package/dist/types/math/float64.d.ts +126 -3
  40. package/dist/types/math/format.d.ts +58 -0
  41. package/dist/types/math/index.d.ts +5 -0
  42. package/dist/types/math/mat4.d.ts +60 -0
  43. package/dist/types/math/project.d.ts +54 -0
  44. package/dist/types/math/solve.d.ts +67 -0
  45. package/dist/types/math/word.d.ts +82 -0
  46. package/dist/types/message.d.ts +52 -0
  47. package/dist/types/object.d.ts +151 -0
  48. package/dist/types/scan.d.ts +30 -0
  49. package/dist/types/schema.d.ts +24 -0
  50. package/dist/types/string.d.ts +96 -0
  51. package/dist/types/text/base64.d.ts +6 -4
  52. package/dist/types/text/email.d.ts +0 -1
  53. package/dist/types/text/host.d.ts +39 -8
  54. package/dist/types/text/i18n.d.ts +13 -1
  55. package/dist/types/text/index.d.ts +1 -0
  56. package/dist/types/text/misc.d.ts +15 -1
  57. package/dist/types/text/punycode.d.ts +45 -85
  58. package/docs/CONVERT.md +45 -0
  59. package/docs/DATES.md +78 -0
  60. package/docs/FINANCE.md +59 -0
  61. package/docs/GEO.md +114 -0
  62. package/docs/MATH.md +75 -0
  63. package/package.json +57 -4
  64. package/src/array.js +16 -0
  65. package/src/cache.js +206 -0
  66. package/src/chunk.js +159 -0
  67. package/src/color.js +125 -0
  68. package/src/convert/convert.js +59 -0
  69. package/src/convert/currency.js +74 -0
  70. package/src/convert/index.js +11 -0
  71. package/src/convert/registry.js +213 -0
  72. package/src/dates/civil.js +360 -0
  73. package/src/dates/duration.js +225 -0
  74. package/src/dates/format.js +238 -0
  75. package/src/dates/index.js +30 -0
  76. package/src/dates/rfc3339.js +621 -0
  77. package/src/errors.js +102 -0
  78. package/src/finance/amortization.js +50 -0
  79. package/src/finance/bond.js +100 -0
  80. package/src/finance/cashflow.js +122 -0
  81. package/src/finance/depreciation.js +54 -0
  82. package/src/finance/index.js +27 -0
  83. package/src/finance/indicators.js +207 -0
  84. package/src/finance/interest.js +63 -0
  85. package/src/finance/returns.js +90 -0
  86. package/src/finance/tvm.js +94 -0
  87. package/src/function.js +0 -2
  88. package/src/geo/angle.js +12 -0
  89. package/src/geo/bbox.js +80 -0
  90. package/src/geo/distance.js +155 -0
  91. package/src/geo/geohash.js +224 -0
  92. package/src/geo/geojson.js +361 -0
  93. package/src/geo/index-tree.js +294 -0
  94. package/src/geo/index.js +52 -0
  95. package/src/geo/mercator.js +124 -0
  96. package/src/geo/predicates.js +313 -0
  97. package/src/geo/ring.js +182 -0
  98. package/src/geo/simplify.js +130 -0
  99. package/src/geo/valid.js +142 -0
  100. package/src/geo/wkt.js +262 -0
  101. package/src/math/float64.js +228 -11
  102. package/src/math/format.js +157 -0
  103. package/src/math/index.js +5 -0
  104. package/src/math/mat4.js +131 -0
  105. package/src/math/project.js +49 -0
  106. package/src/math/solve.js +112 -0
  107. package/src/math/word.js +177 -0
  108. package/src/message.js +119 -0
  109. package/src/object.js +329 -0
  110. package/src/scan.js +42 -0
  111. package/src/schema.js +37 -0
  112. package/src/string.js +189 -0
  113. package/src/text/base64.js +15 -52
  114. package/src/text/email.js +0 -5
  115. package/src/text/host.js +515 -140
  116. package/src/text/i18n.js +13 -6
  117. package/src/text/identifiers.js +1 -1
  118. package/src/text/index.js +1 -0
  119. package/src/text/misc.js +81 -5
  120. package/src/text/punycode.js +235 -323
  121. package/src/dates.js +0 -371
@@ -0,0 +1,360 @@
1
+ //@ts-check
2
+
3
+ //#region Civil calendar arithmetic
4
+ // Proleptic Gregorian calendar math over plain integers. Nothing here
5
+ // allocates, and nothing here constructs a `Date`: going through `Date`
6
+ // to add a day costs ~181 ns against ~1.1 ns for the day-number
7
+ // conversions below, because every `Date` operation allocates and runs
8
+ // the timezone-aware setter machinery to answer a question that is pure
9
+ // integer arithmetic.
10
+ //
11
+ // The representation is the parts record of `parseRFC3339Parts`
12
+ // (rfc3339.js) - `{year, month, day, hours, minutes, seconds, offset}`,
13
+ // with -1 for a half the value does not carry and `offset` in minutes
14
+ // east of UTC. Functions here take and return parts, so a date never
15
+ // becomes an object with methods: it stays two JSON-representable forms,
16
+ // an RFC 3339 string and an epoch number.
17
+
18
+ /** Days per month, 1-based; February is the common-year length. */
19
+ const DAYS_IN_MONTH = Object.freeze([0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]);
20
+
21
+ /** Day number of 1970-01-01, the shift between the era epoch and the Unix epoch. */
22
+ const EPOCH_SHIFT = 719468;
23
+
24
+ /** Days in a 400-year Gregorian era. */
25
+ const ERA_DAYS = 146097;
26
+
27
+ /**
28
+ * Whether a proleptic Gregorian year is a leap year.
29
+ * @param {number} year
30
+ * @returns {boolean}
31
+ */
32
+ export function isLeapYear(year) {
33
+ return year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0);
34
+ }
35
+
36
+ /**
37
+ * Length of a month in days.
38
+ * @param {number} year
39
+ * @param {number} month - 1-12
40
+ * @returns {number}
41
+ */
42
+ export function daysInMonth(year, month) {
43
+ return month === 2 && isLeapYear(year) ? 29 : DAYS_IN_MONTH[month];
44
+ }
45
+
46
+ /**
47
+ * Days since 1970-01-01 for a civil date (Howard Hinnant's algorithm).
48
+ * Exact for every proleptic Gregorian date; no `Date`, no allocation.
49
+ * @param {number} y - year
50
+ * @param {number} m - month 1-12
51
+ * @param {number} d - day 1-31
52
+ * @returns {number} day number, negative before 1970
53
+ */
54
+ export function daysFromCivil(y, m, d) {
55
+ const shifted = y - (m <= 2 ? 1 : 0);
56
+ const era = Math.floor(shifted / 400);
57
+ const yoe = shifted - era * 400; // [0, 399]
58
+ const doy = Math.trunc((153 * (m + (m > 2 ? -3 : 9)) + 2) / 5) + d - 1; // [0, 365]
59
+ const doe = yoe * 365 + Math.trunc(yoe / 4) - Math.trunc(yoe / 100) + doy; // [0, 146096]
60
+ return era * ERA_DAYS + doe - EPOCH_SHIFT;
61
+ }
62
+
63
+ /**
64
+ * The inverse of {@link daysFromCivil}: a civil date from a day number.
65
+ * @param {number} z - days since 1970-01-01
66
+ * @returns {{ year: number, month: number, day: number }}
67
+ */
68
+ export function civilFromDays(z) {
69
+ const shifted = z + EPOCH_SHIFT;
70
+ const era = Math.floor(shifted / ERA_DAYS);
71
+ const doe = shifted - era * ERA_DAYS; // [0, 146096]
72
+ const yoe = Math.trunc((doe - Math.trunc(doe / 1460) + Math.trunc(doe / 36524)
73
+ - Math.trunc(doe / 146096)) / 365); // [0, 399]
74
+ const doy = doe - (365 * yoe + Math.trunc(yoe / 4) - Math.trunc(yoe / 100)); // [0, 365]
75
+ const mp = Math.trunc((5 * doy + 2) / 153); // [0, 11]
76
+ const day = doy - Math.trunc((153 * mp + 2) / 5) + 1; // [1, 31]
77
+ const month = mp + (mp < 10 ? 3 : -9); // [1, 12]
78
+ return { year: yoe + era * 400 + (month <= 2 ? 1 : 0), month, day };
79
+ }
80
+
81
+ /**
82
+ * Day of the week for a day number: 0 = Sunday … 6 = Saturday, matching
83
+ * `Date.prototype.getUTCDay`.
84
+ * @param {number} z - days since 1970-01-01
85
+ * @returns {number} 0-6
86
+ */
87
+ export function weekdayFromDays(z) {
88
+ // 1970-01-01 was a Thursday (4); the modulo is written to stay
89
+ // non-negative for dates before the epoch
90
+ return (((z + 4) % 7) + 7) % 7;
91
+ }
92
+
93
+ /**
94
+ * ISO 8601 weekday: 1 = Monday … 7 = Sunday.
95
+ * @param {number} z - days since 1970-01-01
96
+ * @returns {number} 1-7
97
+ */
98
+ export function isoWeekdayFromDays(z) {
99
+ const w = weekdayFromDays(z);
100
+ return w === 0 ? 7 : w;
101
+ }
102
+
103
+ /**
104
+ * Day of the year, 1-based (1-366).
105
+ * @param {{ year: number, month: number, day: number }} parts
106
+ * @returns {number}
107
+ */
108
+ export function dayOfYear(parts) {
109
+ return daysFromCivil(parts.year, parts.month, parts.day)
110
+ - daysFromCivil(parts.year, 1, 1) + 1;
111
+ }
112
+
113
+ /**
114
+ * Calendar quarter, 1-4.
115
+ * @param {{ month: number }} parts
116
+ * @returns {number}
117
+ */
118
+ export function quarterOfYear(parts) {
119
+ return Math.trunc((parts.month - 1) / 3) + 1;
120
+ }
121
+
122
+ /**
123
+ * ISO 8601 week-numbering week and its year. The ISO year is not always
124
+ * the calendar year: 2027-01-01 is a Friday and belongs to week 53 of
125
+ * 2026, so the pair has to be returned together.
126
+ * @param {{ year: number, month: number, day: number }} parts
127
+ * @returns {{ year: number, week: number }}
128
+ */
129
+ export function isoWeekOfYear(parts) {
130
+ const z = daysFromCivil(parts.year, parts.month, parts.day);
131
+ // the Thursday of this week decides which year the week belongs to
132
+ const thursday = z + (4 - isoWeekdayFromDays(z));
133
+ const year = civilFromDays(thursday).year;
134
+ const jan1 = daysFromCivil(year, 1, 1);
135
+ return { year, week: Math.trunc((thursday - jan1) / 7) + 1 };
136
+ }
137
+
138
+ /**
139
+ * The parts record of an instant, the inverse of
140
+ * `epochOfRFC3339Parts` (rfc3339.js).
141
+ *
142
+ * `offset` selects the wall clock the fields are read on: 0 (the
143
+ * default) gives UTC, 120 gives the clock in `+02:00`. The returned
144
+ * record carries that offset, so rendering it back with
145
+ * `formatRFC3339Parts` yields the same instant spelled in that zone.
146
+ *
147
+ * @param {number} ms - milliseconds since 1970-01-01T00:00:00Z
148
+ * @param {number} [offset] - minutes east of UTC to read the clock in
149
+ * @returns {object} a parts record, always with a time half
150
+ */
151
+ export function partsFromEpoch(ms, offset = 0) {
152
+ const local = ms + offset * 60000;
153
+ const z = Math.floor(local / 86400000);
154
+ let rest = local - z * 86400000;
155
+ const hours = Math.floor(rest / 3600000);
156
+ rest -= hours * 3600000;
157
+ const minutes = Math.floor(rest / 60000);
158
+ rest -= minutes * 60000;
159
+ return { ...civilFromDays(z), hours, minutes, seconds: rest / 1000, offset };
160
+ }
161
+
162
+ //#endregion
163
+
164
+ //#region units
165
+
166
+ /**
167
+ * The calendar units every unit-taking function in this module accepts.
168
+ * Ordered coarse to fine, which is the order `startOfParts` truncates in.
169
+ */
170
+ export const DATE_UNITS = Object.freeze([
171
+ 'year', 'quarter', 'month', 'week', 'day', 'hour', 'minute', 'second', 'millisecond',
172
+ ]);
173
+
174
+ const UNIT_SET = new Set(DATE_UNITS);
175
+
176
+ /**
177
+ * Whether a string names a calendar unit.
178
+ * @param {any} unit
179
+ * @returns {boolean}
180
+ */
181
+ export function isDateUnit(unit) {
182
+ return typeof unit === 'string' && UNIT_SET.has(unit);
183
+ }
184
+
185
+ // fixed-width units in milliseconds; months, quarters and years are
186
+ // absent on purpose - they have no fixed width without a calendar anchor
187
+ const FIXED_MS = Object.freeze({
188
+ millisecond: 1,
189
+ second: 1000,
190
+ minute: 60000,
191
+ hour: 3600000,
192
+ day: 86400000,
193
+ week: 604800000,
194
+ });
195
+
196
+ /**
197
+ * Milliseconds in a fixed-width unit, or 0 for the calendar units
198
+ * (`month`, `quarter`, `year`) that do not have one.
199
+ * @param {string} unit
200
+ * @returns {number}
201
+ */
202
+ export function fixedUnitMs(unit) {
203
+ return FIXED_MS[unit] ?? 0;
204
+ }
205
+
206
+ //#endregion
207
+
208
+ //#region arithmetic over parts
209
+
210
+ // A parts record with no time half reads -1 for hours/minutes/seconds;
211
+ // arithmetic treats that as midnight but must not *introduce* a time, so
212
+ // every function here records whether the input carried one.
213
+ function hasTime(parts) {
214
+ return parts.hours >= 0;
215
+ }
216
+
217
+ function withTime(out, parts, hours, minutes, seconds) {
218
+ if (hasTime(parts)) {
219
+ out.hours = hours;
220
+ out.minutes = minutes;
221
+ out.seconds = seconds;
222
+ out.offset = parts.offset;
223
+ }
224
+ else {
225
+ out.hours = -1;
226
+ out.minutes = -1;
227
+ out.seconds = -1;
228
+ out.offset = parts.offset;
229
+ }
230
+ return out;
231
+ }
232
+
233
+ /**
234
+ * Add a signed amount of calendar units to a parts record, returning a
235
+ * new one. The input is never mutated and its lexical shape is kept: a
236
+ * full-date stays a full-date, and a value keeps its own UTC offset
237
+ * rather than being normalized.
238
+ *
239
+ * Month and year arithmetic **clamps** to the end of the target month —
240
+ * 2026-01-31 plus one month is 2026-02-28 — which is the rule every
241
+ * mainstream date library uses, because the alternative (overflowing
242
+ * into March) makes `add(1, 'month')` non-monotonic.
243
+ *
244
+ * @param {object} parts - a parts record from `parseRFC3339Parts`
245
+ * @param {number} amount - signed count, may be fractional only for
246
+ * fixed-width units (a fractional month has no meaning)
247
+ * @param {string} unit - a {@link DATE_UNITS} member
248
+ * @returns {object} a new parts record
249
+ */
250
+ export function addToParts(parts, amount, unit) {
251
+ if (amount === 0)
252
+ return { ...parts };
253
+ if (unit === 'year' || unit === 'quarter' || unit === 'month') {
254
+ const months = unit === 'year' ? amount * 12 : unit === 'quarter' ? amount * 3 : amount;
255
+ const total = (parts.year * 12 + (parts.month - 1)) + Math.trunc(months);
256
+ const year = Math.floor(total / 12);
257
+ const month = total - year * 12 + 1;
258
+ const day = Math.min(parts.day, daysInMonth(year, month)); // clamp
259
+ return withTime({ year, month, day }, parts, parts.hours, parts.minutes, parts.seconds);
260
+ }
261
+ if (unit === 'day' || unit === 'week') {
262
+ const days = unit === 'week' ? amount * 7 : amount;
263
+ const z = daysFromCivil(parts.year, parts.month, parts.day) + Math.trunc(days);
264
+ const civil = civilFromDays(z);
265
+ return withTime(civil, parts, parts.hours, parts.minutes, parts.seconds);
266
+ }
267
+ // fixed sub-day units: carry through the day number so a time crossing
268
+ // midnight moves the date with it
269
+ const ms = FIXED_MS[unit];
270
+ if (ms === undefined)
271
+ throw new TypeError(`'${unit}' is not a calendar unit`);
272
+ const z = daysFromCivil(parts.year, parts.month, parts.day);
273
+ const dayMs = hasTime(parts)
274
+ ? parts.hours * 3600000 + parts.minutes * 60000 + Math.round(parts.seconds * 1000)
275
+ : 0;
276
+ const moved = z * 86400000 + dayMs + amount * ms;
277
+ const dz = Math.floor(moved / 86400000);
278
+ let rest = moved - dz * 86400000;
279
+ const civil = civilFromDays(dz);
280
+ const hours = Math.floor(rest / 3600000);
281
+ rest -= hours * 3600000;
282
+ const minutes = Math.floor(rest / 60000);
283
+ rest -= minutes * 60000;
284
+ // a value with no time half acquires one as soon as a sub-day unit
285
+ // moves it - there is nowhere else for the result to live
286
+ const out = { ...civil, hours, minutes, seconds: rest / 1000, offset: parts.offset };
287
+ if (out.offset === null && !hasTime(parts))
288
+ out.offset = 0; // a bare full-date is UTC midnight by rfc3339.js's rule
289
+ return out;
290
+ }
291
+
292
+ /**
293
+ * Truncate a parts record to the start of a calendar unit, returning a
294
+ * new one. `week` starts on Monday (ISO 8601).
295
+ * @param {object} parts - a parts record
296
+ * @param {string} unit - a {@link DATE_UNITS} member
297
+ * @returns {object} a new parts record
298
+ */
299
+ export function startOfParts(parts, unit) {
300
+ let { year, month, day } = parts;
301
+ let hours = parts.hours;
302
+ let minutes = parts.minutes;
303
+ let seconds = parts.seconds;
304
+ switch (unit) {
305
+ case 'year':
306
+ month = 1; day = 1; break;
307
+ case 'quarter':
308
+ month = (quarterOfYear(parts) - 1) * 3 + 1; day = 1; break;
309
+ case 'month':
310
+ day = 1; break;
311
+ case 'week': {
312
+ const z = daysFromCivil(year, month, day);
313
+ ({ year, month, day } = civilFromDays(z - (isoWeekdayFromDays(z) - 1)));
314
+ break;
315
+ }
316
+ case 'day':
317
+ break;
318
+ case 'hour':
319
+ minutes = 0; seconds = 0; break;
320
+ case 'minute':
321
+ seconds = 0; break;
322
+ case 'second':
323
+ seconds = Math.trunc(seconds); break;
324
+ case 'millisecond':
325
+ return { ...parts };
326
+ default:
327
+ throw new TypeError(`'${unit}' is not a calendar unit`);
328
+ }
329
+ if (unit === 'year' || unit === 'quarter' || unit === 'month'
330
+ || unit === 'week' || unit === 'day') {
331
+ hours = hasTime(parts) ? 0 : -1;
332
+ minutes = hours;
333
+ seconds = hours;
334
+ }
335
+ return { year, month, day, hours, minutes, seconds, offset: parts.offset };
336
+ }
337
+
338
+ /**
339
+ * The last representable instant inside a calendar unit: the start of
340
+ * the next unit less one millisecond. A value with no time half is
341
+ * truncated to the unit's last *day* instead, so a full-date stays a
342
+ * full-date.
343
+ * @param {object} parts - a parts record
344
+ * @param {string} unit - a {@link DATE_UNITS} member
345
+ * @returns {object} a new parts record
346
+ */
347
+ export function endOfParts(parts, unit) {
348
+ const start = startOfParts(parts, unit);
349
+ if (unit === 'millisecond')
350
+ return start;
351
+ const next = addToParts(start, 1, unit === 'quarter' ? 'quarter' : unit);
352
+ if (!hasTime(parts)) {
353
+ // date-only: step back one whole day rather than one millisecond
354
+ const z = daysFromCivil(next.year, next.month, next.day) - 1;
355
+ return { ...civilFromDays(z), hours: -1, minutes: -1, seconds: -1, offset: parts.offset };
356
+ }
357
+ return addToParts(next, -1, 'millisecond');
358
+ }
359
+
360
+ //#endregion
@@ -0,0 +1,225 @@
1
+ //@ts-check
2
+
3
+ //#region Durations
4
+ // `isValidDuration` (rfc3339.js) recognizes an ISO 8601 duration but
5
+ // never takes one apart, so a duration in a document is a string nothing
6
+ // can do arithmetic with. This module decomposes it.
7
+ //
8
+ // The design constraint is the one that makes durations awkward
9
+ // everywhere: **a duration is not a number of milliseconds**. `P1M` is
10
+ // 28, 29, 30 or 31 days depending on where you stand, and `P1Y` is 365
11
+ // or 366. So a duration decomposes into two groups - the calendar part
12
+ // (years, months) and the fixed part (weeks down to seconds) - and only
13
+ // the fixed part converts to milliseconds without an anchor. Anything
14
+ // that needs the calendar part applies it to a date, through
15
+ // `addToParts`, where the anchor exists.
16
+
17
+ import { addToParts, daysFromCivil } from './civil.js';
18
+
19
+ /**
20
+ * A decomposed ISO 8601 duration. Every field is a non-negative number;
21
+ * `negative` carries the sign, so the parts describe a magnitude.
22
+ * @typedef {Object} DurationParts
23
+ * @property {boolean} negative
24
+ * @property {number} years
25
+ * @property {number} months
26
+ * @property {number} weeks
27
+ * @property {number} days
28
+ * @property {number} hours
29
+ * @property {number} minutes
30
+ * @property {number} seconds - may be fractional
31
+ */
32
+
33
+ const ZERO = Object.freeze({
34
+ negative: false, years: 0, months: 0, weeks: 0, days: 0, hours: 0, minutes: 0, seconds: 0,
35
+ });
36
+
37
+ // Designators, split at 'T'. The same letter means months before the
38
+ // 'T' and minutes after it, which is the whole reason a duration cannot
39
+ // be scanned with one table. The number is the designator's rank: they
40
+ // MUST appear coarse-to-fine, so 'PT1S1H' is not a duration.
41
+ const DATE_FIELDS = { Y: ['years', 0], M: ['months', 1], W: ['weeks', 2], D: ['days', 3] };
42
+ const TIME_FIELDS = { H: ['hours', 0], M: ['minutes', 1], S: ['seconds', 2] };
43
+
44
+ /**
45
+ * Decompose an ISO 8601 / RFC 3339 duration string into its parts.
46
+ *
47
+ * This is a deliberate **superset** of `isValidDuration` (rfc3339.js),
48
+ * which stays strict because it backs the JSON Schema `duration` format
49
+ * and must reject what the RFC rejects. This parser additionally accepts
50
+ * a leading `-` and a fractional component (`PT1.5H`), both of which ISO
51
+ * 8601 allows. So `parseDuration(s) !== null` does not imply
52
+ * `isValidDuration(s)`, and a consumer that needs RFC strictness must
53
+ * ask the validator, not this.
54
+ *
55
+ * Designators must appear coarse-to-fine within each half (`P1Y2M`, not
56
+ * `P2M1Y`), each at most once, and `W` does not mix with `Y`/`M`/`D`.
57
+ * Returns null for anything it cannot read, so callers branch rather
58
+ * than catch.
59
+ *
60
+ * @param {any} str - the duration string, e.g. `'P1Y2M3DT4H5M6S'`
61
+ * @returns {DurationParts | null}
62
+ * @example
63
+ * parseDuration('P1Y2M3DT4H5M6S');
64
+ * // { negative: false, years: 1, months: 2, weeks: 0, days: 3,
65
+ * // hours: 4, minutes: 5, seconds: 6 }
66
+ */
67
+ export function parseDuration(str) {
68
+ if (typeof str !== 'string' || str.length < 3)
69
+ return null;
70
+ let i = 0;
71
+ const negative = str.charCodeAt(0) === 0x2D; // '-'
72
+ if (negative)
73
+ i++;
74
+ if (str.charCodeAt(i) !== 0x50) // 'P'
75
+ return null;
76
+ i++;
77
+
78
+ const out = { ...ZERO, negative };
79
+ let fields = DATE_FIELDS;
80
+ let seenAny = false;
81
+ let seenTime = false;
82
+ let lastRank = -1;
83
+
84
+ while (i < str.length) {
85
+ if (str.charCodeAt(i) === 0x54) { // 'T'
86
+ if (seenTime)
87
+ return null;
88
+ seenTime = true;
89
+ fields = TIME_FIELDS;
90
+ lastRank = -1;
91
+ i++;
92
+ // 'T' must be followed by at least one component
93
+ if (i >= str.length)
94
+ return null;
95
+ continue;
96
+ }
97
+ // a number: digits, optionally one fraction
98
+ const start = i;
99
+ while (i < str.length) {
100
+ const c = str.charCodeAt(i);
101
+ if (c < 0x30 || c > 0x39)
102
+ break;
103
+ i++;
104
+ }
105
+ if (i === start)
106
+ return null; // a designator with no number
107
+ if (i < str.length && (str.charCodeAt(i) === 0x2E || str.charCodeAt(i) === 0x2C)) {
108
+ i++; // '.' or ',' - ISO 8601 allows both as the decimal sign
109
+ const fracStart = i;
110
+ while (i < str.length) {
111
+ const c = str.charCodeAt(i);
112
+ if (c < 0x30 || c > 0x39)
113
+ break;
114
+ i++;
115
+ }
116
+ if (i === fracStart)
117
+ return null;
118
+ }
119
+ if (i >= str.length)
120
+ return null; // a number with no designator
121
+ const entry = fields[str[i]];
122
+ if (entry === undefined)
123
+ return null; // wrong designator for this side of the 'T'
124
+ if (entry[1] <= lastRank)
125
+ return null; // out of order, or the same designator twice
126
+ lastRank = entry[1];
127
+ // '.' and ',' both spell the decimal point; Number wants '.'
128
+ out[entry[0]] = Number(str.slice(start, i).replace(',', '.'));
129
+ i++;
130
+ seenAny = true;
131
+ }
132
+ if (!seenAny)
133
+ return null; // bare 'P' or 'PT'
134
+ // 'W' does not combine with the other date designators (RFC 3339 App. A)
135
+ if (out.weeks !== 0 && (out.years !== 0 || out.months !== 0 || out.days !== 0))
136
+ return null;
137
+ return out;
138
+ }
139
+
140
+ /**
141
+ * The fixed-width span of a duration in milliseconds — weeks, days,
142
+ * hours, minutes and seconds only.
143
+ *
144
+ * Returns NaN when the duration carries years or months, because those
145
+ * have no width without a date to stand on. That is a deliberate refusal
146
+ * rather than an approximation: silently calling a month 30 days is how
147
+ * "in 1 month" lands on the wrong day.
148
+ *
149
+ * @param {DurationParts} parts
150
+ * @returns {number} signed milliseconds, or NaN if not fixed-width
151
+ */
152
+ export function durationToMs(parts) {
153
+ if (parts.years !== 0 || parts.months !== 0)
154
+ return NaN;
155
+ const ms = parts.weeks * 604800000
156
+ + parts.days * 86400000
157
+ + parts.hours * 3600000
158
+ + parts.minutes * 60000
159
+ + parts.seconds * 1000;
160
+ return parts.negative ? -ms : ms;
161
+ }
162
+
163
+ /**
164
+ * Whether a duration has a width independent of where it is applied.
165
+ * @param {DurationParts} parts
166
+ * @returns {boolean}
167
+ */
168
+ export function isFixedDuration(parts) {
169
+ return parts.years === 0 && parts.months === 0;
170
+ }
171
+
172
+ /**
173
+ * Apply a duration to a parts record, the operation that gives the
174
+ * calendar fields their meaning. Coarse units apply first, so
175
+ * `2026-01-31` plus `P1M1D` is February's clamped 28th plus a day, not
176
+ * March 1st plus a month.
177
+ *
178
+ * @param {object} dateParts - a parts record from `parseRFC3339Parts`
179
+ * @param {DurationParts} duration
180
+ * @param {number} [sign] - 1 to add (default), -1 to subtract
181
+ * @returns {object} a new parts record
182
+ */
183
+ export function addDuration(dateParts, duration, sign = 1) {
184
+ const s = (duration.negative ? -1 : 1) * (sign < 0 ? -1 : 1);
185
+ let out = dateParts;
186
+ if (duration.years !== 0) out = addToParts(out, s * duration.years, 'year');
187
+ if (duration.months !== 0) out = addToParts(out, s * duration.months, 'month');
188
+ if (duration.weeks !== 0) out = addToParts(out, s * duration.weeks, 'week');
189
+ if (duration.days !== 0) out = addToParts(out, s * duration.days, 'day');
190
+ if (duration.hours !== 0) out = addToParts(out, s * duration.hours, 'hour');
191
+ if (duration.minutes !== 0) out = addToParts(out, s * duration.minutes, 'minute');
192
+ if (duration.seconds !== 0) out = addToParts(out, s * duration.seconds, 'second');
193
+ return out === dateParts ? { ...dateParts } : out;
194
+ }
195
+
196
+ /**
197
+ * Whole calendar months between two dates, ignoring the time of day —
198
+ * the building block `diff` needs for the units that are not fixed-width.
199
+ *
200
+ * "Whole" is defined by `addToParts`, so that adding the result back to
201
+ * `from` never overshoots `to`. That makes 2026-01-31 → 2026-02-28 **one**
202
+ * month, not zero, because the clamping rule already says Jan 31 plus a
203
+ * month IS Feb 28. Keeping the two operations inverse is worth more than
204
+ * matching moment here, which reports zero for the same pair.
205
+ *
206
+ * @param {object} from - a parts record
207
+ * @param {object} to - a parts record
208
+ * @returns {number} signed month count
209
+ */
210
+ export function monthsBetween(from, to) {
211
+ let months = (to.year - from.year) * 12 + (to.month - from.month);
212
+ if (months === 0)
213
+ return 0;
214
+ // step back if the day of month has not been reached yet
215
+ const advanced = addToParts(from, months, 'month');
216
+ const advancedDay = daysFromCivil(advanced.year, advanced.month, advanced.day);
217
+ const toDay = daysFromCivil(to.year, to.month, to.day);
218
+ if (months > 0 && advancedDay > toDay)
219
+ months -= 1;
220
+ else if (months < 0 && advancedDay < toDay)
221
+ months += 1;
222
+ return months;
223
+ }
224
+
225
+ //#endregion