daymath 0.2.3 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +161 -9
- package/index.d.ts +43 -7
- package/index.js +575 -93
- package/package.json +21 -7
package/index.js
CHANGED
|
@@ -1,15 +1,86 @@
|
|
|
1
|
-
/** daymath — calendar date math (ISO 8601 day). date-fns-shaped. No Date
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
1
|
+
/** daymath — calendar date math (ISO 8601 day). date-fns-shaped. No Date. No time zones. */
|
|
2
|
+
// temporal-polyfill already resolves this: its entry is `globalThis.Temporal ||
|
|
3
|
+
// bundled`, so a runtime with native Temporal gets native. Re-reading
|
|
4
|
+
// globalThis here only duplicated that check and hid where it happens.
|
|
5
|
+
import { Temporal } from 'temporal-polyfill'
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* ISO 8601 calendar day string:
|
|
8
9
|
* - `YYYY-MM-DD` (years 0000–9999)
|
|
9
10
|
* - expanded `±YYYYYY-MM-DD` (Temporal form, e.g. `+010000-01-01`)
|
|
11
|
+
*
|
|
12
|
+
* The shape passes this regex; the value must also fall inside the Temporal
|
|
13
|
+
* `PlainDate` range `-271821-04-19` … `+275760-09-13` (roughly ±10^8 days from
|
|
14
|
+
* the epoch). Outside it, Temporal throws and we re-throw with the `daymath:`
|
|
15
|
+
* prefix.
|
|
10
16
|
*/
|
|
11
|
-
const ISO_DAY =
|
|
12
|
-
|
|
17
|
+
const ISO_DAY = /^(?:[+-]\d{6}|\d{4})-\d{2}-\d{2}$/u
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Temporal's calendar annotation, `[u-ca=…]` or the critical `[!u-ca=…]`.
|
|
21
|
+
* Returns what it is attached to, and the calendar it names, or `null`.
|
|
22
|
+
*
|
|
23
|
+
* String operations, not a regex, and the reason is measured. The annotation can
|
|
24
|
+
* sit behind another one — `'…[America/New_York][u-ca=buddhist]'` — so the head
|
|
25
|
+
* may itself contain `[`. A pattern that allows that needs `^(.*)\[…\]$`, whose
|
|
26
|
+
* `.*` backtracks: `'[u-ca='.repeat(64000)` cost 9.0 s, growing with the square
|
|
27
|
+
* of the input. `lastIndexOf` answers the same question in one pass. CodeQL
|
|
28
|
+
* `js/polynomial-redos` caught the regex form; timing it confirmed the report.
|
|
29
|
+
* @param {string} text
|
|
30
|
+
* @returns {{ head: string, calendar: string } | null}
|
|
31
|
+
*/
|
|
32
|
+
function calendarAnnotation(text) {
|
|
33
|
+
if (!text.endsWith(']')) return null
|
|
34
|
+
const open = text.lastIndexOf('[')
|
|
35
|
+
if (open === -1) return null
|
|
36
|
+
const inner = text.slice(open + 1, -1)
|
|
37
|
+
const body = inner.startsWith('!') ? inner.slice(1) : inner
|
|
38
|
+
if (!body.startsWith('u-ca=')) return null
|
|
39
|
+
const calendar = body.slice(5)
|
|
40
|
+
// A `]` inside means the brackets do not nest as they appear, so this is not
|
|
41
|
+
// an annotation. `'[u-ca=[u-ca=[u-ca=x]]]'` reads as the calendar `x]]` here
|
|
42
|
+
// and as a malformed day everywhere else, which is what it is.
|
|
43
|
+
if (calendar === '' || calendar.includes(']')) return null
|
|
44
|
+
return { head: text.slice(0, open), calendar }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* True when the string carries a time-zone annotation, `[Zone]` or `[!Zone]`.
|
|
49
|
+
* It is the one annotation with no `=`, which separates it from `[u-ca=…]`, and
|
|
50
|
+
* Temporal writes it first, so only the leading bracket can be one.
|
|
51
|
+
*
|
|
52
|
+
* Asking "does the string name a zone?" is a different question from "does it
|
|
53
|
+
* resolve?". A string can name one and still fail: an offset that disagrees with
|
|
54
|
+
* the zone, or a misspelled name. Both must be errors, never a silent fallback
|
|
55
|
+
* to the UTC default.
|
|
56
|
+
*
|
|
57
|
+
* String operations again. An unanchored `/\[!?[^\]=]+\]/` restarts at every
|
|
58
|
+
* position: `'[!'.repeat(64000)` cost 6.8 s, and it is the same defect an
|
|
59
|
+
* earlier commit removed from the calendar pattern.
|
|
60
|
+
* @param {string} text
|
|
61
|
+
*/
|
|
62
|
+
function hasZoneAnnotation(text) {
|
|
63
|
+
const open = text.indexOf('[')
|
|
64
|
+
if (open === -1) return false
|
|
65
|
+
const close = text.indexOf(']', open)
|
|
66
|
+
if (close === -1) return false
|
|
67
|
+
return !text.slice(open + 1, close).includes('=')
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* A time zone, by shape: an IANA name, or a bare offset.
|
|
72
|
+
*
|
|
73
|
+
* A name is letter-led, slash-separated segments of letters, digits, `_`, `+`,
|
|
74
|
+
* `-` and `.`. It carries no `:`, which is what keeps an ISO time out. The
|
|
75
|
+
* `(?![Tt]\d)` guard rejects the compact spelling `T120000Z`, which has no `:`
|
|
76
|
+
* to catch it. Both matter: `T12:00:00Z` is a zone to native Temporal and not
|
|
77
|
+
* to temporal-polyfill, so letting either through splits the answer by runtime.
|
|
78
|
+
*
|
|
79
|
+
* Verified against every zone this runtime knows: 0 of 418 rejected, plus the
|
|
80
|
+
* aliases `UTC`, `GMT`, `US/Eastern`, `Asia/Calcutta` and `Etc/GMT+5`.
|
|
81
|
+
*/
|
|
82
|
+
const ZONE_LIKE =
|
|
83
|
+
/^(?:(?![Tt]\d)[A-Za-z][A-Za-z0-9_+.-]*(?:\/[A-Za-z0-9_+.-]+)*|[+-]\d{2}(?::?\d{2})?)$/u
|
|
13
84
|
|
|
14
85
|
/** @typedef {string | Temporal.PlainDate} DayInput */
|
|
15
86
|
/**
|
|
@@ -19,13 +90,82 @@ const ISO_DAY =
|
|
|
19
90
|
*/
|
|
20
91
|
/**
|
|
21
92
|
* @typedef {object} WeekOptions
|
|
22
|
-
* @property {0|1|2|3|4|5|6} [weekStartsOn]
|
|
93
|
+
* @property {0|1|2|3|4|5|6|7} [weekStartsOn] ISO 1=Mon … 7=Sun (default 7).
|
|
94
|
+
* `0` is also accepted for Sunday, because 0 ≡ 7 (mod 7) and the week-offset
|
|
95
|
+
* arithmetic cannot tell them apart. Pre-0.3.0 callers keep working unchanged.
|
|
23
96
|
*/
|
|
24
97
|
|
|
25
98
|
// ─── core conversion ───────────────────────────────────────────────
|
|
26
99
|
|
|
100
|
+
/**
|
|
101
|
+
* The bare ISO day inside a string, or `null` if there is not one.
|
|
102
|
+
*
|
|
103
|
+
* An annotation is dropped, not judged: `assertIsoCalendar` owns that rule and
|
|
104
|
+
* runs before both callers, so by here the only calendar left is ISO. Temporal
|
|
105
|
+
* writes `[u-ca=iso8601]` itself for `toString({ calendarName: 'always' })`, and
|
|
106
|
+
* it names the very calendar daymath reads, so refusing a caller's own
|
|
107
|
+
* round-trip would be arbitrary.
|
|
108
|
+
*
|
|
109
|
+
* One predicate, because `toPlainDate` and `day()` both ask this question. They
|
|
110
|
+
* asked it separately once, and day() alone then refused a string that every
|
|
111
|
+
* other export accepted.
|
|
112
|
+
* @param {string} text
|
|
113
|
+
* @returns {string | null}
|
|
114
|
+
*/
|
|
115
|
+
function bareDay(text) {
|
|
116
|
+
const annotated = calendarAnnotation(text)
|
|
117
|
+
const bare = annotated ? annotated.head : text
|
|
118
|
+
return ISO_DAY.test(bare) ? bare : null
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Refuse a non-ISO calendar, wherever the annotation is attached.
|
|
123
|
+
*
|
|
124
|
+
* Checked on the *string*, before any parse. Implementations disagree past this
|
|
125
|
+
* point: native Temporal builds a `[u-ca=buddhist]` ZonedDateTime and the
|
|
126
|
+
* polyfill refuses to, so parsing first made the error depend on the runtime.
|
|
127
|
+
* @param {string} text
|
|
128
|
+
* @param {string} label
|
|
129
|
+
*/
|
|
130
|
+
function assertIsoCalendar(text, label) {
|
|
131
|
+
const annotated = calendarAnnotation(text)
|
|
132
|
+
if (annotated && annotated.calendar.toLowerCase() !== 'iso8601') {
|
|
133
|
+
throw new RangeError(
|
|
134
|
+
`daymath: ${label} must use the ISO 8601 calendar, not ${JSON.stringify(annotated.calendar)} (convert with withCalendar('iso8601'))`,
|
|
135
|
+
)
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* True for a `Temporal.PlainDate` from *any* implementation: native, the
|
|
141
|
+
* bundled polyfill, or a second copy of it in the same dependency tree.
|
|
142
|
+
* `instanceof` recognises only one of those. Every Temporal puts this tag on
|
|
143
|
+
* `PlainDate.prototype` as a non-writable property, so it is the portable brand.
|
|
144
|
+
* @param {unknown} value
|
|
145
|
+
* @returns {value is Temporal.PlainDate} a predicate, so callers narrow
|
|
146
|
+
*/
|
|
147
|
+
function isPlainDate(value) {
|
|
148
|
+
return Object.prototype.toString.call(value) === '[object Temporal.PlainDate]'
|
|
149
|
+
}
|
|
150
|
+
|
|
27
151
|
/**
|
|
28
152
|
* Reject Date and non-calendar values. Accept ISO day string or PlainDate.
|
|
153
|
+
*
|
|
154
|
+
* A PlainDate becomes its ISO day string first, so every input reaches one
|
|
155
|
+
* validation path and daymath owns the resulting instance.
|
|
156
|
+
*
|
|
157
|
+
* A non-ISO calendar is refused rather than reinterpreted. `toString()` writes
|
|
158
|
+
* the ISO date then an optional `[u-ca=…]`, so the *day* would survive — but
|
|
159
|
+
* the field numbers would not. Thai Buddhist years run 543 ahead, so
|
|
160
|
+
* `2026-01-31[u-ca=buddhist]` is year 2569, and `getYear` would answer `2026`
|
|
161
|
+
* where the caller's own object says `2569`. A string carrying the annotation is
|
|
162
|
+
* refused for the same reason, and the error names both the calendar it found
|
|
163
|
+
* and the way through, `withCalendar('iso8601')`.
|
|
164
|
+
*
|
|
165
|
+
* `[u-ca=iso8601]` is the exception: it is accepted and dropped. Temporal writes
|
|
166
|
+
* it itself for `toString({ calendarName: 'always' })`, and it names the very
|
|
167
|
+
* calendar daymath reads, so refusing a caller's own round-trip would be
|
|
168
|
+
* arbitrary.
|
|
29
169
|
* @param {unknown} value
|
|
30
170
|
* @param {string} label
|
|
31
171
|
* @returns {Temporal.PlainDate}
|
|
@@ -36,26 +176,28 @@ function toPlainDate(value, label = 'date') {
|
|
|
36
176
|
`daymath: Date is not allowed for ${label} (pass ISO 8601 day string)`,
|
|
37
177
|
)
|
|
38
178
|
}
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
179
|
+
// Named `text`, not `day`: a local `day` would shadow the exported day().
|
|
180
|
+
const text = isPlainDate(value) ? value.toString() : value
|
|
181
|
+
if (typeof text !== 'string') {
|
|
182
|
+
throw new TypeError(
|
|
183
|
+
`daymath: ${label} must be ISO 8601 day string or Temporal.PlainDate`,
|
|
184
|
+
)
|
|
185
|
+
}
|
|
186
|
+
// Before the shape check: an annotated day is well formed, not malformed.
|
|
187
|
+
assertIsoCalendar(text, label)
|
|
188
|
+
const bare = bareDay(text)
|
|
189
|
+
if (bare === null) {
|
|
190
|
+
throw new RangeError(
|
|
191
|
+
`daymath: ${label} must be ISO 8601 day YYYY-MM-DD or ±YYYYYY-MM-DD (got ${JSON.stringify(text)})`,
|
|
192
|
+
)
|
|
52
193
|
}
|
|
53
|
-
|
|
54
|
-
return
|
|
194
|
+
try {
|
|
195
|
+
return Temporal.PlainDate.from(bare)
|
|
196
|
+
} catch (err) {
|
|
197
|
+
throw new RangeError(`daymath: invalid ${label} ${JSON.stringify(text)}`, {
|
|
198
|
+
cause: err,
|
|
199
|
+
})
|
|
55
200
|
}
|
|
56
|
-
throw new TypeError(
|
|
57
|
-
`daymath: ${label} must be ISO 8601 day string or Temporal.PlainDate`,
|
|
58
|
-
)
|
|
59
201
|
}
|
|
60
202
|
|
|
61
203
|
/** @param {Temporal.PlainDate} plain @returns {string} */
|
|
@@ -63,11 +205,6 @@ function toDayString(plain) {
|
|
|
63
205
|
return plain.toString()
|
|
64
206
|
}
|
|
65
207
|
|
|
66
|
-
/** Temporal ISO weekday 1=Mon…7=Sun → JS/date-fns 0=Sun…6=Sat */
|
|
67
|
-
function isoToJsWeekday(isoDayOfWeek) {
|
|
68
|
-
return isoDayOfWeek === 7 ? 0 : isoDayOfWeek
|
|
69
|
-
}
|
|
70
|
-
|
|
71
208
|
/** @param {unknown} n @param {string} label */
|
|
72
209
|
function assertFiniteNumber(n, label) {
|
|
73
210
|
if (typeof n !== 'number' || !Number.isFinite(n)) {
|
|
@@ -78,6 +215,44 @@ function assertFiniteNumber(n, label) {
|
|
|
78
215
|
}
|
|
79
216
|
}
|
|
80
217
|
|
|
218
|
+
/**
|
|
219
|
+
* Run a Temporal op and keep the `daymath:` message contract when it fails.
|
|
220
|
+
* Covers both a result past the range and an argument Temporal refuses
|
|
221
|
+
* outright, hence the neutral wording.
|
|
222
|
+
*
|
|
223
|
+
* The message carries no Temporal text. Implementations word the same failure
|
|
224
|
+
* differently — the polyfill says `Out-of-bounds date` where native V8 says
|
|
225
|
+
* `Temporal error: epoch days exceed maximum range.` — so quoting it made
|
|
226
|
+
* daymath's own message vary by runtime. `cause` still holds the original
|
|
227
|
+
* error, which is where the detail belongs.
|
|
228
|
+
* @template T
|
|
229
|
+
* @param {string} label
|
|
230
|
+
* @param {() => T} op
|
|
231
|
+
* @returns {T}
|
|
232
|
+
*/
|
|
233
|
+
function guardRange(label, op) {
|
|
234
|
+
try {
|
|
235
|
+
return op()
|
|
236
|
+
} catch (err) {
|
|
237
|
+
throw new RangeError(`daymath: ${label} could not produce a valid date`, {
|
|
238
|
+
cause: err,
|
|
239
|
+
})
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Shared body for every add/sub function. Each caller passes its own name, so
|
|
245
|
+
* the message never reports a function the caller did not call.
|
|
246
|
+
* @param {DayInput} date
|
|
247
|
+
* @param {{ days?: number, months?: number, years?: number }} delta
|
|
248
|
+
* @param {string} label
|
|
249
|
+
* @returns {string}
|
|
250
|
+
*/
|
|
251
|
+
function addDuration(date, delta, label) {
|
|
252
|
+
const d = toPlainDate(date)
|
|
253
|
+
return guardRange(label, () => toDayString(d.add(delta)))
|
|
254
|
+
}
|
|
255
|
+
|
|
81
256
|
/** @param {unknown} dates */
|
|
82
257
|
function assertNonEmptyDates(dates) {
|
|
83
258
|
if (!Array.isArray(dates) || dates.length === 0) {
|
|
@@ -87,14 +262,14 @@ function assertNonEmptyDates(dates) {
|
|
|
87
262
|
|
|
88
263
|
/**
|
|
89
264
|
* @param {WeekOptions} [options]
|
|
90
|
-
* @returns {0|1|2|3|4|5|6}
|
|
265
|
+
* @returns {0|1|2|3|4|5|6|7} 7 is the default, so 0-6 was never the real range
|
|
91
266
|
*/
|
|
92
267
|
function weekStartsOnFrom(options) {
|
|
93
|
-
const w = options?.weekStartsOn ??
|
|
94
|
-
if (!Number.isInteger(w) || w < 0 || w >
|
|
95
|
-
throw new RangeError('daymath: weekStartsOn must be an integer 0…
|
|
268
|
+
const w = options?.weekStartsOn ?? 7
|
|
269
|
+
if (!Number.isInteger(w) || w < 0 || w > 7) {
|
|
270
|
+
throw new RangeError('daymath: weekStartsOn must be an integer 0…7 (7 or 0 = Sunday)')
|
|
96
271
|
}
|
|
97
|
-
return /** @type {0|1|2|3|4|5|6} */ (w)
|
|
272
|
+
return /** @type {0|1|2|3|4|5|6|7} */ (w)
|
|
98
273
|
}
|
|
99
274
|
|
|
100
275
|
/**
|
|
@@ -102,7 +277,15 @@ function weekStartsOnFrom(options) {
|
|
|
102
277
|
* @returns {{ start: Temporal.PlainDate, end: Temporal.PlainDate }}
|
|
103
278
|
*/
|
|
104
279
|
function toInterval(interval) {
|
|
105
|
-
|
|
280
|
+
// `typeof x === 'object'` alone let a Date, an array or a PlainDate through,
|
|
281
|
+
// and the failure then surfaced as "start must be ISO 8601 day string" —
|
|
282
|
+
// naming a property the caller never meant to pass.
|
|
283
|
+
if (
|
|
284
|
+
interval === null || // typeof null is 'object', so it needs its own test
|
|
285
|
+
typeof interval !== 'object' || // and this already catches undefined
|
|
286
|
+
!('start' in interval) ||
|
|
287
|
+
!('end' in interval)
|
|
288
|
+
) {
|
|
106
289
|
throw new TypeError('daymath: interval must be { start, end }')
|
|
107
290
|
}
|
|
108
291
|
const start = toPlainDate(/** @type {Interval} */ (interval).start, 'start')
|
|
@@ -110,6 +293,212 @@ function toInterval(interval) {
|
|
|
110
293
|
return { start, end }
|
|
111
294
|
}
|
|
112
295
|
|
|
296
|
+
// ─── the clock ─────────────────────────────────────────────────────
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* The calendar day of a moment, in a zone. The way in.
|
|
300
|
+
*
|
|
301
|
+
* Both arguments have a **stated** default: the moment is now, and the zone is
|
|
302
|
+
* UTC. A default that is written down is not a guess; a default that is assumed
|
|
303
|
+
* is. UTC is still not your day for part of every day — it runs ahead of
|
|
304
|
+
* America/New_York for 16.7% of the day, and behind Asia/Tokyo for 37.5% — so
|
|
305
|
+
* name your zone when that matters.
|
|
306
|
+
*
|
|
307
|
+
* `moment` accepts what the world actually hands you:
|
|
308
|
+
* - a `Date`, the only carrier of an instant JavaScript has
|
|
309
|
+
* - a number, read as **epoch milliseconds**, exactly as `new Date(n)` reads it,
|
|
310
|
+
* truncated the same way, so a fractional value is not an error
|
|
311
|
+
* - an ISO 8601 day string, or a `Temporal.PlainDate` from any implementation,
|
|
312
|
+
* both of which are already a day
|
|
313
|
+
* - an ISO 8601 timestamp carrying `Z` or an offset, which names an exact
|
|
314
|
+
* instant, so there is nothing left to guess
|
|
315
|
+
* - a string carrying a `[Zone]` annotation, which names its own zone, so it
|
|
316
|
+
* answers its own civil day and the `tz` default never applies
|
|
317
|
+
*
|
|
318
|
+
* `'11/12/2026'` is refused. Nobody can tell November from December in it.
|
|
319
|
+
* `'2026-08-08T12:00'` is refused too: no offset and no zone, so daymath would
|
|
320
|
+
* have to pick one, and it will not pick on the caller's behalf. Name the zone
|
|
321
|
+
* — `'2026-08-08T12:00[America/New_York]'` — and it is accepted.
|
|
322
|
+
*
|
|
323
|
+
* A lone string takes one of four roles, decided in this order: a day, then a
|
|
324
|
+
* zoned time, then an instant, then a zone. The zone test is by **shape**, and
|
|
325
|
+
* the shape is on `ZONE_LIKE`. Temporal's own zone grammar cannot decide the role, because it
|
|
326
|
+
* accepts a whole timestamp and reads the zone out of it, so
|
|
327
|
+
* `day('1999-01-01T00:00:00Z')` would answer today. The grammar also differs
|
|
328
|
+
* between implementations: `'2026-08-08T25:00:00Z'` and `'T12:00:00Z'` are both
|
|
329
|
+
* zones to native Temporal and neither is one to `temporal-polyfill`. Shape
|
|
330
|
+
* settles the role and the runtime split together.
|
|
331
|
+
*
|
|
332
|
+
* With `day()` this is the only export that reads a clock, so the only one
|
|
333
|
+
* whose answer depends on when you call it. Give it a moment and it becomes a
|
|
334
|
+
* pure function, which is how the cross-runtime battery covers it.
|
|
335
|
+
*
|
|
336
|
+
* @param {Date | number | DayInput | null} [moment] instant, epoch ms, day, or a zone
|
|
337
|
+
* @param {string} [tz] IANA time zone id, e.g. `'utc'`, `'Asia/Tokyo'`
|
|
338
|
+
* @returns {string} `YYYY-MM-DD`
|
|
339
|
+
* @throws {TypeError} If `moment` is not one of the accepted shapes
|
|
340
|
+
* @throws {RangeError} On an Invalid Date, a non-finite number, or an unknown zone
|
|
341
|
+
* @example day() // '2026-08-08' now, UTC
|
|
342
|
+
* @example day('Asia/Tokyo') // '2026-08-09' today in Tokyo
|
|
343
|
+
* @example day(row.createdAt) // '2026-08-07' a Date, UTC
|
|
344
|
+
* @example day(row.createdAt, 'America/New_York') // '2026-08-06'
|
|
345
|
+
* @example day(1761616161771) // '2025-10-28' epoch ms
|
|
346
|
+
* @example day('1999-01-01T00:00:00Z') // '1999-01-01' an ISO timestamp
|
|
347
|
+
* @example day(zdt.toString()) // the zone in the string wins
|
|
348
|
+
* @example addDays(day(), 2) // '2026-08-10'
|
|
349
|
+
*/
|
|
350
|
+
export function day(moment, tz) {
|
|
351
|
+
// Already a day, in every accepted spelling. `bareDay` is the same predicate
|
|
352
|
+
// toPlainDate uses, so day() cannot drift from the other 68 exports.
|
|
353
|
+
const isDay =
|
|
354
|
+
isPlainDate(moment) || (typeof moment === 'string' && bareDay(moment) !== null)
|
|
355
|
+
const isMoment = isDay || moment instanceof Date || typeof moment === 'number'
|
|
356
|
+
|
|
357
|
+
let zone = tz
|
|
358
|
+
/** @type {Temporal.Instant | undefined} */
|
|
359
|
+
let instant
|
|
360
|
+
/** @type {Temporal.ZonedDateTime | undefined} */
|
|
361
|
+
let zoned
|
|
362
|
+
if (!isMoment && moment !== undefined && moment !== null) {
|
|
363
|
+
if (typeof moment !== 'string') {
|
|
364
|
+
throw new TypeError(
|
|
365
|
+
'daymath: day() takes a Date, epoch milliseconds, or an ISO 8601 day',
|
|
366
|
+
)
|
|
367
|
+
}
|
|
368
|
+
// A `[Zone]` annotation makes the string a ZonedDateTime, and Temporal will
|
|
369
|
+
// not build one without it: a bare offset is refused. So the bracket is the
|
|
370
|
+
// caller naming a zone, and the string carries its own civil day. Reading
|
|
371
|
+
// the instant instead and applying UTC would move a browser's date by one.
|
|
372
|
+
//
|
|
373
|
+
// Naming a zone and resolving as one are different questions, so the
|
|
374
|
+
// annotation is detected first. A string can name a zone and still fail:
|
|
375
|
+
// `'…-05:00[America/New_York]'` has an offset the zone contradicts, and
|
|
376
|
+
// `'…[Asia/Tokoy]'` is a typo. Falling back to the instant would answer a
|
|
377
|
+
// UTC day for both, silently, which is the defect this branch exists to fix.
|
|
378
|
+
if (hasZoneAnnotation(moment)) {
|
|
379
|
+
// A calendar is refused only where it is applied. With a zone bracket it
|
|
380
|
+
// is: the fields get renumbered, and `toPlainDate()` carries the
|
|
381
|
+
// annotation into daymath's own output. Refused on the string, before the
|
|
382
|
+
// parse, because native Temporal builds a `[u-ca=buddhist]` ZonedDateTime
|
|
383
|
+
// and the polyfill refuses to — judging after the parse would make the
|
|
384
|
+
// message depend on the runtime.
|
|
385
|
+
assertIsoCalendar(moment, 'date')
|
|
386
|
+
if (tz !== undefined && tz !== null) {
|
|
387
|
+
throw new TypeError(
|
|
388
|
+
`daymath: day() got two time zones, ${JSON.stringify(moment)} and ${JSON.stringify(tz)}`,
|
|
389
|
+
)
|
|
390
|
+
}
|
|
391
|
+
try {
|
|
392
|
+
zoned = Temporal.ZonedDateTime.from(moment)
|
|
393
|
+
} catch (err) {
|
|
394
|
+
throw new RangeError(
|
|
395
|
+
`daymath: day() could not read ${JSON.stringify(moment)} in the time zone it names`,
|
|
396
|
+
{ cause: err },
|
|
397
|
+
)
|
|
398
|
+
}
|
|
399
|
+
} else {
|
|
400
|
+
try {
|
|
401
|
+
// A timestamp carrying `Z` or an offset names an exact instant, so it
|
|
402
|
+
// reads as a moment. Temporal's own grammar is the definition of that.
|
|
403
|
+
//
|
|
404
|
+
// A calendar annotation here is inert and is ignored. An Instant has no
|
|
405
|
+
// year, month or day for a calendar to renumber, and without a zone
|
|
406
|
+
// bracket Temporal will not build anything that does. Refusing it would
|
|
407
|
+
// reject a right answer for a reason that cannot apply.
|
|
408
|
+
instant = Temporal.Instant.from(moment)
|
|
409
|
+
} catch {
|
|
410
|
+
// Not a moment, so the string must be a zone — decided by shape, before
|
|
411
|
+
// Temporal sees it. Temporal's zone grammar also accepts a whole
|
|
412
|
+
// timestamp and pulls the zone out of it, so letting it decide the role
|
|
413
|
+
// would read a date as a zone and silently answer today.
|
|
414
|
+
if (!ZONE_LIKE.test(moment)) {
|
|
415
|
+
throw new RangeError(
|
|
416
|
+
`daymath: day() got ${JSON.stringify(moment)}, which is neither a moment nor a time zone`,
|
|
417
|
+
)
|
|
418
|
+
}
|
|
419
|
+
if (tz !== undefined && tz !== null) {
|
|
420
|
+
throw new TypeError(
|
|
421
|
+
`daymath: day() got two time zones, ${JSON.stringify(moment)} and ${JSON.stringify(tz)}`,
|
|
422
|
+
)
|
|
423
|
+
}
|
|
424
|
+
zone = moment
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
zone ??= 'utc'
|
|
429
|
+
|
|
430
|
+
if (moment instanceof Date && Number.isNaN(moment.getTime())) {
|
|
431
|
+
throw new RangeError('daymath: day() got an Invalid Date')
|
|
432
|
+
}
|
|
433
|
+
if (typeof moment === 'number' && !Number.isFinite(moment)) {
|
|
434
|
+
throw new RangeError(`daymath: day() got a non-finite time value ${moment}`)
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// Shape first, because implementations disagree past this point. The reasons
|
|
438
|
+
// are on ZONE_LIKE. The typeof test comes first because `.test()` coerces,
|
|
439
|
+
// and a caller-supplied toString could throw an error that is not ours.
|
|
440
|
+
if (typeof zone !== 'string' || !ZONE_LIKE.test(zone)) {
|
|
441
|
+
throw new RangeError(
|
|
442
|
+
`daymath: day() got an unknown time zone ${JSON.stringify(zone)}`,
|
|
443
|
+
)
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// Checked before anything returns, so a mistyped zone fails the same way
|
|
447
|
+
// whatever the moment is. A caller mapping rows that are sometimes a Date and
|
|
448
|
+
// sometimes a day string would otherwise see the typo only on some rows.
|
|
449
|
+
//
|
|
450
|
+
// `ZonedDateTime.from` accepts exactly what `Temporal.Now` accepts and reads
|
|
451
|
+
// no clock, so a day input stays a pure function.
|
|
452
|
+
try {
|
|
453
|
+
Temporal.ZonedDateTime.from({ timeZone: zone, year: 1970, month: 1, day: 1 })
|
|
454
|
+
} catch (err) {
|
|
455
|
+
throw new RangeError(
|
|
456
|
+
`daymath: day() got an unknown time zone ${JSON.stringify(zone)}`,
|
|
457
|
+
{ cause: err },
|
|
458
|
+
)
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// A day carries no time, so a zone has nothing to shift. Applying one would
|
|
462
|
+
// invent a moment the caller never gave.
|
|
463
|
+
if (isDay) return toDayString(toPlainDate(moment))
|
|
464
|
+
|
|
465
|
+
// The string named its own zone, so that zone decides the day, not the
|
|
466
|
+
// default. This is the one path where `zone` is deliberately not consulted.
|
|
467
|
+
//
|
|
468
|
+
// The result still goes through `toPlainDate`. A ZonedDateTime keeps a
|
|
469
|
+
// `[u-ca=…]` annotation, and `PlainDate.toString()` writes it back out, so
|
|
470
|
+
// returning directly emitted `'2026-08-08[u-ca=buddhist]'` — a value daymath
|
|
471
|
+
// itself refuses. Every path adjudicates the calendar in one place or none.
|
|
472
|
+
if (zoned !== undefined) {
|
|
473
|
+
const plain = guardRange('day', () => zoned.toPlainDate())
|
|
474
|
+
return toDayString(toPlainDate(plain))
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
if (instant !== undefined) {
|
|
478
|
+
return guardRange('day', () =>
|
|
479
|
+
instant.toZonedDateTimeISO(zone).toPlainDate().toString(),
|
|
480
|
+
)
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
if (!isMoment) return Temporal.Now.plainDateISO(zone).toString()
|
|
484
|
+
|
|
485
|
+
// Truncate, because `new Date(n)` truncates, and the contract here is that a
|
|
486
|
+
// number reads exactly as it does. Verified equal on positive and negative
|
|
487
|
+
// fractions. Sub-millisecond precision cannot change a calendar day anyway.
|
|
488
|
+
// Every other shape returned above, so only a number or a Date reaches here.
|
|
489
|
+
// The cast says what the control flow already guarantees but tsc cannot see.
|
|
490
|
+
const epochMs =
|
|
491
|
+
typeof moment === 'number'
|
|
492
|
+
? Math.trunc(moment)
|
|
493
|
+
: /** @type {Date} */ (moment).getTime()
|
|
494
|
+
return guardRange('day', () =>
|
|
495
|
+
Temporal.Instant.fromEpochMilliseconds(epochMs)
|
|
496
|
+
.toZonedDateTimeISO(zone)
|
|
497
|
+
.toPlainDate()
|
|
498
|
+
.toString(),
|
|
499
|
+
)
|
|
500
|
+
}
|
|
501
|
+
|
|
113
502
|
// ─── parse / format / valid ────────────────────────────────────────
|
|
114
503
|
|
|
115
504
|
/**
|
|
@@ -166,7 +555,7 @@ export function format(date, pattern = 'yyyy-MM-dd') {
|
|
|
166
555
|
*/
|
|
167
556
|
export function addDays(date, amount) {
|
|
168
557
|
assertFiniteNumber(amount, 'amount')
|
|
169
|
-
return
|
|
558
|
+
return addDuration(date, { days: amount }, 'addDays')
|
|
170
559
|
}
|
|
171
560
|
|
|
172
561
|
/**
|
|
@@ -176,7 +565,7 @@ export function addDays(date, amount) {
|
|
|
176
565
|
*/
|
|
177
566
|
export function subDays(date, amount) {
|
|
178
567
|
assertFiniteNumber(amount, 'amount')
|
|
179
|
-
return
|
|
568
|
+
return addDuration(date, { days: -amount }, 'subDays')
|
|
180
569
|
}
|
|
181
570
|
|
|
182
571
|
/**
|
|
@@ -186,7 +575,9 @@ export function subDays(date, amount) {
|
|
|
186
575
|
*/
|
|
187
576
|
export function addWeeks(date, amount) {
|
|
188
577
|
assertFiniteNumber(amount, 'amount')
|
|
189
|
-
|
|
578
|
+
const days = amount * 7 // re-check: 7x a finite amount can still reach Infinity
|
|
579
|
+
assertFiniteNumber(days, 'amount')
|
|
580
|
+
return addDuration(date, { days }, 'addWeeks')
|
|
190
581
|
}
|
|
191
582
|
|
|
192
583
|
/**
|
|
@@ -196,7 +587,9 @@ export function addWeeks(date, amount) {
|
|
|
196
587
|
*/
|
|
197
588
|
export function subWeeks(date, amount) {
|
|
198
589
|
assertFiniteNumber(amount, 'amount')
|
|
199
|
-
|
|
590
|
+
const days = -amount * 7
|
|
591
|
+
assertFiniteNumber(days, 'amount')
|
|
592
|
+
return addDuration(date, { days }, 'subWeeks')
|
|
200
593
|
}
|
|
201
594
|
|
|
202
595
|
/**
|
|
@@ -207,7 +600,7 @@ export function subWeeks(date, amount) {
|
|
|
207
600
|
*/
|
|
208
601
|
export function addMonths(date, amount) {
|
|
209
602
|
assertFiniteNumber(amount, 'amount')
|
|
210
|
-
return
|
|
603
|
+
return addDuration(date, { months: amount }, 'addMonths')
|
|
211
604
|
}
|
|
212
605
|
|
|
213
606
|
/**
|
|
@@ -217,7 +610,7 @@ export function addMonths(date, amount) {
|
|
|
217
610
|
*/
|
|
218
611
|
export function subMonths(date, amount) {
|
|
219
612
|
assertFiniteNumber(amount, 'amount')
|
|
220
|
-
return
|
|
613
|
+
return addDuration(date, { months: -amount }, 'subMonths')
|
|
221
614
|
}
|
|
222
615
|
|
|
223
616
|
/**
|
|
@@ -227,7 +620,7 @@ export function subMonths(date, amount) {
|
|
|
227
620
|
*/
|
|
228
621
|
export function addYears(date, amount) {
|
|
229
622
|
assertFiniteNumber(amount, 'amount')
|
|
230
|
-
return
|
|
623
|
+
return addDuration(date, { years: amount }, 'addYears')
|
|
231
624
|
}
|
|
232
625
|
|
|
233
626
|
/**
|
|
@@ -237,7 +630,7 @@ export function addYears(date, amount) {
|
|
|
237
630
|
*/
|
|
238
631
|
export function subYears(date, amount) {
|
|
239
632
|
assertFiniteNumber(amount, 'amount')
|
|
240
|
-
return
|
|
633
|
+
return addDuration(date, { years: -amount }, 'subYears')
|
|
241
634
|
}
|
|
242
635
|
|
|
243
636
|
/**
|
|
@@ -247,7 +640,9 @@ export function subYears(date, amount) {
|
|
|
247
640
|
*/
|
|
248
641
|
export function addQuarters(date, amount) {
|
|
249
642
|
assertFiniteNumber(amount, 'amount')
|
|
250
|
-
|
|
643
|
+
const months = amount * 3
|
|
644
|
+
assertFiniteNumber(months, 'amount')
|
|
645
|
+
return addDuration(date, { months }, 'addQuarters')
|
|
251
646
|
}
|
|
252
647
|
|
|
253
648
|
/**
|
|
@@ -257,7 +652,9 @@ export function addQuarters(date, amount) {
|
|
|
257
652
|
*/
|
|
258
653
|
export function subQuarters(date, amount) {
|
|
259
654
|
assertFiniteNumber(amount, 'amount')
|
|
260
|
-
|
|
655
|
+
const months = -amount * 3
|
|
656
|
+
assertFiniteNumber(months, 'amount')
|
|
657
|
+
return addDuration(date, { months }, 'subQuarters')
|
|
261
658
|
}
|
|
262
659
|
|
|
263
660
|
// ─── getters / setters (date-fns / Date month & weekday indexing) ─
|
|
@@ -268,12 +665,13 @@ export function getYear(date) {
|
|
|
268
665
|
}
|
|
269
666
|
|
|
270
667
|
/**
|
|
271
|
-
* Month
|
|
668
|
+
* Month number, ISO 8601: 1 = January … 12 = December. Matches the `MM` field
|
|
669
|
+
* of the input string, and Temporal. **Not** date-fns, which is 0-based.
|
|
272
670
|
* @param {DayInput} date
|
|
273
671
|
* @returns {number}
|
|
274
672
|
*/
|
|
275
673
|
export function getMonth(date) {
|
|
276
|
-
return toPlainDate(date).month
|
|
674
|
+
return toPlainDate(date).month
|
|
277
675
|
}
|
|
278
676
|
|
|
279
677
|
/** Day of month 1…31. @param {DayInput} date @returns {number} */
|
|
@@ -282,12 +680,14 @@ export function getDate(date) {
|
|
|
282
680
|
}
|
|
283
681
|
|
|
284
682
|
/**
|
|
285
|
-
* Weekday
|
|
683
|
+
* Weekday, ISO 8601: 1 = Monday … 7 = Sunday. Matches Temporal and
|
|
684
|
+
* `Intl.Locale#weekInfo.firstDay`. **Not** date-fns, where Sunday is 0.
|
|
685
|
+
* Only Sunday differs; Monday–Saturday are 1–6 in both.
|
|
286
686
|
* @param {DayInput} date
|
|
287
687
|
* @returns {number}
|
|
288
688
|
*/
|
|
289
689
|
export function getDay(date) {
|
|
290
|
-
return
|
|
690
|
+
return toPlainDate(date).dayOfWeek
|
|
291
691
|
}
|
|
292
692
|
|
|
293
693
|
/** @param {DayInput} date @returns {number} */
|
|
@@ -317,71 +717,81 @@ export function isLeapYear(date) {
|
|
|
317
717
|
*/
|
|
318
718
|
export function setYear(date, year) {
|
|
319
719
|
assertFiniteNumber(year, 'year')
|
|
320
|
-
|
|
720
|
+
const d = toPlainDate(date)
|
|
721
|
+
return guardRange('setYear', () => toDayString(d.with({ year })))
|
|
321
722
|
}
|
|
322
723
|
|
|
323
724
|
/**
|
|
324
725
|
* @param {DayInput} date
|
|
325
|
-
* @param {number} month
|
|
726
|
+
* @param {number} month 1 = January … 12 = December (ISO 8601)
|
|
326
727
|
* @returns {string}
|
|
327
728
|
*/
|
|
328
729
|
export function setMonth(date, month) {
|
|
329
730
|
assertFiniteNumber(month, 'month')
|
|
330
|
-
if (month <
|
|
331
|
-
throw new RangeError('daymath: month must be
|
|
731
|
+
if (month < 1 || month > 12) {
|
|
732
|
+
throw new RangeError('daymath: month must be 1…12 (1=January)')
|
|
332
733
|
}
|
|
333
|
-
|
|
734
|
+
const d = toPlainDate(date)
|
|
735
|
+
return guardRange('setMonth', () => toDayString(d.with({ month })))
|
|
334
736
|
}
|
|
335
737
|
|
|
336
738
|
/**
|
|
337
739
|
* @param {DayInput} date
|
|
338
|
-
* @param {number} dayOfMonth
|
|
740
|
+
* @param {number} dayOfMonth 1…31; a day past the month end constrains to the
|
|
741
|
+
* last day of that month (no roll-over into the next month, unlike date-fns)
|
|
339
742
|
* @returns {string}
|
|
340
743
|
*/
|
|
341
744
|
export function setDate(date, dayOfMonth) {
|
|
342
745
|
assertFiniteNumber(dayOfMonth, 'day')
|
|
343
|
-
|
|
746
|
+
const d = toPlainDate(date)
|
|
747
|
+
return guardRange('setDate', () => toDayString(d.with({ day: dayOfMonth })))
|
|
344
748
|
}
|
|
345
749
|
|
|
346
750
|
// ─── start / end of unit ───────────────────────────────────────────
|
|
347
751
|
|
|
752
|
+
// The first/last day of a unit can fall outside the PlainDate range even when
|
|
753
|
+
// the input is inside it — startOfMonth('-271821-04-19') wants April 1st, which
|
|
754
|
+
// is below the minimum. Throwing is right; guardRange keeps the message ours.
|
|
755
|
+
|
|
348
756
|
/** @param {DayInput} date @returns {string} */
|
|
349
757
|
export function startOfMonth(date) {
|
|
350
758
|
const d = toPlainDate(date)
|
|
351
|
-
return toDayString(d.with({ day: 1 }))
|
|
759
|
+
return guardRange('startOfMonth', () => toDayString(d.with({ day: 1 })))
|
|
352
760
|
}
|
|
353
761
|
|
|
354
762
|
/** @param {DayInput} date @returns {string} */
|
|
355
763
|
export function endOfMonth(date) {
|
|
356
764
|
const d = toPlainDate(date)
|
|
357
|
-
return toDayString(d.with({ day: d.daysInMonth }))
|
|
765
|
+
return guardRange('endOfMonth', () => toDayString(d.with({ day: d.daysInMonth })))
|
|
358
766
|
}
|
|
359
767
|
|
|
360
768
|
/** @param {DayInput} date @returns {string} */
|
|
361
769
|
export function startOfYear(date) {
|
|
362
770
|
const d = toPlainDate(date)
|
|
363
|
-
return toDayString(d.with({ month: 1, day: 1 }))
|
|
771
|
+
return guardRange('startOfYear', () => toDayString(d.with({ month: 1, day: 1 })))
|
|
364
772
|
}
|
|
365
773
|
|
|
366
774
|
/** @param {DayInput} date @returns {string} */
|
|
367
775
|
export function endOfYear(date) {
|
|
368
776
|
const d = toPlainDate(date)
|
|
369
|
-
return toDayString(d.with({ month: 12, day: 31 }))
|
|
777
|
+
return guardRange('endOfYear', () => toDayString(d.with({ month: 12, day: 31 })))
|
|
370
778
|
}
|
|
371
779
|
|
|
372
780
|
/** @param {DayInput} date @returns {string} */
|
|
373
781
|
export function startOfQuarter(date) {
|
|
374
782
|
const d = toPlainDate(date)
|
|
375
783
|
const month = (getQuarter(d) - 1) * 3 + 1
|
|
376
|
-
return toDayString(d.with({ month, day: 1 }))
|
|
784
|
+
return guardRange('startOfQuarter', () => toDayString(d.with({ month, day: 1 })))
|
|
377
785
|
}
|
|
378
786
|
|
|
379
787
|
/** @param {DayInput} date @returns {string} */
|
|
380
788
|
export function endOfQuarter(date) {
|
|
381
789
|
const d = toPlainDate(date)
|
|
382
790
|
const month = getQuarter(d) * 3
|
|
383
|
-
|
|
384
|
-
|
|
791
|
+
return guardRange('endOfQuarter', () => {
|
|
792
|
+
const mid = d.with({ month, day: 1 })
|
|
793
|
+
return toDayString(mid.with({ day: mid.daysInMonth }))
|
|
794
|
+
})
|
|
385
795
|
}
|
|
386
796
|
|
|
387
797
|
/**
|
|
@@ -391,10 +801,20 @@ export function endOfQuarter(date) {
|
|
|
391
801
|
*/
|
|
392
802
|
export function startOfWeek(date, options) {
|
|
393
803
|
const d = toPlainDate(date)
|
|
804
|
+
const diff = daysIntoWeek(d, options)
|
|
805
|
+
return guardRange('startOfWeek', () => toDayString(d.subtract({ days: diff })))
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
/**
|
|
809
|
+
* How far the day sits past the start of its week.
|
|
810
|
+
* @param {Temporal.PlainDate} d
|
|
811
|
+
* @param {WeekOptions} [options]
|
|
812
|
+
* @returns {number}
|
|
813
|
+
*/
|
|
814
|
+
function daysIntoWeek(d, options) {
|
|
394
815
|
const weekStartsOn = weekStartsOnFrom(options)
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
return toDayString(d.subtract({ days: diff }))
|
|
816
|
+
// mod 7 makes weekStartsOn 0 and 7 identical, so both spellings of Sunday work
|
|
817
|
+
return (d.dayOfWeek - weekStartsOn + 7) % 7
|
|
398
818
|
}
|
|
399
819
|
|
|
400
820
|
/**
|
|
@@ -403,7 +823,12 @@ export function startOfWeek(date, options) {
|
|
|
403
823
|
* @returns {string}
|
|
404
824
|
*/
|
|
405
825
|
export function endOfWeek(date, options) {
|
|
406
|
-
|
|
826
|
+
const d = toPlainDate(date)
|
|
827
|
+
const diff = daysIntoWeek(d, options)
|
|
828
|
+
// one guard for the whole walk, so a failure at either end says endOfWeek
|
|
829
|
+
return guardRange('endOfWeek', () =>
|
|
830
|
+
toDayString(d.subtract({ days: diff }).add({ days: 6 })),
|
|
831
|
+
)
|
|
407
832
|
}
|
|
408
833
|
|
|
409
834
|
// ─── differences ───────────────────────────────────────────────────
|
|
@@ -427,11 +852,20 @@ export function differenceInDays(dateLeft, dateRight) {
|
|
|
427
852
|
* @returns {number}
|
|
428
853
|
*/
|
|
429
854
|
export function differenceInWeeks(dateLeft, dateRight) {
|
|
430
|
-
|
|
855
|
+
// `|| 0` normalises -0: Math.trunc keeps the sign of a negative gap shorter
|
|
856
|
+
// than a week, so a 1..6 day backwards difference returned -0
|
|
857
|
+
return Math.trunc(differenceInDays(dateLeft, dateRight) / 7) || 0
|
|
431
858
|
}
|
|
432
859
|
|
|
433
860
|
/**
|
|
434
|
-
* Full months (signed)
|
|
861
|
+
* Full months (signed). A month counts as full when `addMonths` would carry the
|
|
862
|
+
* earlier date to the later one, so the end of a short month counts: 31 January
|
|
863
|
+
* to 28 February is one month, because `addMonths` clamps 31 February to the
|
|
864
|
+
* 28th. That keeps `differenceInMonths(addMonths(d, n), d) === n`.
|
|
865
|
+
*
|
|
866
|
+
* Temporal's `since` is not used here. It has no overflow option, so it counts
|
|
867
|
+
* 28 days rather than one month for that pair, and the round trip breaks in
|
|
868
|
+
* 21,934 of 1,761,936 cases. `add` clamps, so the measurement has to match.
|
|
435
869
|
* @param {DayInput} dateLeft
|
|
436
870
|
* @param {DayInput} dateRight
|
|
437
871
|
* @returns {number}
|
|
@@ -439,8 +873,18 @@ export function differenceInWeeks(dateLeft, dateRight) {
|
|
|
439
873
|
export function differenceInMonths(dateLeft, dateRight) {
|
|
440
874
|
const left = toPlainDate(dateLeft, 'dateLeft')
|
|
441
875
|
const right = toPlainDate(dateRight, 'dateRight')
|
|
442
|
-
const
|
|
443
|
-
return
|
|
876
|
+
const sign = Temporal.PlainDate.compare(left, right)
|
|
877
|
+
if (sign === 0) return 0
|
|
878
|
+
const diff = Math.abs(differenceInCalendarMonths(left, right))
|
|
879
|
+
if (diff < 1) return 0
|
|
880
|
+
const [earlier, later] = sign > 0 ? [right, left] : [left, right]
|
|
881
|
+
// Where `earlier` lands after `diff` months: same year-month as `later` by
|
|
882
|
+
// construction, on `earlier`'s day clamped to that month's length. Compared
|
|
883
|
+
// as day numbers rather than built as a date, because the landing can sit
|
|
884
|
+
// past the maximum PlainDate even when both operands are inside the range.
|
|
885
|
+
const landingDay = Math.min(earlier.day, later.daysInMonth)
|
|
886
|
+
const isLastMonthNotFull = landingDay > later.day
|
|
887
|
+
return sign * (diff - +isLastMonthNotFull) || 0
|
|
444
888
|
}
|
|
445
889
|
|
|
446
890
|
/**
|
|
@@ -456,7 +900,11 @@ export function differenceInCalendarMonths(dateLeft, dateRight) {
|
|
|
456
900
|
}
|
|
457
901
|
|
|
458
902
|
/**
|
|
459
|
-
* Full years (signed).
|
|
903
|
+
* Full years (signed). Same rule as `differenceInMonths`: a year counts as full
|
|
904
|
+
* when `addYears` would carry the earlier date to the later one, so 29 February
|
|
905
|
+
* to 28 February of a common year is one year, because `addYears` clamps.
|
|
906
|
+
* That keeps `differenceInYears(addYears(d, n), d) === n`, and keeps this
|
|
907
|
+
* function agreeing with `trunc(differenceInMonths(a, b) / 12)`.
|
|
460
908
|
* @param {DayInput} dateLeft
|
|
461
909
|
* @param {DayInput} dateRight
|
|
462
910
|
* @returns {number}
|
|
@@ -464,7 +912,22 @@ export function differenceInCalendarMonths(dateLeft, dateRight) {
|
|
|
464
912
|
export function differenceInYears(dateLeft, dateRight) {
|
|
465
913
|
const left = toPlainDate(dateLeft, 'dateLeft')
|
|
466
914
|
const right = toPlainDate(dateRight, 'dateRight')
|
|
467
|
-
|
|
915
|
+
const sign = Temporal.PlainDate.compare(left, right)
|
|
916
|
+
if (sign === 0) return 0
|
|
917
|
+
const diff = Math.abs(left.year - right.year)
|
|
918
|
+
if (diff < 1) return 0
|
|
919
|
+
const [earlier, later] = sign > 0 ? [right, left] : [left, right]
|
|
920
|
+
// Where `earlier` lands after `diff` years: same year as `later`, same month,
|
|
921
|
+
// and the same day except that 29 February clamps to the 28th in a common
|
|
922
|
+
// year, which is the only day-of-month that changes length year to year.
|
|
923
|
+
// Compared field by field rather than built as a date, because the landing
|
|
924
|
+
// can sit past the maximum PlainDate even with both operands inside the range.
|
|
925
|
+
const landingDay =
|
|
926
|
+
earlier.month === 2 && earlier.day === 29 && !later.inLeapYear ? 28 : earlier.day
|
|
927
|
+
const isLastYearNotFull =
|
|
928
|
+
earlier.month > later.month ||
|
|
929
|
+
(earlier.month === later.month && landingDay > later.day)
|
|
930
|
+
return sign * (diff - +isLastYearNotFull) || 0
|
|
468
931
|
}
|
|
469
932
|
|
|
470
933
|
/**
|
|
@@ -484,7 +947,9 @@ export function differenceInCalendarYears(dateLeft, dateRight) {
|
|
|
484
947
|
* @returns {number}
|
|
485
948
|
*/
|
|
486
949
|
export function differenceInQuarters(dateLeft, dateRight) {
|
|
487
|
-
|
|
950
|
+
// `|| 0` normalises -0, same reason as differenceInWeeks: a backwards gap of
|
|
951
|
+
// one or two months truncates to -0
|
|
952
|
+
return Math.trunc(differenceInMonths(dateLeft, dateRight) / 3) || 0
|
|
488
953
|
}
|
|
489
954
|
|
|
490
955
|
/**
|
|
@@ -496,9 +961,7 @@ export function differenceInQuarters(dateLeft, dateRight) {
|
|
|
496
961
|
export function differenceInCalendarQuarters(dateLeft, dateRight) {
|
|
497
962
|
const left = toPlainDate(dateLeft, 'dateLeft')
|
|
498
963
|
const right = toPlainDate(dateRight, 'dateRight')
|
|
499
|
-
return (
|
|
500
|
-
(left.year - right.year) * 4 + (getQuarter(left) - getQuarter(right))
|
|
501
|
-
)
|
|
964
|
+
return (left.year - right.year) * 4 + (getQuarter(left) - getQuarter(right))
|
|
502
965
|
}
|
|
503
966
|
|
|
504
967
|
// ─── compare / equal ───────────────────────────────────────────────
|
|
@@ -556,7 +1019,14 @@ export const isSameDay = isEqual
|
|
|
556
1019
|
* @returns {boolean}
|
|
557
1020
|
*/
|
|
558
1021
|
export function isSameWeek(dateLeft, dateRight, options) {
|
|
559
|
-
|
|
1022
|
+
const left = toPlainDate(dateLeft, 'dateLeft')
|
|
1023
|
+
const right = toPlainDate(dateRight, 'dateRight')
|
|
1024
|
+
// own guard, so a week start below the minimum does not say startOfWeek
|
|
1025
|
+
return guardRange('isSameWeek', () =>
|
|
1026
|
+
left
|
|
1027
|
+
.subtract({ days: daysIntoWeek(left, options) })
|
|
1028
|
+
.equals(right.subtract({ days: daysIntoWeek(right, options) })),
|
|
1029
|
+
)
|
|
560
1030
|
}
|
|
561
1031
|
|
|
562
1032
|
/**
|
|
@@ -610,7 +1080,8 @@ export function compareAsc(dateLeft, dateRight) {
|
|
|
610
1080
|
* @returns {-1 | 0 | 1}
|
|
611
1081
|
*/
|
|
612
1082
|
export function compareDesc(dateLeft, dateRight) {
|
|
613
|
-
|
|
1083
|
+
// `|| 0` normalises -0 for equal days
|
|
1084
|
+
return /** @type {-1 | 0 | 1} */ (-compareAsc(dateLeft, dateRight) || 0)
|
|
614
1085
|
}
|
|
615
1086
|
|
|
616
1087
|
/**
|
|
@@ -643,7 +1114,7 @@ export function max(dates) {
|
|
|
643
1114
|
|
|
644
1115
|
/** @param {DayInput} date @returns {boolean} */
|
|
645
1116
|
export function isSunday(date) {
|
|
646
|
-
return getDay(date) ===
|
|
1117
|
+
return getDay(date) === 7
|
|
647
1118
|
}
|
|
648
1119
|
/** @param {DayInput} date @returns {boolean} */
|
|
649
1120
|
export function isMonday(date) {
|
|
@@ -672,7 +1143,7 @@ export function isSaturday(date) {
|
|
|
672
1143
|
/** @param {DayInput} date @returns {boolean} */
|
|
673
1144
|
export function isWeekend(date) {
|
|
674
1145
|
const d = getDay(date)
|
|
675
|
-
return d ===
|
|
1146
|
+
return d === 6 || d === 7
|
|
676
1147
|
}
|
|
677
1148
|
|
|
678
1149
|
/** @param {DayInput} date @returns {boolean} */
|
|
@@ -701,8 +1172,11 @@ export function eachDayOfInterval(interval) {
|
|
|
701
1172
|
/** @type {string[]} */
|
|
702
1173
|
const out = []
|
|
703
1174
|
let cur = start
|
|
704
|
-
|
|
1175
|
+
// break on the last day, never step past it — an add beyond the max
|
|
1176
|
+
// PlainDate (+275760-09-13) throws
|
|
1177
|
+
for (;;) {
|
|
705
1178
|
out.push(toDayString(cur))
|
|
1179
|
+
if (Temporal.PlainDate.compare(cur, end) >= 0) break
|
|
706
1180
|
cur = cur.add({ days: 1 })
|
|
707
1181
|
}
|
|
708
1182
|
return out
|
|
@@ -720,10 +1194,16 @@ export function eachMonthOfInterval(interval) {
|
|
|
720
1194
|
}
|
|
721
1195
|
/** @type {string[]} */
|
|
722
1196
|
const out = []
|
|
723
|
-
|
|
724
|
-
const last =
|
|
725
|
-
|
|
1197
|
+
// the 1st of start's month can sit below the minimum PlainDate
|
|
1198
|
+
const [cur0, last] = guardRange('eachMonthOfInterval', () => [
|
|
1199
|
+
start.with({ day: 1 }),
|
|
1200
|
+
end.with({ day: 1 }),
|
|
1201
|
+
])
|
|
1202
|
+
let cur = cur0
|
|
1203
|
+
// same boundary rule as eachDayOfInterval
|
|
1204
|
+
for (;;) {
|
|
726
1205
|
out.push(toDayString(cur))
|
|
1206
|
+
if (Temporal.PlainDate.compare(cur, last) >= 0) break
|
|
727
1207
|
cur = cur.add({ months: 1 })
|
|
728
1208
|
}
|
|
729
1209
|
return out
|
|
@@ -742,10 +1222,13 @@ export function eachYearOfInterval(interval) {
|
|
|
742
1222
|
/** @type {string[]} */
|
|
743
1223
|
const out = []
|
|
744
1224
|
let y = start.year
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
y
|
|
748
|
-
|
|
1225
|
+
// Jan 1 of start's year can sit below the minimum PlainDate
|
|
1226
|
+
guardRange('eachYearOfInterval', () => {
|
|
1227
|
+
while (y <= end.year) {
|
|
1228
|
+
out.push(toDayString(Temporal.PlainDate.from({ year: y, month: 1, day: 1 })))
|
|
1229
|
+
y += 1
|
|
1230
|
+
}
|
|
1231
|
+
})
|
|
749
1232
|
return out
|
|
750
1233
|
}
|
|
751
1234
|
|
|
@@ -762,8 +1245,7 @@ export function isWithinInterval(date, interval) {
|
|
|
762
1245
|
throw new RangeError('daymath: interval start must not be after end')
|
|
763
1246
|
}
|
|
764
1247
|
return (
|
|
765
|
-
Temporal.PlainDate.compare(d, start) >= 0 &&
|
|
766
|
-
Temporal.PlainDate.compare(d, end) <= 0
|
|
1248
|
+
Temporal.PlainDate.compare(d, start) >= 0 && Temporal.PlainDate.compare(d, end) <= 0
|
|
767
1249
|
)
|
|
768
1250
|
}
|
|
769
1251
|
|
|
@@ -788,7 +1270,7 @@ export function clamp(date, interval) {
|
|
|
788
1270
|
* Whether two inclusive intervals overlap.
|
|
789
1271
|
* @param {Interval} intervalLeft
|
|
790
1272
|
* @param {Interval} intervalRight
|
|
791
|
-
* @param {{ inclusive?: boolean }} [options]
|
|
1273
|
+
* @param {{ inclusive?: boolean }} [options] `inclusive` defaults to false, like date-fns: intervals that only touch at an endpoint do not overlap
|
|
792
1274
|
* @returns {boolean}
|
|
793
1275
|
*/
|
|
794
1276
|
export function areIntervalsOverlapping(intervalLeft, intervalRight, options) {
|