Skip to content

Commit 3504555

Browse files
taylornzTaylor Hfubhytim-smart
authored
Adds DST disambiguation support to the DateTime library and fixes an existing production bug with timezone conversion near DST transitions. (#5275)
Co-authored-by: Taylor H <taylor@aatom.com> Co-authored-by: Sebastian Lorenz <fubhy@fubhy.com> Co-authored-by: Tim <hello@timsmart.co>
1 parent f6c7ca7 commit 3504555

File tree

6 files changed

+749
-21
lines changed

6 files changed

+749
-21
lines changed

.changeset/fancy-taxis-listen.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"effect": patch
3+
---
4+
5+
fix DateTime.makeZoned handling of DST transitions

.changeset/warm-berries-bow.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
"effect": patch
3+
---
4+
5+
add DateTime.Disambiguation for handling DST edge cases
6+
7+
Added four disambiguation strategies to `DateTime.Zoned` constructors for handling DST edge cases:
8+
9+
- `'compatible'` - Maintains backward compatibility
10+
- `'earlier'` - Choose earlier time during ambiguous periods (default)
11+
- `'later'` - Choose later time during ambiguous periods
12+
- `'reject'` - Throw error for ambiguous times

packages/effect/src/DateTime.ts

Lines changed: 88 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,52 @@ export declare namespace TimeZone {
215215
}
216216
}
217217

218+
/**
219+
* A `Disambiguation` is used to resolve ambiguities when a `DateTime` is
220+
* ambiguous, such as during a daylight saving time transition.
221+
*
222+
* For more information, see the [Temporal documentation](https://tc39.es/proposal-temporal/docs/timezone.html#ambiguity-due-to-dst-or-other-time-zone-offset-changes)
223+
*
224+
* - `"compatible"`: (default) Behavior matching Temporal API and legacy JavaScript Date and moment.js.
225+
* For repeated times, chooses the earlier occurrence. For gap times, chooses the later interpretation.
226+
*
227+
* - `"earlier"`: For repeated times, always choose the earlier occurrence.
228+
* For gap times, choose the time before the gap.
229+
*
230+
* - `"later"`: For repeated times, always choose the later occurrence.
231+
* For gap times, choose the time after the gap.
232+
*
233+
* - `"reject"`: Throw an `RangeError` when encountering ambiguous or non-existent times.
234+
*
235+
* @example
236+
* ```ts
237+
* import { DateTime } from "effect"
238+
*
239+
* // Fall-back example: 01:30 on Nov 2, 2025 in New York happens twice
240+
* const ambiguousTime = { year: 2025, month: 11, day: 2, hours: 1, minutes: 30 }
241+
* const timeZone = DateTime.zoneUnsafeMakeNamed("America/New_York")
242+
*
243+
* DateTime.makeZoned(ambiguousTime, { timeZone, adjustForTimeZone: true, disambiguation: "earlier" })
244+
* // Earlier occurrence (DST time): 2025-11-02T05:30:00.000Z
245+
*
246+
* DateTime.makeZoned(ambiguousTime, { timeZone, adjustForTimeZone: true, disambiguation: "later" })
247+
* // Later occurrence (standard time): 2025-11-02T06:30:00.000Z
248+
*
249+
* // Gap example: 02:30 on Mar 9, 2025 in New York doesn't exist
250+
* const gapTime = { year: 2025, month: 3, day: 9, hours: 2, minutes: 30 }
251+
*
252+
* DateTime.makeZoned(gapTime, { timeZone, adjustForTimeZone: true, disambiguation: "earlier" })
253+
* // Time before gap: 2025-03-09T06:30:00.000Z (01:30 EST)
254+
*
255+
* DateTime.makeZoned(gapTime, { timeZone, adjustForTimeZone: true, disambiguation: "later" })
256+
* // Time after gap: 2025-03-09T07:30:00.000Z (03:30 EDT)
257+
* ```
258+
*
259+
* @since 3.18.0
260+
* @category models
261+
*/
262+
export type Disambiguation = "compatible" | "earlier" | "later" | "reject"
263+
218264
// =============================================================================
219265
// guards
220266
// =============================================================================
@@ -332,6 +378,13 @@ export const unsafeMake: <A extends DateTime.Input>(input: A) => DateTime.Preser
332378
* `adjustForTimeZone` is set to `true`. In that case, the input is treated as
333379
* already in the time zone.
334380
*
381+
* When `adjustForTimeZone` is true and ambiguous times occur during DST transitions,
382+
* the `disambiguation` option controls how to resolve the ambiguity:
383+
* - `compatible` (default): Choose earlier time for repeated times, later for gaps
384+
* - `earlier`: Always choose the earlier of two possible times
385+
* - `later`: Always choose the later of two possible times
386+
* - `reject`: Throw an error when ambiguous times are encountered
387+
*
335388
* @since 3.6.0
336389
* @category constructors
337390
* @example
@@ -344,12 +397,22 @@ export const unsafeMake: <A extends DateTime.Input>(input: A) => DateTime.Preser
344397
export const unsafeMakeZoned: (input: DateTime.Input, options?: {
345398
readonly timeZone?: number | string | TimeZone | undefined
346399
readonly adjustForTimeZone?: boolean | undefined
400+
readonly disambiguation?: Disambiguation | undefined
347401
}) => Zoned = Internal.unsafeMakeZoned
348402

