@jarenjs/core 0.9.2 → 0.34.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ARCHITECTURE.md +229 -26
- package/README.md +64 -10
- package/dist/types/array.d.ts +13 -0
- package/dist/types/cache.d.ts +166 -0
- package/dist/types/chunk.d.ts +102 -0
- package/dist/types/color.d.ts +64 -0
- package/dist/types/convert/convert.d.ts +33 -0
- package/dist/types/convert/currency.d.ts +47 -0
- package/dist/types/convert/index.d.ts +9 -0
- package/dist/types/convert/registry.d.ts +55 -0
- package/dist/types/dates/civil.d.ts +146 -0
- package/dist/types/dates/duration.d.ts +83 -0
- package/dist/types/dates/format.d.ts +59 -0
- package/dist/types/dates/index.d.ts +4 -0
- package/dist/types/{dates.d.ts → dates/rfc3339.d.ts} +57 -3
- package/dist/types/errors.d.ts +77 -0
- package/dist/types/finance/amortization.d.ts +51 -0
- package/dist/types/finance/bond.d.ts +45 -0
- package/dist/types/finance/cashflow.d.ts +44 -0
- package/dist/types/finance/depreciation.d.ts +26 -0
- package/dist/types/finance/index.d.ts +16 -0
- package/dist/types/finance/indicators.d.ts +76 -0
- package/dist/types/finance/interest.d.ts +44 -0
- package/dist/types/finance/returns.d.ts +43 -0
- package/dist/types/finance/tvm.d.ts +50 -0
- package/dist/types/geo/angle.d.ts +4 -0
- package/dist/types/geo/bbox.d.ts +32 -0
- package/dist/types/geo/distance.d.ts +67 -0
- package/dist/types/geo/geohash.d.ts +63 -0
- package/dist/types/geo/geojson.d.ts +108 -0
- package/dist/types/geo/index-tree.d.ts +52 -0
- package/dist/types/geo/index.d.ts +11 -0
- package/dist/types/geo/mercator.d.ts +57 -0
- package/dist/types/geo/predicates.d.ts +44 -0
- package/dist/types/geo/ring.d.ts +80 -0
- package/dist/types/geo/simplify.d.ts +31 -0
- package/dist/types/geo/valid.d.ts +24 -0
- package/dist/types/geo/wkt.d.ts +16 -0
- package/dist/types/math/float64.d.ts +126 -3
- package/dist/types/math/format.d.ts +58 -0
- package/dist/types/math/index.d.ts +5 -0
- package/dist/types/math/mat4.d.ts +60 -0
- package/dist/types/math/project.d.ts +54 -0
- package/dist/types/math/solve.d.ts +67 -0
- package/dist/types/math/word.d.ts +82 -0
- package/dist/types/message.d.ts +52 -0
- package/dist/types/object.d.ts +151 -0
- package/dist/types/scan.d.ts +30 -0
- package/dist/types/schema.d.ts +24 -0
- package/dist/types/string.d.ts +96 -0
- package/dist/types/text/base64.d.ts +6 -4
- package/dist/types/text/email.d.ts +0 -1
- package/dist/types/text/host.d.ts +39 -8
- package/dist/types/text/i18n.d.ts +13 -1
- package/dist/types/text/index.d.ts +1 -0
- package/dist/types/text/misc.d.ts +15 -1
- package/dist/types/text/punycode.d.ts +45 -85
- package/docs/CONVERT.md +45 -0
- package/docs/DATES.md +78 -0
- package/docs/FINANCE.md +59 -0
- package/docs/GEO.md +114 -0
- package/docs/MATH.md +75 -0
- package/package.json +57 -4
- package/src/array.js +16 -0
- package/src/cache.js +206 -0
- package/src/chunk.js +159 -0
- package/src/color.js +125 -0
- package/src/convert/convert.js +59 -0
- package/src/convert/currency.js +74 -0
- package/src/convert/index.js +11 -0
- package/src/convert/registry.js +213 -0
- package/src/dates/civil.js +360 -0
- package/src/dates/duration.js +225 -0
- package/src/dates/format.js +238 -0
- package/src/dates/index.js +30 -0
- package/src/dates/rfc3339.js +621 -0
- package/src/errors.js +102 -0
- package/src/finance/amortization.js +50 -0
- package/src/finance/bond.js +100 -0
- package/src/finance/cashflow.js +122 -0
- package/src/finance/depreciation.js +54 -0
- package/src/finance/index.js +27 -0
- package/src/finance/indicators.js +207 -0
- package/src/finance/interest.js +63 -0
- package/src/finance/returns.js +90 -0
- package/src/finance/tvm.js +94 -0
- package/src/function.js +0 -2
- package/src/geo/angle.js +12 -0
- package/src/geo/bbox.js +80 -0
- package/src/geo/distance.js +155 -0
- package/src/geo/geohash.js +224 -0
- package/src/geo/geojson.js +361 -0
- package/src/geo/index-tree.js +294 -0
- package/src/geo/index.js +52 -0
- package/src/geo/mercator.js +124 -0
- package/src/geo/predicates.js +313 -0
- package/src/geo/ring.js +182 -0
- package/src/geo/simplify.js +130 -0
- package/src/geo/valid.js +142 -0
- package/src/geo/wkt.js +262 -0
- package/src/math/float64.js +228 -11
- package/src/math/format.js +157 -0
- package/src/math/index.js +5 -0
- package/src/math/mat4.js +131 -0
- package/src/math/project.js +49 -0
- package/src/math/solve.js +112 -0
- package/src/math/word.js +177 -0
- package/src/message.js +119 -0
- package/src/object.js +329 -0
- package/src/scan.js +42 -0
- package/src/schema.js +37 -0
- package/src/string.js +189 -0
- package/src/text/base64.js +15 -52
- package/src/text/email.js +0 -5
- package/src/text/host.js +515 -140
- package/src/text/i18n.js +13 -6
- package/src/text/identifiers.js +1 -1
- package/src/text/index.js +1 -0
- package/src/text/misc.js +81 -5
- package/src/text/punycode.js +235 -323
- 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
|