diff --git a/src/datetime/localDate.test.ts b/src/datetime/localDate.test.ts index 6ad973e1..9c3d4534 100644 --- a/src/datetime/localDate.test.ts +++ b/src/datetime/localDate.test.ts @@ -22,6 +22,11 @@ test('basic', () => { expect(ld.month()).toBe(6) expect(ld.year()).toBe(1984) expect(ld.toMonthId()).toBe('1984-06') + expect(ld.toDateObject()).toEqual({ + year: 1984, + month: 6, + day: 21, + }) expect(JSON.stringify(ld)).toBe(`"${str}"`) expect(JSON.parse(JSON.stringify(ld))).toBe(str) expect(ld.absDiff(str, 'day')).toBe(0) diff --git a/src/datetime/localDate.ts b/src/datetime/localDate.ts index ef7a08ae..12aba035 100644 --- a/src/datetime/localDate.ts +++ b/src/datetime/localDate.ts @@ -9,7 +9,7 @@ import type { UnixTimestampMillisNumber, UnixTimestampNumber, } from '../types' -import { ISODayOfWeek, localTime, LocalTime } from './localTime' +import { DateObject, ISODayOfWeek, localTime, LocalTime } from './localTime' export type LocalDateUnit = LocalDateUnitStrict | 'week' export type LocalDateUnitStrict = 'year' | 'month' | 'day' @@ -381,6 +381,14 @@ export class LocalDate { return new Date(this.toISODateTimeInUTC()) } + toDateObject(): DateObject { + return { + year: this.$year, + month: this.$month, + day: this.$day, + } + } + /** * Converts LocalDate to LocalTime with 0 hours, 0 minutes, 0 seconds. * LocalTime's Date will be in local timezone. diff --git a/src/datetime/localTime.test.ts b/src/datetime/localTime.test.ts index 56892d97..ec35c90b 100644 --- a/src/datetime/localTime.test.ts +++ b/src/datetime/localTime.test.ts @@ -1,15 +1,7 @@ import { dayjs } from '@naturalcycles/time-lib' import { _range } from '../array/range' import { expectWithMessage, isUTC } from '../test/test.util' -import { - LocalTimeFormatter, - LocalTimeUnit, - nowUnix, - ISODayOfWeek, - localTime, - getUTCOffsetMinutes, - getUTCOffsetHours, -} from './localTime' +import { LocalTimeFormatter, LocalTimeUnit, nowUnix, ISODayOfWeek, localTime } from './localTime' const units: LocalTimeUnit[] = ['year', 'month', 'day', 'hour', 'minute', 'second', 'week'] @@ -129,6 +121,10 @@ test('basic', () => { expect(() => localTime(undefined as any)).toThrowErrorMatchingInlineSnapshot( `"Cannot parse "undefined" into LocalTime"`, ) + + expect(localTime.getTimezone()).toBe('UTC') + expect(localTime.isTimezoneValid('Europe/Stockholm')).toBe(true) + expect(localTime.isTimezoneValid('Europe/Stockholm2')).toBe(false) }) test('isBetween', () => { @@ -434,8 +430,8 @@ test('utcOffset', () => { expect(offset2).toBe(offset) if (isUTC()) { - expect(getUTCOffsetMinutes()).toBe(0) - expect(getUTCOffsetHours()).toBe(0) + expect(localTime.now().getUTCOffsetMinutes()).toBe(0) + expect(localTime.now().getUTCOffsetHours()).toBe(0) } }) @@ -460,3 +456,45 @@ test('fromDateUTC', () => { expect(ltLocal.toPretty()).toBe(lt.toPretty()) // todo: figure out what to assert in non-utc mode }) + +test('getUTCOffsetMinutes', () => { + const now = localTime('2024-05-14') + expect(now.getUTCOffsetMinutes('America/Los_Angeles')).toBe(-7 * 60) + expect(now.getUTCOffsetMinutes('America/New_York')).toBe(-4 * 60) + expect(now.getUTCOffsetMinutes('Europe/Stockholm')).toBe(2 * 60) + expect(now.getUTCOffsetHours('Europe/Stockholm')).toBe(2) + expect(now.getUTCOffsetMinutes('UTC')).toBe(0) + expect(now.getUTCOffsetHours('UTC')).toBe(0) + expect(now.getUTCOffsetMinutes('GMT')).toBe(0) + expect(now.getUTCOffsetMinutes('Asia/Tokyo')).toBe(9 * 60) +}) + +test('getUTCOffsetString', () => { + const now = localTime('2024-05-14') + expect(now.getUTCOffsetString('America/Los_Angeles')).toBe('-07:00') + expect(now.getUTCOffsetString('America/New_York')).toBe('-04:00') + expect(now.getUTCOffsetString('Europe/Stockholm')).toBe('+02:00') + expect(now.getUTCOffsetString('UTC')).toBe('+00:00') + expect(now.getUTCOffsetString('Asia/Tokyo')).toBe('+09:00') +}) + +test('inTimezone', () => { + const lt = localTime(`1984-06-21T05:00:00`) + expect(lt.toPretty()).toBe(`1984-06-21 05:00:00`) + + // Nope, unix doesn't match ;( + // expect(lt.inTimezone('Europe/Stockholm').unix()).toBe(lt.unix()) + + expect(lt.inTimezone('Europe/Stockholm').toPretty()).toBe(`1984-06-21 07:00:00`) + expect(lt.inTimezone('America/New_York').toPretty()).toBe(`1984-06-21 01:00:00`) + expect(lt.inTimezone('America/Los_Angeles').toPretty()).toBe(`1984-06-20 22:00:00`) + expect(lt.inTimezone('Asia/Tokyo').toPretty()).toBe(`1984-06-21 14:00:00`) + expect(lt.inTimezone('Asia/Tokyo').toPretty(false)).toBe(`1984-06-21 14:00`) + + const lt2 = localTime(`1984-02-14T21:00:00`) + expect(lt2.toPretty()).toBe(`1984-02-14 21:00:00`) + expect(lt2.inTimezone('Europe/Stockholm').toPretty()).toBe(`1984-02-14 22:00:00`) + expect(lt2.inTimezone('America/New_York').toPretty()).toBe(`1984-02-14 16:00:00`) + expect(lt2.inTimezone('America/Los_Angeles').toPretty()).toBe(`1984-02-14 13:00:00`) + expect(lt2.inTimezone('Asia/Tokyo').toPretty()).toBe(`1984-02-15 06:00:00`) +}) diff --git a/src/datetime/localTime.ts b/src/datetime/localTime.ts index f35a35fa..ccd0c7a6 100644 --- a/src/datetime/localTime.ts +++ b/src/datetime/localTime.ts @@ -12,6 +12,7 @@ import type { UnixTimestampNumber, } from '../types' import { localDate, LocalDate } from './localDate' +import { WallTime } from './wallTime' export type LocalTimeUnit = 'year' | 'month' | 'week' | 'day' | 'hour' | 'minute' | 'second' @@ -28,15 +29,15 @@ export enum ISODayOfWeek { export type LocalTimeInput = LocalTime | Date | IsoDateTimeString | UnixTimestampNumber export type LocalTimeFormatter = (ld: LocalTime) => string -export type LocalTimeComponents = DateComponents & TimeComponents +export type DateTimeObject = DateObject & TimeObject -interface DateComponents { +export interface DateObject { year: number month: number day: number } -interface TimeComponents { +export interface TimeObject { hour: number minute: number second: number @@ -68,6 +69,83 @@ export class LocalTime { return new LocalTime(new Date(this.$date.getTime())) } + /** + * Returns [cloned] fake LocalTime that has yyyy-mm-dd hh:mm:ss in the provided timezone. + * It is a fake LocalTime in a sense that it's timezone is not real. + * See this ("common errors"): https://stackoverflow.com/a/15171030/4919972 + * Fake also means that unixTimestamp of that new LocalDate is not the same. + * For that reason we return WallTime, and not a LocalTime. + * WallTime can be pretty-printed as Date-only, Time-only or DateAndTime. + * + * E.g `inTimezone('America/New_York').toISOTime()` + * + * https://en.wikipedia.org/wiki/List_of_tz_database_time_zones + * + * @experimental + */ + inTimezone(tz: string): WallTime { + const d = new Date(this.$date.toLocaleString('en-US', { timeZone: tz })) + return new WallTime({ + year: d.getFullYear(), + month: d.getMonth() + 1, + day: d.getDate(), + hour: d.getHours(), + minute: d.getMinutes(), + second: d.getSeconds(), + }) + } + + /** + * UTC offset is the opposite of "timezone offset" - it's the number of minutes to add + * to the local time to get UTC time. + * + * E.g utcOffset for CEST is -120, + * which means that you need to add -120 minutes to the local time to get UTC time. + * + * Instead of -0 it returns 0, for the peace of mind and less weird test/snapshot differences. + * + * If timezone (tz) is specified, e.g `America/New_York`, + * it will return the UTC offset for that timezone. + * + * https://en.wikipedia.org/wiki/List_of_tz_database_time_zones + */ + getUTCOffsetMinutes(tz?: string): NumberOfMinutes { + if (tz) { + // based on: https://stackoverflow.com/a/53652131/4919972 + const nowTime = this.$date.getTime() + const tzTime = new Date(this.$date.toLocaleString('en-US', { timeZone: tz })).getTime() + return Math.round((tzTime - nowTime) / 60000) || 0 + } + + return -this.$date.getTimezoneOffset() || 0 + } + + /** + * Same as getUTCOffsetMinutes, but rounded to hours. + * + * E.g for CEST it is -2. + * + * Instead of -0 it returns 0, for the peace of mind and less weird test/snapshot differences. + * + * If timezone (tz) is specified, e.g `America/New_York`, + * it will return the UTC offset for that timezone. + */ + getUTCOffsetHours(tz?: string): NumberOfHours { + return Math.round(this.getUTCOffsetMinutes(tz) / 60) + } + + /** + * Returns e.g `-05:00` for New_York winter time. + */ + getUTCOffsetString(tz: string): string { + const minutes = this.getUTCOffsetMinutes(tz) + const hours = Math.trunc(minutes / 60) + const sign = hours < 0 ? '-' : '+' + const h = String(Math.abs(hours)).padStart(2, '0') + const m = String(minutes % 60).padStart(2, '0') + return `${sign}${h}:${m}` + } + get(unit: LocalTimeUnit): number { if (unit === 'year') { return this.$date.getFullYear() @@ -166,7 +244,7 @@ export class LocalTime { return v === undefined ? this.get('second') : this.set('second', v) } - setComponents(c: Partial, mutate = false): LocalTime { + setComponents(c: Partial, mutate = false): LocalTime { const d = mutate ? this.$date : new Date(this.$date) // Year, month and day set all-at-once, to avoid 30/31 (and 28/29) mishap @@ -434,14 +512,14 @@ export class LocalTime { return t1 < t2 ? -1 : 1 } - components(): LocalTimeComponents { + getDateTimeObject(): DateTimeObject { return { - ...this.dateComponents(), - ...this.timeComponents(), + ...this.getDateObject(), + ...this.getTimeObject(), } } - private dateComponents(): DateComponents { + getDateObject(): DateObject { return { year: this.$date.getFullYear(), month: this.$date.getMonth() + 1, @@ -449,7 +527,7 @@ export class LocalTime { } } - private timeComponents(): TimeComponents { + getTimeObject(): TimeObject { return { hour: this.$date.getHours(), minute: this.$date.getMinutes(), @@ -518,7 +596,7 @@ export class LocalTime { * Returns e.g: `1984-06-21`, only the date part of DateTime */ toISODate(): IsoDateString { - const { year, month, day } = this.dateComponents() + const { year, month, day } = this.getDateObject() return [ String(year).padStart(4, '0'), @@ -534,7 +612,7 @@ export class LocalTime { * Returns e.g: `17:03:15` (or `17:03` with seconds=false) */ toISOTime(seconds = true): string { - const { hour, minute, second } = this.timeComponents() + const { hour, minute, second } = this.getTimeObject() return [ String(hour).padStart(2, '0'), @@ -552,7 +630,7 @@ export class LocalTime { * Returns e.g: `19840621_1705` */ toStringCompact(seconds = false): string { - const { year, month, day, hour, minute, second } = this.components() + const { year, month, day, hour, minute, second } = this.getDateTimeObject() return [ String(year).padStart(4, '0'), @@ -672,6 +750,25 @@ class LocalTimeFactory { return this.parseOrNull(d) !== null } + /** + * Returns the IANA timezone e.g `Europe/Stockholm`. + * https://en.wikipedia.org/wiki/List_of_tz_database_time_zones + */ + getTimezone(): string { + return Intl.DateTimeFormat().resolvedOptions().timeZone + } + + /** + * Returns true if passed IANA timezone is valid/supported. + * E.g `Europe/Stockholm` is valid, but `Europe/Stockholm2` is not. + * + * This implementation is not optimized for performance. If you need frequent validation - + * consider caching the Intl.supportedValuesOf values as Set and reuse that. + */ + isTimezoneValid(tz: string): boolean { + return Intl.supportedValuesOf('timeZone').includes(tz) + } + now(): LocalTime { return new LocalTime(new Date()) } @@ -692,7 +789,7 @@ class LocalTimeFactory { return d ? this.of(d) : this.now() } - fromComponents(c: { year: number; month: number } & Partial): LocalTime { + fromComponents(c: { year: number; month: number } & Partial): LocalTime { return new LocalTime( new Date(c.year, c.month - 1, c.day || 1, c.hour || 0, c.minute || 0, c.second || 0), ) @@ -857,27 +954,3 @@ Object.setPrototypeOf(localTime, localTimeFactory) export function nowUnix(): UnixTimestampNumber { return Math.floor(Date.now() / 1000) } - -/** - * UTC offset is the opposite of "timezone offset" - it's the number of minutes to add - * to the local time to get UTC time. - * - * E.g utcOffset for CEST is -120, - * which means that you need to add -120 minutes to the local time to get UTC time. - * - * Instead of -0 it returns 0, for the peace of mind and less weird test/snapshot differences. - */ -export function getUTCOffsetMinutes(): NumberOfMinutes { - return -new Date().getTimezoneOffset() || 0 -} - -/** - * Same as getUTCOffsetMinutes, but rounded to hours. - * - * E.g for CEST it is -2. - * - * Instead of -0 it returns 0, for the peace of mind and less weird test/snapshot differences. - */ -export function getUTCOffsetHours(): NumberOfHours { - return Math.round(getUTCOffsetMinutes() / 60) -} diff --git a/src/datetime/wallTime.ts b/src/datetime/wallTime.ts new file mode 100644 index 00000000..41d22d78 --- /dev/null +++ b/src/datetime/wallTime.ts @@ -0,0 +1,56 @@ +import { DateTimeObject } from './localTime' + +/** + * Representation of a "time on the wall clock", + * which means "local time, regardless of timezone". + * + * Experimental simplified container object to hold + * date and time components as numbers. + * No math or manipulation is possible here. + * Can be pretty-printed as Date, Time or DateAndTime. + */ +export class WallTime implements DateTimeObject { + year!: number + month!: number + day!: number + hour!: number + minute!: number + second!: number + + constructor(obj: DateTimeObject) { + Object.assign(this, obj) + } + + /** + * Returns e.g: `1984-06-21 17:56:21` + * or (if seconds=false): + * `1984-06-21 17:56` + */ + toPretty(seconds = true): string { + return this.toISODate() + ' ' + this.toISOTime(seconds) + } + + /** + * Returns e.g: `1984-06-21`, only the date part of DateTime + */ + toISODate(): string { + return [ + String(this.year).padStart(4, '0'), + String(this.month).padStart(2, '0'), + String(this.day).padStart(2, '0'), + ].join('-') + } + + /** + * Returns e.g: `17:03:15` (or `17:03` with seconds=false) + */ + toISOTime(seconds = true): string { + return [ + String(this.hour).padStart(2, '0'), + String(this.minute).padStart(2, '0'), + seconds && String(this.second).padStart(2, '0'), + ] + .filter(Boolean) + .join(':') + } +} diff --git a/src/index.ts b/src/index.ts index f0abde5b..a0ea1463 100644 --- a/src/index.ts +++ b/src/index.ts @@ -26,7 +26,6 @@ export * from './json-schema/jsonSchema.cnst' export * from './json-schema/jsonSchema.model' export * from './json-schema/jsonSchema.util' export * from './json-schema/jsonSchemaBuilder' -export * from './json-schema/jsonSchemaBuilder' export * from './math/math.util' export * from './math/sma' export * from './number/createDeterministicRandom' @@ -68,10 +67,7 @@ export * from './math/stack.util' export * from './string/leven' export * from './datetime/localDate' export * from './datetime/localTime' -export * from './datetime/dateInterval' -export * from './datetime/timeInterval' -export * from './datetime/localDate' -export * from './datetime/localTime' +export * from './datetime/wallTime' export * from './datetime/dateInterval' export * from './datetime/timeInterval' export * from './env'