Refactored Date to Temporal in critical date zones (#16544)
Fixes https://github.com/twentyhq/twenty/issues/16110 This PR implements Temporal to replace the legacy Date object, in all features that are time zone sensitive. (around 80% of the app) Here we define a few utils to handle Temporal primitives and obtain an easier DX for timezone manipulation, front end and back end. This PR deactivates the usage of timezone from the graph configuration, because for now it's always UTC and is not really relevant, let's handle that later. Workflows code and backend only code that don't take user input are using UTC time zone, the affected utils have not been refactored yet because this PR is big enough. # New way of filtering on date intervals As we'll progressively rollup Temporal everywhere in the codebase and remove `Date` JS object everywhere possible, we'll use the way to filter that is recommended by Temporal. This way of filtering on date intervals involves half-open intervals, and is the preferred way to avoid edge-cases with DST and smallest time increment edge-case. ## Filtering endOfX with DST edge-cases Some day-light save time shifts involve having no existing hour, or even day on certain days, for example Samoa Islands have no 30th of December 2011 : https://www.timeanddate.com/news/time/samoa-dateline.html, it jumps from 29th to 31st, so filtering on `< next period start` makes it easier to let the date library handle the strict inferior comparison, than filtering on `≤ end of period` and trying to compute manually the end of the period. For example for Samoa Islands, is end of day `2011-12-29T23:59:59.999` or is it `2011-12-30T23:59:59.999` ? If you say I don't need to know and compute it, because I want everything strictly before `2011-12-29T00:00:00 + start of next day (according to the library which knows those edge-cases)`, then you have a 100% deterministic way of computing date intervals in any timezone, for any day of any year. Of course the Samoa example is an extreme one, but more common ones involve DST shifts of 1 hour, which are still problematic on certain days of the year. ## Computing the exact _end of period_ Having an open interval filtering, with `[included - included]` instead of half-open `[included - excluded)`, forces to compute the open end of an interval, which often involves taking an arbitrary unit like minute, second, microsecond or nanosecond, which will lead to edge-case of unhandled values. For example, let's say my code computes endOfDay by setting the time to `23:59:59.999`, if another library, API, or anything else, ends up giving me a date-time with another time precision `23:59:59.999999999` (down to the nanosecond), then this date-time will be filtered out, while it should not. The good deterministic way to avoid 100% of those complex bugs is to create a half-open filter : `≥ start of period` to `< start of next period` For example : `≥ 2025-01-01T00:00:00` to `< 2025-01-02T00:00:00` instead of `≥ 2025-01-01T00:00:00` to `≤ 2025-01-01T23:59:59.999` Because, `2025-01-01T00:00:00` = `2025-01-01T00:00:00.000` = `2025-01-01T00:00:00.000000` = `2025-01-01T00:00:00.000000000` => no risk of error in computing start of period But `2025-01-01T23:59:59` ≠ `2025-01-01T23:59:59.999` ≠ `2025-01-01T23:59:59.999999` ≠ `2025-01-01T23:59:59.999999999` => existing risk of error in computing end of period This is why an half-open interval has no risk of error in computing a date-time interval filter. Here is a link to this debate : https://github.com/tc39/proposal-temporal/issues/2568 > For this reason, we recommend not calculating the exact nanosecond at the end of the day if it's not absolutely necessary. For example, if it's needed for <= comparisons, we recommend just changing the comparison code. So instead of <= zdtEndOfDay your code could be < zdtStartOfNextDay which is easier to calculate and not subject to the issue of not knowing which unit is the right one. > > [Justin Grant](https://github.com/justingrant), top contributor of Temporal ## Application to our codebase Applying this half-open filtering paradigm to our codebase means we would have to rename `IS_AFTER` to `IS_AFTER_OR_EQUAL` and to keep `IS_BEFORE` (or even `IS_STRICTLY_BEFORE`) to make this half-open interval self-explanatory everywhere in the codebase, this will avoid any confusion. See the relevant issue : https://github.com/twentyhq/core-team-issues/issues/2010 In the mean time, we'll keep this operand and add this semantic in the naming everywhere possible. ## Example with a different user timezone Example on a graph grouped by week in timezone Pacific/Samoa, on a computer running on Europe/Paris : <img width="342" height="511" alt="image" src="https://github.com/user-attachments/assets/9e7d5121-ecc4-4233-835b-f59293fbd8c8" /> Then the associated data in the table view, with our **half-open date-time filter** : <img width="804" height="262" alt="image" src="https://github.com/user-attachments/assets/28efe1d7-d2fc-4aec-b521-bada7f980447" /> And the associated SQL query result to see how DATE_TRUNC in Postgres applies its internal start of week logic : <img width="709" height="220" alt="image" src="https://github.com/user-attachments/assets/4d0542e1-eaae-4b4b-afa9-5005f48ffdca" /> The associated SQL query without parameters to test in your SQL client : ```SQL SELECT "opportunity"."closeDate" as "close_date", TO_CHAR(DATE_TRUNC('week', "opportunity"."closeDate", 'Pacific/Samoa') AT TIME ZONE 'Pacific/Samoa', 'YYYY-MM-DD') AS "DATE_TRUNC by week start in timezone Pacific/Samoa", "opportunity"."name" FROM "workspace_1wgvd1injqtife6y4rvfbu3h5"."opportunity" "opportunity" ORDER BY "opportunity"."closeDate" ASC NULLS LAST ``` # Date picker simplification (not in this PR) Our DatePicker component, which is wrapping `react-datepicker` library component, is now exposing plain dates as string instead of Date object. The Date object is still used internally to manage the library component, but since the date picker calendar is only manipulating plain dates, there is no need to add timezone management to it, and no need to expose a handleChange with Date object. The timezone management relies on date time inputs now. The modification has been made in a previous PR : https://github.com/twentyhq/twenty/issues/15377 but it's good to reference it here. # Calendar feature refactor Calendar feature has been refactored to rely on Temporal.PlainDate as much as possible, while leaving some date-fns utils to avoid re-coding them. Since the trick is to use utils to convert back and from Date object in exec env reliably, we can do it everywhere we need to interface legacy Date object utils and Temporal related code. ## TimeZone is now shown on Calendar : <img width="894" height="958" alt="image" src="https://github.com/user-attachments/assets/231f8107-fad6-4786-b532-456692c20f1d" /> ## Month picker has been refactored <img width="503" height="266" alt="image" src="https://github.com/user-attachments/assets/cb90bc34-6c4d-436d-93bc-4b6fb00de7f5" /> Since the days weren't useful, the picker has been refactored to remove the days. # Miscellaneous - Fixed a bug with drag and drop edge-case with 2 items in a list. # Improvements ## Lots of chained operations It would be nice to create small utils to avoid repeated chained operations, but that is how Temporal is designed, a very small set of primitive operations that allow to compose everything needed. Maybe we'll have wrappers on top of Temporal in the coming years. ## Creation of Temporal objects is throwing errors If the input is badly formatted Temporal will throw, we might want to adopt a global strategy to avoid that. Example : ```ts const newPlainDate = Temporal.PlainDate.from('bad-string'); // Will throw ```
This commit is contained in:
+47
-39
@@ -5,7 +5,7 @@ import Skeleton, { SkeletonTheme } from 'react-loading-skeleton';
|
||||
|
||||
import { SKELETON_LOADER_HEIGHT_SIZES } from '@/activities/components/SkeletonLoader';
|
||||
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
|
||||
import { CalendarStartDay } from 'twenty-shared';
|
||||
import { CalendarStartDay } from 'twenty-shared/constants';
|
||||
|
||||
import { detectCalendarStartDay } from '@/localization/utils/detection/detectCalendarStartDay';
|
||||
import { DatePickerHeader } from '@/ui/input/components/internal/date/components/DatePickerHeader';
|
||||
@@ -14,15 +14,15 @@ import { getHighlightedDates } from '@/ui/input/components/internal/date/utils/g
|
||||
import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown';
|
||||
import { useTheme } from '@emotion/react';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { addMonths, setMonth, setYear, subMonths } from 'date-fns';
|
||||
import 'react-datepicker/dist/react-datepicker.css';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
|
||||
import { useUserTimezone } from '@/ui/input/components/internal/date/hooks/useUserTimezone';
|
||||
import { Temporal } from 'temporal-polyfill';
|
||||
import { type Nullable } from 'twenty-shared/types';
|
||||
import {
|
||||
getDateFromPlainDate,
|
||||
getPlainDateFromDate,
|
||||
isDefined,
|
||||
turnJSDateToPlainDate,
|
||||
type RelativeDateFilter,
|
||||
} from 'twenty-shared/utils';
|
||||
import { IconCalendarX } from 'twenty-ui/display';
|
||||
@@ -305,7 +305,7 @@ type DatePickerProps = {
|
||||
instanceId: string;
|
||||
isRelative?: boolean;
|
||||
hideHeaderInput?: boolean;
|
||||
date: Nullable<string>;
|
||||
plainDateString: Nullable<string>;
|
||||
relativeDate?: RelativeDateFilter & {
|
||||
start: string;
|
||||
end: string;
|
||||
@@ -320,6 +320,7 @@ type DatePickerProps = {
|
||||
onEscape?: (date: string | null) => void;
|
||||
keyboardEventsDisabled?: boolean;
|
||||
onClear?: () => void;
|
||||
hideCalendar?: boolean;
|
||||
};
|
||||
|
||||
type DatePickerPropsType = ReactDatePickerLibProps<
|
||||
@@ -335,7 +336,7 @@ const ReactDatePicker = lazy<ComponentType<DatePickerPropsType>>(() =>
|
||||
|
||||
export const DatePicker = ({
|
||||
instanceId,
|
||||
date,
|
||||
plainDateString,
|
||||
onChange,
|
||||
onClose,
|
||||
clearable = true,
|
||||
@@ -345,8 +346,11 @@ export const DatePicker = ({
|
||||
onRelativeDateChange,
|
||||
hideHeaderInput,
|
||||
}: DatePickerProps) => {
|
||||
const dateOrToday = date ?? getPlainDateFromDate(new Date());
|
||||
const shiftedDateForReactPicker = getDateFromPlainDate(dateOrToday);
|
||||
const plainDate = isDefined(plainDateString)
|
||||
? Temporal.PlainDate.from(plainDateString)
|
||||
: Temporal.Now.plainDateISO();
|
||||
|
||||
const { userTimezone } = useUserTimezone();
|
||||
|
||||
const theme = useTheme();
|
||||
|
||||
@@ -369,61 +373,54 @@ export const DatePicker = ({
|
||||
};
|
||||
|
||||
const handleChangeMonth = (month: number) => {
|
||||
const newDate = setMonth(shiftedDateForReactPicker, month);
|
||||
const newDate = plainDate?.with({ month: month });
|
||||
|
||||
const plainDate = getPlainDateFromDate(newDate);
|
||||
|
||||
onChange?.(plainDate);
|
||||
onChange?.(newDate?.toString() ?? null);
|
||||
};
|
||||
|
||||
const handleAddMonth = () => {
|
||||
const dateParsed = addMonths(shiftedDateForReactPicker, 1);
|
||||
const newDate = plainDate?.add({ months: 1 });
|
||||
|
||||
const plainDate = getPlainDateFromDate(dateParsed);
|
||||
|
||||
onChange?.(plainDate);
|
||||
onChange?.(newDate?.toString() ?? null);
|
||||
};
|
||||
|
||||
const handleSubtractMonth = () => {
|
||||
const dateParsed = subMonths(shiftedDateForReactPicker, 1);
|
||||
const newDate = plainDate?.subtract({ months: 1 });
|
||||
|
||||
const plainDate = getPlainDateFromDate(dateParsed);
|
||||
|
||||
onChange?.(plainDate);
|
||||
onChange?.(newDate?.toString() ?? null);
|
||||
};
|
||||
|
||||
const handleChangeYear = (year: number) => {
|
||||
const dateParsed = setYear(shiftedDateForReactPicker, year);
|
||||
const newDate = plainDate?.with({ year: year });
|
||||
|
||||
const plainDate = getPlainDateFromDate(dateParsed);
|
||||
|
||||
onChange?.(plainDate);
|
||||
onChange?.(newDate?.toString() ?? null);
|
||||
};
|
||||
|
||||
const handleDateChange = (date: Date) => {
|
||||
const plainDate = getPlainDateFromDate(date);
|
||||
const handleDateChange = (datePicked: Date) => {
|
||||
const plainDatePicked = turnJSDateToPlainDate(datePicked);
|
||||
|
||||
onChange?.(plainDate);
|
||||
onChange?.(plainDatePicked.toString());
|
||||
};
|
||||
|
||||
const handleDateSelect = (date: Date) => {
|
||||
const plainDate = getPlainDateFromDate(date);
|
||||
const handleDateSelect = (datePicked: Date) => {
|
||||
const plainDatePicked = turnJSDateToPlainDate(datePicked);
|
||||
|
||||
handleClose?.(plainDate);
|
||||
handleClose?.(plainDatePicked.toString());
|
||||
};
|
||||
|
||||
const highlightedDates =
|
||||
isRelative && isDefined(relativeDate?.end) && isDefined(relativeDate?.start)
|
||||
? getHighlightedDates({
|
||||
start: getDateFromPlainDate(relativeDate.start),
|
||||
end: getDateFromPlainDate(relativeDate.end),
|
||||
})
|
||||
? getHighlightedDates(
|
||||
Temporal.PlainDate.from(relativeDate.start),
|
||||
Temporal.PlainDate.from(relativeDate.end).subtract({ days: 1 }),
|
||||
userTimezone,
|
||||
)
|
||||
: [];
|
||||
|
||||
const dateAsDate = isDefined(date) ? getDateFromPlainDate(date) : undefined;
|
||||
const dateAsDate = new Date(plainDate.toString());
|
||||
|
||||
const selectedDates = isRelative
|
||||
? highlightedDates
|
||||
? highlightedDates.map((plainDate) => new Date(plainDate.toString()))
|
||||
: isDefined(dateAsDate)
|
||||
? [dateAsDate]
|
||||
: [];
|
||||
@@ -433,6 +430,17 @@ export const DatePicker = ({
|
||||
? CalendarStartDay[detectCalendarStartDay()]
|
||||
: (currentWorkspaceMember?.calendarStartDay ?? undefined);
|
||||
|
||||
const systemTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
|
||||
const dateShiftedToISOString = plainDate
|
||||
?.toZonedDateTime(systemTimeZone)
|
||||
.toInstant()
|
||||
.toString();
|
||||
|
||||
const dateForDatePicker = isDefined(dateShiftedToISOString)
|
||||
? new Date(dateShiftedToISOString)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<StyledContainer calendarDisabled={isRelative}>
|
||||
<div className={clearable ? 'clearable ' : ''}>
|
||||
@@ -466,9 +474,9 @@ export const DatePicker = ({
|
||||
>
|
||||
<ReactDatePicker
|
||||
open={true}
|
||||
selected={shiftedDateForReactPicker}
|
||||
selected={dateForDatePicker}
|
||||
selectedDates={selectedDates}
|
||||
openToDate={shiftedDateForReactPicker}
|
||||
openToDate={dateForDatePicker ?? undefined}
|
||||
disabledKeyboardNavigation
|
||||
onChange={handleDateChange}
|
||||
calendarStartDay={calendarStartDay}
|
||||
@@ -486,7 +494,7 @@ export const DatePicker = ({
|
||||
/>
|
||||
) : (
|
||||
<DatePickerHeader
|
||||
date={dateOrToday}
|
||||
date={plainDate?.toString() ?? null}
|
||||
onChange={onChange}
|
||||
onChangeMonth={handleChangeMonth}
|
||||
onChangeYear={handleChangeYear}
|
||||
|
||||
+5
-5
@@ -13,10 +13,10 @@ import {
|
||||
} from './DateTimePicker';
|
||||
|
||||
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
|
||||
import { parse } from 'date-fns';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { DATE_TYPE_FORMAT } from 'twenty-shared/constants';
|
||||
import { Temporal } from 'temporal-polyfill';
|
||||
import { SOURCE_LOCALE } from 'twenty-shared/translations';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
const StyledCustomDatePickerHeader = styled.div`
|
||||
align-items: center;
|
||||
@@ -60,7 +60,7 @@ export const DatePickerHeader = ({
|
||||
const currentWorkspaceMember = useRecoilValue(currentWorkspaceMemberState);
|
||||
const userLocale = currentWorkspaceMember?.locale ?? SOURCE_LOCALE;
|
||||
|
||||
const dateParsed = date ? parse(date, DATE_TYPE_FORMAT, new Date()) : null;
|
||||
const dateParsed = isDefined(date) ? Temporal.PlainDate.from(date) : null;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -75,7 +75,7 @@ export const DatePickerHeader = ({
|
||||
dropdownId={MONTH_AND_YEAR_DROPDOWN_MONTH_SELECT_ID}
|
||||
options={getMonthSelectOptions(userLocale)}
|
||||
onChange={onChangeMonth}
|
||||
value={dateParsed?.getMonth()}
|
||||
value={dateParsed?.month}
|
||||
fullWidth
|
||||
/>
|
||||
</ClickOutsideListenerContext.Provider>
|
||||
@@ -87,7 +87,7 @@ export const DatePickerHeader = ({
|
||||
<Select
|
||||
dropdownId={MONTH_AND_YEAR_DROPDOWN_YEAR_SELECT_ID}
|
||||
onChange={onChangeYear}
|
||||
value={dateParsed?.getFullYear()}
|
||||
value={dateParsed?.year}
|
||||
options={years}
|
||||
fullWidth
|
||||
/>
|
||||
|
||||
+2
-1
@@ -80,7 +80,8 @@ export const DatePickerInput = ({ date, onChange }: DatePickerInputProps) => {
|
||||
blocks,
|
||||
min: MIN_DATE,
|
||||
max: MAX_DATE,
|
||||
format: (date: any) => parseIMaskJSDateIMaskDateInputString(date),
|
||||
format: (date: any) =>
|
||||
isDefined(date) ? parseIMaskJSDateIMaskDateInputString(date) : '',
|
||||
parse: parseIMaskDateInputStringToJSDate,
|
||||
lazy: false,
|
||||
autofix: true,
|
||||
|
||||
+466
@@ -0,0 +1,466 @@
|
||||
import styled from '@emotion/styled';
|
||||
import { lazy, Suspense, type ComponentType } from 'react';
|
||||
import type { ReactDatePickerProps as ReactDatePickerLibProps } from 'react-datepicker';
|
||||
import Skeleton, { SkeletonTheme } from 'react-loading-skeleton';
|
||||
|
||||
import { SKELETON_LOADER_HEIGHT_SIZES } from '@/activities/components/SkeletonLoader';
|
||||
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
|
||||
import { CalendarStartDay } from 'twenty-shared/constants';
|
||||
|
||||
import { detectCalendarStartDay } from '@/localization/utils/detection/detectCalendarStartDay';
|
||||
import { DatePickerHeader } from '@/ui/input/components/internal/date/components/DatePickerHeader';
|
||||
import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown';
|
||||
import { useTheme } from '@emotion/react';
|
||||
import 'react-datepicker/dist/react-datepicker.css';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
|
||||
import { Temporal } from 'temporal-polyfill';
|
||||
import { type Nullable } from 'twenty-shared/types';
|
||||
import { isDefined, turnJSDateToPlainDate } from 'twenty-shared/utils';
|
||||
|
||||
export const MONTH_AND_YEAR_DROPDOWN_MONTH_SELECT_ID =
|
||||
'date-picker-month-and-year-dropdown-month-select';
|
||||
export const MONTH_AND_YEAR_DROPDOWN_YEAR_SELECT_ID =
|
||||
'date-picker-month-and-year-dropdown-year-select';
|
||||
|
||||
const DATE_PICKER_CONTAINER_WIDTH = 280;
|
||||
const DATE_PICKER_SKELETON_PADDING = 16;
|
||||
|
||||
const StyledContainer = styled.div<{
|
||||
calendarDisabled?: boolean;
|
||||
hideCalendar?: boolean;
|
||||
}>`
|
||||
width: ${DATE_PICKER_CONTAINER_WIDTH}px;
|
||||
|
||||
& .react-datepicker {
|
||||
border-color: ${({ theme }) => theme.border.color.light};
|
||||
background: transparent;
|
||||
font-family: 'Inter';
|
||||
font-size: ${({ theme }) => theme.font.size.md};
|
||||
border: none;
|
||||
display: block;
|
||||
font-weight: ${({ theme }) => theme.font.weight.regular};
|
||||
}
|
||||
|
||||
& .react-datepicker-popper {
|
||||
position: relative !important;
|
||||
inset: auto !important;
|
||||
transform: none !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
& .react-datepicker__triangle {
|
||||
display: none;
|
||||
}
|
||||
|
||||
& .react-datepicker__triangle::after {
|
||||
display: none;
|
||||
}
|
||||
|
||||
& .react-datepicker__triangle::before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
& .react-datepicker-wrapper {
|
||||
display: none;
|
||||
}
|
||||
|
||||
// Header
|
||||
|
||||
& .react-datepicker__header {
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
&
|
||||
.react-datepicker__input-time-container
|
||||
.react-datepicker-time__input-container
|
||||
.react-datepicker-time__input {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
& .react-datepicker__header__dropdown {
|
||||
display: flex;
|
||||
color: ${({ theme }) => theme.font.color.primary};
|
||||
margin-left: ${({ theme }) => theme.spacing(1)};
|
||||
margin-bottom: ${({ theme }) => theme.spacing(10)};
|
||||
}
|
||||
|
||||
& .react-datepicker__month-dropdown-container,
|
||||
& .react-datepicker__year-dropdown-container {
|
||||
text-align: left;
|
||||
border-radius: ${({ theme }) => theme.border.radius.sm};
|
||||
margin-left: ${({ theme }) => theme.spacing(1)};
|
||||
margin-right: 0;
|
||||
padding: ${({ theme }) => theme.spacing(2)};
|
||||
padding-right: ${({ theme }) => theme.spacing(4)};
|
||||
background-color: ${({ theme }) => theme.background.tertiary};
|
||||
}
|
||||
|
||||
& .react-datepicker__month-read-view--down-arrow,
|
||||
& .react-datepicker__year-read-view--down-arrow {
|
||||
height: 5px;
|
||||
width: 5px;
|
||||
border-width: 1px 1px 0 0;
|
||||
border-color: ${({ theme }) => theme.border.color.light};
|
||||
top: 3px;
|
||||
right: -6px;
|
||||
}
|
||||
|
||||
& .react-datepicker__year-read-view,
|
||||
& .react-datepicker__month-read-view {
|
||||
padding-right: ${({ theme }) => theme.spacing(2)};
|
||||
}
|
||||
|
||||
& .react-datepicker__month-dropdown-container {
|
||||
width: 80px;
|
||||
}
|
||||
|
||||
& .react-datepicker__year-dropdown-container {
|
||||
width: 50px;
|
||||
}
|
||||
|
||||
& .react-datepicker__month-dropdown,
|
||||
& .react-datepicker__year-dropdown {
|
||||
overflow-y: scroll;
|
||||
top: ${({ theme }) => theme.spacing(2)};
|
||||
}
|
||||
& .react-datepicker__month-dropdown {
|
||||
left: ${({ theme }) => theme.spacing(2)};
|
||||
height: 260px;
|
||||
}
|
||||
|
||||
& .react-datepicker__year-dropdown {
|
||||
left: calc(${({ theme }) => theme.spacing(9)} + 80px);
|
||||
width: 100px;
|
||||
height: 260px;
|
||||
}
|
||||
|
||||
& .react-datepicker__navigation--years {
|
||||
display: none;
|
||||
}
|
||||
|
||||
& .react-datepicker__month-option--selected,
|
||||
& .react-datepicker__year-option--selected {
|
||||
display: none;
|
||||
}
|
||||
|
||||
& .react-datepicker__year-option,
|
||||
& .react-datepicker__month-option {
|
||||
text-align: left;
|
||||
padding: ${({ theme }) => theme.spacing(2)}
|
||||
calc(${({ theme }) => theme.spacing(2)} - 2px);
|
||||
width: calc(100% - ${({ theme }) => theme.spacing(4)});
|
||||
border-radius: ${({ theme }) => theme.border.radius.xs};
|
||||
color: ${({ theme }) => theme.font.color.secondary};
|
||||
cursor: pointer;
|
||||
margin: 2px;
|
||||
|
||||
&:hover {
|
||||
background: ${({ theme }) => theme.background.transparent.light};
|
||||
}
|
||||
}
|
||||
|
||||
& .react-datepicker__year-option {
|
||||
&:first-of-type,
|
||||
&:last-of-type {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
& .react-datepicker__current-month {
|
||||
display: none;
|
||||
}
|
||||
|
||||
& .react-datepicker__day-name {
|
||||
color: ${({ theme }) => theme.font.color.secondary};
|
||||
width: 34px;
|
||||
height: 40px;
|
||||
line-height: 40px;
|
||||
}
|
||||
|
||||
& .react-datepicker__month-container {
|
||||
float: none;
|
||||
}
|
||||
|
||||
// Days
|
||||
|
||||
& .react-datepicker__month {
|
||||
margin-top: 0;
|
||||
|
||||
pointer-events: ${({ calendarDisabled }) =>
|
||||
calendarDisabled ? 'none' : 'auto'};
|
||||
opacity: ${({ calendarDisabled }) => (calendarDisabled ? '0.5' : '1')};
|
||||
|
||||
display: ${({ hideCalendar }) =>
|
||||
hideCalendar === true ? 'none' : 'visible'};
|
||||
}
|
||||
|
||||
& .react-datepicker__day-names {
|
||||
display: ${({ hideCalendar }) =>
|
||||
hideCalendar === true ? 'none' : 'visible'};
|
||||
}
|
||||
|
||||
& .react-datepicker__day {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
line-height: 34px;
|
||||
}
|
||||
|
||||
& .react-datepicker__navigation--previous,
|
||||
& .react-datepicker__navigation--next {
|
||||
height: 34px;
|
||||
border-radius: ${({ theme }) => theme.border.radius.sm};
|
||||
padding-top: 6px;
|
||||
&:hover {
|
||||
background: ${({ theme }) => theme.background.transparent.light};
|
||||
}
|
||||
}
|
||||
& .react-datepicker__navigation--previous {
|
||||
right: 38px;
|
||||
top: 6px;
|
||||
left: auto;
|
||||
|
||||
& > span {
|
||||
margin-left: -6px;
|
||||
}
|
||||
}
|
||||
|
||||
& .react-datepicker__navigation--next {
|
||||
right: 6px;
|
||||
top: 6px;
|
||||
|
||||
& > span {
|
||||
margin-left: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
& .react-datepicker__navigation-icon::before {
|
||||
height: 7px;
|
||||
width: 7px;
|
||||
border-width: 1px 1px 0 0;
|
||||
border-color: ${({ theme }) => theme.font.color.tertiary};
|
||||
}
|
||||
|
||||
& .react-datepicker__day--keyboard-selected {
|
||||
background-color: inherit;
|
||||
}
|
||||
|
||||
& .react-datepicker__day,
|
||||
.react-datepicker__time-name {
|
||||
color: ${({ theme }) => theme.font.color.primary};
|
||||
}
|
||||
|
||||
& .react-datepicker__day--selected {
|
||||
background-color: ${({ theme }) => theme.color.blue};
|
||||
color: ${({ theme }) => theme.background.primary};
|
||||
|
||||
&.react-datepicker__day:hover {
|
||||
color: ${({ theme }) => theme.background.primary};
|
||||
}
|
||||
}
|
||||
|
||||
& .react-datepicker__day--outside-month {
|
||||
color: ${({ theme }) => theme.font.color.tertiary};
|
||||
}
|
||||
|
||||
& .react-datepicker__day:hover {
|
||||
color: ${({ theme }) => theme.font.color.tertiary};
|
||||
}
|
||||
|
||||
& .clearable {
|
||||
border-bottom: 1px solid ${({ theme }) => theme.border.color.light};
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledSpacer = styled.div`
|
||||
height: ${({ theme }) => theme.spacing(2)};
|
||||
width: auto;
|
||||
`;
|
||||
|
||||
const StyledDatePickerFallback = styled.div`
|
||||
align-items: center;
|
||||
background: ${({ theme }) => theme.background.secondary};
|
||||
border-radius: ${({ theme }) => theme.border.radius.md};
|
||||
color: ${({ theme }) => theme.font.color.tertiary};
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${({ theme }) => theme.spacing(2)};
|
||||
height: 300px;
|
||||
justify-content: center;
|
||||
padding: ${({ theme }) => theme.spacing(4)};
|
||||
width: 280px;
|
||||
`;
|
||||
|
||||
type DatePickerWithoutCalendarProps = {
|
||||
instanceId: string;
|
||||
|
||||
date: Nullable<string>;
|
||||
|
||||
onClose?: (date: string | null) => void;
|
||||
onChange?: (date: string | null) => void;
|
||||
|
||||
onEnter?: (date: string | null) => void;
|
||||
onEscape?: (date: string | null) => void;
|
||||
keyboardEventsDisabled?: boolean;
|
||||
};
|
||||
|
||||
type DatePickerPropsType = ReactDatePickerLibProps<
|
||||
boolean | undefined,
|
||||
boolean | undefined
|
||||
>;
|
||||
|
||||
const ReactDatePicker = lazy<ComponentType<DatePickerPropsType>>(() =>
|
||||
import('react-datepicker').then((mod) => ({
|
||||
default: mod.default as unknown as ComponentType<DatePickerPropsType>,
|
||||
})),
|
||||
);
|
||||
|
||||
export const DatePickerWithoutCalendar = ({
|
||||
date,
|
||||
onChange,
|
||||
onClose,
|
||||
}: DatePickerWithoutCalendarProps) => {
|
||||
const plainDate = isDefined(date) ? Temporal.PlainDate.from(date) : null;
|
||||
|
||||
const theme = useTheme();
|
||||
|
||||
const { closeDropdown: closeDropdownMonthSelect } = useCloseDropdown();
|
||||
const { closeDropdown: closeDropdownYearSelect } = useCloseDropdown();
|
||||
const currentWorkspaceMember = useRecoilValue(currentWorkspaceMemberState);
|
||||
|
||||
const closeDropdowns = () => {
|
||||
closeDropdownYearSelect(MONTH_AND_YEAR_DROPDOWN_YEAR_SELECT_ID);
|
||||
closeDropdownMonthSelect(MONTH_AND_YEAR_DROPDOWN_MONTH_SELECT_ID);
|
||||
};
|
||||
|
||||
const handleClose = (newDate: string) => {
|
||||
closeDropdowns();
|
||||
onClose?.(newDate);
|
||||
};
|
||||
|
||||
const handleChangeMonth = (month: number) => {
|
||||
const newDate = plainDate?.with({ month: month });
|
||||
|
||||
onChange?.(newDate?.toString() ?? null);
|
||||
};
|
||||
|
||||
const handleAddMonth = () => {
|
||||
const newDate = plainDate?.add({ months: 1 });
|
||||
|
||||
onChange?.(newDate?.toString() ?? null);
|
||||
};
|
||||
|
||||
const handleSubtractMonth = () => {
|
||||
const newDate = plainDate?.subtract({ months: 1 });
|
||||
|
||||
onChange?.(newDate?.toString() ?? null);
|
||||
};
|
||||
|
||||
const handleChangeYear = (year: number) => {
|
||||
const newDate = plainDate?.with({ year: year });
|
||||
|
||||
onChange?.(newDate?.toString() ?? null);
|
||||
};
|
||||
|
||||
const handleDateChange = (datePicked: Date) => {
|
||||
const plainDatePicked = turnJSDateToPlainDate(datePicked);
|
||||
|
||||
onChange?.(plainDatePicked.toString());
|
||||
};
|
||||
|
||||
const handleDateSelect = (datePicked: Date) => {
|
||||
const plainDatePicked = turnJSDateToPlainDate(datePicked);
|
||||
|
||||
handleClose?.(plainDatePicked.toString());
|
||||
};
|
||||
|
||||
const calendarStartDay =
|
||||
currentWorkspaceMember?.calendarStartDay === CalendarStartDay.SYSTEM
|
||||
? CalendarStartDay[detectCalendarStartDay()]
|
||||
: (currentWorkspaceMember?.calendarStartDay ?? undefined);
|
||||
|
||||
const systemTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
|
||||
const dateShiftedToISOString = plainDate
|
||||
?.toZonedDateTime(systemTimeZone)
|
||||
.toInstant()
|
||||
.toString();
|
||||
|
||||
const dateForDatePicker = isDefined(dateShiftedToISOString)
|
||||
? new Date(dateShiftedToISOString)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<StyledContainer hideCalendar={true}>
|
||||
<div>
|
||||
<Suspense
|
||||
fallback={
|
||||
<StyledDatePickerFallback>
|
||||
<SkeletonTheme
|
||||
baseColor={theme.background.tertiary}
|
||||
highlightColor={theme.background.transparent.lighter}
|
||||
borderRadius={2}
|
||||
>
|
||||
<Skeleton
|
||||
width={
|
||||
DATE_PICKER_CONTAINER_WIDTH - DATE_PICKER_SKELETON_PADDING
|
||||
}
|
||||
height={SKELETON_LOADER_HEIGHT_SIZES.standard.l}
|
||||
/>
|
||||
<Skeleton
|
||||
width={
|
||||
DATE_PICKER_CONTAINER_WIDTH - DATE_PICKER_SKELETON_PADDING
|
||||
}
|
||||
height={SKELETON_LOADER_HEIGHT_SIZES.standard.l}
|
||||
/>
|
||||
<Skeleton
|
||||
width={
|
||||
DATE_PICKER_CONTAINER_WIDTH - DATE_PICKER_SKELETON_PADDING
|
||||
}
|
||||
height={SKELETON_LOADER_HEIGHT_SIZES.standard.l}
|
||||
/>
|
||||
<Skeleton
|
||||
width={
|
||||
DATE_PICKER_CONTAINER_WIDTH - DATE_PICKER_SKELETON_PADDING
|
||||
}
|
||||
height={SKELETON_LOADER_HEIGHT_SIZES.standard.l}
|
||||
/>
|
||||
</SkeletonTheme>
|
||||
</StyledDatePickerFallback>
|
||||
}
|
||||
>
|
||||
<ReactDatePicker
|
||||
open={true}
|
||||
selected={dateForDatePicker}
|
||||
openToDate={dateForDatePicker ?? undefined}
|
||||
disabledKeyboardNavigation
|
||||
onChange={handleDateChange}
|
||||
calendarStartDay={calendarStartDay}
|
||||
renderCustomHeader={({
|
||||
prevMonthButtonDisabled,
|
||||
nextMonthButtonDisabled,
|
||||
}) => (
|
||||
<>
|
||||
<DatePickerHeader
|
||||
date={plainDate?.toString() ?? null}
|
||||
onChange={onChange}
|
||||
onChangeMonth={handleChangeMonth}
|
||||
onChangeYear={handleChangeYear}
|
||||
onAddMonth={handleAddMonth}
|
||||
onSubtractMonth={handleSubtractMonth}
|
||||
prevMonthButtonDisabled={prevMonthButtonDisabled}
|
||||
nextMonthButtonDisabled={nextMonthButtonDisabled}
|
||||
hideInput={true}
|
||||
/>
|
||||
<StyledSpacer />
|
||||
</>
|
||||
)}
|
||||
onSelect={handleDateSelect}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
</StyledContainer>
|
||||
);
|
||||
};
|
||||
+87
-67
@@ -1,7 +1,10 @@
|
||||
import { SKELETON_LOADER_HEIGHT_SIZES } from '@/activities/components/SkeletonLoader';
|
||||
import { CalendarStartDay } from 'twenty-shared';
|
||||
import {
|
||||
convertFirstDayOfTheWeekToCalendarStartDayNumber,
|
||||
isDefined,
|
||||
type RelativeDateFilter,
|
||||
} from 'twenty-shared/utils';
|
||||
|
||||
import { detectCalendarStartDay } from '@/localization/utils/detection/detectCalendarStartDay';
|
||||
import { DateTimePickerHeader } from '@/ui/input/components/internal/date/components/DateTimePickerHeader';
|
||||
import { RelativeDatePickerHeader } from '@/ui/input/components/internal/date/components/RelativeDatePickerHeader';
|
||||
import { getHighlightedDates } from '@/ui/input/components/internal/date/utils/getHighlightedDates';
|
||||
@@ -9,7 +12,6 @@ import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown';
|
||||
import { useTheme } from '@emotion/react';
|
||||
import styled from '@emotion/styled';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { addMonths, setMonth, setYear, subMonths } from 'date-fns';
|
||||
import { lazy, Suspense, type ComponentType } from 'react';
|
||||
import type { ReactDatePickerProps as ReactDatePickerLibProps } from 'react-datepicker';
|
||||
import Skeleton, { SkeletonTheme } from 'react-loading-skeleton';
|
||||
@@ -22,11 +24,10 @@ import {
|
||||
StyledHoverableMenuItemBase,
|
||||
} from 'twenty-ui/navigation';
|
||||
|
||||
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
|
||||
import { useTurnPointInTimeIntoReactDatePickerShiftedDate } from '@/ui/input/components/internal/date/hooks/useTurnPointInTimeIntoReactDatePickerShiftedDate';
|
||||
import { useTurnReactDatePickerShiftedDateBackIntoPointInTime } from '@/ui/input/components/internal/date/hooks/useTurnReactDatePickerShiftedDateBackIntoPointInTime';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { isDefined, type RelativeDateFilter } from 'twenty-shared/utils';
|
||||
import { useGetShiftedDateToSystemTimeZone } from '@/ui/input/components/internal/date/hooks/useGetShiftedDateToSystemTimeZone';
|
||||
import { useUserFirstDayOfTheWeek } from '@/ui/input/components/internal/date/hooks/useUserFirstDayOfTheWeek';
|
||||
import { useUserTimezone } from '@/ui/input/components/internal/date/hooks/useUserTimezone';
|
||||
import { Temporal } from 'temporal-polyfill';
|
||||
|
||||
export const MONTH_AND_YEAR_DROPDOWN_MONTH_SELECT_ID =
|
||||
'date-picker-month-and-year-dropdown-month-select';
|
||||
@@ -303,21 +304,22 @@ type DateTimePickerProps = {
|
||||
instanceId: string;
|
||||
isRelative?: boolean;
|
||||
hideHeaderInput?: boolean;
|
||||
date: Date | null;
|
||||
date: Temporal.ZonedDateTime | null;
|
||||
relativeDate?: RelativeDateFilter & {
|
||||
start: Date;
|
||||
end: Date;
|
||||
start: Temporal.ZonedDateTime;
|
||||
end?: Temporal.ZonedDateTime;
|
||||
};
|
||||
onClose?: (date: Date | null) => void;
|
||||
onChange?: (date: Date | null) => void;
|
||||
onClose?: (date: Temporal.ZonedDateTime | null) => void;
|
||||
onChange?: (date: Temporal.ZonedDateTime | null) => void;
|
||||
onRelativeDateChange?: (
|
||||
relativeDateFilter: RelativeDateFilter | null,
|
||||
) => void;
|
||||
clearable?: boolean;
|
||||
onEnter?: (date: Date | null) => void;
|
||||
onEscape?: (date: Date | null) => void;
|
||||
onEnter?: (date: Temporal.ZonedDateTime | null) => void;
|
||||
onEscape?: (date: Temporal.ZonedDateTime | null) => void;
|
||||
keyboardEventsDisabled?: boolean;
|
||||
onClear?: () => void;
|
||||
timeZone?: string;
|
||||
};
|
||||
|
||||
type DatePickerPropsType = ReactDatePickerLibProps<
|
||||
@@ -342,14 +344,36 @@ export const DateTimePicker = ({
|
||||
relativeDate,
|
||||
onRelativeDateChange,
|
||||
hideHeaderInput,
|
||||
timeZone,
|
||||
}: DateTimePickerProps) => {
|
||||
const theme = useTheme();
|
||||
const currentWorkspaceMember = useRecoilValue(currentWorkspaceMemberState);
|
||||
|
||||
const { turnReactDatePickerShiftedDateBackIntoPointInTime } =
|
||||
useTurnReactDatePickerShiftedDateBackIntoPointInTime();
|
||||
const { turnPointInTimeIntoReactDatePickerShiftedDate } =
|
||||
useTurnPointInTimeIntoReactDatePickerShiftedDate();
|
||||
const { userFirstDayOfTheWeek } = useUserFirstDayOfTheWeek();
|
||||
|
||||
const { userTimezone } = useUserTimezone();
|
||||
|
||||
const dateToUse =
|
||||
date ?? Temporal.Now.zonedDateTimeISO(timeZone ?? userTimezone);
|
||||
|
||||
const { getShiftedDateToSystemTimeZone } =
|
||||
useGetShiftedDateToSystemTimeZone();
|
||||
|
||||
const getZonedDateTimeFromDatePicked = (datePicked: Date) => {
|
||||
const plainDatePart = Temporal.PlainDate.from({
|
||||
day: datePicked.getDate(),
|
||||
month: datePicked.getMonth() + 1,
|
||||
year: datePicked.getFullYear(),
|
||||
});
|
||||
|
||||
const zonedDateTime = plainDatePart
|
||||
.toZonedDateTime(timeZone ?? userTimezone)
|
||||
.with({
|
||||
hour: dateToUse?.hour ?? 0,
|
||||
minute: dateToUse?.minute ?? 0,
|
||||
});
|
||||
|
||||
return { zonedDateTime };
|
||||
};
|
||||
|
||||
const { closeDropdown: closeDropdownMonthSelect } = useCloseDropdown();
|
||||
const { closeDropdown: closeDropdownYearSelect } = useCloseDropdown();
|
||||
@@ -364,79 +388,79 @@ export const DateTimePicker = ({
|
||||
closeDropdownMonthSelect(MONTH_AND_YEAR_DROPDOWN_MONTH_SELECT_ID);
|
||||
};
|
||||
|
||||
const handleClose = (newDate: Date) => {
|
||||
const handleClose = (newDate: Temporal.ZonedDateTime) => {
|
||||
closeDropdowns();
|
||||
onClose?.(newDate);
|
||||
};
|
||||
|
||||
const handleChangeMonth = (month: number) => {
|
||||
const newDateTz = setMonth(reactPickerShiftedDate, month);
|
||||
const newZonedDateTime = dateToUse?.with({ month: month }) ?? null;
|
||||
|
||||
const normalDate =
|
||||
turnReactDatePickerShiftedDateBackIntoPointInTime(newDateTz);
|
||||
|
||||
onChange?.(normalDate);
|
||||
onChange?.(newZonedDateTime);
|
||||
};
|
||||
|
||||
const handleAddMonth = () => {
|
||||
const newDateTz = addMonths(reactPickerShiftedDate, 1);
|
||||
const newZonedDateTime = dateToUse?.add({ months: 1 }) ?? null;
|
||||
|
||||
const normalDate =
|
||||
turnReactDatePickerShiftedDateBackIntoPointInTime(newDateTz);
|
||||
|
||||
onChange?.(normalDate);
|
||||
onChange?.(newZonedDateTime);
|
||||
};
|
||||
|
||||
const handleSubtractMonth = () => {
|
||||
const newDateTz = subMonths(reactPickerShiftedDate, 1);
|
||||
const newZonedDateTime = dateToUse?.subtract({ months: 1 }) ?? null;
|
||||
|
||||
const normalDate =
|
||||
turnReactDatePickerShiftedDateBackIntoPointInTime(newDateTz);
|
||||
|
||||
onChange?.(normalDate);
|
||||
onChange?.(newZonedDateTime);
|
||||
};
|
||||
|
||||
const handleChangeYear = (year: number) => {
|
||||
const newDateTz = setYear(reactPickerShiftedDate, year);
|
||||
const newZonedDateTime = dateToUse?.with({ year: year }) ?? null;
|
||||
|
||||
const normalDate =
|
||||
turnReactDatePickerShiftedDateBackIntoPointInTime(newDateTz);
|
||||
|
||||
onChange?.(normalDate);
|
||||
onChange?.(newZonedDateTime);
|
||||
};
|
||||
|
||||
const handleDateChange = (newDate: Date) => {
|
||||
const normalDate =
|
||||
turnReactDatePickerShiftedDateBackIntoPointInTime(newDate);
|
||||
const { zonedDateTime } = getZonedDateTimeFromDatePicked(newDate);
|
||||
|
||||
onChange?.(normalDate);
|
||||
onChange?.(zonedDateTime);
|
||||
};
|
||||
|
||||
const handleDateSelect = (newReactDatePickerShiftedDateSelected: Date) => {
|
||||
const normalDate = turnReactDatePickerShiftedDateBackIntoPointInTime(
|
||||
newReactDatePickerShiftedDateSelected,
|
||||
);
|
||||
const handleDateSelect = (newDate: Date) => {
|
||||
const { zonedDateTime } = getZonedDateTimeFromDatePicked(newDate);
|
||||
|
||||
handleClose?.(normalDate);
|
||||
handleClose?.(zonedDateTime);
|
||||
};
|
||||
|
||||
const highlightedDates =
|
||||
isRelative && isDefined(relativeDate?.end) && isDefined(relativeDate?.start)
|
||||
? getHighlightedDates({
|
||||
end: turnPointInTimeIntoReactDatePickerShiftedDate(relativeDate?.end),
|
||||
start: turnPointInTimeIntoReactDatePickerShiftedDate(
|
||||
relativeDate?.start,
|
||||
),
|
||||
})
|
||||
? getHighlightedDates(
|
||||
relativeDate?.start.toPlainDate(),
|
||||
relativeDate?.end.subtract({ days: 1 }).toPlainDate(),
|
||||
timeZone ?? userTimezone,
|
||||
)
|
||||
: [];
|
||||
|
||||
const reactPickerShiftedDate = turnPointInTimeIntoReactDatePickerShiftedDate(
|
||||
date ?? new Date(),
|
||||
const nonShiftedDateForReactDatePicker = new Date(
|
||||
dateToUse.toInstant().toString(),
|
||||
);
|
||||
|
||||
const shiftedDateForReactDatePicker = getShiftedDateToSystemTimeZone(
|
||||
nonShiftedDateForReactDatePicker,
|
||||
timeZone ?? userTimezone,
|
||||
);
|
||||
|
||||
const selectedDates = isRelative
|
||||
? highlightedDates
|
||||
: [reactPickerShiftedDate];
|
||||
? highlightedDates.map((plainDate) => {
|
||||
const date = new Date();
|
||||
|
||||
date.setDate(plainDate.day);
|
||||
date.setMonth(plainDate.month - 1);
|
||||
date.setFullYear(plainDate.year);
|
||||
|
||||
return date;
|
||||
})
|
||||
: [shiftedDateForReactDatePicker];
|
||||
|
||||
const calendarStartDayNumber =
|
||||
convertFirstDayOfTheWeekToCalendarStartDayNumber(userFirstDayOfTheWeek);
|
||||
|
||||
return (
|
||||
<StyledContainer calendarDisabled={isRelative}>
|
||||
@@ -470,16 +494,12 @@ export const DateTimePicker = ({
|
||||
>
|
||||
<ReactDatePicker
|
||||
open={true}
|
||||
selected={reactPickerShiftedDate}
|
||||
selected={shiftedDateForReactDatePicker}
|
||||
selectedDates={selectedDates}
|
||||
openToDate={reactPickerShiftedDate}
|
||||
openToDate={shiftedDateForReactDatePicker}
|
||||
disabledKeyboardNavigation
|
||||
onChange={handleDateChange}
|
||||
calendarStartDay={
|
||||
currentWorkspaceMember?.calendarStartDay === CalendarStartDay.SYSTEM
|
||||
? CalendarStartDay[detectCalendarStartDay()]
|
||||
: (currentWorkspaceMember?.calendarStartDay ?? undefined)
|
||||
}
|
||||
calendarStartDay={calendarStartDayNumber}
|
||||
renderCustomHeader={({
|
||||
prevMonthButtonDisabled,
|
||||
nextMonthButtonDisabled,
|
||||
@@ -495,7 +515,7 @@ export const DateTimePicker = ({
|
||||
/>
|
||||
) : (
|
||||
<DateTimePickerHeader
|
||||
date={reactPickerShiftedDate}
|
||||
date={dateToUse}
|
||||
onChange={onChange}
|
||||
onChangeMonth={handleChangeMonth}
|
||||
onChangeYear={handleChangeYear}
|
||||
|
||||
+17
-5
@@ -7,6 +7,7 @@ import { Select } from '@/ui/input/components/Select';
|
||||
import { DateTimePickerInput } from '@/ui/input/components/internal/date/components/DateTimePickerInput';
|
||||
import { getMonthSelectOptions } from '@/ui/input/components/internal/date/utils/getMonthSelectOptions';
|
||||
import { ClickOutsideListenerContext } from '@/ui/utilities/pointer-event/contexts/ClickOutsideListenerContext';
|
||||
import { type Temporal } from 'temporal-polyfill';
|
||||
import { SOURCE_LOCALE } from 'twenty-shared/translations';
|
||||
import { IconChevronLeft, IconChevronRight } from 'twenty-ui/display';
|
||||
import { LightIconButton } from 'twenty-ui/input';
|
||||
@@ -26,14 +27,20 @@ const StyledCustomDatePickerHeader = styled.div`
|
||||
gap: ${({ theme }) => theme.spacing(1)};
|
||||
`;
|
||||
|
||||
const StyledSeparator = styled.div`
|
||||
border-bottom: 1px solid ${({ theme }) => theme.border.color.light};
|
||||
height: 1px;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const years = Array.from(
|
||||
{ length: 200 },
|
||||
(_, i) => new Date().getFullYear() + 50 - i,
|
||||
).map((year) => ({ label: year.toString(), value: year }));
|
||||
|
||||
type DateTimePickerHeaderProps = {
|
||||
date: Date;
|
||||
onChange?: (date: Date | null) => void;
|
||||
date: Temporal.ZonedDateTime | null;
|
||||
onChange?: (date: Temporal.ZonedDateTime | null) => void;
|
||||
onChangeMonth: (month: number) => void;
|
||||
onChangeYear: (year: number) => void;
|
||||
onAddMonth: () => void;
|
||||
@@ -59,7 +66,12 @@ export const DateTimePickerHeader = ({
|
||||
|
||||
return (
|
||||
<>
|
||||
{!hideInput && <DateTimePickerInput date={date} onChange={onChange} />}
|
||||
{!hideInput && (
|
||||
<>
|
||||
<DateTimePickerInput date={date} onChange={onChange} />
|
||||
<StyledSeparator />
|
||||
</>
|
||||
)}
|
||||
<StyledCustomDatePickerHeader>
|
||||
<ClickOutsideListenerContext.Provider
|
||||
value={{
|
||||
@@ -70,7 +82,7 @@ export const DateTimePickerHeader = ({
|
||||
dropdownId={MONTH_AND_YEAR_DROPDOWN_MONTH_SELECT_ID}
|
||||
options={getMonthSelectOptions(userLocale)}
|
||||
onChange={onChangeMonth}
|
||||
value={date.getMonth()}
|
||||
value={date?.month ?? undefined}
|
||||
fullWidth
|
||||
/>
|
||||
</ClickOutsideListenerContext.Provider>
|
||||
@@ -82,7 +94,7 @@ export const DateTimePickerHeader = ({
|
||||
<Select
|
||||
dropdownId={MONTH_AND_YEAR_DROPDOWN_YEAR_SELECT_ID}
|
||||
onChange={onChangeYear}
|
||||
value={date.getFullYear()}
|
||||
value={date?.year}
|
||||
options={years}
|
||||
fullWidth
|
||||
/>
|
||||
|
||||
+86
-18
@@ -8,15 +8,19 @@ import { MIN_DATE } from '@/ui/input/components/internal/date/constants/MinDate'
|
||||
import { getDateTimeMask } from '@/ui/input/components/internal/date/utils/getDateTimeMask';
|
||||
|
||||
import { TimeZoneAbbreviation } from '@/ui/input/components/internal/date/components/TimeZoneAbbreviation';
|
||||
import { useGetShiftedDateToCustomTimeZone } from '@/ui/input/components/internal/date/hooks/useGetShiftedDateToCustomTimeZone';
|
||||
import { useGetShiftedDateToSystemTimeZone } from '@/ui/input/components/internal/date/hooks/useGetShiftedDateToSystemTimeZone';
|
||||
import { useParseDateTimeInputStringToJSDate } from '@/ui/input/components/internal/date/hooks/useParseDateTimeInputStringToJSDate';
|
||||
import { useParseJSDateToIMaskDateTimeInputString } from '@/ui/input/components/internal/date/hooks/useParseJSDateToIMaskDateTimeInputString';
|
||||
import { useTurnReactDatePickerShiftedDateBackIntoPointInTime } from '@/ui/input/components/internal/date/hooks/useTurnReactDatePickerShiftedDateBackIntoPointInTime';
|
||||
import { useUserTimezone } from '@/ui/input/components/internal/date/hooks/useUserTimezone';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Temporal } from 'temporal-polyfill';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { isDifferentZonedDateTime } from '~/utils/dates/isDifferentZonedDateTime';
|
||||
|
||||
const StyledInputContainer = styled.div`
|
||||
align-items: center;
|
||||
border-bottom: 1px solid ${({ theme }) => theme.border.color.light};
|
||||
|
||||
border-top-left-radius: ${({ theme }) => theme.border.radius.md};
|
||||
border-top-right-radius: ${({ theme }) => theme.border.radius.md};
|
||||
display: flex;
|
||||
@@ -36,21 +40,32 @@ const StyledInput = styled.input<{ hasError?: boolean }>`
|
||||
`;
|
||||
|
||||
type DateTimePickerInputProps = {
|
||||
onChange?: (date: Date | null) => void;
|
||||
date: Date | null;
|
||||
onChange?: (date: Temporal.ZonedDateTime | null) => void;
|
||||
date: Temporal.ZonedDateTime | null;
|
||||
onFocus?: () => void;
|
||||
readonly?: boolean;
|
||||
timeZone?: string;
|
||||
};
|
||||
|
||||
export const DateTimePickerInput = ({
|
||||
date,
|
||||
onChange,
|
||||
onFocus,
|
||||
readonly,
|
||||
timeZone,
|
||||
}: DateTimePickerInputProps) => {
|
||||
const { turnReactDatePickerShiftedDateBackIntoPointInTime } =
|
||||
useTurnReactDatePickerShiftedDateBackIntoPointInTime();
|
||||
|
||||
const [internalDate, setInternalDate] = useState(date);
|
||||
|
||||
const { userTimezone } = useUserTimezone();
|
||||
|
||||
const { dateFormat } = useDateTimeFormat();
|
||||
|
||||
const { getShiftedDateToSystemTimeZone } =
|
||||
useGetShiftedDateToSystemTimeZone();
|
||||
|
||||
const { getShiftedDateToCustomTimeZone } =
|
||||
useGetShiftedDateToCustomTimeZone();
|
||||
|
||||
const { parseDateTimeInputStringToJSDate } =
|
||||
useParseDateTimeInputStringToJSDate();
|
||||
const { parseJSDateToDateTimeInputString } =
|
||||
@@ -66,6 +81,17 @@ export const DateTimePickerInput = ({
|
||||
|
||||
const blocks = DATE_TIME_BLOCKS;
|
||||
|
||||
const defaultValueForIMask = isDefined(internalDate)
|
||||
? new Date(internalDate?.toInstant().toString())
|
||||
: null;
|
||||
|
||||
const shiftedIMaskDate = isDefined(defaultValueForIMask)
|
||||
? getShiftedDateToSystemTimeZone(
|
||||
defaultValueForIMask,
|
||||
timeZone ?? userTimezone,
|
||||
)
|
||||
: null;
|
||||
|
||||
const { ref, setValue } = useIMask(
|
||||
{
|
||||
mask: Date,
|
||||
@@ -79,9 +105,9 @@ export const DateTimePickerInput = ({
|
||||
autofix: false,
|
||||
},
|
||||
{
|
||||
defaultValue: parseJSDateToDateTimeInputString(
|
||||
internalDate ?? new Date(),
|
||||
),
|
||||
defaultValue: isDefined(shiftedIMaskDate)
|
||||
? parseJSDateToDateTimeInputString(shiftedIMaskDate)
|
||||
: undefined,
|
||||
onComplete: (value) => {
|
||||
const parsedDate = parseDateTimeInputStringToJSDate(value);
|
||||
|
||||
@@ -89,25 +115,67 @@ export const DateTimePickerInput = ({
|
||||
return;
|
||||
}
|
||||
|
||||
const pointInTime =
|
||||
turnReactDatePickerShiftedDateBackIntoPointInTime(parsedDate);
|
||||
const pointInTime = getShiftedDateToCustomTimeZone(
|
||||
parsedDate,
|
||||
timeZone ?? userTimezone,
|
||||
);
|
||||
|
||||
onChange?.(pointInTime);
|
||||
setInternalDate(date);
|
||||
|
||||
const zonedDateTime = Temporal.Instant.from(
|
||||
pointInTime.toISOString(),
|
||||
).toZonedDateTimeISO(timeZone ?? userTimezone);
|
||||
|
||||
onChange?.(zonedDateTime);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isDefined(date) && internalDate !== date) {
|
||||
if (isDifferentZonedDateTime(internalDate, date)) {
|
||||
setInternalDate(date);
|
||||
setValue(parseJSDateToDateTimeInputString(date));
|
||||
|
||||
if (!isDefined(date)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newDateAsDate = new Date(date.toInstant().toString());
|
||||
|
||||
const newShiftedDate = getShiftedDateToSystemTimeZone(
|
||||
newDateAsDate,
|
||||
timeZone ?? userTimezone,
|
||||
);
|
||||
|
||||
setValue(parseJSDateToDateTimeInputString(newShiftedDate));
|
||||
}
|
||||
}, [date, internalDate, parseJSDateToDateTimeInputString, setValue]);
|
||||
}, [
|
||||
date,
|
||||
internalDate,
|
||||
parseJSDateToDateTimeInputString,
|
||||
setValue,
|
||||
shiftedIMaskDate,
|
||||
timeZone,
|
||||
getShiftedDateToSystemTimeZone,
|
||||
userTimezone,
|
||||
]);
|
||||
|
||||
const shouldDisplayReadOnly = readonly === true;
|
||||
|
||||
const internalDateForTimeZoneAbbreviation =
|
||||
internalDate?.toInstant() ?? Temporal.Now.instant();
|
||||
|
||||
return (
|
||||
<StyledInputContainer>
|
||||
<StyledInput type="text" ref={ref as any} />
|
||||
<TimeZoneAbbreviation date={internalDate ?? new Date()} />
|
||||
<StyledInput
|
||||
disabled={shouldDisplayReadOnly}
|
||||
type="text"
|
||||
ref={ref as any}
|
||||
onFocus={!shouldDisplayReadOnly ? onFocus : undefined}
|
||||
/>
|
||||
<TimeZoneAbbreviation
|
||||
instant={internalDateForTimeZoneAbbreviation}
|
||||
timeZone={timeZone ?? userTimezone}
|
||||
/>
|
||||
</StyledInputContainer>
|
||||
);
|
||||
};
|
||||
|
||||
+21
-8
@@ -1,5 +1,8 @@
|
||||
import { useUserTimezone } from '@/ui/input/components/internal/date/hooks/useUserTimezone';
|
||||
import { getTimezoneAbbreviationForZonedDateTime } from '@/ui/input/components/internal/date/utils/getTimeZoneAbbreviationForZonedDateTime';
|
||||
import styled from '@emotion/styled';
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
import { type Temporal } from 'temporal-polyfill';
|
||||
|
||||
const StyledTimezoneAbbreviation = styled.span<{ hasError?: boolean }>`
|
||||
background: transparent;
|
||||
@@ -9,17 +12,27 @@ const StyledTimezoneAbbreviation = styled.span<{ hasError?: boolean }>`
|
||||
width: fit-content;
|
||||
|
||||
user-select: none;
|
||||
|
||||
line-height: 0.5px;
|
||||
`;
|
||||
|
||||
export const TimeZoneAbbreviation = ({ date }: { date: Date }) => {
|
||||
const { isSystemTimezone, getTimezoneAbbreviationForPointInTime } =
|
||||
useUserTimezone();
|
||||
export const TimeZoneAbbreviation = ({
|
||||
instant,
|
||||
timeZone,
|
||||
}: {
|
||||
instant: Temporal.Instant;
|
||||
timeZone?: string;
|
||||
}) => {
|
||||
const { isSystemTimezone, userTimezone, systemTimeZone } = useUserTimezone();
|
||||
|
||||
const shouldShowTimezoneAbbreviation = !isSystemTimezone;
|
||||
const timezoneSuffix = !isSystemTimezone
|
||||
? ` ${getTimezoneAbbreviationForPointInTime(date ?? new Date())}`
|
||||
const zonedDate = instant.toZonedDateTimeISO(timeZone ?? userTimezone);
|
||||
|
||||
const shouldUseParamsTimeZone =
|
||||
isNonEmptyString(timeZone) && systemTimeZone !== timeZone;
|
||||
|
||||
const shouldShowTimezoneAbbreviation =
|
||||
!isSystemTimezone || shouldUseParamsTimeZone;
|
||||
|
||||
const timezoneSuffix = shouldShowTimezoneAbbreviation
|
||||
? ` ${getTimezoneAbbreviationForZonedDateTime(zonedDate)}`
|
||||
: '';
|
||||
|
||||
if (!shouldShowTimezoneAbbreviation) {
|
||||
|
||||
+7
-3
@@ -1,7 +1,7 @@
|
||||
import { useArgs } from '@storybook/preview-api';
|
||||
import { type Meta, type StoryObj } from '@storybook/react';
|
||||
import { expect, userEvent, within } from '@storybook/test';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { Temporal } from 'temporal-polyfill';
|
||||
import { ComponentDecorator } from 'twenty-ui/testing';
|
||||
import { DateTimePicker } from '../DateTimePicker';
|
||||
|
||||
@@ -18,12 +18,16 @@ const meta: Meta<typeof DateTimePicker> = {
|
||||
return (
|
||||
<DateTimePicker
|
||||
instanceId="story-date-time-picker"
|
||||
date={isDefined(date) ? new Date(date) : new Date()}
|
||||
date={date}
|
||||
onChange={(newDate) => updateArgs({ date: newDate })}
|
||||
/>
|
||||
);
|
||||
},
|
||||
args: { date: new Date('January 1, 2023 02:00:00') },
|
||||
args: {
|
||||
date: Temporal.Instant.from('2023-01-01T02:00:00Z').toZonedDateTimeISO(
|
||||
'UTC',
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { RecoilRoot } from 'recoil';
|
||||
import { Temporal } from 'temporal-polyfill';
|
||||
|
||||
import { DateFormat } from '@/localization/constants/DateFormat';
|
||||
import { NumberFormat } from '@/localization/constants/NumberFormat';
|
||||
import { TimeFormat } from '@/localization/constants/TimeFormat';
|
||||
import { workspaceMemberFormatPreferencesState } from '@/localization/states/workspaceMemberFormatPreferencesState';
|
||||
import { useParseZonedDateTimeToIMaskDateTimeInputString } from '@/ui/input/components/internal/date/hooks/useParseZonedDateTimeToIMaskDateTimeInputString';
|
||||
import { CalendarStartDay } from 'twenty-shared/constants';
|
||||
|
||||
describe('useParseZonedDateTimeToIMaskDateTimeInputString', () => {
|
||||
const testZonedDateTime = Temporal.ZonedDateTime.from({
|
||||
year: 2024,
|
||||
month: 3,
|
||||
day: 15,
|
||||
hour: 14,
|
||||
minute: 30,
|
||||
timeZone: 'Pacific/Auckland',
|
||||
});
|
||||
|
||||
const createWrapper =
|
||||
(dateFormat: DateFormat) =>
|
||||
({ children }: { children: React.ReactNode }) => (
|
||||
<RecoilRoot
|
||||
initializeState={(snapshot) => {
|
||||
snapshot.set(workspaceMemberFormatPreferencesState, {
|
||||
timeZone: 'Pacific/Auckland',
|
||||
dateFormat,
|
||||
timeFormat: TimeFormat.HOUR_24,
|
||||
numberFormat: NumberFormat.COMMAS_AND_DOT,
|
||||
calendarStartDay: CalendarStartDay.MONDAY,
|
||||
});
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</RecoilRoot>
|
||||
);
|
||||
|
||||
it('should format with day first format (DD/MM/YYYY, HH:mm)', () => {
|
||||
const { result } = renderHook(
|
||||
() => useParseZonedDateTimeToIMaskDateTimeInputString(),
|
||||
{ wrapper: createWrapper(DateFormat.DAY_FIRST) },
|
||||
);
|
||||
|
||||
const formattedString =
|
||||
result.current.parseZonedDateTimeToDateTimeInputString(testZonedDateTime);
|
||||
|
||||
expect(formattedString).toBe('15/03/2024, 14:30');
|
||||
});
|
||||
|
||||
it('should format with year first format (YYYY-MM-DD, HH:mm)', () => {
|
||||
const { result } = renderHook(
|
||||
() => useParseZonedDateTimeToIMaskDateTimeInputString(),
|
||||
{ wrapper: createWrapper(DateFormat.YEAR_FIRST) },
|
||||
);
|
||||
|
||||
const formattedString =
|
||||
result.current.parseZonedDateTimeToDateTimeInputString(testZonedDateTime);
|
||||
|
||||
expect(formattedString).toBe('2024-03-15, 14:30');
|
||||
});
|
||||
|
||||
it('should format with month first format (MM/DD/YYYY, HH:mm)', () => {
|
||||
const { result } = renderHook(
|
||||
() => useParseZonedDateTimeToIMaskDateTimeInputString(),
|
||||
{ wrapper: createWrapper(DateFormat.MONTH_FIRST) },
|
||||
);
|
||||
|
||||
const formattedString =
|
||||
result.current.parseZonedDateTimeToDateTimeInputString(testZonedDateTime);
|
||||
|
||||
expect(formattedString).toBe('03/15/2024, 14:30');
|
||||
});
|
||||
|
||||
it('should format with month first format when dateFormat is SYSTEM', () => {
|
||||
const { result } = renderHook(
|
||||
() => useParseZonedDateTimeToIMaskDateTimeInputString(),
|
||||
{ wrapper: createWrapper(DateFormat.SYSTEM) },
|
||||
);
|
||||
|
||||
const formattedString =
|
||||
result.current.parseZonedDateTimeToDateTimeInputString(testZonedDateTime);
|
||||
|
||||
expect(formattedString).toBe('03/15/2024, 14:30');
|
||||
});
|
||||
});
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
import { Temporal } from 'temporal-polyfill';
|
||||
|
||||
export const useGetShiftedDateToCustomTimeZone = () => {
|
||||
const systemTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
|
||||
const getShiftedDateToCustomTimeZone = (
|
||||
instantExpressedInSystemTimeZone: Date,
|
||||
timeZone: string,
|
||||
) => {
|
||||
const correctZonedDateTime = Temporal.Instant.from(
|
||||
instantExpressedInSystemTimeZone.toISOString(),
|
||||
).toZonedDateTimeISO(systemTimeZone);
|
||||
|
||||
const plainDateTimeWithoutTimeZone = correctZonedDateTime.toPlainDateTime();
|
||||
|
||||
const shiftedZonedDateTime =
|
||||
plainDateTimeWithoutTimeZone.toZonedDateTime(timeZone);
|
||||
|
||||
const dateObjectShiftedToCustomTimeZone = new Date(
|
||||
shiftedZonedDateTime.toInstant().toString(),
|
||||
);
|
||||
|
||||
return dateObjectShiftedToCustomTimeZone;
|
||||
};
|
||||
|
||||
return {
|
||||
getShiftedDateToCustomTimeZone,
|
||||
};
|
||||
};
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
import { useUserTimezone } from '@/ui/input/components/internal/date/hooks/useUserTimezone';
|
||||
import { Temporal } from 'temporal-polyfill';
|
||||
|
||||
export const useGetShiftedDateToSystemTimeZone = () => {
|
||||
const systemTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
|
||||
const { userTimezone } = useUserTimezone();
|
||||
|
||||
const getShiftedDateToSystemTimeZone = (
|
||||
instantShiftedInCustomTimeZone: Date,
|
||||
timeZone?: string,
|
||||
) => {
|
||||
const zonedDateTimeInCustomTimeZone = Temporal.Instant.from(
|
||||
instantShiftedInCustomTimeZone.toISOString(),
|
||||
).toZonedDateTimeISO(timeZone ?? userTimezone);
|
||||
|
||||
const plainDateTimeWithoutTimeZone =
|
||||
zonedDateTimeInCustomTimeZone.toPlainDateTime();
|
||||
|
||||
const zonedDateTimeShiftedToSystemTimeZone =
|
||||
plainDateTimeWithoutTimeZone.toZonedDateTime(systemTimeZone);
|
||||
|
||||
const dateObjectShiftedToSystemTimeZone = new Date(
|
||||
zonedDateTimeShiftedToSystemTimeZone.toInstant().toString(),
|
||||
);
|
||||
|
||||
return dateObjectShiftedToSystemTimeZone;
|
||||
};
|
||||
|
||||
return {
|
||||
getShiftedDateToSystemTimeZone,
|
||||
};
|
||||
};
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
import { DateFormat } from '@/localization/constants/DateFormat';
|
||||
import { useDateTimeFormat } from '@/localization/hooks/useDateTimeFormat';
|
||||
import { type Temporal } from 'temporal-polyfill';
|
||||
|
||||
export const useParseZonedDateTimeToIMaskDateTimeInputString = () => {
|
||||
const { dateFormat } = useDateTimeFormat();
|
||||
|
||||
const parseZonedDateTimeToDateTimeInputString = (
|
||||
zonedDateTime: Temporal.ZonedDateTime,
|
||||
) => {
|
||||
switch (dateFormat) {
|
||||
case DateFormat.DAY_FIRST: {
|
||||
return zonedDateTime.toLocaleString('en-GB', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
});
|
||||
}
|
||||
case DateFormat.YEAR_FIRST: {
|
||||
return zonedDateTime.toLocaleString('en-CA', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
});
|
||||
}
|
||||
case DateFormat.SYSTEM:
|
||||
case DateFormat.MONTH_FIRST: {
|
||||
return zonedDateTime.toLocaleString('en-US', {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
parseZonedDateTimeToDateTimeInputString,
|
||||
};
|
||||
};
|
||||
-29
@@ -1,29 +0,0 @@
|
||||
import { useUserTimezone } from '@/ui/input/components/internal/date/hooks/useUserTimezone';
|
||||
import { TZDate } from '@date-fns/tz';
|
||||
|
||||
export const useTurnPointInTimeIntoReactDatePickerShiftedDate = () => {
|
||||
const systemTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
|
||||
const { userTimezone } = useUserTimezone();
|
||||
|
||||
// TODO: replace here with shiftPointInTimeToFromTimezoneDifference
|
||||
const turnPointInTimeIntoReactDatePickerShiftedDate = (pointInTime: Date) => {
|
||||
const dateSure = new TZDate(pointInTime).withTimeZone(userTimezone);
|
||||
|
||||
const shiftedDate = new TZDate(
|
||||
dateSure.getFullYear(),
|
||||
dateSure.getMonth(),
|
||||
dateSure.getDate(),
|
||||
dateSure.getHours(),
|
||||
dateSure.getMinutes(),
|
||||
dateSure.getSeconds(),
|
||||
systemTimeZone,
|
||||
);
|
||||
|
||||
return shiftedDate;
|
||||
};
|
||||
|
||||
return {
|
||||
turnPointInTimeIntoReactDatePickerShiftedDate,
|
||||
};
|
||||
};
|
||||
-33
@@ -1,33 +0,0 @@
|
||||
import { useUserTimezone } from '@/ui/input/components/internal/date/hooks/useUserTimezone';
|
||||
import { TZDate } from '@date-fns/tz';
|
||||
|
||||
export const useTurnReactDatePickerShiftedDateBackIntoPointInTime = () => {
|
||||
const systemTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
|
||||
const { userTimezone } = useUserTimezone();
|
||||
|
||||
// TODO: replace here with shiftPointInTimeToFromTimezoneDifference
|
||||
const turnReactDatePickerShiftedDateBackIntoPointInTime = (
|
||||
reactDatePickerShiftedDate: Date,
|
||||
) => {
|
||||
const dateSure = new TZDate(reactDatePickerShiftedDate).withTimeZone(
|
||||
systemTimeZone,
|
||||
);
|
||||
|
||||
const dateTz = new TZDate(
|
||||
dateSure.getFullYear(),
|
||||
dateSure.getMonth(),
|
||||
dateSure.getDate(),
|
||||
dateSure.getHours(),
|
||||
dateSure.getMinutes(),
|
||||
dateSure.getSeconds(),
|
||||
userTimezone,
|
||||
);
|
||||
|
||||
return dateTz;
|
||||
};
|
||||
|
||||
return {
|
||||
turnReactDatePickerShiftedDateBackIntoPointInTime,
|
||||
};
|
||||
};
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
|
||||
import { detectCalendarStartDay } from '@/localization/utils/detection/detectCalendarStartDay';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { CalendarStartDay } from 'twenty-shared/constants';
|
||||
import {
|
||||
convertCalendarStartDayNonIsoNumberToFirstDayOfTheWeek,
|
||||
isDefined,
|
||||
} from 'twenty-shared/utils';
|
||||
|
||||
export const useUserFirstDayOfTheWeek = () => {
|
||||
const currentWorkspaceMember = useRecoilValue(currentWorkspaceMemberState);
|
||||
const systemFirstDayOfTheWeek = detectCalendarStartDay();
|
||||
|
||||
const isSystemFirstDayOfTheWeek =
|
||||
currentWorkspaceMember?.calendarStartDay === CalendarStartDay.SYSTEM;
|
||||
|
||||
const currentWorkspaceMemberCalendarStartDayNonIsoNumber =
|
||||
currentWorkspaceMember?.calendarStartDay;
|
||||
|
||||
const currentWorkspaceMemberFirstDayOfTheWeek = isDefined(
|
||||
currentWorkspaceMemberCalendarStartDayNonIsoNumber,
|
||||
)
|
||||
? convertCalendarStartDayNonIsoNumberToFirstDayOfTheWeek(
|
||||
currentWorkspaceMemberCalendarStartDayNonIsoNumber,
|
||||
systemFirstDayOfTheWeek,
|
||||
)
|
||||
: undefined;
|
||||
|
||||
const userFirstDayOfTheWeek = isSystemFirstDayOfTheWeek
|
||||
? systemFirstDayOfTheWeek
|
||||
: (currentWorkspaceMemberFirstDayOfTheWeek ?? systemFirstDayOfTheWeek);
|
||||
|
||||
return {
|
||||
isSystemFirstDayOfTheWeek,
|
||||
systemFirstDayOfTheWeek,
|
||||
userFirstDayOfTheWeek,
|
||||
};
|
||||
};
|
||||
+1
-6
@@ -1,5 +1,4 @@
|
||||
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
|
||||
import { tzName } from '@date-fns/tz';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
|
||||
export const useUserTimezone = () => {
|
||||
@@ -13,13 +12,9 @@ export const useUserTimezone = () => {
|
||||
|
||||
const isSystemTimezone = userTimezone === systemTimeZone;
|
||||
|
||||
const getTimezoneAbbreviationForPointInTime = (date: Date) => {
|
||||
return tzName(userTimezone, date, 'short');
|
||||
};
|
||||
|
||||
return {
|
||||
userTimezone,
|
||||
isSystemTimezone,
|
||||
getTimezoneAbbreviationForPointInTime,
|
||||
systemTimeZone,
|
||||
};
|
||||
};
|
||||
|
||||
+42
-31
@@ -1,58 +1,69 @@
|
||||
import { getHighlightedDates } from '@/ui/input/components/internal/date/utils/getHighlightedDates';
|
||||
import { expect } from '@storybook/test';
|
||||
import { Temporal } from 'temporal-polyfill';
|
||||
|
||||
jest.useFakeTimers().setSystemTime(new Date('2024-10-01T00:00:00.000Z'));
|
||||
|
||||
describe('getHighlightedDates', () => {
|
||||
it('should should return empty if range is undefined', () => {
|
||||
const dateRange = undefined;
|
||||
expect(getHighlightedDates(dateRange)).toEqual([]);
|
||||
});
|
||||
const TIME_ZONE = 'UTC';
|
||||
|
||||
const getUTCPlainDateFromISO = (isoStringDate: string) => {
|
||||
return Temporal.Instant.from(isoStringDate)
|
||||
.toZonedDateTimeISO('UTC')
|
||||
.toPlainDate();
|
||||
};
|
||||
|
||||
describe('getHighlightedDates', () => {
|
||||
it('should should return one day if range is one day', () => {
|
||||
const dateRange = {
|
||||
start: new Date('2024-10-12T00:00:00.000Z'),
|
||||
end: new Date('2024-10-12T00:00:00.000Z'),
|
||||
start: getUTCPlainDateFromISO('2024-10-12T00:00:00.000Z'),
|
||||
end: getUTCPlainDateFromISO('2024-10-12T00:00:00.000Z'),
|
||||
};
|
||||
expect(getHighlightedDates(dateRange)).toEqual([
|
||||
new Date('2024-10-12T00:00:00.000Z'),
|
||||
]);
|
||||
expect(
|
||||
getHighlightedDates(dateRange.start, dateRange.end, TIME_ZONE),
|
||||
).toEqual([getUTCPlainDateFromISO('2024-10-12T00:00:00.000Z')]);
|
||||
});
|
||||
|
||||
it('should should return two days if range is 2 days', () => {
|
||||
const dateRange = {
|
||||
start: new Date('2024-10-12T00:00:00.000Z'),
|
||||
end: new Date('2024-10-13T00:00:00.000Z'),
|
||||
start: getUTCPlainDateFromISO('2024-10-12T00:00:00.000Z'),
|
||||
end: getUTCPlainDateFromISO('2024-10-13T00:00:00.000Z'),
|
||||
};
|
||||
expect(getHighlightedDates(dateRange)).toEqual([
|
||||
new Date('2024-10-12T00:00:00.000Z'),
|
||||
new Date('2024-10-13T00:00:00.000Z'),
|
||||
expect(
|
||||
getHighlightedDates(dateRange.start, dateRange.end, TIME_ZONE),
|
||||
).toEqual([
|
||||
getUTCPlainDateFromISO('2024-10-12T00:00:00.000Z'),
|
||||
getUTCPlainDateFromISO('2024-10-13T00:00:00.000Z'),
|
||||
]);
|
||||
});
|
||||
|
||||
it('should should return 10 days if range is 10 days', () => {
|
||||
const dateRange = {
|
||||
start: new Date('2024-10-12T00:00:00.000Z'),
|
||||
end: new Date('2024-10-21T00:00:00.000Z'),
|
||||
start: getUTCPlainDateFromISO('2024-10-12T00:00:00.000Z'),
|
||||
end: getUTCPlainDateFromISO('2024-10-21T00:00:00.000Z'),
|
||||
};
|
||||
expect(getHighlightedDates(dateRange)).toEqual([
|
||||
new Date('2024-10-12T00:00:00.000Z'),
|
||||
new Date('2024-10-13T00:00:00.000Z'),
|
||||
new Date('2024-10-14T00:00:00.000Z'),
|
||||
new Date('2024-10-15T00:00:00.000Z'),
|
||||
new Date('2024-10-16T00:00:00.000Z'),
|
||||
new Date('2024-10-17T00:00:00.000Z'),
|
||||
new Date('2024-10-18T00:00:00.000Z'),
|
||||
new Date('2024-10-19T00:00:00.000Z'),
|
||||
new Date('2024-10-20T00:00:00.000Z'),
|
||||
new Date('2024-10-21T00:00:00.000Z'),
|
||||
expect(
|
||||
getHighlightedDates(dateRange.start, dateRange.end, TIME_ZONE),
|
||||
).toEqual([
|
||||
getUTCPlainDateFromISO('2024-10-12T00:00:00.000Z'),
|
||||
getUTCPlainDateFromISO('2024-10-13T00:00:00.000Z'),
|
||||
getUTCPlainDateFromISO('2024-10-14T00:00:00.000Z'),
|
||||
getUTCPlainDateFromISO('2024-10-15T00:00:00.000Z'),
|
||||
getUTCPlainDateFromISO('2024-10-16T00:00:00.000Z'),
|
||||
getUTCPlainDateFromISO('2024-10-17T00:00:00.000Z'),
|
||||
getUTCPlainDateFromISO('2024-10-18T00:00:00.000Z'),
|
||||
getUTCPlainDateFromISO('2024-10-19T00:00:00.000Z'),
|
||||
getUTCPlainDateFromISO('2024-10-20T00:00:00.000Z'),
|
||||
getUTCPlainDateFromISO('2024-10-21T00:00:00.000Z'),
|
||||
]);
|
||||
});
|
||||
|
||||
it('should should return empty if range is 10 days but out of range', () => {
|
||||
const dateRange = {
|
||||
start: new Date('2023-10-01T00:00:00.000Z'),
|
||||
end: new Date('2023-10-10T00:00:00.000Z'),
|
||||
start: getUTCPlainDateFromISO('2023-10-01T00:00:00.000Z'),
|
||||
end: getUTCPlainDateFromISO('2023-10-10T00:00:00.000Z'),
|
||||
};
|
||||
expect(getHighlightedDates(dateRange)).toEqual([]);
|
||||
expect(
|
||||
getHighlightedDates(dateRange.start, dateRange.end, TIME_ZONE),
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
+19
-16
@@ -1,25 +1,28 @@
|
||||
import { addDays, addMonths, startOfDay, subMonths } from 'date-fns';
|
||||
import { Temporal } from 'temporal-polyfill';
|
||||
import { isPlainDateAfter, isPlainDateBefore } from 'twenty-shared/utils';
|
||||
|
||||
export const getHighlightedDates = (highlightedDateRange?: {
|
||||
start: Date;
|
||||
end: Date;
|
||||
}): Date[] => {
|
||||
if (!highlightedDateRange) return [];
|
||||
const { start, end } = highlightedDateRange;
|
||||
export const getHighlightedDates = (
|
||||
start: Temporal.PlainDate,
|
||||
end: Temporal.PlainDate,
|
||||
timeZone: string,
|
||||
): Temporal.PlainDate[] => {
|
||||
const highlightedDates: Temporal.PlainDate[] = [];
|
||||
|
||||
const highlightedDates: Date[] = [];
|
||||
const currentDate = startOfDay(new Date());
|
||||
const minDate = subMonths(currentDate, 2);
|
||||
const maxDate = addMonths(currentDate, 2);
|
||||
const currentDate = Temporal.Now.zonedDateTimeISO(timeZone)
|
||||
.startOfDay()
|
||||
.toPlainDate();
|
||||
|
||||
const startDate = start < minDate ? minDate : start;
|
||||
const lastDate = end > maxDate ? maxDate : end;
|
||||
const minDate = currentDate.subtract({ months: 2 });
|
||||
const maxDate = currentDate.add({ months: 2 });
|
||||
|
||||
let dateToHighlight = new Date(startDate);
|
||||
const startDate = isPlainDateBefore(start, minDate) ? minDate : start;
|
||||
const lastDate = isPlainDateAfter(end, maxDate) ? maxDate : end;
|
||||
|
||||
while (dateToHighlight <= lastDate) {
|
||||
let dateToHighlight = startDate;
|
||||
|
||||
while (isPlainDateBefore(dateToHighlight, lastDate.add({ days: 1 }))) {
|
||||
highlightedDates.push(dateToHighlight);
|
||||
dateToHighlight = addDays(dateToHighlight, 1);
|
||||
dateToHighlight = dateToHighlight.add({ days: 1 });
|
||||
}
|
||||
|
||||
return highlightedDates;
|
||||
|
||||
+1
-1
@@ -20,5 +20,5 @@ export const getMonthSelectOptions = (
|
||||
): { label: string; value: number }[] =>
|
||||
getMonthNames(locale).map((month, index) => ({
|
||||
label: month,
|
||||
value: index,
|
||||
value: index + 1,
|
||||
}));
|
||||
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
import { type Temporal } from 'temporal-polyfill';
|
||||
|
||||
export const getTimezoneAbbreviationForZonedDateTime = (
|
||||
zonedDateTime: Temporal.ZonedDateTime,
|
||||
) => {
|
||||
const parts = new Intl.DateTimeFormat('en', {
|
||||
timeZoneName: 'short',
|
||||
timeZone: zonedDateTime.timeZoneId,
|
||||
}).formatToParts(new Date(zonedDateTime.toInstant().toString()));
|
||||
|
||||
const timeZoneName = parts.filter((p) => p.type === 'timeZoneName')[0].value;
|
||||
|
||||
return timeZoneName;
|
||||
};
|
||||
-26
@@ -1,26 +0,0 @@
|
||||
import { DateFormat } from '@/localization/constants/DateFormat';
|
||||
import { format } from 'date-fns';
|
||||
import { formatInTimeZone } from 'date-fns-tz';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { getDateTimeFormatStringFoDatePickerInputMask } from '~/utils/date-utils';
|
||||
|
||||
type ParseDateTimeToStringArgs = {
|
||||
date: Date;
|
||||
userTimezone: string | undefined;
|
||||
dateFormat?: DateFormat;
|
||||
};
|
||||
|
||||
export const parseDateTimeToString = ({
|
||||
date,
|
||||
userTimezone,
|
||||
dateFormat = DateFormat.MONTH_FIRST,
|
||||
}: ParseDateTimeToStringArgs) => {
|
||||
const parsingFormat =
|
||||
getDateTimeFormatStringFoDatePickerInputMask(dateFormat);
|
||||
|
||||
if (isDefined(userTimezone)) {
|
||||
return formatInTimeZone(date, userTimezone, parsingFormat);
|
||||
} else {
|
||||
return format(date, parsingFormat);
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user