@@ -21,6 +21,41 @@ const COMMON_TIMEZONES = [
2121 'Australia/Sydney' ,
2222]
2323
24+ /** A wall-clock reading of an instant in some timezone. */
25+ export interface WallClockParts {
26+ year : number
27+ /** 1-based month. */
28+ month : number
29+ day : number
30+ hour : number
31+ minute : number
32+ second : number
33+ }
34+
35+ function pad ( value : number ) : string {
36+ return String ( value ) . padStart ( 2 , '0' )
37+ }
38+
39+ /** RFC 3339 offset suffix: `Z` for zero, else `±HH:MM`. */
40+ export function formatUtcOffsetSuffix ( offsetMinutes : number ) : string {
41+ if ( offsetMinutes === 0 ) return 'Z'
42+ const sign = offsetMinutes > 0 ? '+' : '-'
43+ const absoluteMinutes = Math . abs ( offsetMinutes )
44+ return `${ sign } ${ pad ( Math . floor ( absoluteMinutes / 60 ) ) } :${ pad ( absoluteMinutes % 60 ) } `
45+ }
46+
47+ function offsetMsFromWallClock ( instant : Date , wall : WallClockParts ) : number {
48+ const wallAsUtc = Date . UTC (
49+ wall . year ,
50+ wall . month - 1 ,
51+ wall . day ,
52+ wall . hour ,
53+ wall . minute ,
54+ wall . second
55+ )
56+ return wallAsUtc - instant . getTime ( )
57+ }
58+
2459/** The IANA timezone the current runtime resolves to (e.g. `America/New_York`). */
2560export function getBrowserTimezone ( ) : string {
2661 return Intl . DateTimeFormat ( ) . resolvedOptions ( ) . timeZone
@@ -83,22 +118,57 @@ export function getTimezoneOptions(): TimezoneOption[] {
83118}
84119
85120/**
86- * An instant's wall-clock time in `timeZone` as a naive `yyyy-MM-ddTHH:mm`
87- * string. Lets callers reason about a user's local date/time without UTC — e.g.
88- * to recover the local date/time a stored task instant represents in its zone.
121+ * The wall-clock fields of `instant` in `timeZone`, or in the runtime's local
122+ * timezone when omitted.
89123 */
90- export function zonedWallClock ( instant : Date , timeZone : string ) : string {
91- const parts = new Intl . DateTimeFormat ( 'en-CA' , {
124+ export function getWallClockParts ( instant : Date , timeZone ?: string ) : WallClockParts {
125+ if ( ! timeZone ) {
126+ return {
127+ year : instant . getFullYear ( ) ,
128+ month : instant . getMonth ( ) + 1 ,
129+ day : instant . getDate ( ) ,
130+ hour : instant . getHours ( ) ,
131+ minute : instant . getMinutes ( ) ,
132+ second : instant . getSeconds ( ) ,
133+ }
134+ }
135+
136+ const parts = new Intl . DateTimeFormat ( 'en-US' , {
92137 timeZone,
138+ hourCycle : 'h23' ,
93139 year : 'numeric' ,
94140 month : '2-digit' ,
95141 day : '2-digit' ,
96142 hour : '2-digit' ,
97143 minute : '2-digit' ,
98- hourCycle : 'h23 ' ,
144+ second : '2-digit ' ,
99145 } ) . formatToParts ( instant )
100- const get = ( type : string ) => parts . find ( ( p ) => p . type === type ) ?. value ?? '00'
101- return `${ get ( 'year' ) } -${ get ( 'month' ) } -${ get ( 'day' ) } T${ get ( 'hour' ) } :${ get ( 'minute' ) } `
146+ const get = ( type : string ) => Number ( parts . find ( ( part ) => part . type === type ) ?. value )
147+ return {
148+ year : get ( 'year' ) ,
149+ month : get ( 'month' ) ,
150+ day : get ( 'day' ) ,
151+ hour : get ( 'hour' ) ,
152+ minute : get ( 'minute' ) ,
153+ second : get ( 'second' ) ,
154+ }
155+ }
156+
157+ /** Formats an instant as an RFC 3339 wall time in an IANA timezone. */
158+ export function formatInstantInTimeZone ( instant : Date , timeZone : string ) : string {
159+ const wall = getWallClockParts ( instant , timeZone )
160+ const offsetMinutes = Math . round ( offsetMsFromWallClock ( instant , wall ) / 60_000 )
161+ return `${ wall . year } -${ pad ( wall . month ) } -${ pad ( wall . day ) } T${ pad ( wall . hour ) } :${ pad ( wall . minute ) } :${ pad ( wall . second ) } ${ formatUtcOffsetSuffix ( offsetMinutes ) } `
162+ }
163+
164+ /**
165+ * An instant's wall-clock time in `timeZone` as a naive `yyyy-MM-ddTHH:mm`
166+ * string. Lets callers reason about a user's local date/time without UTC — e.g.
167+ * to recover the local date/time a stored task instant represents in its zone.
168+ */
169+ export function zonedWallClock ( instant : Date , timeZone : string ) : string {
170+ const wall = getWallClockParts ( instant , timeZone )
171+ return `${ wall . year } -${ pad ( wall . month ) } -${ pad ( wall . day ) } T${ pad ( wall . hour ) } :${ pad ( wall . minute ) } `
102172}
103173
104174/** The current wall-clock time in `timeZone` as a naive `yyyy-MM-ddTHH:mm` string. */
@@ -123,26 +193,40 @@ export function zonedClockDate(instant: Date, timeZone: string): Date {
123193
124194/** The UTC offset (ms, east-positive) of `timeZone` at a given instant. */
125195function timezoneOffsetMs ( instant : Date , timeZone : string ) : number {
126- const parts = new Intl . DateTimeFormat ( 'en-US' , {
127- timeZone,
128- hourCycle : 'h23' ,
129- year : 'numeric' ,
130- month : '2-digit' ,
131- day : '2-digit' ,
132- hour : '2-digit' ,
133- minute : '2-digit' ,
134- second : '2-digit' ,
135- } ) . formatToParts ( instant )
136- const get = ( type : string ) => Number ( parts . find ( ( p ) => p . type === type ) ?. value )
137- const asUtc = Date . UTC (
138- get ( 'year' ) ,
139- get ( 'month' ) - 1 ,
140- get ( 'day' ) ,
141- get ( 'hour' ) ,
142- get ( 'minute' ) ,
143- get ( 'second' )
196+ return offsetMsFromWallClock ( instant , getWallClockParts ( instant , timeZone ) )
197+ }
198+
199+ interface ZonedWallClockResolution {
200+ instant : Date
201+ offsetMinutes : number
202+ }
203+
204+ function resolveZonedWallClock ( wallClock : string , timeZone : string ) : ZonedWallClockResolution {
205+ const [ datePart , timePart ] = wallClock . split ( 'T' )
206+ const [ year , month , day ] = datePart . split ( '-' ) . map ( Number )
207+ const [ hour , minute , second = 0 ] = timePart . split ( ':' ) . map ( Number )
208+ const utcGuess = Date . UTC ( year , month - 1 , day , hour , minute , second )
209+ const dayMs = 24 * 60 * 60 * 1000
210+ const offsets = new Set (
211+ [ - dayMs , 0 , dayMs ] . map ( ( distance ) => timezoneOffsetMs ( new Date ( utcGuess + distance ) , timeZone ) )
144212 )
145- return asUtc - instant . getTime ( )
213+ const candidates = [ ...offsets ] . map ( ( offset ) => {
214+ const instantMs = utcGuess - offset
215+ const actualOffset = timezoneOffsetMs ( new Date ( instantMs ) , timeZone )
216+ return { instantMs, wallClockMs : instantMs + actualOffset }
217+ } )
218+ const exactCandidate = candidates
219+ . filter ( ( { wallClockMs } ) => wallClockMs === utcGuess )
220+ . sort ( ( a , b ) => a . instantMs - b . instantMs ) [ 0 ]
221+ const compatibleCandidate = candidates
222+ . filter ( ( { wallClockMs } ) => wallClockMs > utcGuess )
223+ . sort ( ( a , b ) => a . wallClockMs - b . wallClockMs || a . instantMs - b . instantMs ) [ 0 ]
224+ const chosenCandidate = exactCandidate ?? compatibleCandidate ?? candidates [ 0 ]
225+ const instantMs = chosenCandidate . instantMs
226+ return {
227+ instant : new Date ( instantMs ) ,
228+ offsetMinutes : Math . round ( ( utcGuess - instantMs ) / 60_000 ) ,
229+ }
146230}
147231
148232/**
@@ -152,22 +236,17 @@ function timezoneOffsetMs(instant: Date, timeZone: string): number {
152236 * date (including future ones whose offset differs from today's) and across DST:
153237 * a naive single pass reads the offset on the wrong side of a same-day boundary
154238 * — notably the autumn fall-back hour — and lands an hour off. For an ambiguous
155- * fall-back wall-clock the later (post-transition) instant is chosen; a
239+ * fall-back wall-clock the earlier instant is chosen; a
156240 * wall-clock in the spring-forward gap (a nonexistent local hour) has no
157241 * self-consistent instant and resolves forward by the DST shift, matching how
158242 * calendar apps treat that once-a-year hour.
159243 */
160244export function zonedWallClockToUtc ( wallClock : string , timeZone : string ) : Date {
161- const [ datePart , timePart ] = wallClock . split ( 'T' )
162- const [ year , month , day ] = datePart . split ( '-' ) . map ( Number )
163- const [ hour , minute , second = 0 ] = timePart . split ( ':' ) . map ( Number )
164- const utcGuess = Date . UTC ( year , month - 1 , day , hour , minute , second )
165- const guessOffset = timezoneOffsetMs ( new Date ( utcGuess ) , timeZone )
166- const candidate = utcGuess - guessOffset
167- const candidateOffset = timezoneOffsetMs ( new Date ( candidate ) , timeZone )
168- if ( candidateOffset === guessOffset ) return new Date ( candidate )
169- const adjusted = utcGuess - candidateOffset
170- return timezoneOffsetMs ( new Date ( adjusted ) , timeZone ) === candidateOffset
171- ? new Date ( adjusted )
172- : new Date ( candidate )
245+ return resolveZonedWallClock ( wallClock , timeZone ) . instant
246+ }
247+
248+ /** Stamps a naive wall-clock with the offset selected by the shared timezone resolver. */
249+ export function zonedWallClockWithOffset ( wallClock : string , timeZone : string ) : string {
250+ const { offsetMinutes } = resolveZonedWallClock ( wallClock , timeZone )
251+ return `${ wallClock } ${ formatUtcOffsetSuffix ( offsetMinutes ) } `
173252}
0 commit comments