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:
Lucas Bordeau
2025-12-23 07:40:26 -10:00
committed by GitHub
parent 4d5d2233bc
commit 0b5be7caa3
205 changed files with 3813 additions and 1755 deletions
@@ -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}
@@ -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
/>
@@ -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,
@@ -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>
);
};
@@ -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}
@@ -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
/>
@@ -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>
);
};
@@ -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) {
@@ -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;
@@ -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');
});
});
@@ -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,
};
};
@@ -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,
};
};
@@ -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,
};
};
@@ -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,
};
};
@@ -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,
};
};
@@ -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,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,
};
};
@@ -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([]);
});
});
@@ -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;
@@ -20,5 +20,5 @@ export const getMonthSelectOptions = (
): { label: string; value: number }[] =>
getMonthNames(locale).map((month, index) => ({
label: month,
value: index,
value: index + 1,
}));
@@ -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;
};
@@ -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);
}
};