1import { describe, it } from "@effect/vitest"
2import { assertNone, assertSome, deepStrictEqual, strictEqual, throws } from "@effect/vitest/utils"
3import { DateTime, Duration, Effect, Option } from "effect"
4import { TestClock } from "effect/testing"
5import { vi } from "vitest"
6
7const setTo2024NZ = TestClock.setTime(new Date("2023-12-31T11:00:00.000Z").getTime())
8const assertSomeIso = (value: Option.Option<DateTime.DateTime>, expected: string) => {
9 const iso = Option.map(value, (value) => DateTime.formatIso(DateTime.toUtc(value)))
10 assertSome(iso, expected)
11}
12const isDeno = "Deno" in globalThis
13
14describe("DateTime", () => {
15 it("preserves pre-epoch milliseconds when Intl omits fractionalSecond", () => {
16 const formatToParts = Intl.DateTimeFormat.prototype.formatToParts
17 const mock = vi.spyOn(Intl.DateTimeFormat.prototype, "formatToParts").mockImplementation(function(
18 this: Intl.DateTimeFormat,
19 date
20 ) {
21 return formatToParts.call(this, date).filter((part) => part.type !== "fractionalSecond")
22 })
23 try {
24 const dt = DateTime.makeZonedUnsafe("1969-12-31T23:59:59.999Z", {
25 timeZone: "Australia/Brisbane"
26 })
27 strictEqual(DateTime.toDate(dt).toISOString(), "1970-01-01T09:59:59.999Z")
28 strictEqual(DateTime.formatIsoDate(dt), "1970-01-01")
29 } finally {
30 mock.mockRestore()
31 }
32 })
33
34 describe("mutate", () => {
35 it.effect("should mutate the date", () =>
36 Effect.gen(function*() {
37 const now = yield* DateTime.now
38 const tomorrow = DateTime.mutate(now, (date) => {
39 date.setUTCDate(date.getUTCDate() + 1)
40 })
41 const diff = DateTime.distance(now, tomorrow)
42 deepStrictEqual(diff, Duration.fromInputUnsafe("1 day"))
43 }))
44
45 it.effect("preserves the named zone when mutating across DST", () =>
46 Effect.gen(function*() {
47 yield* setTo2024NZ
48 const now = yield* DateTime.nowInCurrentZone.pipe(
49 DateTime.withCurrentZoneNamed("Pacific/Auckland")
50 )
51 const future = DateTime.mutate(now, (date) => {
52 date.setUTCMonth(date.getUTCMonth() + 6)
53 })
54 strictEqual(DateTime.toDateUtc(future).toISOString(), "2024-06-30T12:00:00.000Z")
55 strictEqual(DateTime.toDate(future).toISOString(), "2024-07-01T00:00:00.000Z")
56 const plusOne = DateTime.mutate(future, (date) => {
57 date.setUTCDate(date.getUTCDate() + 1)
58 })
59 strictEqual(DateTime.toDateUtc(plusOne).toISOString(), "2024-07-01T12:00:00.000Z")
60 strictEqual(DateTime.toDate(plusOne).toISOString(), "2024-07-02T00:00:00.000Z")
61 }))
62 })
63
64 describe("add", () => {
65 it.effect("adds calendar days to a UTC DateTime", () =>
66 Effect.gen(function*() {
67 const now = yield* DateTime.now
68 const tomorrow = DateTime.add(now, { days: 1 })
69 const diff = DateTime.distance(now, tomorrow)
70 deepStrictEqual(diff, Duration.fromInputUnsafe("1 day"))
71 }))
72
73 it("clamps to the last valid day when changing months", () => {
74 const jan = DateTime.makeUnsafe({ year: 2023, month: 1, day: 31 })
75 let feb = DateTime.add(jan, { months: 1 })
76 strictEqual(feb.toJSON(), "2023-02-28T00:00:00.000Z")
77
78 const mar = DateTime.makeUnsafe({ year: 2023, month: 3, day: 31 })
79 feb = DateTime.subtract(mar, { months: 1 })
80 strictEqual(feb.toJSON(), "2023-02-28T00:00:00.000Z")
81 })
82
83 it.effect("preserves the named zone when adding calendar units across DST", () =>
84 Effect.gen(function*() {
85 yield* setTo2024NZ
86 const now = yield* DateTime.nowInCurrentZone.pipe(
87 DateTime.withCurrentZoneNamed("Pacific/Auckland")
88 )
89 const future = DateTime.add(now, { months: 6 })
90 strictEqual(DateTime.toDateUtc(future).toISOString(), "2024-06-30T12:00:00.000Z")
91 strictEqual(DateTime.toDate(future).toISOString(), "2024-07-01T00:00:00.000Z")
92 const plusOne = DateTime.add(future, { days: 1 })
93 strictEqual(DateTime.toDateUtc(plusOne).toISOString(), "2024-07-01T12:00:00.000Z")
94 strictEqual(DateTime.toDate(plusOne).toISOString(), "2024-07-02T00:00:00.000Z")
95 const minusOne = DateTime.subtract(plusOne, { days: 1 })
96 strictEqual(DateTime.toDateUtc(minusOne).toISOString(), "2024-06-30T12:00:00.000Z")
97 strictEqual(DateTime.toDate(minusOne).toISOString(), "2024-07-01T00:00:00.000Z")
98 }))
99
100 it.effect("leap years", () =>
101 Effect.gen(function*() {
102 yield* setTo2024NZ
103 const now = DateTime.make({ year: 2024, month: 2, day: 29 })
104 assertSomeIso(now, "2024-02-29T00:00:00.000Z")
105 const future = Option.map(now, (value) => DateTime.add(value, { years: 1 }))
106 assertSome(Option.map(future, DateTime.formatIso), "2025-02-28T00:00:00.000Z")
107 }))
108 })
109
110 describe("endOf", () => {
111 it("month", () => {
112 const mar = DateTime.makeUnsafe("2024-03-15T12:00:00.000Z")
113 const end = DateTime.endOf(mar, "month")
114 strictEqual(end.toJSON(), "2024-03-31T23:59:59.999Z")
115 })
116
117 it("feb leap year", () => {
118 const feb = DateTime.makeUnsafe("2024-02-15T12:00:00.000Z")
119 const end = DateTime.endOf(feb, "month")
120 strictEqual(end.toJSON(), "2024-02-29T23:59:59.999Z")
121 })
122
123 it("week", () => {
124 const start = DateTime.makeUnsafe("2024-03-15T12:00:00.000Z")
125 const end = DateTime.endOf(start, "week")
126 strictEqual(end.toJSON(), "2024-03-16T23:59:59.999Z")
127 strictEqual(DateTime.getPartUtc(end, "weekDay"), 6)
128 })
129
130 it("week last day", () => {
131 const start = DateTime.makeUnsafe("2024-03-16T12:00:00.000Z")
132 const end = DateTime.endOf(start, "week")
133 strictEqual(end.toJSON(), "2024-03-16T23:59:59.999Z")
134 })
135
136 it("week with options", () => {
137 const start = DateTime.makeUnsafe("2024-03-15T12:00:00.000Z")
138 const end = DateTime.endOf(start, "week", {
139 weekStartsOn: 1
140 })
141 strictEqual(end.toJSON(), "2024-03-17T23:59:59.999Z")
142 })
143
144 it.effect("returns the zoned end of the month", () =>
145 Effect.gen(function*() {
146 yield* setTo2024NZ
147 const now = yield* DateTime.nowInCurrentZone.pipe(
148 DateTime.withCurrentZoneNamed("Pacific/Auckland")
149 )
150 const future = DateTime.endOf(now, "month")
151 strictEqual(DateTime.toDateUtc(future).toISOString(), "2024-01-31T10:59:59.999Z")
152 strictEqual(DateTime.toDate(future).toISOString(), "2024-01-31T23:59:59.999Z")
153 }))
154 })
155
156 describe("startOf", () => {
157 it("month", () => {
158 const mar = DateTime.makeUnsafe("2024-03-15T12:00:00.000Z")
159 const end = DateTime.startOf(mar, "month")
160 strictEqual(end.toJSON(), "2024-03-01T00:00:00.000Z")
161 })
162
163 it("month duplicated", () => {
164 const mar = DateTime.makeUnsafe("2024-03-15T12:00:00.000Z")
165 const end = DateTime.startOf(mar, "month").pipe(
166 DateTime.startOf("month")
167 )
168 strictEqual(end.toJSON(), "2024-03-01T00:00:00.000Z")
169 })
170
171 it("feb leap year", () => {
172 const feb = DateTime.makeUnsafe("2024-02-15T12:00:00.000Z")
173 const end = DateTime.startOf(feb, "month")
174 strictEqual(end.toJSON(), "2024-02-01T00:00:00.000Z")
175 })
176
177 it("week", () => {
178 const start = DateTime.makeUnsafe("2024-03-15T12:00:00.000Z")
179 const end = DateTime.startOf(start, "week")
180 strictEqual(end.toJSON(), "2024-03-10T00:00:00.000Z")
181 strictEqual(DateTime.getPartUtc(end, "weekDay"), 0)
182 })
183
184 it("week first day", () => {
185 const start = DateTime.makeUnsafe("2024-03-10T12:00:00.000Z")
186 const end = DateTime.startOf(start, "week")
187 strictEqual(end.toJSON(), "2024-03-10T00:00:00.000Z")
188 })
189
190 it("week with options", () => {
191 const start = DateTime.makeUnsafe("2024-03-15T12:00:00.000Z")
192 const end = DateTime.startOf(start, "week", {
193 weekStartsOn: 1
194 })
195 strictEqual(end.toJSON(), "2024-03-11T00:00:00.000Z")
196 })
197 })
198
199 describe("nearest", () => {
200 it("month up", () => {
201 const mar = DateTime.makeUnsafe("2024-03-16T12:00:00.000Z")
202 const end = DateTime.nearest(mar, "month")
203 strictEqual(end.toJSON(), "2024-04-01T00:00:00.000Z")
204 })
205
206 it("month down", () => {
207 const mar = DateTime.makeUnsafe("2024-03-16T11:00:00.000Z")
208 const end = DateTime.nearest(mar, "month")
209 strictEqual(end.toJSON(), "2024-03-01T00:00:00.000Z")
210 })
211
212 it("second up", () => {
213 const mar = DateTime.makeUnsafe("2024-03-20T12:00:00.500Z")
214 const end = DateTime.nearest(mar, "second")
215 strictEqual(end.toJSON(), "2024-03-20T12:00:01.000Z")
216 })
217
218 it("second down", () => {
219 const mar = DateTime.makeUnsafe("2024-03-20T12:00:00.400Z")
220 const end = DateTime.nearest(mar, "second")
221 strictEqual(end.toJSON(), "2024-03-20T12:00:00.000Z")
222 })
223 })
224
225 describe.skipIf(isDeno)("format", () => {
226 it.effect("full", () =>
227 Effect.gen(function*() {
228 const now = yield* DateTime.now
229 strictEqual(
230 DateTime.format(now, {
231 locale: "en-US",
232 dateStyle: "full",
233 timeStyle: "full"
234 }),
235 "Thursday, January 1, 1970 at 12:00:00 AM Coordinated Universal Time"
236 )
237 }))
238 })
239
240 describe.skipIf(isDeno)("formatUtc", () => {
241 it.effect("full", () =>
242 Effect.gen(function*() {
243 const now = yield* DateTime.now
244 strictEqual(
245 DateTime.formatUtc(now, {
246 locale: "en-US",
247 dateStyle: "full",
248 timeStyle: "full"
249 }),
250 "Thursday, January 1, 1970 at 12:00:00 AM Coordinated Universal Time"
251 )
252 }))
253 })
254
255 describe.skipIf(isDeno)("format zoned", () => {
256 it.effect("full", () =>
257 Effect.gen(function*() {
258 const now = yield* DateTime.nowInCurrentZone.pipe(
259 DateTime.withCurrentZoneNamed("Pacific/Auckland")
260 )
261 strictEqual(
262 DateTime.format(now, {
263 locale: "en-US",
264 dateStyle: "full",
265 timeStyle: "full"
266 }),
267 "Thursday, January 1, 1970 at 12:00:00 PM New Zealand Standard Time"
268 )
269 }))
270
271 it.effect.skipIf(isDeno)("long with offset", () =>
272 Effect.gen(function*() {
273 const now = yield* DateTime.now
274 const formatted = now.pipe(
275 DateTime.setZoneOffset(10 * 60 * 60 * 1000),
276 DateTime.format({
277 locale: "en-US",
278 dateStyle: "long",
279 timeStyle: "short"
280 })
281 )
282 strictEqual(formatted, "January 1, 1970 at 10:00 AM")
283 }))
284 })
285
286 describe("fromParts", () => {
287 it("partial", () => {
288 const date = DateTime.makeUnsafe({
289 year: 2024,
290 month: 12,
291 day: 25
292 })
293 strictEqual(date.toJSON(), "2024-12-25T00:00:00.000Z")
294 })
295
296 it("month is set correctly", () => {
297 const date = DateTime.makeUnsafe({ year: 2024 })
298 strictEqual(date.toJSON(), "2024-01-01T00:00:00.000Z")
299 })
300 })
301
302 describe("setPartsUtc", () => {
303 it("applies calendar parts without intermediate overflow", () => {
304 const date = DateTime.makeUnsafe("2024-01-31T12:34:56.789Z")
305 const updated = DateTime.setPartsUtc(date, { month: 2, day: 1 })
306 strictEqual(DateTime.formatIso(updated), "2024-02-01T12:34:56.789Z")
307 })
308
309 it("partial", () => {
310 const date = DateTime.makeUnsafe({
311 year: 2024,
312 month: 12,
313 day: 25
314 })
315 strictEqual(date.toJSON(), "2024-12-25T00:00:00.000Z")
316
317 const updated = DateTime.setPartsUtc(date, {
318 year: 2023,
319 month: 1
320 })
321 strictEqual(updated.toJSON(), "2023-01-25T00:00:00.000Z")
322 })
323
324 it("ignores time zones", () => {
325 const date = DateTime.makeUnsafe({
326 year: 2024,
327 month: 12,
328 day: 25
329 }).pipe(DateTime.setZoneNamedUnsafe("Pacific/Auckland"))
330 strictEqual(date.toJSON(), "2024-12-25T00:00:00.000Z")
331
332 const updated = DateTime.setPartsUtc(date, {
333 year: 2023,
334 month: 1
335 })
336 strictEqual(updated.toJSON(), "2023-01-25T00:00:00.000Z")
337 })
338 })
339
340 describe("setParts", () => {
341 it("partial", () => {
342 const date = DateTime.makeUnsafe({
343 year: 2024,
344 month: 12,
345 day: 25
346 })
347 strictEqual(date.toJSON(), "2024-12-25T00:00:00.000Z")
348
349 const updated = DateTime.setParts(date, {
350 year: 2023,
351 month: 1
352 })
353 strictEqual(updated.toJSON(), "2023-01-25T00:00:00.000Z")
354 })
355
356 it("accounts for time zone", () => {
357 const date = DateTime.makeUnsafe({
358 year: 2024,
359 month: 12,
360 day: 25
361 }).pipe(DateTime.setZoneNamedUnsafe("Pacific/Auckland"))
362 strictEqual(date.toJSON(), "2024-12-25T00:00:00.000Z")
363
364 const updated = DateTime.setParts(date, {
365 year: 2023,
366 month: 6,
367 hour: 12
368 })
369 strictEqual(updated.toJSON(), "2023-06-25T00:00:00.000Z")
370 })
371 })
372
373 describe("formatIso", () => {
374 it("full", () => {
375 const now = DateTime.makeUnsafe("2024-03-15T12:00:00.000Z")
376 strictEqual(DateTime.formatIso(now), "2024-03-15T12:00:00.000Z")
377 })
378 })
379
380 describe("formatIsoOffset", () => {
381 it.effect("correctly adds offset", () =>
382 Effect.gen(function*() {
383 const now = yield* DateTime.nowInCurrentZone.pipe(
384 DateTime.withCurrentZoneNamed("Pacific/Auckland")
385 )
386 strictEqual(DateTime.formatIsoOffset(now), "1970-01-01T12:00:00.000+12:00")
387 }))
388 })
389
390 describe("layerCurrentZoneNamed", () => {
391 it.effect("correctly adds offset", () =>
392 Effect.gen(function*() {
393 const now = yield* DateTime.nowInCurrentZone
394 strictEqual(DateTime.formatIsoOffset(now), "1970-01-01T12:00:00.000+12:00")
395 }).pipe(
396 Effect.provide(DateTime.layerCurrentZoneNamed("Pacific/Auckland"))
397 ))
398 })
399
400 describe("removeTime", () => {
401 it("removes time", () => {
402 const dt = DateTime.makeZonedUnsafe("2024-01-01T01:00:00Z", {
403 timeZone: "Pacific/Auckland",
404 adjustForTimeZone: true
405 }).pipe(DateTime.removeTime)
406 strictEqual(dt.toJSON(), "2024-01-01T00:00:00.000Z")
407 })
408 })
409
410 describe("toEpochSeconds", () => {
411 it("returns epoch seconds", () => {
412 const dt = DateTime.makeUnsafe("2024-01-01T00:00:00Z")
413 strictEqual(DateTime.toEpochSeconds(dt), 1704067200)
414 })
415
416 it("floors to nearest second", () => {
417 const dt = DateTime.makeUnsafe("2024-01-01T00:00:00.999Z")
418 strictEqual(DateTime.toEpochSeconds(dt), 1704067200)
419 })
420 })
421
422 describe("fromEpochSeconds", () => {
423 it("creates DateTime from epoch seconds", () => {
424 const dt = DateTime.fromEpochSeconds(1704067200)
425 strictEqual(dt.toJSON(), "2024-01-01T00:00:00.000Z")
426 })
427
428 it("roundtrips with toEpochSeconds", () => {
429 const original = DateTime.makeUnsafe("2024-06-15T12:30:00Z")
430 const seconds = DateTime.toEpochSeconds(original)
431 const restored = DateTime.fromEpochSeconds(seconds)
432 strictEqual(DateTime.toEpochSeconds(restored), seconds)
433 })
434 })
435
436 describe("makeZonedFromString", () => {
437 it.effect("parses an instant with an offset and IANA zone", () =>
438 Effect.gen(function*() {
439 const dt = DateTime.makeZonedFromString("2024-07-21T20:12:34.112546348+12:00[Pacific/Auckland]")
440 assertSome(Option.map(dt, (value) => value.toJSON()), "2024-07-21T08:12:34.112Z")
441 }))
442
443 it.effect("parses an offset-only string as an Offset zone", () =>
444 Effect.gen(function*() {
445 const dt = DateTime.makeZonedFromString("2024-07-21T20:12:34.112546348+12:00")
446 assertSome(Option.map(dt, (value) => value.zone._tag), "Offset")
447 assertSome(Option.map(dt, (value) => value.toJSON()), "2024-07-21T08:12:34.112Z")
448 }))
449
450 it.effect("parses a UTC offset-only string as an Offset zone", () =>
451 Effect.gen(function*() {
452 const dt = DateTime.makeZonedFromString("2024-07-21T20:12:34.112546348+00:00")
453 assertSome(Option.map(dt, (value) => value.zone._tag), "Offset")
454 assertSome(Option.map(dt, (value) => value.toJSON()), "2024-07-21T20:12:34.112Z")
455 }))
456
457 it.effect("roundtrips a bracketed IANA zone string", () =>
458 Effect.gen(function*() {
459 const dt = Option.flatMap(
460 DateTime.makeZonedFromString("2024-07-21T20:12:34.112546348+12:00[Pacific/Auckland]"),
461 (d) => DateTime.makeZonedFromString(DateTime.formatIsoZoned(d))
462 )
463 assertSome(Option.map(dt, (value) => DateTime.zoneToString(value.zone)), "Pacific/Auckland")
464 assertSome(Option.map(dt, (value) => value.toJSON()), "2024-07-21T08:12:34.112Z")
465 }))
466 })
467
468 it("parts equality", () => {
469 const d1 = DateTime.makeUnsafe("2025-01-01")
470 const d2 = DateTime.makeUnsafe("2025-01-01")
471 deepStrictEqual(d1, d2)
472 DateTime.toPartsUtc(d2)
473 deepStrictEqual(d1, d2)
474 })
475
476 // doesnt work in CI
477 it.skip("makeZonedUnsafe no options", () => {
478 const date = new Date("2024-07-21T20:12:34.112Z")
479 ;(date as any).getTimezoneOffset = () => -60
480 const dt = DateTime.makeZonedUnsafe(date)
481 deepStrictEqual(dt.zone, DateTime.zoneMakeOffset(60 * 60 * 1000))
482 })
483
484 describe("toUtc", () => {
485 it.effect("with a Utc", () =>
486 Effect.gen(function*() {
487 const dt = DateTime.makeUnsafe("2024-01-01T01:00:00Z")
488 strictEqual(dt.toJSON(), "2024-01-01T01:00:00.000Z")
489 }))
490
491 it.effect("with a Zoned", () =>
492 Effect.gen(function*() {
493 const dt = DateTime.makeZonedUnsafe("2024-01-01T01:00:00Z", {
494 timeZone: "Pacific/Auckland",
495 adjustForTimeZone: true
496 })
497 strictEqual(dt.toJSON(), "2023-12-31T12:00:00.000Z")
498 }))
499 })
500
501 describe("nowAsDate", () => {
502 it.effect("should return the current Date", () =>
503 Effect.gen(function*() {
504 yield* setTo2024NZ
505 const now = yield* DateTime.nowAsDate
506 deepStrictEqual(now, new Date("2023-12-31T11:00:00.000Z"))
507 }))
508 })
509
510 describe("make", () => {
511 it("rejects invalid object instants", () => {
512 assertNone(DateTime.make({ epochMilliseconds: NaN }))
513 assertNone(DateTime.make({ epochMilliseconds: 8_640_000_000_000_001 }))
514 })
515 })
516
517 describe("makeUnsafe", () => {
518 it("throws for invalid object instants", () => {
519 throws(() => DateTime.makeUnsafe({ epochMilliseconds: NaN }))
520 throws(() => DateTime.makeUnsafe({ epochMilliseconds: 8_640_000_000_000_001 }))
521 })
522
523 it("treats strings without zone info as UTC", () => {
524 const dt = DateTime.makeUnsafe("2024-01-01 01:00:00")
525 strictEqual(dt.toJSON(), "2024-01-01T01:00:00.000Z")
526 })
527
528 it("does not append Z to strings ending with Z", () => {
529 const dt = DateTime.makeUnsafe("2026-01-27T17:14:06.000Z")
530 strictEqual(dt.toJSON(), "2026-01-27T17:14:06.000Z")
531 })
532
533 it("does not append Z to strings with explicit offset", () => {
534 const dt = DateTime.makeUnsafe("2020-02-01T11:17:00+1100")
535 strictEqual(dt.toJSON(), "2020-02-01T00:17:00.000Z")
536 })
537
538 it("does not append Z to strings containing GMT", () => {
539 const dt = DateTime.makeUnsafe("Tue, 27 Jan 2026 17:14:06 GMT")
540 strictEqual(dt.toJSON(), "2026-01-27T17:14:06.000Z")
541 })
542 })
543
544 describe("Disambiguation", () => {
545 it.each<{
546 zone: string
547 time: Partial<DateTime.DateTime.Parts>
548 description: string
549 expectedResults: Record<DateTime.Disambiguation, string>
550 }>(
551 [
552 {
553 zone: "America/New_York",
554 time: { year: 2024, month: 3, day: 10, hour: 2 },
555 description: "America/New_York 02:00 gap time during leap year (2024)",
556 expectedResults: {
557 compatible: "2024-03-10T07:00:00.000Z",
558 earlier: "2024-03-10T06:00:00.000Z",
559 later: "2024-03-10T07:00:00.000Z",
560 reject: "REJECT"
561 }
562 },
563 {
564 zone: "America/New_York",
565 time: { year: 2024, month: 11, day: 3, hour: 1, minute: 30 },
566 description: "America/New_York 01:30 ambiguous time during leap year (2024)",
567 expectedResults: {
568 compatible: "2024-11-03T05:30:00.000Z",
569 earlier: "2024-11-03T05:30:00.000Z",
570 later: "2024-11-03T06:30:00.000Z",
571 reject: "REJECT"
572 }
573 },
574 {
575 zone: "America/New_York",
576 time: { year: 2025, month: 3, day: 9, hour: 1 },
577 description: "America/New_York 01:00 before DST transition",
578 expectedResults: {
579 compatible: "2025-03-09T06:00:00.000Z",
580 earlier: "2025-03-09T06:00:00.000Z",
581 later: "2025-03-09T06:00:00.000Z",
582 reject: "2025-03-09T06:00:00.000Z"
583 }
584 },
585 {
586 zone: "America/New_York",
587 time: { year: 2025, month: 3, day: 9, hour: 1, minute: 59, second: 59 },
588 description: "America/New_York 01:59:59 last second before DST gap",
589 expectedResults: {
590 compatible: "2025-03-09T06:59:59.000Z",
591 earlier: "2025-03-09T06:59:59.000Z",
592 later: "2025-03-09T06:59:59.000Z",
593 reject: "2025-03-09T06:59:59.000Z"
594 }
595 },
596 {
597 zone: "America/New_York",
598 time: { year: 2025, month: 3, day: 9, hour: 2 },
599 description: "America/New_York 02:00 gap time (DST spring forward)",
600 expectedResults: {
601 compatible: "2025-03-09T07:00:00.000Z",
602 earlier: "2025-03-09T06:00:00.000Z",
603 later: "2025-03-09T07:00:00.000Z",
604 reject: "REJECT"
605 }
606 },
607 {
608 zone: "America/New_York",
609 time: { year: 2025, month: 3, day: 9, hour: 2, minute: 15 },
610 description: "America/New_York 02:15 gap time (DST spring forward)",
611 expectedResults: {
612 compatible: "2025-03-09T07:15:00.000Z",
613 earlier: "2025-03-09T06:15:00.000Z",
614 later: "2025-03-09T07:15:00.000Z",
615 reject: "REJECT"
616 }
617 },
618 {
619 zone: "America/New_York",
620 time: { year: 2025, month: 3, day: 9, hour: 2, minute: 30 },
621 description: "America/New_York 02:30 gap time (DST spring forward)",
622 expectedResults: {
623 compatible: "2025-03-09T07:30:00.000Z",
624 earlier: "2025-03-09T06:30:00.000Z",
625 later: "2025-03-09T07:30:00.000Z",
626 reject: "REJECT"
627 }
628 },
629 {
630 zone: "America/New_York",
631 time: { year: 2025, month: 3, day: 9, hour: 2, minute: 45 },
632 description: "America/New_York 02:45 gap time (DST spring forward)",
633 expectedResults: {
634 compatible: "2025-03-09T07:45:00.000Z",
635 earlier: "2025-03-09T06:45:00.000Z",
636 later: "2025-03-09T07:45:00.000Z",
637 reject: "REJECT"
638 }
639 },
640 {
641 zone: "America/New_York",
642 time: { year: 2025, month: 3, day: 9, hour: 3 },
643 description: "America/New_York 03:00 first valid time after DST gap",
644 expectedResults: {
645 compatible: "2025-03-09T07:00:00.000Z",
646 earlier: "2025-03-09T07:00:00.000Z",
647 later: "2025-03-09T07:00:00.000Z",
648 reject: "2025-03-09T07:00:00.000Z"
649 }
650 },
651 {
652 zone: "America/New_York",
653 time: { year: 2025, month: 11, day: 2, hour: 1, minute: 0, second: 0 },
654 description: "America/New_York 01:00:00 exact start of ambiguous period",
655 expectedResults: {
656 compatible: "2025-11-02T05:00:00.000Z",
657 earlier: "2025-11-02T05:00:00.000Z",
658 later: "2025-11-02T06:00:00.000Z",
659 reject: "REJECT"
660 }
661 },
662 {
663 zone: "America/New_York",
664 time: { year: 2025, month: 11, day: 2, hour: 1, minute: 30 },
665 description: "America/New_York 01:30 ambiguous time (DST fall back)",
666 expectedResults: {
667 compatible: "2025-11-02T05:30:00.000Z",
668 earlier: "2025-11-02T05:30:00.000Z",
669 later: "2025-11-02T06:30:00.000Z",
670 reject: "REJECT"
671 }
672 },
673 {
674 zone: "America/New_York",
675 time: { year: 2025, month: 11, day: 2, hour: 1, minute: 59, second: 59 },
676 description: "America/New_York 01:59:59 last second of ambiguous period",
677 expectedResults: {
678 compatible: "2025-11-02T05:59:59.000Z",
679 earlier: "2025-11-02T05:59:59.000Z",
680 later: "2025-11-02T06:59:59.000Z",
681 reject: "REJECT"
682 }
683 },
684 {
685 zone: "Asia/Kathmandu",
686 time: { year: 2025, month: 6, day: 15, hour: 12 },
687 description: "Asia/Kathmandu 12:00 unusual offset (UTC+05:45)",
688 expectedResults: {
689 compatible: "2025-06-15T06:15:00.000Z",
690 earlier: "2025-06-15T06:15:00.000Z",
691 later: "2025-06-15T06:15:00.000Z",
692 reject: "2025-06-15T06:15:00.000Z"
693 }
694 },
695 {
696 zone: "Australia/Melbourne",
697 time: { year: 2025, month: 10, day: 5, hour: 2 },
698 description: "Australia/Melbourne 02:00 gap time (DST starts, spring forward)",
699 expectedResults: {
700 compatible: "2025-10-04T16:00:00.000Z",
701 earlier: "2025-10-04T15:00:00.000Z",
702 later: "2025-10-04T16:00:00.000Z",
703 reject: "REJECT"
704 }
705 },
706 {
707 zone: "Australia/Sydney",
708 time: { year: 2025, month: 4, day: 6, hour: 1 },
709 description: "Australia/Sydney 01:00 normal timezone conversion",
710 expectedResults: {
711 compatible: "2025-04-05T14:00:00.000Z",
712 earlier: "2025-04-05T14:00:00.000Z",
713 later: "2025-04-05T14:00:00.000Z",
714 reject: "2025-04-05T14:00:00.000Z"
715 }
716 },
717 {
718 zone: "Australia/Sydney",
719 time: { year: 2025, month: 4, day: 6, hour: 2, minute: 30 },
720 description: "Australia/Sydney 02:30 ambiguous time (DST ends, fall back)",
721 expectedResults: {
722 compatible: "2025-04-05T15:30:00.000Z",
723 earlier: "2025-04-05T15:30:00.000Z",
724 later: "2025-04-05T16:30:00.000Z",
725 reject: "REJECT"
726 }
727 },
728 {
729 zone: "Australia/Sydney",
730 time: { year: 2025, month: 10, day: 5, hour: 2, minute: 30 },
731 description: "Australia/Sydney 02:30 gap time (DST starts, spring forward)",
732 expectedResults: {
733 compatible: "2025-10-04T16:30:00.000Z",
734 earlier: "2025-10-04T15:30:00.000Z",
735 later: "2025-10-04T16:30:00.000Z",
736 reject: "REJECT"
737 }
738 },
739 {
740 zone: "Europe/Athens",
741 time: { year: 2024, month: 10, day: 27, hour: 3 },
742 description: "Europe/Athens 03:00 ambiguous time during leap year (2024)",
743 expectedResults: {
744 compatible: "2024-10-27T00:00:00.000Z",
745 earlier: "2024-10-27T00:00:00.000Z",
746 later: "2024-10-27T01:00:00.000Z",
747 reject: "REJECT"
748 }
749 },
750 {
751 zone: "Europe/Athens",
752 time: { year: 2025, month: 3, day: 27, hour: 1 },
753 description: "Europe/Athens 01:00 normal time before DST",
754 expectedResults: {
755 compatible: "2025-03-26T23:00:00.000Z",
756 earlier: "2025-03-26T23:00:00.000Z",
757 later: "2025-03-26T23:00:00.000Z",
758 reject: "2025-03-26T23:00:00.000Z"
759 }
760 },
761 {
762 zone: "Europe/Athens",
763 time: { year: 2025, month: 3, day: 30, hour: 1 },
764 description: "Europe/Athens 01:00 before DST transition (UTC+2)",
765 expectedResults: {
766 compatible: "2025-03-29T23:00:00.000Z",
767 earlier: "2025-03-29T23:00:00.000Z",
768 later: "2025-03-29T23:00:00.000Z",
769 reject: "2025-03-29T23:00:00.000Z"
770 }
771 },
772 {
773 zone: "Europe/Athens",
774 time: { year: 2025, month: 3, day: 30, hour: 2, minute: 30 },
775 description: "Europe/Athens 02:30 normal time before DST transition",
776 expectedResults: {
777 compatible: "2025-03-30T00:30:00.000Z",
778 earlier: "2025-03-30T00:30:00.000Z",
779 later: "2025-03-30T00:30:00.000Z",
780 reject: "2025-03-30T00:30:00.000Z"
781 }
782 },
783 {
784 zone: "Europe/Athens",
785 time: { year: 2025, month: 3, day: 30, hour: 3 },
786 description: "Europe/Athens 03:00 gap time (DST spring forward)",
787 expectedResults: {
788 compatible: "2025-03-30T01:00:00.000Z",
789 earlier: "2025-03-30T00:00:00.000Z",
790 later: "2025-03-30T01:00:00.000Z",
791 reject: "REJECT"
792 }
793 },
794 {
795 zone: "Europe/Athens",
796 time: { year: 2025, month: 3, day: 30, hour: 4 },
797 description: "Europe/Athens 04:00 normal time after DST transition",
798 expectedResults: {
799 compatible: "2025-03-30T01:00:00.000Z",
800 earlier: "2025-03-30T01:00:00.000Z",
801 later: "2025-03-30T01:00:00.000Z",
802 reject: "2025-03-30T01:00:00.000Z"
803 }
804 },
805 {
806 zone: "Europe/Athens",
807 time: { year: 2025, month: 10, day: 26, hour: 3 },
808 description: "Europe/Athens 03:00 ambiguous time (DST fall back)",
809 expectedResults: {
810 compatible: "2025-10-26T00:00:00.000Z",
811 earlier: "2025-10-26T00:00:00.000Z",
812 later: "2025-10-26T01:00:00.000Z",
813 reject: "REJECT"
814 }
815 },
816 {
817 zone: "Europe/Berlin",
818 time: { year: 2025, month: 10, day: 26, hour: 2, minute: 30 },
819 description: "Europe/Berlin 02:30 ambiguous time (DST fall back)",
820 expectedResults: {
821 compatible: "2025-10-26T00:30:00.000Z",
822 earlier: "2025-10-26T00:30:00.000Z",
823 later: "2025-10-26T01:30:00.000Z",
824 reject: "REJECT"
825 }
826 },
827 {
828 zone: "Europe/London",
829 time: { year: 2024, month: 3, day: 31, hour: 1 },
830 description: "Europe/London 01:00 gap time during leap year (2024)",
831 expectedResults: {
832 compatible: "2024-03-31T01:00:00.000Z",
833 earlier: "2024-03-31T00:00:00.000Z",
834 later: "2024-03-31T01:00:00.000Z",
835 reject: "REJECT"
836 }
837 },
838 {
839 zone: "Europe/London",
840 time: { year: 2025, month: 3, day: 29, hour: 1 },
841 description: "Europe/London 01:00 normal time day before DST",
842 expectedResults: {
843 compatible: "2025-03-29T01:00:00.000Z",
844 earlier: "2025-03-29T01:00:00.000Z",
845 later: "2025-03-29T01:00:00.000Z",
846 reject: "2025-03-29T01:00:00.000Z"
847 }
848 },
849 {
850 zone: "Europe/London",
851 time: { year: 2025, month: 3, day: 30, hour: 1 },
852 description: "Europe/London 01:00 gap time (DST spring forward)",
853 expectedResults: {
854 compatible: "2025-03-30T01:00:00.000Z",
855 earlier: "2025-03-30T00:00:00.000Z",
856 later: "2025-03-30T01:00:00.000Z",
857 reject: "REJECT"
858 }
859 },
860 {
861 zone: "Europe/London",
862 time: { year: 2025, month: 10, day: 26, hour: 1, minute: 30 },
863 description: "Europe/London 01:30 ambiguous time (DST fall back)",
864 expectedResults: {
865 compatible: "2025-10-26T00:30:00.000Z",
866 earlier: "2025-10-26T00:30:00.000Z",
867 later: "2025-10-26T01:30:00.000Z",
868 reject: "REJECT"
869 }
870 },
871 {
872 zone: "Pacific/Auckland",
873 time: { year: 2025, month: 1, day: 15, hour: 12 },
874 description: "Pacific/Auckland 12:00 during DST period (NZDT, UTC+13)",
875 expectedResults: {
876 compatible: "2025-01-14T23:00:00.000Z",
877 earlier: "2025-01-14T23:00:00.000Z",
878 later: "2025-01-14T23:00:00.000Z",
879 reject: "2025-01-14T23:00:00.000Z"
880 }
881 },
882 {
883 zone: "Pacific/Auckland",
884 time: { year: 2025, month: 4, day: 6, hour: 1, minute: 59 },
885 description: "Pacific/Auckland 01:59 last minute before DST ends",
886 expectedResults: {
887 compatible: "2025-04-05T12:59:00.000Z",
888 earlier: "2025-04-05T12:59:00.000Z",
889 later: "2025-04-05T12:59:00.000Z",
890 reject: "2025-04-05T12:59:00.000Z"
891 }
892 },
893 {
894 zone: "Pacific/Auckland",
895 time: { year: 2025, month: 4, day: 6, hour: 2, minute: 30 },
896 description: "Pacific/Auckland 02:30 ambiguous time (DST ends, fall back)",
897 expectedResults: {
898 compatible: "2025-04-05T13:30:00.000Z",
899 earlier: "2025-04-05T13:30:00.000Z",
900 later: "2025-04-05T14:30:00.000Z",
901 reject: "REJECT"
902 }
903 },
904 {
905 zone: "Pacific/Auckland",
906 time: { year: 2025, month: 4, day: 6, hour: 3 },
907 description: "Pacific/Auckland 03:00 normal time after DST ends",
908 expectedResults: {
909 compatible: "2025-04-05T15:00:00.000Z",
910 earlier: "2025-04-05T15:00:00.000Z",
911 later: "2025-04-05T15:00:00.000Z",
912 reject: "2025-04-05T15:00:00.000Z"
913 }
914 },
915 {
916 zone: "Pacific/Auckland",
917 time: { year: 2025, month: 7, day: 15, hour: 12 },
918 description: "Pacific/Auckland 12:00 during standard time (NZST, UTC+12)",
919 expectedResults: {
920 compatible: "2025-07-15T00:00:00.000Z",
921 earlier: "2025-07-15T00:00:00.000Z",
922 later: "2025-07-15T00:00:00.000Z",
923 reject: "2025-07-15T00:00:00.000Z"
924 }
925 },
926 {
927 zone: "Pacific/Auckland",
928 time: { year: 2025, month: 9, day: 28, hour: 1, minute: 59, second: 59 },
929 description: "Pacific/Auckland 01:59:59 last second before DST starts",
930 expectedResults: {
931 compatible: "2025-09-27T13:59:59.000Z",
932 earlier: "2025-09-27T13:59:59.000Z",
933 later: "2025-09-27T13:59:59.000Z",
934 reject: "2025-09-27T13:59:59.000Z"
935 }
936 },
937 {
938 zone: "Pacific/Auckland",
939 time: { year: 2025, month: 9, day: 28, hour: 2, minute: 30 },
940 description: "Pacific/Auckland 02:30 gap time (DST starts, spring forward)",
941 expectedResults: {
942 compatible: "2025-09-27T14:30:00.000Z",
943 earlier: "2025-09-27T13:30:00.000Z",
944 later: "2025-09-27T14:30:00.000Z",
945 reject: "REJECT"
946 }
947 },
948 {
949 zone: "Pacific/Auckland",
950 time: { year: 2025, month: 9, day: 28, hour: 3 },
951 description: "Pacific/Auckland 03:00 first valid time after DST gap",
952 expectedResults: {
953 compatible: "2025-09-27T14:00:00.000Z",
954 earlier: "2025-09-27T14:00:00.000Z",
955 later: "2025-09-27T14:00:00.000Z",
956 reject: "2025-09-27T14:00:00.000Z"
957 }
958 },
959 {
960 zone: "Pacific/Kiritimati",
961 time: { year: 2025, month: 6, day: 15, hour: 12 },
962 description: "Pacific/Kiritimati 12:00 extreme positive offset (UTC+14)",
963 expectedResults: {
964 compatible: "2025-06-14T22:00:00.000Z",
965 earlier: "2025-06-14T22:00:00.000Z",
966 later: "2025-06-14T22:00:00.000Z",
967 reject: "2025-06-14T22:00:00.000Z"
968 }
969 },
970 {
971 zone: "Pacific/Marquesas",
972 time: { year: 2025, month: 6, day: 15, hour: 12 },
973 description: "Pacific/Marquesas 12:00 unusual negative offset (UTC-09:30)",
974 expectedResults: {
975 compatible: "2025-06-15T21:30:00.000Z",
976 earlier: "2025-06-15T21:30:00.000Z",
977 later: "2025-06-15T21:30:00.000Z",
978 reject: "2025-06-15T21:30:00.000Z"
979 }
980 }
981 ]
982 )(
983 "should handle $description",
984 ({ expectedResults, time, zone }) => {
985 // Test normal strategies
986 for (const strategy of ["compatible", "earlier", "later"] as const) {
987 const result = DateTime.makeZoned(time, {
988 timeZone: zone,
989 adjustForTimeZone: true,
990 disambiguation: strategy
991 })
992 assertSomeIso(result, expectedResults[strategy])
993 }
994
995 // Test default strategy
996 const defaultResult = DateTime.makeZoned(time, {
997 timeZone: zone,
998 adjustForTimeZone: true
999 })
1000 assertSomeIso(defaultResult, expectedResults.compatible)
1001
1002 // Test reject strategy
1003 const rejectResult = DateTime.makeZoned(time, {
1004 timeZone: zone,
1005 adjustForTimeZone: true,
1006 disambiguation: "reject"
1007 })
1008
1009 if (expectedResults.reject === "REJECT") {
1010 assertNone(rejectResult)
1011 } else {
1012 assertSomeIso(rejectResult, expectedResults.reject)
1013 }
1014 }
1015 )
1016 })
1017})