Skip to content

Commit

Permalink
Added IANA TimeZone-type. (#110)
Browse files Browse the repository at this point in the history
* Renamed the former type `TimeZone` to `TimeZoneOffset`. (This should not be breaking, since it is not exported in `index.ts` and only internal type.)

* Change to existing code is the `TimeZone`-parameter for DateTime.localize which took what before was a `TimeZoneOffset`. However, this wasn't working. In reality it expected a IANA-timezone. (Else an error was thrown.)

* Added a `TimeZone` type for IANA-timezone identifiers. (Not strictly ISO-standard, but related to ISO 8601)

IANA-Timezone looks like this:
* `"Europe/Stockholm"`
* `"Europe/London"`
* `"UTC"`
  • Loading branch information
tvartom authored May 4, 2023
1 parent ca4b7bd commit 71ba173
Show file tree
Hide file tree
Showing 8 changed files with 190 additions and 75 deletions.
11 changes: 11 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,17 @@
"node_modules": true,
"*.code-workspace": true
},
"search.exclude": {
".*": false,
"*.json": false,
"CODEOWNERS": false,
"dist": false,
"README.md": false,
"LICENSE": false,
"tsconfig.*": false,
"node_modules": false,
"*.code-workspace": false
},
"files.insertFinalNewline": true,
"editor.tabSize": 2,
"editor.detectIndentation": false,
Expand Down
65 changes: 35 additions & 30 deletions DateTime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Date } from "./Date"
import { Locale } from "./Locale"
import { TimeSpan } from "./TimeSpan"
import { TimeZone } from "./TimeZone"
import { TimeZoneOffset } from "./TimeZoneOffset"

export type DateTime = string

