diff --git a/src/components/DatePicker/DatePicker.css b/src/components/DatePicker/DatePicker.css index 1b6f3a626..39053460f 100644 --- a/src/components/DatePicker/DatePicker.css +++ b/src/components/DatePicker/DatePicker.css @@ -167,8 +167,8 @@ @apply !bg-blue !text-white; } -/* Make the start date have a 50% light blue background towards the RIGHT side */ -.date-range-picker .DayPicker-Day--start:not(.DayPicker-Day--outside) { +/* Make the start date have a 50% light blue background towards the RIGHT side when start and end is not same */ +.date-range-picker .DayPicker-Day--start:not(.DayPicker-Day--outside):not(.DayPicker-Day--end) { @apply rounded-none; background: linear-gradient(90deg, #ffffff 40%, #d1e1ff 25%); } @@ -177,18 +177,23 @@ @apply rounded-full; } -/* Make the end date have a 50% light blue background towards the LEFT side */ -.date-range-picker .DayPicker-Day--end:not(.DayPicker-Day--outside) { +/* Make the end date have a 50% light blue background towards the LEFT side when start and end is not same */ +.date-range-picker .DayPicker-Day--end:not(.DayPicker-Day--outside):not(.DayPicker-Day--start) { @apply rounded-none; background: linear-gradient(90deg, #d1e1ff 40%, #ffffff 25%); /* D1E1FF Blue lighter */ } +/* This is when start & end are the same. We shown an outline to indicate it is different */ +.date-range-picker .DayPicker-Day--start.DayPicker-Day--end:not(.DayPicker-Day--disabled) .ui-date-picker-day { + @apply outline outline-offset-1 outline-blue-lighter; +} + .date-range-picker .DayPicker-Day--end div { @apply rounded-full; } /** - * These are stylings for the Date Picker, but only when they have custom content like prices or seller messaging + * These are styles for the Date Picker, but only when they have custom content like prices or seller messaging */ .has-custom-content .DayPicker-Month { border-spacing: 1px 16px; diff --git a/src/components/DatePicker/DatePicker.jsx b/src/components/DatePicker/DatePicker.jsx index 44ff4869b..a2a21306f 100644 --- a/src/components/DatePicker/DatePicker.jsx +++ b/src/components/DatePicker/DatePicker.jsx @@ -7,6 +7,7 @@ import "react-day-picker/lib/style.css"; import "./DatePicker.css"; import { isArray, isFunction } from "lodash"; import { Tooltip } from "../.."; +import { now, isSame, toDate, isValidTimeZoneName } from "../../helpers/date"; import { Day } from "./Day"; import { MonthYearSelector } from "./MonthYearSelector"; import { RelativeDateRange } from "./RelativeDateRange"; @@ -41,7 +42,7 @@ export const DatePicker = ({ ...rest }) => { const initialValue = value ? (variant === variants.single ? value : value.from) : null; - const [currentMonth, setCurrentMonth] = useState(initialValue ?? dayjs().toDate()); + const [currentMonth, setCurrentMonth] = useState(initialValue ?? now(null, timezoneName).toDate()); const [startMonth, setStartMonth] = useState(() => { if (!value || !value.from) { return new Date(); @@ -51,11 +52,11 @@ export const DatePicker = ({ }); const [endMonth, setEndMonth] = useState(() => { if (!value || !value.to || !value.from) { - return dayjs(new Date()).add(1, "month").toDate(); + return now(null, timezoneName).add(1, "month").toDate(); } - return dayjs(value.to).isSame(dayjs(value.from), "month") - ? dayjs(value.from).add(1, "month").toDate() + return isSame(now(value.to, timezoneName), now(value.from, timezoneName), "month") + ? now(value.from, timezoneName).add(1, "month").toDate() : value.to; }); const [rangeName, setRangeName] = useState(""); @@ -67,24 +68,31 @@ export const DatePicker = ({ onMonthChange?.(currentMonth); }, [currentMonth, onMonthChange]); + useEffect(() => { + if (timezoneName && !isValidTimeZoneName(timezoneName)) { + console.log(`${timezoneName} is not a valid timezone. Using default timezone now`); + dayjs.tz.setDefault(); + } + }, [timezoneName]); + const handleTodayClick = (day, options, event) => { if (isRangeVariant) { return; } - const today = timezoneName ? dayjs().tz(timezoneName).toDate() : new Date(); + const today = timezoneName ? toDate(now(day, timezoneName)) : new Date(); if (options.disabled || isDisabled(today)) { setCurrentMonth(today); onMonthChange?.(today); } else { - onChange(day, options, event); + onChange(today, options, event); } }; const isDisabled = (date) => { if (isArray(disabledDays)) { - return disabledDays.some((_date) => dayjs(_date).isSame(date, "day")); + return disabledDays.some((_date) => isSame(now(_date, timezoneName), date, "day")); } if (isFunction(disabledDays)) { @@ -97,6 +105,7 @@ export const DatePicker = ({ const handleRelativeRangeChanged = (rangeName, range) => { setCurrentMonth(range.from); setStartMonth(range.from); + setEndMonth(range.to); onChange({ ...range, rangeName }, modifiers, null); }; @@ -120,7 +129,7 @@ export const DatePicker = ({ return; } - if (dayjs(value?.from).isSame(day, "month")) { + if (isSame(now(value?.from, timezoneName), now(day, timezoneName), "month")) { handleStartMonthChange(day); } @@ -129,25 +138,31 @@ export const DatePicker = ({ if (isValidValue) { // This allows us to easily select another date range, // if both dates are selected. - onChange({ from: day, to: null }, options, event); + onChange({ from: toDate(now(day, timezoneName).startOf("day")), to: null }, options, event); } else if (value && (value.from || value.to) && (value.from || value.to).getTime() === day.getTime()) { - const from = dayjs(day).startOf("day").toDate(); - const to = dayjs(day).endOf("day").toDate(); + const from = toDate(now(day, timezoneName).startOf("day")); + const to = toDate(now(day, timezoneName).endOf("day"), false); onChange({ from, to }, options, event); } else { - onChange(DateUtils.addDayToRange(day, value), options, event); + onChange( + DateUtils.addDayToRange(toDate(now(day, timezoneName).endOf("day"), false), value), + options, + event, + ); } } else { - onChange(day, options, event); + onChange(toDate(now(day, timezoneName)), options, event); } }; + // TODO: Should be outside this component because this returns JSX const CaptionElement = shouldShowYearPicker && currentMonth ? ({ date }) => : undefined; + // TODO: Should be outside this component because this returns JSX const renderDay = (date) => { const tooltipContent = getTooltip?.(date); const disabled = isDisabled(date); @@ -178,7 +193,7 @@ export const DatePicker = ({ // Comparing `from` and `to` dates hides a weird CSS style when you select the same date twice in a date range. const useDateRangeStyle = isRangeVariant && isValidValue && value.from?.getTime() !== value.to?.getTime(); // Return the same value if it is already dayjs object or has range variant otherwise format it to dayJs object - const selectedDays = value && (dayjs.isDayjs(value) || isRangeVariant ? value : dayjs(value).toDate()); + const selectedDays = value && (dayjs.isDayjs(value) || isRangeVariant ? value : now(value, timezoneName).toDate()); return ( <> @@ -208,6 +223,7 @@ export const DatePicker = ({ handleEndMonthChange={handleEndMonthChange} handleTodayClick={handleTodayClick} selectedDays={selectedDays} + timezoneName={timezoneName} {...rest} /> ) : ( @@ -236,14 +252,17 @@ export const DatePicker = ({ {components.Footer ? : null} - {useDateRangeStyle && shouldShowRelativeRanges && ( -
- + {shouldShowRelativeRanges && ( +
+
+ +
)} diff --git a/src/components/DatePicker/RangeDatePicker.jsx b/src/components/DatePicker/RangeDatePicker.jsx index 75a1d846f..06d83994f 100644 --- a/src/components/DatePicker/RangeDatePicker.jsx +++ b/src/components/DatePicker/RangeDatePicker.jsx @@ -2,9 +2,9 @@ import React from "react"; import PropTypes from "prop-types"; import DayPicker from "react-day-picker"; import clsx from "clsx"; -import dayjs from "dayjs"; import { isArray, isFunction } from "lodash"; import { Tooltip } from "../Tooltip"; +import { now } from "../../helpers/date"; import { NavbarElement } from "./NavbarElement"; import { MonthYearSelector } from "./MonthYearSelector"; import { Day } from "./Day"; @@ -24,10 +24,11 @@ const RangeDatePicker = ({ handleStartMonthChange, handleEndMonthChange, handleTodayClick, + timezoneName, ...rest }) => { - const isStartDateIsTheSameMonth = dayjs(value?.from).isSame(dayjs(value?.to), "month"); - const isSingleDayDateRange = dayjs(value?.from).isSame(dayjs(value.to), "day"); + const isStartDateIsTheSameMonth = now(value?.from, timezoneName).isSame(now(value?.to, timezoneName), "month"); + const isSingleDayDateRange = now(value?.from, timezoneName).isSame(now(value?.to, timezoneName), "day"); const createCaptionElement = (currentMonth, handleChange) => shouldShowYearPicker && currentMonth @@ -43,7 +44,7 @@ const RangeDatePicker = ({ } if (isArray(disabledDays)) { - return disabledDays.some((_date) => dayjs(_date).isSame(date, "day")); + return disabledDays.some((_date) => now(_date, timezoneName).isSame(date, "day")); } return false; @@ -54,7 +55,7 @@ const RangeDatePicker = ({ }; const isDisabledEndDays = (date) => { - const isDateBeforeStartDate = dayjs(date).isBefore(value?.from, "day"); + const isDateBeforeStartDate = now(date, timezoneName).isBefore(value?.from, "day"); return isDateDisabledFromOutside(date) || (isDateBeforeStartDate && !isSingleDayDateRange); }; @@ -148,6 +149,7 @@ RangeDatePicker.propTypes = { handleStartMonthChange: PropTypes.func, handleEndMonthChange: PropTypes.func, handleTodayClick: PropTypes.func, + timezoneName: PropTypes.string, }; export default RangeDatePicker; diff --git a/src/components/DatePicker/RelativeDateRange.jsx b/src/components/DatePicker/RelativeDateRange.jsx index 1fa660696..6e0d83e7a 100644 --- a/src/components/DatePicker/RelativeDateRange.jsx +++ b/src/components/DatePicker/RelativeDateRange.jsx @@ -1,6 +1,6 @@ -import dayjs from "dayjs"; import PropTypes from "prop-types"; import React from "react"; +import { now, toDate } from "../../helpers/date"; import { Button, Select } from "../.."; const options = { @@ -113,98 +113,102 @@ export const dateRanges = { }; const handlers = { - [options.YESTERDAY]: () => { - const yesterday = dayjs().subtract(1, "day"); + [options.YESTERDAY]: (timezone) => { + const yesterday = now(null, timezone).subtract(1, "day"); return { - from: yesterday.startOf("day").toDate(), - to: yesterday.endOf("day").toDate(), + from: toDate(yesterday.startOf("day")), + to: toDate(yesterday.endOf("day"), false), }; }, - [options.TODAY]: () => ({ - from: dayjs().startOf("day").toDate(), - to: dayjs().endOf("day").toDate(), - }), + [options.TODAY]: (timezone) => { + return { + from: toDate(now(null, timezone).startOf("day")), + to: toDate(now(null, timezone).endOf("day"), false), + }; + }, - [options.LAST_WEEK]: () => { - const lastWeek = dayjs().subtract(7, "day"); + [options.LAST_WEEK]: (timezone) => { + const lastWeek = now(null, timezone).subtract(7, "day"); return { - from: lastWeek.startOf("week").toDate(), - to: lastWeek.endOf("week").toDate(), + from: toDate(lastWeek.startOf("week")), + to: toDate(lastWeek.endOf("week"), false), }; }, - [options.TRAILING_WEEK]: () => { + [options.TRAILING_WEEK]: (timezone) => { return { - from: dayjs().subtract(7, "day").startOf("dat").toDate(), - to: dayjs().subtract(1, "day").endOf("day").toDate(), + from: toDate(now(null, timezone).subtract(7, "day").startOf("day")), + to: toDate(now(null, timezone).subtract(1, "day").endOf("day"), false), }; }, - [options.THIS_WEEK]: () => ({ - from: dayjs().startOf("week").toDate(), - to: dayjs().endOf("week").toDate(), - }), + [options.THIS_WEEK]: (timezone) => { + return { + from: toDate(now(null, timezone).startOf("week")), + to: toDate(now(null, timezone).endOf("week"), false), + }; + }, - [options.LAST_MONTH]: () => { - const lastMonth = dayjs().subtract(1, "month"); + [options.LAST_MONTH]: (timezone) => { + const lastMonth = now(null, timezone).subtract(1, "month"); return { - from: lastMonth.startOf("month").toDate(), - to: lastMonth.endOf("month").toDate(), + from: toDate(lastMonth.startOf("month")), + to: toDate(lastMonth.endOf("month"), false), }; }, - [options.TRAILING_MONTH]: () => { + [options.TRAILING_MONTH]: (timezone) => { return { - from: dayjs().subtract(1, "month").startOf("day").toDate(), - to: dayjs().subtract(1, "day").endOf("day").toDate(), + from: toDate(now(null, timezone).subtract(1, "month").startOf("day")), + to: toDate(now(null, timezone).subtract(1, "day").endOf("day"), false), }; }, - [options.THIS_MONTH]: () => ({ - from: dayjs().startOf("month").toDate(), - to: dayjs().endOf("month").toDate(), + [options.THIS_MONTH]: (timezone) => ({ + from: toDate(now(null, timezone).startOf("month")), + to: toDate(now(null, timezone).endOf("month"), false), }), - [options.LAST_QUARTER]: () => { + [options.LAST_QUARTER]: (timezone) => { return { - from: dayjs().startOf("month").subtract(3, "month").toDate(), - to: dayjs().startOf("month").subtract(1, "day").toDate(), + from: toDate(now(null, timezone).startOf("quarter").subtract(3, "month").startOf("month")), + to: toDate(now(null, timezone).endOf("quarter").subtract(3, "month").endOf("month"), false), }; }, - [options.TRAILING_QUARTER]: () => { + [options.TRAILING_QUARTER]: (timezone) => { return { - from: dayjs().subtract(3, "month").startOf("day").toDate(), - to: dayjs().subtract(1, "day").endOf("day").toDate(), + from: toDate(now(null, timezone).subtract(3, "month").startOf("day")), + to: toDate(now(null, timezone).subtract(1, "day").endOf("day"), false), }; }, - [options.THIS_QUARTER]: () => { + [options.THIS_QUARTER]: (timezone) => { return { - from: dayjs().startOf("Q").toDate(), - to: dayjs().endOf("Q").toDate(), + from: toDate(now(null, timezone).startOf("Q")), + to: toDate(now(null, timezone).endOf("Q"), false), }; }, - [options.LAST_YEAR]: () => { - const lastYear = dayjs().subtract(1, "year"); + [options.LAST_YEAR]: (timezone) => { + const lastYear = now(null, timezone).subtract(1, "year"); return { - from: lastYear.startOf("year").toDate(), - to: lastYear.endOf("year").toDate(), + from: toDate(lastYear.startOf("year")), + to: toDate(lastYear.endOf("year"), false), }; }, - [options.TRAILING_YEAR]: () => { + [options.TRAILING_YEAR]: (timezone) => { return { - from: dayjs().subtract(1, "year").startOf("day").toDate(), - to: dayjs().subtract(1, "day").endOf("day").toDate(), + from: toDate(now(null, timezone).subtract(1, "year").startOf("day")), + to: toDate(now(null, timezone).subtract(1, "day").endOf("day"), false), }; }, - [options.THIS_YEAR]: () => ({ - from: dayjs().startOf("year").toDate(), - to: dayjs().endOf("year").toDate(), + [options.THIS_YEAR]: (timezone) => ({ + from: toDate(now(null, timezone).startOf("year")), + to: toDate(now(null, timezone).endOf("year"), false), }), }; @@ -215,10 +219,11 @@ export const RelativeDateRange = ({ showApply = true, onChange, onSubmit, + timezoneName, }) => { const handleChange = (e) => { const rangeName = e.target.value; - const range = handlers[rangeName](); + const range = handlers[rangeName](timezoneName); onChange(rangeName, range); }; @@ -252,4 +257,5 @@ RelativeDateRange.propTypes = { // eslint-disable-next-line react/boolean-prop-naming showApply: PropTypes.bool, value: PropTypes.string, + timezoneName: PropTypes.string, }; diff --git a/src/helpers/date.js b/src/helpers/date.js index 06cb79a0d..1cf1fe063 100644 --- a/src/helpers/date.js +++ b/src/helpers/date.js @@ -1,13 +1,31 @@ -import dayjs from "dayjs"; +import dayjs, { isDayjs } from "dayjs"; import LocalizedFormat from "dayjs/plugin/localizedFormat"; import customParseFormat from "dayjs/plugin/customParseFormat"; import quarterOfYear from "dayjs/plugin/quarterOfYear"; +import utc from "dayjs/plugin/utc"; +import timezone from "dayjs/plugin/timezone"; dayjs.extend(customParseFormat); dayjs.extend(LocalizedFormat); dayjs.extend(quarterOfYear); +dayjs.extend(utc); +dayjs.extend(timezone); -export const formatDate = (date, format = "YYYY-MM-DD") => { +export const DateFormat = { + DATE_ISO: "YYYY-MM-DD", +}; + +export const isValidTimeZoneName = (timezoneName) => { + try { + dayjs.tz(new Date(), timezoneName); + } catch { + return false; + } + + return true; +}; + +export const formatDate = (date, format = DateFormat.DATE_ISO) => { return dayjs(date).format(format); }; @@ -19,3 +37,58 @@ export const formatTime = (time, format = "h:mm a") => { export const dateFromObjectId = (id) => { return dayjs(new Date(Number.parseInt(id.slice(0, 8), 16) * 1000)); }; + +export const now = (date, timezone) => { + if (!date) { + return timezone ? dayjs().tz(timezone).startOf("day") : dayjs(); + } + + if (typeof date === "number") { + const timestamp = date <= 2_147_483_647 ? date * 1000 : date; + return timezone ? dayjs(timestamp).tz(timezone) : dayjs(timestamp); + } + + if (typeof date === "string") { + return timezone ? dayjs(date).tz(timezone) : dayjs(); + } + + if (isDayjs(date)) { + // We do this late because under some conditions this is expensive (see: X2-9122) + return date; + } + + if (date instanceof Date && !Number.isNaN(date.getTime())) { + return timezone ? dayjs.tz(dateToString(date), timezone) : dayjs(dateToString(date)); + } + + return timezone ? dayjs().tz(timezone) : dayjs(); +}; + +const padNumber = (value) => value.toString().padStart(2, "0"); + +export const dateToString = (date) => { + const dateString = `${date.getFullYear()}-${padNumber(date.getMonth() + 1)}-${padNumber(date.getDate())}`; + const timeString = `${padNumber(date.getHours())}:${padNumber(date.getMinutes())}:${padNumber(date.getSeconds())}`; + return `${dateString} ${timeString}`; +}; + +const DayTimeStart = "T00:00:00"; +const DayTimeEnd = "T23:59:59"; + +export const toDate = (date, isStartDate = true) => { + const suffix = isStartDate ? DayTimeStart : DayTimeEnd; + + if (isDayjs(date)) { + return new Date(date.format(DateFormat.DATE_ISO) + suffix); + } + + return new Date(formatDate(date) + suffix); +}; + +export const isSame = (date1, date2, unit = "day") => { + if (isDayjs(date1) && isDayjs(date2)) { + return date1.isSame(date2, unit); + } + + return false; +}; diff --git a/src/stories/DataDisplay/DateRangePicker.stories.js b/src/stories/DataDisplay/DateRangePicker.stories.js index 7017e8558..f77a1e8f3 100644 --- a/src/stories/DataDisplay/DateRangePicker.stories.js +++ b/src/stories/DataDisplay/DateRangePicker.stories.js @@ -35,7 +35,10 @@ const DateRangePickerStories = { }, }; -const today = dayjs("2022-10-10").toDate(); +const today = dayjs.tz("2022-10-10").toDate(); +const handleSubmitDateRange = (e) => { + console.log("handleSubmitDateRange", { event: e }); +} export const Default = () => { const [value, setValue] = useState({ from: new Date("2022-02-03"), to: new Date("2022-03-08") }); @@ -54,7 +57,25 @@ export const RelativeDateRanges = () => { value={value} variant="range" onChange={setValue} - onSubmitDateRange={console.log} + onSubmitDateRange={handleSubmitDateRange} + /> +
+ ); +}; + +export const RelativeDateRangesWithTimeZone = () => { + const [value, setValue] = useState({ from: new Date("2022-03-03"), to: new Date("2022-04-08") }); + + return ( +
+
);