From 5a85843e3f984d6b01aaaef7dbaa8a943f9113ca Mon Sep 17 00:00:00 2001 From: lovebuizel Date: Tue, 30 Sep 2025 12:00:55 +0800 Subject: [PATCH] fix: timezone-matcher-support - Add matchersWithTimeZone utility for timezone-aware date conversions - Integrate timezone conversion for disabled, hidden, and custom modifiers - Ensure consistent date comparisons across different timezones --- src/DayPicker.tsx | 31 +++ src/utils/index.ts | 1 + src/utils/matcherWithTimeZone.test.ts | 296 ++++++++++++++++++++++++++ src/utils/matcherWithTimeZone.ts | 108 ++++++++++ 4 files changed, 436 insertions(+) create mode 100644 src/utils/matcherWithTimeZone.test.ts create mode 100644 src/utils/matcherWithTimeZone.ts diff --git a/src/DayPicker.tsx b/src/DayPicker.tsx index 2221694cb8..cd51ea5f74 100644 --- a/src/DayPicker.tsx +++ b/src/DayPicker.tsx @@ -28,6 +28,7 @@ import { useCalendar } from "./useCalendar.js"; import { type DayPickerContext, dayPickerContext } from "./useDayPicker.js"; import { useFocus } from "./useFocus.js"; import { useSelection } from "./useSelection.js"; +import { matchersWithTimeZone } from "./utils/matcherWithTimeZone.js"; import { rangeIncludesDate } from "./utils/rangeIncludesDate.js"; import { isDateRange } from "./utils/typeguards.js"; @@ -77,6 +78,36 @@ export function DayPicker(initialProps: DayPickerProps) { : undefined, }; } + + // Convert disabled and hidden matchers to use timezone-aware dates + const dateLibInstance = new DateLib({ timeZone: props.timeZone }); + if (props.disabled && props.timeZone) { + props.disabled = matchersWithTimeZone( + props.disabled, + props.timeZone, + dateLibInstance, + ); + } + if (props.hidden && props.timeZone) { + props.hidden = matchersWithTimeZone( + props.hidden, + props.timeZone, + dateLibInstance, + ); + } + + // Convert custom modifiers to use timezone-aware dates + if (props.modifiers && props.timeZone) { + const convertedModifiers: typeof props.modifiers = {}; + for (const [key, value] of Object.entries(props.modifiers)) { + convertedModifiers[key] = matchersWithTimeZone( + value, + props.timeZone, + dateLibInstance, + ); + } + props.modifiers = convertedModifiers; + } } const { components, formatters, labels, dateLib, locale, classNames } = useMemo(() => { diff --git a/src/utils/index.ts b/src/utils/index.ts index 967c825b9d..4e36034db9 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,5 +1,6 @@ export * from "./addToRange.js"; export * from "./dateMatchModifiers.js"; +export * from "./matcherWithTimeZone.js"; export * from "./rangeContainsDayOfWeek.js"; export * from "./rangeContainsModifiers.js"; export * from "./rangeIncludesDate.js"; diff --git a/src/utils/matcherWithTimeZone.test.ts b/src/utils/matcherWithTimeZone.test.ts new file mode 100644 index 0000000000..20ca61c828 --- /dev/null +++ b/src/utils/matcherWithTimeZone.test.ts @@ -0,0 +1,296 @@ +import { TZDate } from "@date-fns/tz"; + +import { DateLib } from "../classes/DateLib.js"; +import type { + DateAfter, + DateBefore, + DateInterval, + DateRange, + DayOfWeek, + Matcher, +} from "../types/index.js"; + +import { + matchersWithTimeZone, + matcherWithTimeZone, +} from "./matcherWithTimeZone.js"; + +const testDate = new Date(2023, 5, 15, 12, 0, 0); // June 15, 2023, 12:00 +const timeZone = "America/New_York"; +const dateLib = new DateLib({ timeZone }); + +describe("matcherWithTimeZone", () => { + describe("when matcher is a boolean", () => { + test("should return the boolean unchanged", () => { + expect(matcherWithTimeZone(true, timeZone, dateLib)).toBe(true); + expect(matcherWithTimeZone(false, timeZone, dateLib)).toBe(false); + }); + }); + + describe("when matcher is a function", () => { + test("should return the function unchanged", () => { + const matcher = () => true; + expect(matcherWithTimeZone(matcher, timeZone, dateLib)).toBe(matcher); + }); + }); + + describe("when matcher is a single Date", () => { + test("should convert to TZDate with specified timezone", () => { + const result = matcherWithTimeZone(testDate, timeZone, dateLib); + expect(result).toBeInstanceOf(TZDate); + expect((result as TZDate).withTimeZone(timeZone).getTime()).toBe( + testDate.getTime(), + ); + }); + }); + + describe("when matcher is an array of Dates", () => { + test("should convert all dates to TZDate with specified timezone", () => { + const dates = [testDate, new Date(2023, 5, 16), new Date(2023, 5, 17)]; + const result = matcherWithTimeZone(dates, timeZone, dateLib) as TZDate[]; + + expect(Array.isArray(result)).toBe(true); + expect(result).toHaveLength(3); + result.forEach((date, index) => { + expect(date).toBeInstanceOf(TZDate); + expect(date.withTimeZone(timeZone).getTime()).toBe( + dates[index].getTime(), + ); + }); + }); + }); + + describe("when matcher is a DateRange", () => { + test("should convert both from and to dates to TZDate", () => { + const dateRange: DateRange = { + from: testDate, + to: new Date(2023, 5, 20), + }; + const result = matcherWithTimeZone( + dateRange, + timeZone, + dateLib, + ) as DateRange; + + expect(result.from).toBeInstanceOf(TZDate); + expect(result.to).toBeInstanceOf(TZDate); + expect((result.from as TZDate).withTimeZone(timeZone).getTime()).toBe( + dateRange.from?.getTime(), + ); + expect((result.to as TZDate).withTimeZone(timeZone).getTime()).toBe( + dateRange.to?.getTime(), + ); + }); + + test("should handle undefined from and to dates", () => { + const dateRange: DateRange = { + from: testDate, + to: undefined, + }; + const result = matcherWithTimeZone( + dateRange, + timeZone, + dateLib, + ) as DateRange; + + expect(result.from).toBeInstanceOf(TZDate); + expect(result.to).toBeUndefined(); + }); + + test("should handle completely undefined DateRange", () => { + const dateRange: DateRange = { + from: undefined, + to: undefined, + }; + const result = matcherWithTimeZone( + dateRange, + timeZone, + dateLib, + ) as DateRange; + + expect(result.from).toBeUndefined(); + expect(result.to).toBeUndefined(); + }); + }); + + describe("when matcher is a DateBefore", () => { + test("should convert before date to TZDate", () => { + const dateBefore: DateBefore = { + before: testDate, + }; + const result = matcherWithTimeZone( + dateBefore, + timeZone, + dateLib, + ) as DateBefore; + + expect(result.before).toBeInstanceOf(TZDate); + expect((result.before as TZDate).withTimeZone(timeZone).getTime()).toBe( + dateBefore.before.getTime(), + ); + }); + }); + + describe("when matcher is a DateAfter", () => { + test("should convert after date to TZDate", () => { + const dateAfter: DateAfter = { + after: testDate, + }; + const result = matcherWithTimeZone( + dateAfter, + timeZone, + dateLib, + ) as DateAfter; + + expect(result.after).toBeInstanceOf(TZDate); + expect((result.after as TZDate).withTimeZone(timeZone).getTime()).toBe( + dateAfter.after.getTime(), + ); + }); + }); + + describe("when matcher is a DateInterval", () => { + test("should convert both before and after dates to TZDate", () => { + const dateInterval: DateInterval = { + before: new Date(2023, 5, 20), // June 20, 2023 + after: new Date(2023, 5, 10), // June 10, 2023 (after should be before "before" for open interval) + }; + + const result = matcherWithTimeZone( + dateInterval, + timeZone, + dateLib, + ) as DateInterval; + + expect(result.before).toBeInstanceOf(TZDate); + expect(result.after).toBeInstanceOf(TZDate); + expect((result.before as TZDate).withTimeZone(timeZone).getTime()).toBe( + dateInterval.before.getTime(), + ); + expect((result.after as TZDate).withTimeZone(timeZone).getTime()).toBe( + dateInterval.after.getTime(), + ); + }); + }); + + describe("when matcher is a DayOfWeek", () => { + test("should return DayOfWeek unchanged", () => { + const dayOfWeek: DayOfWeek = { + dayOfWeek: [0, 6], // Sunday and Saturday + }; + const result = matcherWithTimeZone(dayOfWeek, timeZone, dateLib); + + expect(result).toBe(dayOfWeek); + }); + }); + + describe("when matcher is an unknown type", () => { + test("should return the matcher unchanged", () => { + const unknownMatcher = { unknown: "property" }; + const result = matcherWithTimeZone( + unknownMatcher as unknown as Matcher, + timeZone, + dateLib, + ); + + expect(result).toBe(unknownMatcher); + }); + }); +}); + +describe("matchersWithTimeZone", () => { + describe("when matchers is undefined", () => { + test("should return undefined", () => { + const result = matchersWithTimeZone(undefined, timeZone, dateLib); + expect(result).toBeUndefined(); + }); + }); + + describe("when matchers is a single matcher", () => { + test("should convert single date matcher", () => { + const result = matchersWithTimeZone(testDate, timeZone, dateLib); + expect(result).toBeInstanceOf(TZDate); + }); + + test("should convert single DateRange matcher", () => { + const dateRange: DateRange = { + from: testDate, + to: new Date(2023, 5, 20), + }; + const result = matchersWithTimeZone( + dateRange, + timeZone, + dateLib, + ) as DateRange; + + expect(result.from).toBeInstanceOf(TZDate); + expect(result.to).toBeInstanceOf(TZDate); + }); + }); + + describe("when matchers is an array", () => { + test("should convert all matchers in array", () => { + const matchers: Matcher[] = [ + testDate, + { before: new Date(2023, 5, 20) }, + { after: new Date(2023, 5, 10) }, + true, + ]; + const result = matchersWithTimeZone( + matchers, + timeZone, + dateLib, + ) as Matcher[]; + + expect(Array.isArray(result)).toBe(true); + expect(result).toHaveLength(4); + + // First matcher (Date) should be converted to TZDate + expect(result[0]).toBeInstanceOf(TZDate); + + // Second matcher (DateBefore) should have before converted to TZDate + expect((result[1] as DateBefore).before).toBeInstanceOf(TZDate); + + // Third matcher (DateAfter) should have after converted to TZDate + expect((result[2] as DateAfter).after).toBeInstanceOf(TZDate); + + // Fourth matcher (boolean) should remain unchanged + expect(result[3]).toBe(true); + }); + + test("should handle empty array", () => { + const result = matchersWithTimeZone([], timeZone, dateLib); + expect(result).toEqual([]); + }); + }); + + describe("timezone handling", () => { + test("should work with different timezones", () => { + const pacificTimeZone = "America/Los_Angeles"; + const result = matchersWithTimeZone( + testDate, + pacificTimeZone, + new DateLib({ timeZone: pacificTimeZone }), + ); + + expect(result).toBeInstanceOf(TZDate); + expect((result as TZDate).withTimeZone(pacificTimeZone).getTime()).toBe( + testDate.getTime(), + ); + }); + + test("should work with UTC timezone", () => { + const utcTimeZone = "UTC"; + const result = matchersWithTimeZone( + testDate, + utcTimeZone, + new DateLib({ timeZone: utcTimeZone }), + ); + + expect(result).toBeInstanceOf(TZDate); + expect((result as TZDate).withTimeZone(utcTimeZone).getTime()).toBe( + testDate.getTime(), + ); + }); + }); +}); diff --git a/src/utils/matcherWithTimeZone.ts b/src/utils/matcherWithTimeZone.ts new file mode 100644 index 0000000000..c0f1c208c2 --- /dev/null +++ b/src/utils/matcherWithTimeZone.ts @@ -0,0 +1,108 @@ +import { TZDate } from "@date-fns/tz"; +import type { DateLib } from "../classes/DateLib.js"; +import type { Matcher } from "../types/shared.js"; +import { + isDateAfterType, + isDateBeforeType, + isDateInterval, + isDateRange, + isDatesArray, + isDayOfWeekType, +} from "./typeguards.js"; + +/** + * Returns a matcher with time zone aware dates when a timeZone is specified. + * This ensures consistent date comparisons when using different time zones. + * + * @param matcher - The matcher to convert + * @param timeZone - The time zone to convert dates to + * @param dateLib - The date library instance + * @returns The matcher with time zone aware dates + * @group Utilities + */ +export function matcherWithTimeZone( + matcher: Matcher, + timeZone: string, + dateLib: DateLib, +): Matcher { + // Return matcher as-is for non-date types + if (typeof matcher === "boolean" || typeof matcher === "function") { + return matcher; + } + + // Convert single Date + if (dateLib.isDate(matcher)) { + return new TZDate(matcher, timeZone); + } + + // Convert array of Dates + if (isDatesArray(matcher, dateLib)) { + return matcher.map((date) => new TZDate(date, timeZone)); + } + + // Convert DateRange + if (isDateRange(matcher)) { + return { + from: matcher.from ? new TZDate(matcher.from, timeZone) : undefined, + to: matcher.to ? new TZDate(matcher.to, timeZone) : undefined, + }; + } + + // Convert DateInterval (must be checked before DateBefore/DateAfter since it has both properties) + if (isDateInterval(matcher)) { + return { + before: new TZDate(matcher.before, timeZone), + after: new TZDate(matcher.after, timeZone), + }; + } + + // Convert DateBefore + if (isDateBeforeType(matcher)) { + return { + before: new TZDate(matcher.before, timeZone), + }; + } + + // Convert DateAfter + if (isDateAfterType(matcher)) { + return { + after: new TZDate(matcher.after, timeZone), + }; + } + + // DayOfWeek doesn't need conversion + if (isDayOfWeekType(matcher)) { + return matcher; + } + + // Fallback: return original matcher + return matcher; +} + +/** + * Returns matchers with time zone aware dates. Handles both single matchers and + * arrays of matchers. + * + * @param matchers - The matchers to convert + * @param timeZone - The time zone to convert dates to + * @param dateLib - The date library instance + * @returns The matchers with time zone aware dates + * @group Utilities + */ +export function matchersWithTimeZone( + matchers: Matcher | Matcher[] | undefined, + timeZone: string, + dateLib: DateLib, +): Matcher | Matcher[] | undefined { + if (!matchers) { + return matchers; + } + + if (Array.isArray(matchers)) { + return matchers.map((matcher) => + matcherWithTimeZone(matcher, timeZone, dateLib), + ); + } + + return matcherWithTimeZone(matchers, timeZone, dateLib); +}