Expand Down Expand Up @@ -38,21 +39,21 @@ export namespace DateTime {
value[10] == "T" &&
isHours(value.substring(11, 13)) &&
(value.length == 13 ||
TimeZone.is(value.substring(13)) ||
TimeZoneOffset.is(value.substring(13)) ||
(value[13] == ":" &&
value.length >= 16 &&
isMinutes(value.substring(14, 16)) &&
(value.length == 16 ||
TimeZone.is(value.substring(16)) ||
TimeZoneOffset.is(value.substring(16)) ||
(value[16] == ":" &&
value.length >= 19 &&
isSeconds(value.substring(17, 19)) &&
(value.length == 19 ||
TimeZone.is(value.substring(19)) ||
TimeZoneOffset.is(value.substring(19)) ||
(value[19] == "." &&
value.length >= 23 &&
[...value.substring(20, 23)].every(c => c >= "0" && c <= "9") &&
(value.length == 23 || TimeZone.is(value.substring(23)))))))))
(value.length == 23 || TimeZoneOffset.is(value.substring(23)))))))))
)
}
export function parse(value: DateTime): globalThis.Date {
Expand All @@ -71,16 +72,16 @@ export namespace DateTime {
switch (resolution) {
case "days":
value = value * 24
// eslint-disable-next-line no-fallthrough
// fallthrough...
case "hours":
value = value * 60
// eslint-disable-next-line no-fallthrough
// fallthrough...
case "minutes":
value = value * 60
// eslint-disable-next-line no-fallthrough
// fallthrough...
case "seconds":
value = value * 1000
// eslint-disable-next-line no-fallthrough
// fallthrough...
case "milliseconds":
}
value = new globalThis.Date(value)
Expand All @@ -91,29 +92,30 @@ export namespace DateTime {
return create(new globalThis.Date())
}
export type Format = Intl.DateTimeFormatOptions
export function localize(
value: DateTime | globalThis.Date,
format: Intl.DateTimeFormatOptions,
locale?: Locale
): string

export function localize(value: DateTime | globalThis.Date, format: Format, locale?: Locale): string
export function localize(value: DateTime | globalThis.Date, locale?: Locale, timeZone?: TimeZone): string
export function localize(
value: DateTime | globalThis.Date,
locale?: Locale | Intl.DateTimeFormatOptions,
timeZone?: string | Locale
formatOrLocale?: Locale | Format,
localeOrTimeZone?: string | Locale
): string {
let result: string
if (typeof locale == "object") {
const localeString = timeZone ? timeZone : Intl.DateTimeFormat().resolvedOptions().locale
if (typeof formatOrLocale == "object") {
// formatOrLocale is Format
// localeOrTimeZone is Locale | undefined
const localeString = localeOrTimeZone ? localeOrTimeZone : Intl.DateTimeFormat().resolvedOptions().locale
result = (is(value) ? parse(value) : value)
.toLocaleString(localeString, locale)
.toLocaleString(localeString, formatOrLocale)
// For consistency, replace NNBSP with space:
// Unicode has decided to use `Narrow No-Break Space (NNBSP)` (U+202F) instead of space in some cases.
// It breaks tests, when running in different environments.
// https://icu.unicode.org/download/72#:~:text=In%20many%20formatting%20patterns%2C%20ASCII%20spaces%20are%20replaced%20with%20Unicode%20spaces%20(e.g.%2C%20a%20%22thin%20space%22)
// This can be removed, with a breaking change and updated tests, when all systems use updated versions of ICU.
.replaceAll(" ", " ")
} else {
// formatOrLocale is Locale | undefined
// localeOrTimeZone is timeZone | undefined
const precision = is(value) ? DateTime.precision(value) : "milliseconds"
result = localize(
value,
Expand All @@ -125,24 +127,27 @@ export namespace DateTime {
minute:
precision == "minutes" || precision == "seconds" || precision == "milliseconds" ? "2-digit" : undefined,
second: precision == "seconds" || precision == "milliseconds" ? "2-digit" : undefined,
timeZone: timeZone,
},
locale
timeZone: localeOrTimeZone,
} as Format,
formatOrLocale
)
}
return result
}

export function timeZone(value: DateTime): TimeZone | "" {
/** @deprecated Use timeZoneOffset() */
export function timeZone(value: DateTime): TimeZoneOffset | "" {
return timeZoneOffset(value)
}
export function timeZoneOffset(value: DateTime): TimeZoneOffset | "" {
const result = value[value.length - 1] == "Z" ? "Z" : value.substring(value.length - 6)
return TimeZone.is(result) ? result : ""
return TimeZoneOffset.is(result) ? result : ""
}
export function timeZoneShort(value: DateTime): number {
return parse(value).getTimezoneOffset()
}
export type Precision = "hours" | "minutes" | "seconds" | "milliseconds"
export function precision(value: DateTime): Precision {
const zone = timeZone(value)
const zone = timeZoneOffset(value)
const time = value.substring(0, value.length - zone.length).split("T", 2)[1]
let result: Precision
switch (time.length) {
Expand All @@ -164,7 +169,7 @@ export namespace DateTime {
}

export function truncate(value: DateTime, precision: Precision): DateTime {
const zone = timeZone(value)
const zone = timeZoneOffset(value)
// eslint-disable-next-line prefer-const
let [date, time] = value.split("T", 2)
time = time.substring(0, time.length - zone.length)
Expand Down Expand Up @@ -204,16 +209,16 @@ export namespace DateTime {
switch (resolution) {
case "days":
result = Math.round(result / 24)
// eslint-disable-next-line no-fallthrough
// fallthrough...
case "hours":
result = Math.round(result / 60)
// eslint-disable-next-line no-fallthrough
// fallthrough...
case "minutes":
result = Math.round(result / 60)
// eslint-disable-next-line no-fallthrough
// fallthrough...
case "seconds":
result = Math.round(result / 1000)
// eslint-disable-next-line no-fallthrough
// fallthrough...
case "milliseconds":
}
return result
Expand Down
80 changes: 80 additions & 0 deletions TimeZone.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import * as isolyGlobal from "."
import { isoly } from "."
import { TimeZone } from "./TimeZone"

describe("TimeZone", () => {
it("Imports", () => {
expect(isolyGlobal.TimeZone.is("Europe/Stockholm")).toBe(true)
expect(isoly.TimeZone.is("Europe/Stockholm")).toBe(true)
expect(TimeZone.is("Europe/Stockholm")).toBe(true)
})
it("Real timezones", () => {
expect(TimeZone.is("Europe/Stockholm")).toBe(true)
expect(TimeZone.is("Europe/London")).toBe(true)
expect(TimeZone.is("Africa/Abidjan")).toBe(true)
expect(TimeZone.is("Africa/Accra")).toBe(true)
expect(TimeZone.is("Africa/Addis_Ababa")).toBe(true)
expect(TimeZone.is("Africa/Algiers")).toBe(true)
expect(TimeZone.is("Africa/Asmara")).toBe(true)
expect(TimeZone.is("Africa/Bamako")).toBe(true)
expect(TimeZone.is("Africa/Bangui")).toBe(true)
expect(TimeZone.is("Africa/Banjul")).toBe(true)
expect(TimeZone.is("Africa/Bissau")).toBe(true)
expect(TimeZone.is("Africa/Blantyre")).toBe(true)
expect(TimeZone.is("Africa/Brazzaville")).toBe(true)
expect(TimeZone.is("Africa/Bujumbura")).toBe(true)
expect(TimeZone.is("Africa/Cairo")).toBe(true)
expect(TimeZone.is("Africa/Casablanca")).toBe(true)
expect(TimeZone.is("Africa/Ceuta")).toBe(true)
expect(TimeZone.is("Africa/Conakry")).toBe(true)
expect(TimeZone.is("Africa/Dakar")).toBe(true)
expect(TimeZone.is("Africa/Dar_es_Salaam")).toBe(true)
expect(TimeZone.is("Africa/Djibouti")).toBe(true)
expect(TimeZone.is("Africa/Douala")).toBe(true)
expect(TimeZone.is("Africa/El_Aaiun")).toBe(true)
expect(TimeZone.is("Africa/Freetown")).toBe(true)
expect(TimeZone.is("Africa/Gaborone")).toBe(true)
expect(TimeZone.is("Africa/Harare")).toBe(true)
expect(TimeZone.is("Africa/Johannesburg")).toBe(true)
expect(TimeZone.is("Africa/Juba")).toBe(true)
expect(TimeZone.is("Africa/Kampala")).toBe(true)
expect(TimeZone.is("Africa/Khartoum")).toBe(true)
expect(TimeZone.is("Africa/Kigali")).toBe(true)
expect(TimeZone.is("Africa/Kinshasa")).toBe(true)
expect(TimeZone.is("Africa/Lagos")).toBe(true)
expect(TimeZone.is("Africa/Libreville")).toBe(true)
expect(TimeZone.is("Africa/Lome")).toBe(true)
expect(TimeZone.is("Africa/Luanda")).toBe(true)
expect(TimeZone.is("Africa/Lubumbashi")).toBe(true)
expect(TimeZone.is("Africa/Lusaka")).toBe(true)
expect(TimeZone.is("Africa/Malabo")).toBe(true)
expect(TimeZone.is("Africa/Maputo")).toBe(true)
expect(TimeZone.is("Africa/Maseru")).toBe(true)
expect(TimeZone.is("Africa/Mbabane")).toBe(true)
expect(TimeZone.is("Africa/Mogadishu")).toBe(true)
expect(TimeZone.is("Africa/Monrovia")).toBe(true)
expect(TimeZone.is("Africa/Nairobi")).toBe(true)
expect(TimeZone.is("Africa/Ndjamena")).toBe(true)
expect(TimeZone.is("Africa/Niamey")).toBe(true)
expect(TimeZone.is("Africa/Nouakchott")).toBe(true)
expect(TimeZone.is("Africa/Ouagadougou")).toBe(true)
expect(TimeZone.is("Africa/Porto-Novo")).toBe(true)
expect(TimeZone.is("Africa/Sao_Tome")).toBe(true)
expect(TimeZone.is("Africa/Tripoli")).toBe(true)
expect(TimeZone.is("Africa/Tunis")).toBe(true)
expect(TimeZone.is("Africa/Windhoek")).toBe(true)
expect(TimeZone.is("America/Adak")).toBe(true)
expect(TimeZone.is("Pacific/Wallis")).toBe(true)
expect(TimeZone.is("UTC")).toBe(true)
expect(TimeZone.is("GMT")).toBe(true)
})
it("Flawed timezones", () => {
expect(TimeZone.is("Europe/Göteborg")).toBe(false)
expect(TimeZone.is("+01:00")).toBe(false)
expect(TimeZone.is(undefined)).toBe(false)
expect(TimeZone.is("timezone")).toBe(false)
expect(TimeZone.is("TZ")).toBe(false)
expect(TimeZone.is("Z")).toBe(false)
expect(TimeZone.is("")).toBe(false)
})
})
52 changes: 9 additions & 43 deletions TimeZone.ts
Original file line number Diff line number Diff line change
@@ -1,47 +1,13 @@
export type TimeZone = typeof TimeZone.values[number]

/** IANA format. */
export type TimeZone = "Europe/Stockholm" | "Europe/London" | "UTC" | (string & Record<never, never>) // The Record<never...> makes autocomplete work in your IDE.
export namespace TimeZone {
export const values: string[] = [
"-12:00",
"-11:00",
"-10:00",
"-09:30",
"-09:00",
"-08:00",
"-07:00",
"-06:00",
"-05:00",
"-04:00",
"-03:30",
"-03:00",
"-02:00",
"-01:00",
"Z",
"+01:00",
"+02:00",
"+03:00",
"+03:30",
"+04:00",
"+04:30",
"+05:00",
"+05:30",
"+05:45",
"+06:00",
"+06:30",
"+07:00",
"+08:00",
"+08:45",
"+09:00",
"+09:30",
"+10:00",
"+10:30",
"+11:00",
"+12:00",
"+12:45",
"+13:00",
"+14:00",
]
export function is(value: TimeZone | any): value is TimeZone {
return typeof value == "string" && values.includes(value)
let result: boolean
try {
result = typeof value == "string" && !!new Intl.DateTimeFormat("en-GB", { timeZone: value })
} catch (error) {
result = false
}
return result
}
}
47 changes: 47 additions & 0 deletions TimeZoneOffset.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
export type TimeZoneOffset = typeof TimeZoneOffset.values[number]

export namespace TimeZoneOffset {
export const values: string[] = [
"-12:00",
"-11:00",
"-10:00",
"-09:30",
"-09:00",
"-08:00",
"-07:00",
"-06:00",
"-05:00",
"-04:00",
"-03:30",
"-03:00",
"-02:00",
"-01:00",
"Z",
"+01:00",
"+02:00",
"+03:00",
"+03:30",
"+04:00",
"+04:30",
"+05:00",
"+05:30",
"+05:45",
"+06:00",
"+06:30",
"+07:00",
"+08:00",
"+08:45",
"+09:00",
"+09:30",
"+10:00",
"+10:30",
"+11:00",
"+12:00",
"+12:45",
"+13:00",
"+14:00",
]
export function is(value: TimeZoneOffset | any): value is TimeZoneOffset {
return typeof value == "string" && values.includes(value)
}
}
2 changes: 2 additions & 0 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { Language } from "./Language"
import { Locale } from "./Locale"
import { TimeRange } from "./TimeRange"
import { TimeSpan } from "./TimeSpan"
import { TimeZone } from "./TimeZone"

export {
CallingCode,
Expand All @@ -27,4 +28,5 @@ export {
Locale,
TimeRange,
TimeSpan,
TimeZone,
}
2 changes: 2 additions & 0 deletions isoly.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { Language } from "./Language"
import { Locale } from "./Locale"
import { TimeRange } from "./TimeRange"
import { TimeSpan } from "./TimeSpan"
import { TimeZone } from "./TimeZone"

export {
CallingCode,
Expand All @@ -26,4 +27,5 @@ export {
Locale,
TimeRange,
TimeSpan,
TimeZone,
}
Loading

0 comments on commit 71ba173

Please sign in to comment.