Skip to content

Commit 44d7fb2

Browse files
feat: localTime timezone operations
1 parent b4c2ef6 commit 44d7fb2

File tree

6 files changed

+230
-54
lines changed

6 files changed

+230
-54
lines changed

src/datetime/localDate.test.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,11 @@ test('basic', () => {
2222
expect(ld.month()).toBe(6)
2323
expect(ld.year()).toBe(1984)
2424
expect(ld.toMonthId()).toBe('1984-06')
25+
expect(ld.toDateObject()).toEqual({
26+
year: 1984,
27+
month: 6,
28+
day: 21,
29+
})
2530
expect(JSON.stringify(ld)).toBe(`"${str}"`)
2631
expect(JSON.parse(JSON.stringify(ld))).toBe(str)
2732
expect(ld.absDiff(str, 'day')).toBe(0)

src/datetime/localDate.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import type {
99
UnixTimestampMillisNumber,
1010
UnixTimestampNumber,
1111
} from '../types'
12-
import { ISODayOfWeek, localTime, LocalTime } from './localTime'
12+
import { DateObject, ISODayOfWeek, localTime, LocalTime } from './localTime'
1313

1414
export type LocalDateUnit = LocalDateUnitStrict | 'week'
1515
export type LocalDateUnitStrict = 'year' | 'month' | 'day'
@@ -381,6 +381,14 @@ export class LocalDate {
381381
return new Date(this.toISODateTimeInUTC())
382382
}
383383

384+
toDateObject(): DateObject {
385+
return {
386+
year: this.$year,
387+
month: this.$month,
388+
day: this.$day,
389+
}
390+
}
391+
384392
/**
385393
* Converts LocalDate to LocalTime with 0 hours, 0 minutes, 0 seconds.
386394
* LocalTime's Date will be in local timezone.

src/datetime/localTime.test.ts

Lines changed: 49 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,7 @@
11
import { dayjs } from '@naturalcycles/time-lib'
22
import { _range } from '../array/range'
33
import { expectWithMessage, isUTC } from '../test/test.util'
4-
import {
5-
LocalTimeFormatter,
6-
LocalTimeUnit,
7-
nowUnix,
8-
ISODayOfWeek,
9-
localTime,
10-
getUTCOffsetMinutes,
11-
getUTCOffsetHours,
12-
} from './localTime'
4+
import { LocalTimeFormatter, LocalTimeUnit, nowUnix, ISODayOfWeek, localTime } from './localTime'
135

146
const units: LocalTimeUnit[] = ['year', 'month', 'day', 'hour', 'minute', 'second', 'week']
157

@@ -129,6 +121,10 @@ test('basic', () => {
129121
expect(() => localTime(undefined as any)).toThrowErrorMatchingInlineSnapshot(
130122
`"Cannot parse "undefined" into LocalTime"`,
131123
)
124+
125+
expect(localTime.getTimezone()).toBe('UTC')
126+
expect(localTime.isTimezoneValid('Europe/Stockholm')).toBe(true)
127+
expect(localTime.isTimezoneValid('Europe/Stockholm2')).toBe(false)
132128
})
133129

134130
test('isBetween', () => {
@@ -434,8 +430,8 @@ test('utcOffset', () => {
434430
expect(offset2).toBe(offset)
435431

436432
if (isUTC()) {
437-
expect(getUTCOffsetMinutes()).toBe(0)
438-
expect(getUTCOffsetHours()).toBe(0)
433+
expect(localTime.now().getUTCOffsetMinutes()).toBe(0)
434+
expect(localTime.now().getUTCOffsetHours()).toBe(0)
439435
}
440436
})
441437

@@ -460,3 +456,45 @@ test('fromDateUTC', () => {
460456
expect(ltLocal.toPretty()).toBe(lt.toPretty())
461457
// todo: figure out what to assert in non-utc mode
462458
})
459+
460+
test('getUTCOffsetMinutes', () => {
461+
const now = localTime('2024-05-14')
462+
expect(now.getUTCOffsetMinutes('America/Los_Angeles')).toBe(-7 * 60)
463+
expect(now.getUTCOffsetMinutes('America/New_York')).toBe(-4 * 60)
464+
expect(now.getUTCOffsetMinutes('Europe/Stockholm')).toBe(2 * 60)
465+
expect(now.getUTCOffsetHours('Europe/Stockholm')).toBe(2)
466+
expect(now.getUTCOffsetMinutes('UTC')).toBe(0)
467+
expect(now.getUTCOffsetHours('UTC')).toBe(0)
468+
expect(now.getUTCOffsetMinutes('GMT')).toBe(0)
469+
expect(now.getUTCOffsetMinutes('Asia/Tokyo')).toBe(9 * 60)
470+
})
471+
472+
test('getUTCOffsetString', () => {
473+
const now = localTime('2024-05-14')
474+
expect(now.getUTCOffsetString('America/Los_Angeles')).toBe('-07:00')
475+
expect(now.getUTCOffsetString('America/New_York')).toBe('-04:00')
476+
expect(now.getUTCOffsetString('Europe/Stockholm')).toBe('+02:00')
477+
expect(now.getUTCOffsetString('UTC')).toBe('+00:00')
478+
expect(now.getUTCOffsetString('Asia/Tokyo')).toBe('+09:00')
479+
})
480+
481+
test('inTimezone', () => {
482+
const lt = localTime(`1984-06-21T05:00:00`)
483+
expect(lt.toPretty()).toBe(`1984-06-21 05:00:00`)
484+
485+
// Nope, unix doesn't match ;(
486+
// expect(lt.inTimezone('Europe/Stockholm').unix()).toBe(lt.unix())
487+
488+
expect(lt.inTimezone('Europe/Stockholm').toPretty()).toBe(`1984-06-21 07:00:00`)
489+
expect(lt.inTimezone('America/New_York').toPretty()).toBe(`1984-06-21 01:00:00`)
490+
expect(lt.inTimezone('America/Los_Angeles').toPretty()).toBe(`1984-06-20 22:00:00`)
491+
expect(lt.inTimezone('Asia/Tokyo').toPretty()).toBe(`1984-06-21 14:00:00`)
492+
expect(lt.inTimezone('Asia/Tokyo').toPretty(false)).toBe(`1984-06-21 14:00`)
493+
494+
const lt2 = localTime(`1984-02-14T21:00:00`)
495+
expect(lt2.toPretty()).toBe(`1984-02-14 21:00:00`)
496+
expect(lt2.inTimezone('Europe/Stockholm').toPretty()).toBe(`1984-02-14 22:00:00`)
497+
expect(lt2.inTimezone('America/New_York').toPretty()).toBe(`1984-02-14 16:00:00`)
498+
expect(lt2.inTimezone('America/Los_Angeles').toPretty()).toBe(`1984-02-14 13:00:00`)
499+
expect(lt2.inTimezone('Asia/Tokyo').toPretty()).toBe(`1984-02-15 06:00:00`)
500+
})

src/datetime/localTime.ts

Lines changed: 110 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import type {
1212
UnixTimestampNumber,
1313
} from '../types'
1414
import { localDate, LocalDate } from './localDate'
15+
import { WallTime } from './wallTime'
1516

1617
export type LocalTimeUnit = 'year' | 'month' | 'week' | 'day' | 'hour' | 'minute' | 'second'
1718

@@ -28,15 +29,15 @@ export enum ISODayOfWeek {
2829
export type LocalTimeInput = LocalTime | Date | IsoDateTimeString | UnixTimestampNumber
2930
export type LocalTimeFormatter = (ld: LocalTime) => string
3031

31-
export type LocalTimeComponents = DateComponents & TimeComponents
32+
export type DateTimeObject = DateObject & TimeObject
3233

33-
interface DateComponents {
34+
export interface DateObject {
3435
year: number
3536
month: number
3637
day: number
3738
}
3839

39-
interface TimeComponents {
40+
export interface TimeObject {
4041
hour: number
4142
minute: number
4243
second: number
@@ -68,6 +69,83 @@ export class LocalTime {
6869
return new LocalTime(new Date(this.$date.getTime()))
6970
}
7071

72+
/**
73+
* Returns [cloned] fake LocalTime that has yyyy-mm-dd hh:mm:ss in the provided timezone.
74+
* It is a fake LocalTime in a sense that it's timezone is not real.
75+
* See this ("common errors"): https://stackoverflow.com/a/15171030/4919972
76+
* Fake also means that unixTimestamp of that new LocalDate is not the same.
77+
* For that reason we return WallTime, and not a LocalTime.
78+
* WallTime can be pretty-printed as Date-only, Time-only or DateAndTime.
79+
*
80+
* E.g `inTimezone('America/New_York').toISOTime()`
81+
*
82+
* https://en.wikipedia.org/wiki/List_of_tz_database_time_zones
83+
*
84+
* @experimental
85+
*/
86+
inTimezone(tz: string): WallTime {
87+
const d = new Date(this.$date.toLocaleString('en-US', { timeZone: tz }))
88+
return new WallTime({
89+
year: d.getFullYear(),
90+
month: d.getMonth() + 1,
91+
day: d.getDate(),
92+
hour: d.getHours(),
93+
minute: d.getMinutes(),
94+
second: d.getSeconds(),
95+
})
96+
}
97+
98+
/**
99+
* UTC offset is the opposite of "timezone offset" - it's the number of minutes to add
100+
* to the local time to get UTC time.
101+
*
102+
* E.g utcOffset for CEST is -120,
103+
* which means that you need to add -120 minutes to the local time to get UTC time.
104+
*
105+
* Instead of -0 it returns 0, for the peace of mind and less weird test/snapshot differences.
106+
*
107+
* If timezone (tz) is specified, e.g `America/New_York`,
108+
* it will return the UTC offset for that timezone.
109+
*
110+
* https://en.wikipedia.org/wiki/List_of_tz_database_time_zones
111+
*/
112+
getUTCOffsetMinutes(tz?: string): NumberOfMinutes {
113+
if (tz) {
114+
// based on: https://stackoverflow.com/a/53652131/4919972
115+
const nowTime = this.$date.getTime()
116+
const tzTime = new Date(this.$date.toLocaleString('en-US', { timeZone: tz })).getTime()
117+
return Math.round((tzTime - nowTime) / 60000) || 0
118+
}
119+
120+
return -this.$date.getTimezoneOffset() || 0
121+
}
122+
123+
/**
124+
* Same as getUTCOffsetMinutes, but rounded to hours.
125+
*
126+
* E.g for CEST it is -2.
127+
*
128+
* Instead of -0 it returns 0, for the peace of mind and less weird test/snapshot differences.
129+
*
130+
* If timezone (tz) is specified, e.g `America/New_York`,
131+
* it will return the UTC offset for that timezone.
132+
*/
133+
getUTCOffsetHours(tz?: string): NumberOfHours {
134+
return Math.round(this.getUTCOffsetMinutes(tz) / 60)
135+
}
136+
137+
/**
138+
* Returns e.g `-05:00` for New_York winter time.
139+
*/
140+
getUTCOffsetString(tz: string): string {
141+
const minutes = this.getUTCOffsetMinutes(tz)
142+
const hours = Math.trunc(minutes / 60)
143+
const sign = hours < 0 ? '-' : '+'
144+
const h = String(Math.abs(hours)).padStart(2, '0')
145+
const m = String(minutes % 60).padStart(2, '0')
146+
return `${sign}${h}:${m}`
147+
}
148+
71149
get(unit: LocalTimeUnit): number {
72150
if (unit === 'year') {
73151
return this.$date.getFullYear()
@@ -166,7 +244,7 @@ export class LocalTime {
166244
return v === undefined ? this.get('second') : this.set('second', v)
167245
}
168246

169-
setComponents(c: Partial<LocalTimeComponents>, mutate = false): LocalTime {
247+
setComponents(c: Partial<DateTimeObject>, mutate = false): LocalTime {
170248
const d = mutate ? this.$date : new Date(this.$date)
171249

172250
// Year, month and day set all-at-once, to avoid 30/31 (and 28/29) mishap
@@ -434,22 +512,22 @@ export class LocalTime {
434512
return t1 < t2 ? -1 : 1
435513
}
436514

437-
components(): LocalTimeComponents {
515+
getDateTimeObject(): DateTimeObject {
438516
return {
439-
...this.dateComponents(),
440-
...this.timeComponents(),
517+
...this.getDateObject(),
518+
...this.getTimeObject(),
441519
}
442520
}
443521

444-
private dateComponents(): DateComponents {
522+
getDateObject(): DateObject {
445523
return {
446524
year: this.$date.getFullYear(),
447525
month: this.$date.getMonth() + 1,
448526
day: this.$date.getDate(),
449527
}
450528
}
451529

452-
private timeComponents(): TimeComponents {
530+
getTimeObject(): TimeObject {
453531
return {
454532
hour: this.$date.getHours(),
455533
minute: this.$date.getMinutes(),
@@ -518,7 +596,7 @@ export class LocalTime {
518596
* Returns e.g: `1984-06-21`, only the date part of DateTime
519597
*/
520598
toISODate(): IsoDateString {
521-
const { year, month, day } = this.dateComponents()
599+
const { year, month, day } = this.getDateObject()
522600

523601
return [
524602
String(year).padStart(4, '0'),
@@ -534,7 +612,7 @@ export class LocalTime {
534612
* Returns e.g: `17:03:15` (or `17:03` with seconds=false)
535613
*/
536614
toISOTime(seconds = true): string {
537-
const { hour, minute, second } = this.timeComponents()
615+
const { hour, minute, second } = this.getTimeObject()
538616

539617
return [
540618
String(hour).padStart(2, '0'),
@@ -552,7 +630,7 @@ export class LocalTime {
552630
* Returns e.g: `19840621_1705`
553631
*/
554632
toStringCompact(seconds = false): string {
555-
const { year, month, day, hour, minute, second } = this.components()
633+
const { year, month, day, hour, minute, second } = this.getDateTimeObject()
556634

557635
return [
558636
String(year).padStart(4, '0'),
@@ -672,6 +750,25 @@ class LocalTimeFactory {
672750
return this.parseOrNull(d) !== null
673751
}
674752

753+
/**
754+
* Returns the IANA timezone e.g `Europe/Stockholm`.
755+
* https://en.wikipedia.org/wiki/List_of_tz_database_time_zones
756+
*/
757+
getTimezone(): string {
758+
return Intl.DateTimeFormat().resolvedOptions().timeZone
759+
}
760+
761+
/**
762+
* Returns true if passed IANA timezone is valid/supported.
763+
* E.g `Europe/Stockholm` is valid, but `Europe/Stockholm2` is not.
764+
*
765+
* This implementation is not optimized for performance. If you need frequent validation -
766+
* consider caching the Intl.supportedValuesOf values as Set and reuse that.
767+
*/
768+
isTimezoneValid(tz: string): boolean {
769+
return Intl.supportedValuesOf('timeZone').includes(tz)
770+
}
771+
675772
now(): LocalTime {
676773
return new LocalTime(new Date())
677774
}
@@ -692,7 +789,7 @@ class LocalTimeFactory {
692789
return d ? this.of(d) : this.now()
693790
}
694791

695-
fromComponents(c: { year: number; month: number } & Partial<LocalTimeComponents>): LocalTime {
792+
fromComponents(c: { year: number; month: number } & Partial<DateTimeObject>): LocalTime {
696793
return new LocalTime(
697794
new Date(c.year, c.month - 1, c.day || 1, c.hour || 0, c.minute || 0, c.second || 0),
698795
)
@@ -857,27 +954,3 @@ Object.setPrototypeOf(localTime, localTimeFactory)
857954
export function nowUnix(): UnixTimestampNumber {
858955
return Math.floor(Date.now() / 1000)
859956
}
860-
861-
/**
862-
* UTC offset is the opposite of "timezone offset" - it's the number of minutes to add
863-
* to the local time to get UTC time.
864-
*
865-
* E.g utcOffset for CEST is -120,
866-
* which means that you need to add -120 minutes to the local time to get UTC time.
867-
*
868-
* Instead of -0 it returns 0, for the peace of mind and less weird test/snapshot differences.
869-
*/
870-
export function getUTCOffsetMinutes(): NumberOfMinutes {
871-
return -new Date().getTimezoneOffset() || 0
872-
}
873-
874-
/**
875-
* Same as getUTCOffsetMinutes, but rounded to hours.
876-
*
877-
* E.g for CEST it is -2.
878-
*
879-
* Instead of -0 it returns 0, for the peace of mind and less weird test/snapshot differences.
880-
*/
881-
export function getUTCOffsetHours(): NumberOfHours {
882-
return Math.round(getUTCOffsetMinutes() / 60)
883-
}

0 commit comments

Comments
 (0)