349403
/**
350404
* Create a `DateTime.Zoned` using `DateTime.make` and a time zone.
351405
*
352-
* The input is treated as UTC and then the time zone is attached.
406+
* The input is treated as UTC and then the time zone is attached, unless
407+
* `adjustForTimeZone` is set to `true`. In that case, the input is treated as
408+
* already in the time zone.
409+
*
410+
* When `adjustForTimeZone` is true and ambiguous times occur during DST transitions,
411+
* the `disambiguation` option controls how to resolve the ambiguity:
412+
* - `compatible` (default): Choose earlier time for repeated times, later for gaps
413+
* - `earlier`: Always choose the earlier of two possible times
414+
* - `later`: Always choose the later of two possible times
415+
* - `reject`: Throw an error when ambiguous times are encountered
353416
*
354417
* If the date time input or time zone is invalid, `None` will be returned.
355418
*
@@ -367,6 +430,7 @@ export const makeZoned: (
367430
options?: {
368431
readonly timeZone?: number | string | TimeZone | undefined
369432
readonly adjustForTimeZone?: boolean | undefined
433+
readonly disambiguation?: Disambiguation | undefined
370434
}
371435
) => Option.Option<Zoned> = Internal.makeZoned
372436

@@ -491,9 +555,11 @@ export const toUtc: (self: DateTime) => Utc = Internal.toUtc
491555
export const setZone: {
492556
(zone: TimeZone, options?: {
493557
readonly adjustForTimeZone?: boolean | undefined
558+
readonly disambiguation?: Disambiguation | undefined
494559
}): (self: DateTime) => Zoned
495560
(self: DateTime, zone: TimeZone, options?: {
496561
readonly adjustForTimeZone?: boolean | undefined
562+
readonly disambiguation?: Disambiguation | undefined
497563
}): Zoned
498564
} = Internal.setZone
499565

@@ -519,9 +585,11 @@ export const setZone: {
519585
export const setZoneOffset: {
520586
(offset: number, options?: {
521587
readonly adjustForTimeZone?: boolean | undefined
588+
readonly disambiguation?: Disambiguation | undefined
522589
}): (self: DateTime) => Zoned
523590
(self: DateTime, offset: number, options?: {
524591
readonly adjustForTimeZone?: boolean | undefined
592+
readonly disambiguation?: Disambiguation | undefined
525593
}): Zoned
526594
} = Internal.setZoneOffset
527595

@@ -616,9 +684,11 @@ export const zoneToString: (self: TimeZone) => string = Internal.zoneToString
616684
export const setZoneNamed: {
617685
(zoneId: string, options?: {
618686
readonly adjustForTimeZone?: boolean | undefined
687+
readonly disambiguation?: Disambiguation | undefined
619688
}): (self: DateTime) => Option.Option<Zoned>
620689
(self: DateTime, zoneId: string, options?: {
621690
readonly adjustForTimeZone?: boolean | undefined
691+
readonly disambiguation?: Disambiguation | undefined
622692
}): Option.Option<Zoned>
623693
} = Internal.setZoneNamed
624694

@@ -642,9 +712,11 @@ export const setZoneNamed: {
642712
export const unsafeSetZoneNamed: {
643713
(zoneId: string, options?: {
644714
readonly adjustForTimeZone?: boolean | undefined
715+
readonly disambiguation?: Disambiguation | undefined
645716
}): (self: DateTime) => Zoned
646717
(self: DateTime, zoneId: string, options?: {
647718
readonly adjustForTimeZone?: boolean | undefined
719+
readonly disambiguation?: Disambiguation | undefined
648720
}): Zoned
649721
} = Internal.unsafeSetZoneNamed
650722

@@ -1149,12 +1221,25 @@ export const nowInCurrentZone: Effect.Effect<Zoned, never, CurrentTimeZone> = Ef
11491221
* The `Date` will first have the time zone applied if possible, and then be
11501222
* converted back to a `DateTime` within the same time zone.
11511223
*
1224+
* Supports `disambiguation` when the new wall clock time is ambiguous.
1225+
*
11521226
* @since 3.6.0
11531227
* @category mapping
11541228
*/
11551229
export const mutate: {
1156-
(f: (date: Date) => void): <A extends DateTime>(self: A) => A
1157-
<A extends DateTime>(self: A, f: (date: Date) => void): A
1230+
(
1231+
f: (date: Date) => void,
1232+
options?: {
1233+
readonly disambiguation?: Disambiguation | undefined
1234+
}
1235+
): <A extends DateTime>(self: A) => A
1236+
<A extends DateTime>(
1237+
self: A,
1238+
f: (date: Date) => void,
1239+
options?: {
1240+
readonly disambiguation?: Disambiguation | undefined
1241+
}
1242+
): A
11581243
} = Internal.mutate
11591244

11601245
/**

0 commit comments

Comments
 (0)