From d42a114b7f58b180587e0374b71bb69fde32a98a Mon Sep 17 00:00:00 2001 From: Kouta Motegi Date: Wed, 17 Apr 2024 22:36:33 +0900 Subject: [PATCH] feat: existing "Z" token is now "ZZ" adds "Z" token style (#52) * Support for the timezone token "ZZ" and changing the timezone format style. * Fix timezone offset parsing and applyOffset function --- src/__tests__/applyOffset.spec.ts | 6 +++ src/__tests__/common.spec.ts | 25 +++++++++++++ src/__tests__/format.spec.ts | 37 +++++++++++++++++-- src/__tests__/offset.spec.ts | 23 ++++++++---- src/__tests__/parse.spec.ts | 29 ++++++++++++--- src/__tests__/removeOffset.spec.ts | 6 +++ src/__tests__/validOffset.spec.ts | 10 ++++- src/applyOffset.ts | 16 ++++++-- src/common.ts | 59 +++++++++++++++++++++++++----- src/format.ts | 6 +-- src/offset.ts | 7 ++-- src/parse.ts | 4 +- src/removeOffset.ts | 4 +- 13 files changed, 191 insertions(+), 41 deletions(-) create mode 100644 src/__tests__/common.spec.ts diff --git a/src/__tests__/applyOffset.spec.ts b/src/__tests__/applyOffset.spec.ts index cfbd6df..853ebd5 100644 --- a/src/__tests__/applyOffset.spec.ts +++ b/src/__tests__/applyOffset.spec.ts @@ -4,12 +4,18 @@ process.env.TZ = "America/New_York" describe("applyOffset", () => { it("can apply a negative offset to a date", () => { + expect(applyOffset("2023-02-22T00:00:00Z", "-05:00").toISOString()).toBe( + "2023-02-21T19:00:00.000Z" + ) expect(applyOffset("2023-02-22T00:00:00Z", "-0500").toISOString()).toBe( "2023-02-21T19:00:00.000Z" ) }) it("can apply a positive offset to a date", () => { + expect(applyOffset("2023-04-13T10:15:00", "+02:00").toISOString()).toBe( + "2023-04-13T16:15:00.000Z" + ) expect(applyOffset("2023-04-13T10:15:00", "+0200").toISOString()).toBe( "2023-04-13T16:15:00.000Z" ) diff --git a/src/__tests__/common.spec.ts b/src/__tests__/common.spec.ts new file mode 100644 index 0000000..ab6b4ef --- /dev/null +++ b/src/__tests__/common.spec.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vitest" +import { getOffsetFormat } from "../common" + +describe("getOffsetFormat", () => { + it("should return 'Z' for format 'YYYY-MM-DDTHH:mm:ssZ'", () => { + expect(getOffsetFormat("YYYY-MM-DDTHH:mm:ssZ")).toBe("Z") + }) + + it("should return 'ZZ' for format 'YYYY-MM-DDTHH:mm:ssZZ'", () => { + expect(getOffsetFormat("YYYY-MM-DDTHH:mm:ssZZ")).toBe("ZZ") + }) + + it("should return 'Z' for formats 'full', 'long', 'medium', and 'short'", () => { + expect(getOffsetFormat("full")).toBe("Z") + expect(getOffsetFormat("long")).toBe("Z") + expect(getOffsetFormat("medium")).toBe("Z") + expect(getOffsetFormat("short")).toBe("Z") + }) + + it("should return 'Z' for formats { date: 'full', time: 'full' }, { date: 'full' }, and { time: 'full' }", () => { + expect(getOffsetFormat({ date: "full", time: "full" })).toBe("Z") + expect(getOffsetFormat({ date: "full" })).toBe("Z") + expect(getOffsetFormat({ time: "full" })).toBe("Z") + }) +}) diff --git a/src/__tests__/format.spec.ts b/src/__tests__/format.spec.ts index 76cd1d2..7e56287 100644 --- a/src/__tests__/format.spec.ts +++ b/src/__tests__/format.spec.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from "vitest" +import { describe, expect, it } from "vitest" import { format } from "../format" import { tzDate } from "../tzDate" process.env.TZ = "America/New_York" @@ -138,7 +138,7 @@ describe("format", () => { it("can render a long time in Japanese", () => { expect( format("2010-06-09T04:32:00Z", { time: "full" }, "ja") - ).toBe("0時32分00秒 -0400") + ).toBe("0時32分00秒 -04:00") }) it("can format the russian month of february", () => { expect(format("2023-03-14", { date: "medium" }, "ru")).toBe( @@ -147,13 +147,16 @@ describe("format", () => { }) it("can include the timezone of a date", () => { expect(format("2023-05-05T05:30:10Z", "HH:mm:ss Z", "en")).toBe( + "01:30:10 -04:00" + ) + expect(format("2023-05-05T05:30:10Z", "HH:mm:ss ZZ", "en")).toBe( "01:30:10 -0400" ) }) it("uses offsets in full date formatting", () => { expect( format("2023-05-05T05:30:10Z", { date: "full", time: "full" }, "en") - ).toBe("Friday, May 5, 2023 at 1:30:10 AM -0400") + ).toBe("Friday, May 5, 2023 at 1:30:10 AM -04:00") }) it("can filter out the month part", () => { expect( @@ -201,6 +204,13 @@ describe("format with a timezone", () => { format: "Z", tz: "Asia/Kolkata", }) + ).toBe("+05:30") + expect( + format({ + date: "2022-10-29T11:30:50Z", + format: "ZZ", + tz: "Asia/Kolkata", + }) ).toBe("+0530") expect( format({ @@ -208,6 +218,13 @@ describe("format with a timezone", () => { format: "D hh:mm a Z", tz: "America/New_York", }) + ).toBe("20 10:15 am -05:00") + expect( + format({ + date: "2023-02-20T10:15:00", + format: "D hh:mm a ZZ", + tz: "America/New_York", + }) ).toBe("20 10:15 am -0500") expect( format({ @@ -215,6 +232,13 @@ describe("format with a timezone", () => { format: "YYYY-MM-DDTHH:mm:ssZ", tz: "Europe/Stockholm", }) + ).toBe("2024-02-16T12:00:00+01:00") + expect( + format({ + date: new Date("2024-02-16T11:00:00Z"), + format: "YYYY-MM-DDTHH:mm:ssZZ", + tz: "Europe/Stockholm", + }) ).toBe("2024-02-16T12:00:00+0100") }) @@ -225,6 +249,13 @@ describe("format with a timezone", () => { format: "HH:mm:ssZ", tz: "UTC", }) + ).toBe("02:30:00+00:00") + expect( + format({ + date: new Date("2024-03-10T02:30:00Z"), + format: "HH:mm:ssZZ", + tz: "UTC", + }) ).toBe("02:30:00+0000") }) it("can render a double character zero with leading zeros in zh (#41)", () => { diff --git a/src/__tests__/offset.spec.ts b/src/__tests__/offset.spec.ts index 4bb187b..494200e 100644 --- a/src/__tests__/offset.spec.ts +++ b/src/__tests__/offset.spec.ts @@ -4,29 +4,38 @@ process.env.TZ = "America/New_York" describe("offset", () => { it("can determine the offset of a winter month to UTC", () => { - expect(offset("2023-02-22")).toBe("-0500") + expect(offset("2023-02-22")).toBe("-05:00") }) it("changes the offset after daylight savings", () => { - expect(offset("2023-03-12T06:59:00Z")).toBe("-0500") - expect(offset("2023-03-12T07:00:00Z")).toBe("-0400") + expect(offset("2023-03-12T06:59:00Z")).toBe("-05:00") + expect(offset("2023-03-12T07:00:00Z")).toBe("-04:00") }) it("can determine the offset to another base timezone", () => { - expect(offset("2023-02-22", "Europe/Amsterdam")).toBe("-0600") + expect(offset("2023-02-22", "Europe/Amsterdam")).toBe("-06:00") }) it("can determine the offset to another base timezone with daylight savings", () => { - expect(offset("2023-03-26T00:59Z", "Europe/Amsterdam")).toBe("-0500") - expect(offset("2023-03-26T01:00Z", "Europe/Amsterdam")).toBe("-0600") + expect(offset("2023-03-26T00:59Z", "Europe/Amsterdam")).toBe("-05:00") + expect(offset("2023-03-26T01:00Z", "Europe/Amsterdam")).toBe("-06:00") }) it("can determine the offset between two arbitrary timezones", () => { expect(offset("2023-02-22", "Europe/Moscow", "America/Los_Angeles")).toBe( - "-1100" + "-11:00" ) expect(offset("2023-02-22", "America/Los_Angeles", "Europe/Moscow")).toBe( + "+11:00" + ) + expect(offset("2023-02-22", "Europe/Moscow", "America/Los_Angeles", "ZZ")).toBe( + "-1100" + ) + expect(offset("2023-02-22", "America/Los_Angeles", "Europe/Moscow", "ZZ")).toBe( "+1100" ) }) it("can determine the offset to a non full-hour offset timezone", () => { expect(offset("2023-02-22", "Europe/London", "Pacific/Chatham")).toBe( + "+13:45" + ) + expect(offset("2023-02-22", "Europe/London", "Pacific/Chatham", "ZZ")).toBe( "+1345" ) }) diff --git a/src/__tests__/parse.spec.ts b/src/__tests__/parse.spec.ts index e2fd53e..6091168 100644 --- a/src/__tests__/parse.spec.ts +++ b/src/__tests__/parse.spec.ts @@ -184,7 +184,7 @@ describe("parse", () => { }) it("can parse a full date with a timezone offset", () => { expect( - parse("Friday, May 5, 2023 at 1:30:10 AM -0600", { + parse("Friday, May 5, 2023 at 1:30:10 AM -06:00", { date: "full", time: "full", }).toISOString() @@ -192,16 +192,22 @@ describe("parse", () => { }) it("can parse a custom format with a timezone offset", () => { expect( - parse("2023-02-24T13:44-0500", "YYYY-MM-DDTHH:mmZ", "en").toISOString() + parse("2023-02-24T13:44-05:00", "YYYY-MM-DDTHH:mmZ", "en").toISOString() ).toBe("2023-02-24T18:44:00.000Z") expect( - parse("2023--0500-02-24T13:44", "YYYY-Z-MM-DDTHH:mm", "en").toISOString() + parse("2023--05:00-02-24T13:44", "YYYY-Z-MM-DDTHH:mm", "en").toISOString() + ).toBe("2023-02-24T18:44:00.000Z") + expect( + parse("2023-02-24T13:44-0500", "YYYY-MM-DDTHH:mmZZ", "en").toISOString() + ).toBe("2023-02-24T18:44:00.000Z") + expect( + parse("2023--0500-02-24T13:44", "YYYY-ZZ-MM-DDTHH:mm", "en").toISOString() ).toBe("2023-02-24T18:44:00.000Z") }) it("can filter out the timezone offset", () => { expect( parse({ - date: "Friday, May 7, 2023 at 1:30:10 AM -1000", + date: "Friday, May 7, 2023 at 1:30:10 AM -10:00", format: { date: "full", time: "full", @@ -214,7 +220,7 @@ describe("parse", () => { it("can filter out the timezone offset", () => { expect( parse({ - date: ", May 7, 2023 at 1:30:10 AM -1000", + date: ", May 7, 2023 at 1:30:10 AM -10:00", format: { date: "full", time: "full", @@ -250,4 +256,17 @@ describe("parse", () => { }).toISOString() ).toThrow() }) + it("should throws an error if the Z token is specified and [+-]HHmm", () => { + expect( + () => parse("1994-06-22T04:22:32-0900", "YYYY-MM-DDTHH:mm:ssZ") + ).toThrow("Invalid offset: -0900") + }) + it("should throws an error when a FormatStyle is specified for [+-]HHmm", () => { + expect( + () => parse("Friday, May 5, 2023 at 1:30:10 AM -0600", { + date: "full", + time: "full", + }) + ).toThrow("Invalid offset: -0600") + }) }) diff --git a/src/__tests__/removeOffset.spec.ts b/src/__tests__/removeOffset.spec.ts index 63369bf..da79e26 100644 --- a/src/__tests__/removeOffset.spec.ts +++ b/src/__tests__/removeOffset.spec.ts @@ -4,12 +4,18 @@ process.env.TZ = "America/New_York" describe("removeOffset", () => { it("can apply a negative offset to a date", () => { + expect( + removeOffset("2023-02-21T19:00:00.000Z", "-05:00").toISOString() + ).toBe("2023-02-22T00:00:00.000Z") expect( removeOffset("2023-02-21T19:00:00.000Z", "-0500").toISOString() ).toBe("2023-02-22T00:00:00.000Z") }) it("can apply a positive offset to a date", () => { + expect( + removeOffset("2023-04-13T16:15:00.000Z", "+02:00").toISOString() + ).toBe("2023-04-13T14:15:00.000Z") expect( removeOffset("2023-04-13T16:15:00.000Z", "+0200").toISOString() ).toBe("2023-04-13T14:15:00.000Z") diff --git a/src/__tests__/validOffset.spec.ts b/src/__tests__/validOffset.spec.ts index 21d4d46..2e203f3 100644 --- a/src/__tests__/validOffset.spec.ts +++ b/src/__tests__/validOffset.spec.ts @@ -4,9 +4,15 @@ process.env.TZ = "America/New_York" describe("validOffset", () => { it("returns its own value when valid", () => { - expect(validOffset("+0000")).toBe("+0000") - expect(validOffset("+0100")).toBe("+0100") + expect(validOffset("+0000", "ZZ")).toBe("+0000") + expect(validOffset("+0100", "ZZ")).toBe("+0100") + expect(validOffset("+00:00", "Z")).toBe("+00:00") + expect(validOffset("+01:00", "Z")).toBe("+01:00") expect(validOffset("+00:00")).toBe("+00:00") expect(validOffset("+01:00")).toBe("+01:00") }) + it("should throw an error when the timezone token does not match the format", () => { + expect(() => validOffset("+0000", "Z")).toThrow() + expect(() => validOffset("+00:00", "ZZ")).toThrow() + }) }) diff --git a/src/applyOffset.ts b/src/applyOffset.ts index 27b18e7..6c5377d 100644 --- a/src/applyOffset.ts +++ b/src/applyOffset.ts @@ -1,15 +1,23 @@ import { date } from "./date" -import { offsetToMins } from "./common" +import { TimezoneToken, fixedLengthByOffset, offsetToMins } from "./common" import type { DateInput } from "./types" /** * Apply a given offset to a date, returning a new date with the offset * applied by adding or subtracting the given number of minutes. * @param dateInput - The date to apply the offset to. - * @param offset - The offset to apply in the +-HHmm format. + * @param offset - The offset to apply in the +-HHmm or +-HH:mm format. */ -export function applyOffset(dateInput: DateInput, offset = "+0000"): Date { +export function applyOffset(dateInput: DateInput, offset = "+00:00"): Date { const d = date(dateInput) - const timeDiffInMins = offsetToMins(offset) + const token = ((): TimezoneToken => { + switch (fixedLengthByOffset(offset)) { + case 5: + return "ZZ" + case 6: + return "Z" + } + })() + const timeDiffInMins = offsetToMins(offset, token) return new Date(d.getTime() + timeDiffInMins * 1000 * 60) } diff --git a/src/common.ts b/src/common.ts index 7958081..b11231c 100644 --- a/src/common.ts +++ b/src/common.ts @@ -7,6 +7,7 @@ import type { FormatStyle, Part, FilledPart, + Format, } from "./types" /** @@ -38,9 +39,20 @@ export const clockAgnostic: FormatPattern[] = [ ["m", { minute: "numeric" }], ["ss", { second: "2-digit" }], ["s", { second: "numeric" }], + ["ZZ", { timeZoneName: "long"}], ["Z", { timeZoneName: "short" }], ] +/** + * Timezone tokens. + */ +const timeZoneTokens = ["Z", "ZZ"] as const + +/** + * Timezone token type. + */ +export type TimezoneToken = typeof timeZoneTokens[number] + /** * 24 hour click format patterns. */ @@ -76,7 +88,7 @@ export const fixedLength = { /** * token Z can have variable length depending on the actual value, so it's */ -export function fixedLengthByOffset(offsetString: string): number { +export function fixedLengthByOffset(offsetString: string): 6 | 5 { // starts with [+-]xx:xx if (/^[+-]\d{2}:\d{2}/.test(offsetString)) { return 6 @@ -187,7 +199,7 @@ export function fill( return token === "A" ? p.toUpperCase() : p.toLowerCase() } if (partName === "timeZoneName") { - return offset ?? minsToOffset(-1 * d.getTimezoneOffset()) + return offset ?? minsToOffset(-1 * d.getTimezoneOffset(), token) } return value } @@ -281,27 +293,33 @@ function createPartMap( } /** - * Converts minutes (300) to an ISO8601 compatible offset (+0400). + * Converts minutes (300) to an ISO8601 compatible offset (+0400 or +04:00). * @param timeDiffInMins - The difference in minutes between two timezones. * @returns */ -export function minsToOffset(timeDiffInMins: number): string { +export function minsToOffset(timeDiffInMins: number, token: string = "Z"): string { const hours = String(Math.floor(Math.abs(timeDiffInMins / 60))).padStart( 2, "0" ) const mins = String(Math.abs(timeDiffInMins % 60)).padStart(2, "0") const sign = timeDiffInMins < 0 ? "-" : "+" - return `${sign}${hours}${mins}` + + if (token === "ZZ") { + return `${sign}${hours}${mins}` + } + + return `${sign}${hours}:${mins}` } /** * Converts an offset (-0500) to minutes (-300). * @param offset - The offset to convert to minutes. + * @param token - The timezone token format. */ -export function offsetToMins(offset: string): number { - validOffset(offset) - const [_, sign, hours, mins] = offset.match(/([+-])([0-3][0-9])([0-6][0-9])/)! +export function offsetToMins(offset: string, token: TimezoneToken): number { + validOffset(offset, token) + const [_, sign, hours, mins] = offset.match(/([+-])([0-3][0-9]):?([0-6][0-9])/)! const offsetInMins = Number(hours) * 60 + Number(mins) return sign === "+" ? offsetInMins : -offsetInMins } @@ -310,9 +328,18 @@ export function offsetToMins(offset: string): number { * Validates that an offset is valid according to the format: * [+-]HHmm or [+-]HH:mm * @param offset - The offset to validate. + * @param token - The timezone token format. */ -export function validOffset(offset: string) { - const valid = /^([+-])[0-3][0-9]:?[0-6][0-9]$/.test(offset) +export function validOffset(offset: string, token: TimezoneToken = "Z") { + const valid = ((token: TimezoneToken): boolean => { + switch (token) { + case "Z": + return /^([+-])[0-3][0-9]:[0-6][0-9]$/.test(offset) + case "ZZ": + return /^([+-])[0-3][0-9][0-6][0-9]$/.test(offset) + } + })(token) + if (!valid) throw new Error(`Invalid offset: ${offset}`) return offset } @@ -369,3 +396,15 @@ export function validate(parts: Part[]): Part[] | never { } return parts } + +/** + * Returns the timezone token format from a given format. + * @param format - The format to check. + * @returns The timezone token format ("Z" or "ZZ"). + */ +export function getOffsetFormat(format: Format): TimezoneToken { + if (typeof format === "string") { + return format.includes("ZZ") ? "ZZ" : "Z" + } + return "Z" +} diff --git a/src/format.ts b/src/format.ts index 78edb20..c2a8ddf 100644 --- a/src/format.ts +++ b/src/format.ts @@ -1,7 +1,7 @@ import { date } from "./date" import { parts } from "./parts" -import { fill } from "./common" -import type { DateInput, Format, FormatOptions, Part } from "./types" +import { fill, getOffsetFormat } from "./common" +import type { DateInput, Format, FormatOptions, FormatStyle, Part } from "./types" import { offset } from "./offset" import { removeOffset } from "./removeOffset" import { deviceLocale } from "./deviceLocale" @@ -72,7 +72,7 @@ export function format( if (format === "ISO8601") return date(inputDateOrOptions).toISOString() if (tz) { - forceOffset = offset(inputDateOrOptions, "utc", tz) + forceOffset = offset(inputDateOrOptions, "utc", tz, getOffsetFormat(format)) } // We need to apply an offset to the date so that it can be formatted as UTC. diff --git a/src/offset.ts b/src/offset.ts index deb2b07..939d394 100644 --- a/src/offset.ts +++ b/src/offset.ts @@ -1,5 +1,5 @@ import { date } from "./date" -import { normStr, minsToOffset } from "./common" +import { normStr, minsToOffset, TimezoneToken } from "./common" import { deviceTZ } from "./deviceTZ" import type { DateInput } from "./types" @@ -49,12 +49,13 @@ function relativeTime(d: Date, timeZone: string): Date { export function offset( utcTime: DateInput, tzA = "UTC", - tzB = "device" + tzB = "device", + timeZoneToken: TimezoneToken = "Z" , ): string { tzB = tzB === "device" ? deviceTZ() ?? "utc" : tzB const d = date(utcTime) const timeA = relativeTime(d, tzA) const timeB = relativeTime(d, tzB) const timeDiffInMins = (timeB.getTime() - timeA.getTime()) / 1000 / 60 - return minsToOffset(timeDiffInMins) + return minsToOffset(timeDiffInMins, timeZoneToken) } diff --git a/src/parse.ts b/src/parse.ts index f862d49..1296a1e 100644 --- a/src/parse.ts +++ b/src/parse.ts @@ -107,8 +107,8 @@ export function parse( parsed.set("MM", v) } else if (t === "a" || t === "A") { a = part.value.toLowerCase() === ap("am", locale).toLowerCase() - } else if (t === "Z") { - offset = validOffset(part.value) + } else if (t === "Z" || t === "ZZ") { + offset = validOffset(part.value, t) } else { const values = range(t as FormatToken, locale, genitive) const index = values.indexOf(part.value) diff --git a/src/removeOffset.ts b/src/removeOffset.ts index aacaa3d..bf221ba 100644 --- a/src/removeOffset.ts +++ b/src/removeOffset.ts @@ -4,9 +4,9 @@ import type { DateInput } from "./types" /** * Inverts the offset and applies it to the given date, returning a new date. * @param dateInput - The date to remove the offset from. - * @param offset - The offset to remove in the +-HHmm format. + * @param offset - The offset to remove in the +-HHmm or +-HH:mm format. */ -export function removeOffset(dateInput: DateInput, offset = "+0000"): Date { +export function removeOffset(dateInput: DateInput, offset = "+00:00"): Date { const positive = offset.slice(0, 1) === "+" return applyOffset( dateInput,