@@ -23,6 +23,41 @@ const COMMON_TIMEZONES = [
2323 'Australia/Sydney' ,
2424]
2525
26+ /** A wall-clock reading of an instant in some timezone. */
27+ export interface WallClockParts {
28+ year : number
29+ /** 1-based month. */
30+ month : number
31+ day : number
32+ hour : number
33+ minute : number
34+ second : number
35+ }
36+
37+ function pad ( value : number ) : string {
38+ return String ( value ) . padStart ( 2 , '0' )
39+ }
40+
41+ /** RFC 3339 offset suffix: `Z` for zero, else `±HH:MM`. */
42+ export function formatUtcOffsetSuffix ( offsetMinutes : number ) : string {
43+ if ( offsetMinutes === 0 ) return 'Z'
44+ const sign = offsetMinutes > 0 ? '+' : '-'
45+ const absoluteMinutes = Math . abs ( offsetMinutes )
46+ return `${ sign } ${ pad ( Math . floor ( absoluteMinutes / 60 ) ) } :${ pad ( absoluteMinutes % 60 ) } `
47+ }
48+
49+ function offsetMsFromWallClock ( instant : Date , wall : WallClockParts ) : number {
50+ const wallAsUtc = Date . UTC (
51+ wall . year ,
52+ wall . month - 1 ,
53+ wall . day ,
54+ wall . hour ,
55+ wall . minute ,
56+ wall . second
57+ )
58+ return wallAsUtc - instant . getTime ( )
59+ }
60+
2661/** The IANA timezone the current runtime resolves to (e.g. `America/New_York`). */
2762export function getBrowserTimezone ( ) : string {
2863 return Intl . DateTimeFormat ( ) . resolvedOptions ( ) . timeZone
@@ -116,22 +151,57 @@ export function getTimezoneOptions(): TimezoneOption[] {
116151}
117152
118153/**
119- * An instant's wall-clock time in `timeZone` as a naive `yyyy-MM-ddTHH:mm`
120- * string. Lets callers reason about a user's local date/time without UTC — e.g.
121- * to recover the local date/time a stored task instant represents in its zone.
154+ * The wall-clock fields of `instant` in `timeZone`, or in the runtime's local
155+ * timezone when omitted.
122156 */
123- export function zonedWallClock ( instant : Date , timeZone : string ) : string {
124- const parts = new Intl . DateTimeFormat ( 'en-CA' , {
157+ export function getWallClockParts ( instant : Date , timeZone ?: string ) : WallClockParts {
158+ if ( ! timeZone ) {
159+ return {
160+ year : instant . getFullYear ( ) ,
161+ month : instant . getMonth ( ) + 1 ,
162+ day : instant . getDate ( ) ,
163+ hour : instant . getHours ( ) ,
164+ minute : instant . getMinutes ( ) ,
165+ second : instant . getSeconds ( ) ,
166+ }
167+ }
168+
169+ const parts = new Intl . DateTimeFormat ( 'en-US' , {
125170 timeZone,
171+ hourCycle : 'h23' ,
126172 year : 'numeric' ,
127173 month : '2-digit' ,
128174 day : '2-digit' ,
129175 hour : '2-digit' ,
130176 minute : '2-digit' ,
131- hourCycle : 'h23 ' ,
177+ second : '2-digit ' ,
132178 } ) . formatToParts ( instant )
133- const get = ( type : string ) => parts . find ( ( p ) => p . type === type ) ?. value ?? '00'
134- return `${ get ( 'year' ) } -${ get ( 'month' ) } -${ get ( 'day' ) } T${ get ( 'hour' ) } :${ get ( 'minute' ) } `
179+ const get = ( type : string ) => Number ( parts . find ( ( part ) => part . type === type ) ?. value )
180+ return {
181+ year : get ( 'year' ) ,
182+ month : get ( 'month' ) ,
183+ day : get ( 'day' ) ,
184+ hour : get ( 'hour' ) ,
185+ minute : get ( 'minute' ) ,
186+ second : get ( 'second' ) ,
187+ }
188+ }
189+
190+ /** Formats an instant as an RFC 3339 wall time in an IANA timezone. */
191+ export function formatInstantInTimeZone ( instant : Date , timeZone : string ) : string {
192+ const wall = getWallClockParts ( instant , timeZone )
193+ const offsetMinutes = Math . round ( offsetMsFromWallClock ( instant , wall ) / 60_000 )
194+ return `${ wall . year } -${ pad ( wall . month ) } -${ pad ( wall . day ) } T${ pad ( wall . hour ) } :${ pad ( wall . minute ) } :${ pad ( wall . second ) } ${ formatUtcOffsetSuffix ( offsetMinutes ) } `
195+ }
196+
197+ /**
198+ * An instant's wall-clock time in `timeZone` as a naive `yyyy-MM-ddTHH:mm`
199+ * string. Lets callers reason about a user's local date/time without UTC — e.g.
200+ * to recover the local date/time a stored task instant represents in its zone.
201+ */
202+ export function zonedWallClock ( instant : Date , timeZone : string ) : string {
203+ const wall = getWallClockParts ( instant , timeZone )
204+ return `${ wall . year } -${ pad ( wall . month ) } -${ pad ( wall . day ) } T${ pad ( wall . hour ) } :${ pad ( wall . minute ) } `
135205}
136206
137207/** The current wall-clock time in `timeZone` as a naive `yyyy-MM-ddTHH:mm` string. */
@@ -156,26 +226,40 @@ export function zonedClockDate(instant: Date, timeZone: string): Date {
156226
157227/** The UTC offset (ms, east-positive) of `timeZone` at a given instant. */
158228function timezoneOffsetMs ( instant : Date , timeZone : string ) : number {
159- const parts = new Intl . DateTimeFormat ( 'en-US' , {
160- timeZone,
161- hourCycle : 'h23' ,
162- year : 'numeric' ,
163- month : '2-digit' ,
164- day : '2-digit' ,
165- hour : '2-digit' ,
166- minute : '2-digit' ,
167- second : '2-digit' ,
168- } ) . formatToParts ( instant )
169- const get = ( type : string ) => Number ( parts . find ( ( p ) => p . type === type ) ?. value )
170- const asUtc = Date . UTC (
171- get ( 'year' ) ,
172- get ( 'month' ) - 1 ,
173- get ( 'day' ) ,
174- get ( 'hour' ) ,
175- get ( 'minute' ) ,
176- get ( 'second' )
229+ return offsetMsFromWallClock ( instant , getWallClockParts ( instant , timeZone ) )
230+ }
231+
232+ interface ZonedWallClockResolution {
233+ instant : Date
234+ offsetMinutes : number
235+ }
236+
237+ function resolveZonedWallClock ( wallClock : string , timeZone : string ) : ZonedWallClockResolution {
238+ const [ datePart , timePart ] = wallClock . split ( 'T' )
239+ const [ year , month , day ] = datePart . split ( '-' ) . map ( Number )
240+ const [ hour , minute , second = 0 ] = timePart . split ( ':' ) . map ( Number )
241+ const utcGuess = Date . UTC ( year , month - 1 , day , hour , minute , second )
242+ const dayMs = 24 * 60 * 60 * 1000
243+ const offsets = new Set (
244+ [ - dayMs , 0 , dayMs ] . map ( ( distance ) => timezoneOffsetMs ( new Date ( utcGuess + distance ) , timeZone ) )
177245 )
178- return asUtc - instant . getTime ( )
246+ const candidates = [ ...offsets ] . map ( ( offset ) => {
247+ const instantMs = utcGuess - offset
248+ const actualOffset = timezoneOffsetMs ( new Date ( instantMs ) , timeZone )
249+ return { instantMs, wallClockMs : instantMs + actualOffset }
250+ } )
251+ const exactCandidate = candidates
252+ . filter ( ( { wallClockMs } ) => wallClockMs === utcGuess )
253+ . sort ( ( a , b ) => a . instantMs - b . instantMs ) [ 0 ]
254+ const compatibleCandidate = candidates
255+ . filter ( ( { wallClockMs } ) => wallClockMs > utcGuess )
256+ . sort ( ( a , b ) => a . wallClockMs - b . wallClockMs || a . instantMs - b . instantMs ) [ 0 ]
257+ const chosenCandidate = exactCandidate ?? compatibleCandidate ?? candidates [ 0 ]
258+ const instantMs = chosenCandidate . instantMs
259+ return {
260+ instant : new Date ( instantMs ) ,
261+ offsetMinutes : Math . round ( ( utcGuess - instantMs ) / 60_000 ) ,
262+ }
179263}
180264
181265/**
@@ -185,22 +269,17 @@ function timezoneOffsetMs(instant: Date, timeZone: string): number {
185269 * date (including future ones whose offset differs from today's) and across DST:
186270 * a naive single pass reads the offset on the wrong side of a same-day boundary
187271 * — notably the autumn fall-back hour — and lands an hour off. For an ambiguous
188- * fall-back wall-clock the later (post-transition) instant is chosen; a
272+ * fall-back wall-clock the earlier instant is chosen; a
189273 * wall-clock in the spring-forward gap (a nonexistent local hour) has no
190274 * self-consistent instant and resolves forward by the DST shift, matching how
191275 * calendar apps treat that once-a-year hour.
192276 */
193277export function zonedWallClockToUtc ( wallClock : string , timeZone : string ) : Date {
194- const [ datePart , timePart ] = wallClock . split ( 'T' )
195- const [ year , month , day ] = datePart . split ( '-' ) . map ( Number )
196- const [ hour , minute , second = 0 ] = timePart . split ( ':' ) . map ( Number )
197- const utcGuess = Date . UTC ( year , month - 1 , day , hour , minute , second )
198- const guessOffset = timezoneOffsetMs ( new Date ( utcGuess ) , timeZone )
199- const candidate = utcGuess - guessOffset
200- const candidateOffset = timezoneOffsetMs ( new Date ( candidate ) , timeZone )
201- if ( candidateOffset === guessOffset ) return new Date ( candidate )
202- const adjusted = utcGuess - candidateOffset
203- return timezoneOffsetMs ( new Date ( adjusted ) , timeZone ) === candidateOffset
204- ? new Date ( adjusted )
205- : new Date ( candidate )
278+ return resolveZonedWallClock ( wallClock , timeZone ) . instant
279+ }
280+
281+ /** Stamps a naive wall-clock with the offset selected by the shared timezone resolver. */
282+ export function zonedWallClockWithOffset ( wallClock : string , timeZone : string ) : string {
283+ const { offsetMinutes } = resolveZonedWallClock ( wallClock , timeZone )
284+ return `${ wallClock } ${ formatUtcOffsetSuffix ( offsetMinutes ) } `
206285}
0 commit comments