From a54be220f1c9009b44ed79d0b2dd7808aa5be644 Mon Sep 17 00:00:00 2001 From: ishiko Date: Tue, 23 Sep 2025 21:37:45 -0700 Subject: [PATCH] feat: Add timezone support to Datepicker and related components --- app/page.tsx | 16 +++++ src/components/Calendar/Days.tsx | 7 ++- src/components/Datepicker.tsx | 21 +++++-- src/components/Shortcuts.tsx | 14 +++-- src/constants/shortcuts.ts | 101 +++++++++++++++++------------- src/contexts/DatepickerContext.ts | 2 + src/libs/date.ts | 56 ++++++++++++++++- src/types/index.ts | 1 + 8 files changed, 156 insertions(+), 62 deletions(-) diff --git a/app/page.tsx b/app/page.tsx index cdcaef8..7393072 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -30,6 +30,7 @@ export default function Playground() { const [placeholder, setPlaceholder] = useState(""); const [separator, setSeparator] = useState("~"); const [i18n, setI18n] = useState("en"); + const [timezone, setTimezone] = useState(Intl.DateTimeFormat().resolvedOptions().timeZone); const [disabled, setDisabled] = useState(false); const [inputClassName, setInputClassName] = useState(""); const [containerClassName, setContainerClassName] = useState(""); @@ -112,6 +113,7 @@ export default function Playground() { separator={separator} startFrom={dateIsValid(new Date(startFrom)) ? new Date(startFrom) : null} i18n={i18n} + timezone={timezone} disabled={disabled} inputClassName={inputClassName} containerClassName={containerClassName} @@ -361,6 +363,20 @@ export default function Playground() { ))} + +
+ + { + setTimezone(e.target.value); + }} + /> +
diff --git a/src/components/Calendar/Days.tsx b/src/components/Calendar/Days.tsx index 10a0dbe..a7dfd9e 100644 --- a/src/components/Calendar/Days.tsx +++ b/src/components/Calendar/Days.tsx @@ -38,17 +38,18 @@ const Days = (props: Props) => { changeDayHover, minDate, maxDate, - disabledDates + disabledDates, + timezone } = useContext(DatepickerContext); // Functions const currentDateClass = useCallback( (day: Date) => { - if (isCurrentDay(day)) + if (isCurrentDay(day, timezone)) return TEXT_COLOR["500"][primaryColor as keyof (typeof TEXT_COLOR)["500"]]; return ""; }, - [primaryColor] + [primaryColor, timezone] ); const activeDateData = useCallback( diff --git a/src/components/Datepicker.tsx b/src/components/Datepicker.tsx index 5f92330..fd0bc3d 100644 --- a/src/components/Datepicker.tsx +++ b/src/components/Datepicker.tsx @@ -24,6 +24,7 @@ import { dateUpdateMonth, dateUpdateYear, firstDayOfMonth, + isValidTimezone, nextMonthBy, previousMonthBy } from "../libs/date"; @@ -74,7 +75,8 @@ const Datepicker = (props: DatepickerType) => { toggleIcon = undefined, useRange = true, - value = null + value = null, + timezone = undefined } = props; // Refs @@ -224,10 +226,10 @@ const Datepicker = (props: DatepickerType) => { }); setInputText( - `${dateFormat(value.startDate, displayFormat, i18n)}${ + `${dateFormat(value.startDate, displayFormat, i18n, timezone)}${ asSingle ? "" - : ` ${separator} ${dateFormat(value.endDate, displayFormat, i18n)}` + : ` ${separator} ${dateFormat(value.endDate, displayFormat, i18n, timezone)}` }` ); } @@ -241,7 +243,7 @@ const Datepicker = (props: DatepickerType) => { setInputText(""); } - }, [asSingle, value, displayFormat, separator, i18n]); + }, [asSingle, value, displayFormat, separator, i18n, timezone]); useEffect(() => { if (startFrom && dateIsValid(startFrom)) { @@ -313,6 +315,11 @@ const Datepicker = (props: DatepickerType) => { /* eslint-enable */ } + if (timezone && timezone.trim() !== "" && !isValidTimezone(timezone)) { + /* eslint-disable-next-line no-console */ + console.error(`timezone (${timezone}) is invalid`); + } + return { arrowContainer: arrowRef, asSingle, @@ -352,7 +359,8 @@ const Datepicker = (props: DatepickerType) => { toggleClassName, toggleIcon, updateFirstDate: (newDate: Date) => firstGotoDate(newDate), - value + value, + timezone }; }, [ minDate, @@ -386,7 +394,8 @@ const Datepicker = (props: DatepickerType) => { toggleClassName, toggleIcon, value, - firstGotoDate + firstGotoDate, + timezone ]); const containerClassNameOverload = useMemo(() => { diff --git a/src/components/Shortcuts.tsx b/src/components/Shortcuts.tsx index 5fbeea2..775e00f 100644 --- a/src/components/Shortcuts.tsx +++ b/src/components/Shortcuts.tsx @@ -1,7 +1,7 @@ import { memo, ReactNode, useCallback, useContext, useMemo } from "react"; import { TEXT_COLOR } from "../constants"; -import DEFAULT_SHORTCUTS from "../constants/shortcuts"; +import { getDefaultShortcuts } from "../constants/shortcuts"; import DatepickerContext from "../contexts/DatepickerContext"; import { dateIsSameOrBefore } from "../libs/date"; import { Period, ShortcutsItem } from "../types"; @@ -88,20 +88,22 @@ ItemTemplate.displayName = "ItemTemplate"; const Shortcuts = () => { // Contexts - const { configs } = useContext(DatepickerContext); + const { configs, timezone } = useContext(DatepickerContext); const callPastFunction = useCallback((data: unknown, numberValue: number) => { return typeof data === "function" ? data(numberValue) : null; }, []); const shortcutOptions = useMemo<[string, ShortcutsItem | ShortcutsItem[]][]>(() => { + const defaultShortcuts = getDefaultShortcuts(timezone); + if (!configs?.shortcuts) { - return Object.entries(DEFAULT_SHORTCUTS); + return Object.entries(defaultShortcuts); } return Object.entries(configs.shortcuts).flatMap(([key, customConfig]) => { - if (Object.prototype.hasOwnProperty.call(DEFAULT_SHORTCUTS, key)) { - return [[key, DEFAULT_SHORTCUTS[key]]]; + if (Object.prototype.hasOwnProperty.call(defaultShortcuts, key)) { + return [[key, defaultShortcuts[key]]]; } const { text, period } = customConfig as ShortcutsItem; @@ -129,7 +131,7 @@ const Shortcuts = () => { return []; }); - }, [configs]); + }, [configs, timezone]); const printItemText = useCallback((item: ShortcutsItem) => { return item?.text ?? null; diff --git a/src/constants/shortcuts.ts b/src/constants/shortcuts.ts index 1be42a5..9799cef 100644 --- a/src/constants/shortcuts.ts +++ b/src/constants/shortcuts.ts @@ -1,57 +1,68 @@ -import { dateAdd, endDayOfMonth, firstDayOfMonth, previousMonthBy } from "../libs/date"; +import { + dateAdd, + endDayOfMonth, + firstDayOfMonth, + getCurrentDate, + previousMonthBy +} from "../libs/date"; import { ShortcutsItem } from "../types"; -const CURRENT_DATE = new Date(); - -const DEFAULT_SHORTCUTS: { +export function getDefaultShortcuts(timezone?: string): { [key in string]: ShortcutsItem | ShortcutsItem[]; -} = { - today: { - text: "Today", - period: { - start: CURRENT_DATE, - end: CURRENT_DATE - } - }, - yesterday: { - text: "Yesterday", - period: { - start: dateAdd(CURRENT_DATE, -1, "day"), - end: dateAdd(CURRENT_DATE, -1, "day") - } - }, - past: [ - { - daysNumber: 7, - text: "Last 7 days", +} { + const CURRENT_DATE = getCurrentDate(timezone); + + return { + today: { + text: "Today", period: { - start: dateAdd(CURRENT_DATE, -7, "day"), + start: CURRENT_DATE, end: CURRENT_DATE } }, - { - daysNumber: 30, - text: "Last 30 days", + yesterday: { + text: "Yesterday", period: { - start: dateAdd(CURRENT_DATE, -30, "day"), - end: CURRENT_DATE + start: dateAdd(CURRENT_DATE, -1, "day"), + end: dateAdd(CURRENT_DATE, -1, "day") + } + }, + past: [ + { + daysNumber: 7, + text: "Last 7 days", + period: { + start: dateAdd(CURRENT_DATE, -7, "day"), + end: CURRENT_DATE + } + }, + { + daysNumber: 30, + text: "Last 30 days", + period: { + start: dateAdd(CURRENT_DATE, -30, "day"), + end: CURRENT_DATE + } + } + ], + currentMonth: { + text: "This month", + period: { + start: firstDayOfMonth(CURRENT_DATE), + end: endDayOfMonth(CURRENT_DATE) + } + }, + pastMonth: { + text: "Last month", + period: { + start: firstDayOfMonth(previousMonthBy(CURRENT_DATE)), + end: endDayOfMonth(previousMonthBy(CURRENT_DATE)) } } - ], - currentMonth: { - text: "This month", - period: { - start: firstDayOfMonth(CURRENT_DATE), - end: endDayOfMonth(CURRENT_DATE) - } - }, - pastMonth: { - text: "Last month", - period: { - start: firstDayOfMonth(previousMonthBy(CURRENT_DATE)), - end: endDayOfMonth(previousMonthBy(CURRENT_DATE)) - } - } -}; + }; +} + +// Keep backward compatibility +const DEFAULT_SHORTCUTS = getDefaultShortcuts(); export default DEFAULT_SHORTCUTS; diff --git a/src/contexts/DatepickerContext.ts b/src/contexts/DatepickerContext.ts index 1110859..9226c5d 100644 --- a/src/contexts/DatepickerContext.ts +++ b/src/contexts/DatepickerContext.ts @@ -67,6 +67,7 @@ interface DatepickerStore { toggleClassName?: ((className: string) => string) | string | null; toggleIcon?: (open: boolean) => ReactNode; + timezone?: string; updateFirstDate: (date: Date) => void; @@ -120,6 +121,7 @@ const DatepickerContext = createContext({ toggleClassName: "", toggleIcon: undefined, + timezone: undefined, updateFirstDate: () => {}, diff --git a/src/libs/date.ts b/src/libs/date.ts index d5887a0..3ef4a45 100644 --- a/src/libs/date.ts +++ b/src/libs/date.ts @@ -3,6 +3,8 @@ import isBetween from "dayjs/plugin/isBetween"; import isSameOrAfter from "dayjs/plugin/isSameOrAfter"; import isSameOrBefore from "dayjs/plugin/isSameOrBefore"; import isToday from "dayjs/plugin/isToday"; +import timezone from "dayjs/plugin/timezone"; +import utc from "dayjs/plugin/utc"; import { LANGUAGE } from "../constants"; import { DateType, WeekDaysIndexType, WeekStringType } from "../types"; @@ -11,6 +13,8 @@ dayjs.extend(isBetween); dayjs.extend(isSameOrAfter); dayjs.extend(isSameOrBefore); dayjs.extend(isToday); +dayjs.extend(utc); +dayjs.extend(timezone); export function loadLanguageModule(language = LANGUAGE) { switch (language) { @@ -449,9 +453,23 @@ export function dateIsValid(date: DateType) { return dayjs(date).isValid(); } -export function isCurrentDay(date: Date) { +export function isCurrentDay(date: Date, timezone?: string) { if (!dateIsValid(date)) return false; + if (timezone) { + try { + // Format the date as YYYY-MM-DD and parse it in the target timezone + const dateStr = dayjs(date).format("YYYY-MM-DD"); + const dateInTimezone = dayjs.tz(dateStr, timezone); + const todayInTimezone = dayjs().tz(timezone); + return dateInTimezone.isSame(todayInTimezone, "day"); + } catch { + // If timezone is invalid, fall back to local timezone comparison + /* eslint-disable-next-line no-console */ + console.warn(`Invalid timezone: ${timezone}. Falling back to local timezone.`); + return dayjs(date).isSame(dayjs(), "day"); + } + } return dayjs(date).isToday(); } @@ -504,9 +522,19 @@ export function dateIsBetween( ); } -export function dateFormat(date: DateType, format: string, local = "en") { +export function dateFormat(date: DateType, format: string, local = "en", timezone?: string) { if (!dateIsValid(date)) return null; + if (timezone) { + try { + return dayjs(date).locale(local).tz(timezone).format(format); + } catch { + // If timezone is invalid, fall back to local timezone + /* eslint-disable-next-line no-console */ + console.warn(`Invalid timezone: ${timezone}. Falling back to local timezone.`); + return dayjs(date).locale(local).format(format); + } + } return dayjs(date).locale(local).format(format); } @@ -669,3 +697,27 @@ export function getNextDates(date: Date, limit: number) { return nexDates; } + +export function isValidTimezone(timezone: string): boolean { + try { + dayjs().tz(timezone); + return true; + } catch { + return false; + } +} + +export function getCurrentDate(timezone?: string) { + if (timezone) { + try { + // Get current time in the specified timezone, but set to start of day + return dayjs().tz(timezone).startOf("day").toDate(); + } catch { + // If timezone is invalid, fall back to local timezone + /* eslint-disable-next-line no-console */ + console.warn(`Invalid timezone: ${timezone}. Falling back to local timezone.`); + return new Date(); + } + } + return new Date(); +} diff --git a/src/types/index.ts b/src/types/index.ts index cac5be8..7158c72 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -92,6 +92,7 @@ export interface DatepickerType { startWeekOn?: WeekStringType; popoverDirection?: PopoverDirectionType; required?: boolean; + timezone?: string; } export type ColorKeys = (typeof COLORS)[number]; // "blue" | "orange"