feat: allow users to start the calendar week on Monday (#13295)
resolve twentyhq/core-team-issues#1255 - Introduced a new field isWeekStartMonday in the workspaceMember table. - Added a function updateCalendarStartDay to update the isWeekStartMonday value when the user toggles the corresponding field in the settings. The logic works for me, but I couldn’t find a way to create the column locally in the database for the workspaceMember tables. Please let me know if this approach looks good and how to properly create dynamic columns that are not directly handled via migration. https://github.com/user-attachments/assets/4eebada5-6a96-4a88-8e96-98f1501859e3 --------- Co-authored-by: Félix Malfait <felix.malfait@gmail.com> Co-authored-by: Félix Malfait <felix@twenty.com>
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
export enum CalendarStartDay {
|
||||
SYSTEM = 7,
|
||||
SUNDAY = 0,
|
||||
MONDAY = 1,
|
||||
SATURDAY = 6,
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { CalendarStartDay } from '@/localization/constants/CalendarStartDay';
|
||||
|
||||
const MONDAY_KEY: keyof typeof CalendarStartDay = 'MONDAY';
|
||||
const SATURDAY_KEY: keyof typeof CalendarStartDay = 'SATURDAY';
|
||||
const SUNDAY_KEY: keyof typeof CalendarStartDay = 'SUNDAY';
|
||||
|
||||
export const detectCalendarStartDay = (): keyof typeof CalendarStartDay => {
|
||||
// Use Intl.Locale to get the first day of the week from the user's locale
|
||||
// This requires a modern browser that supports Intl.Locale
|
||||
try {
|
||||
const locale = new Intl.Locale(navigator.language);
|
||||
|
||||
// Check if the weekInfo property is available (newer browsers)
|
||||
if (
|
||||
'weekInfo' in locale &&
|
||||
locale.weekInfo !== null &&
|
||||
locale.weekInfo !== undefined &&
|
||||
typeof locale.weekInfo === 'object' &&
|
||||
'firstDay' in locale.weekInfo
|
||||
) {
|
||||
const firstDay = locale.weekInfo.firstDay;
|
||||
|
||||
// Map Intl.Locale firstDay values to our enum keys
|
||||
// Intl.Locale uses 1=Monday, 7=Sunday, 6=Saturday
|
||||
switch (firstDay) {
|
||||
case 1:
|
||||
return MONDAY_KEY;
|
||||
case 6:
|
||||
return SATURDAY_KEY;
|
||||
case 7:
|
||||
default:
|
||||
return SUNDAY_KEY;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Fallback if Intl.Locale is not supported or fails
|
||||
}
|
||||
|
||||
// Fallback: Use a heuristic based on common locale patterns
|
||||
const language = navigator.language.toLowerCase();
|
||||
|
||||
// Most European countries, Australia, New Zealand start with Monday
|
||||
if (
|
||||
language.startsWith('de') || // German
|
||||
language.startsWith('fr') || // French
|
||||
language.startsWith('es') || // Spanish
|
||||
language.startsWith('it') || // Italian
|
||||
language.startsWith('pt') || // Portuguese
|
||||
language.startsWith('nl') || // Dutch
|
||||
language.startsWith('sv') || // Swedish
|
||||
language.startsWith('no') || // Norwegian
|
||||
language.startsWith('da') || // Danish
|
||||
language.startsWith('fi') || // Finnish
|
||||
language.startsWith('pl') || // Polish
|
||||
language.startsWith('ru') || // Russian
|
||||
language.startsWith('en-gb') || // British English
|
||||
language.startsWith('en-au') || // Australian English
|
||||
language.startsWith('en-nz') // New Zealand English
|
||||
) {
|
||||
return MONDAY_KEY;
|
||||
}
|
||||
|
||||
// Middle Eastern countries often start with Saturday
|
||||
if (
|
||||
language.startsWith('ar') || // Arabic
|
||||
language.startsWith('he') || // Hebrew
|
||||
language.startsWith('fa') // Persian
|
||||
) {
|
||||
return SATURDAY_KEY;
|
||||
}
|
||||
|
||||
// Default to Sunday (US, Canada, Japan, etc.)
|
||||
return SUNDAY_KEY;
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
import { formatTimeZoneLabel } from '@/localization/utils/formatTimeZoneLabel';
|
||||
import { AVAILABLE_TIME_ZONE_OPTIONS_BY_LABEL } from '@/settings/accounts/constants/AvailableTimezoneOptionsByLabel';
|
||||
import { AVAILABLE_TIME_ZONE_OPTIONS_BY_LABEL } from '@/settings/experience/constants/AvailableTimezoneOptionsByLabel';
|
||||
|
||||
/**
|
||||
* Finds the matching available IANA time zone select option from a given IANA time zone.
|
||||
|
||||
-34
@@ -1,34 +0,0 @@
|
||||
import { formatInTimeZone } from 'date-fns-tz';
|
||||
|
||||
import { DateFormat } from '@/localization/constants/DateFormat';
|
||||
import { Select } from '@/ui/input/components/Select';
|
||||
|
||||
type SettingsAccountsCalendarDateFormatSelectProps = {
|
||||
value: DateFormat;
|
||||
onChange: (nextValue: DateFormat) => void;
|
||||
timeZone: string;
|
||||
};
|
||||
|
||||
export const SettingsAccountsCalendarDateFormatSelect = ({
|
||||
onChange,
|
||||
timeZone,
|
||||
value,
|
||||
}: SettingsAccountsCalendarDateFormatSelectProps) => (
|
||||
<Select
|
||||
dropdownId="settings-accounts-calendar-date-format"
|
||||
label="Date format"
|
||||
fullWidth
|
||||
value={value}
|
||||
options={[
|
||||
{
|
||||
label: formatInTimeZone(Date.now(), timeZone, DateFormat.MONTH_FIRST),
|
||||
value: DateFormat.MONTH_FIRST,
|
||||
},
|
||||
{
|
||||
label: formatInTimeZone(Date.now(), timeZone, DateFormat.DAY_FIRST),
|
||||
value: DateFormat.DAY_FIRST,
|
||||
},
|
||||
]}
|
||||
onChange={onChange}
|
||||
/>
|
||||
);
|
||||
+6
-9
@@ -1,9 +1,9 @@
|
||||
import styled from '@emotion/styled';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { SettingsAccountsCalendarDateFormatSelect } from '@/settings/accounts/components/SettingsAccountsCalendarDateFormatSelect';
|
||||
import { SettingsAccountsCalendarTimeFormatSelect } from '@/settings/accounts/components/SettingsAccountsCalendarTimeFormatSelect';
|
||||
import { SettingsAccountsCalendarTimeZoneSelect } from '@/settings/accounts/components/SettingsAccountsCalendarTimeZoneSelect';
|
||||
import { DateTimeSettingsDateFormatSelect } from '@/settings/experience/components/DateTimeSettingsDateFormatSelect';
|
||||
import { DateTimeSettingsTimeFormatSelect } from '@/settings/experience/components/DateTimeSettingsTimeFormatSelect';
|
||||
import { DateTimeSettingsTimeZoneSelect } from '@/settings/experience/components/DateTimeSettingsTimeZoneSelect';
|
||||
|
||||
import { DateFormat } from '@/localization/constants/DateFormat';
|
||||
import { TimeFormat } from '@/localization/constants/TimeFormat';
|
||||
@@ -27,16 +27,13 @@ export const SettingsAccountsCalendarDisplaySettings = () => {
|
||||
|
||||
return (
|
||||
<StyledContainer>
|
||||
<SettingsAccountsCalendarTimeZoneSelect
|
||||
value={timeZone}
|
||||
onChange={setTimeZone}
|
||||
/>
|
||||
<SettingsAccountsCalendarDateFormatSelect
|
||||
<DateTimeSettingsTimeZoneSelect value={timeZone} onChange={setTimeZone} />
|
||||
<DateTimeSettingsDateFormatSelect
|
||||
value={dateFormat}
|
||||
onChange={setDateFormat}
|
||||
timeZone={timeZone}
|
||||
/>
|
||||
<SettingsAccountsCalendarTimeFormatSelect
|
||||
<DateTimeSettingsTimeFormatSelect
|
||||
value={timeFormat}
|
||||
onChange={setTimeFormat}
|
||||
timeZone={timeZone}
|
||||
|
||||
-42
@@ -1,42 +0,0 @@
|
||||
import { formatInTimeZone } from 'date-fns-tz';
|
||||
|
||||
import { TimeFormat } from '@/localization/constants/TimeFormat';
|
||||
import { Select } from '@/ui/input/components/Select';
|
||||
|
||||
type SettingsAccountsCalendarTimeFormatSelectProps = {
|
||||
value: TimeFormat;
|
||||
onChange: (nextValue: TimeFormat) => void;
|
||||
timeZone: string;
|
||||
};
|
||||
|
||||
export const SettingsAccountsCalendarTimeFormatSelect = ({
|
||||
onChange,
|
||||
timeZone,
|
||||
value,
|
||||
}: SettingsAccountsCalendarTimeFormatSelectProps) => (
|
||||
<Select
|
||||
dropdownId="settings-accounts-calendar-time-format"
|
||||
label="Time format"
|
||||
fullWidth
|
||||
value={value}
|
||||
options={[
|
||||
{
|
||||
label: `24h (${formatInTimeZone(
|
||||
Date.now(),
|
||||
timeZone,
|
||||
TimeFormat.HOUR_24,
|
||||
)})`,
|
||||
value: TimeFormat.HOUR_24,
|
||||
},
|
||||
{
|
||||
label: `12h (${formatInTimeZone(
|
||||
Date.now(),
|
||||
timeZone,
|
||||
TimeFormat.HOUR_12,
|
||||
)})`,
|
||||
value: TimeFormat.HOUR_12,
|
||||
},
|
||||
]}
|
||||
onChange={onChange}
|
||||
/>
|
||||
);
|
||||
-26
@@ -1,26 +0,0 @@
|
||||
import { detectTimeZone } from '@/localization/utils/detectTimeZone';
|
||||
import { findAvailableTimeZoneOption } from '@/localization/utils/findAvailableTimeZoneOption';
|
||||
import { AVAILABLE_TIMEZONE_OPTIONS } from '@/settings/accounts/constants/AvailableTimezoneOptions';
|
||||
|
||||
import { Select } from '@/ui/input/components/Select';
|
||||
|
||||
type SettingsAccountsCalendarTimeZoneSelectProps = {
|
||||
value?: string;
|
||||
onChange: (nextValue: string) => void;
|
||||
};
|
||||
|
||||
export const SettingsAccountsCalendarTimeZoneSelect = ({
|
||||
value = detectTimeZone(),
|
||||
onChange,
|
||||
}: SettingsAccountsCalendarTimeZoneSelectProps) => (
|
||||
<Select
|
||||
dropdownId="settings-accounts-calendar-time-zone"
|
||||
dropdownWidth={416}
|
||||
label="Time zone"
|
||||
fullWidth
|
||||
value={findAvailableTimeZoneOption(value)?.value}
|
||||
options={AVAILABLE_TIMEZONE_OPTIONS}
|
||||
onChange={onChange}
|
||||
withSearchInput
|
||||
/>
|
||||
);
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
import { formatInTimeZone } from 'date-fns-tz';
|
||||
|
||||
import { DateFormat } from '@/localization/constants/DateFormat';
|
||||
import { detectDateFormat } from '@/localization/utils/detectDateFormat';
|
||||
import { detectTimeZone } from '@/localization/utils/detectTimeZone';
|
||||
import { Select } from '@/ui/input/components/Select';
|
||||
import { t } from '@lingui/core/macro';
|
||||
|
||||
type DateTimeSettingsDateFormatSelectProps = {
|
||||
value: DateFormat;
|
||||
onChange: (nextValue: DateFormat) => void;
|
||||
timeZone: string;
|
||||
};
|
||||
|
||||
export const DateTimeSettingsDateFormatSelect = ({
|
||||
onChange,
|
||||
timeZone,
|
||||
value,
|
||||
}: DateTimeSettingsDateFormatSelectProps) => {
|
||||
const systemTimeZone = detectTimeZone();
|
||||
|
||||
const usedTimeZone = timeZone === 'system' ? systemTimeZone : timeZone;
|
||||
|
||||
const systemDateFormat = DateFormat[detectDateFormat()];
|
||||
|
||||
const systemDateFormatLabel = formatInTimeZone(
|
||||
Date.now(),
|
||||
usedTimeZone,
|
||||
systemDateFormat,
|
||||
);
|
||||
|
||||
return (
|
||||
<Select
|
||||
dropdownId="datetime-settings-date-format"
|
||||
dropdownWidth={218}
|
||||
label={t`Date format`}
|
||||
fullWidth
|
||||
dropdownWidthAuto
|
||||
value={value}
|
||||
options={[
|
||||
{
|
||||
label: t`System settings - ${systemDateFormatLabel}`,
|
||||
value: DateFormat.SYSTEM,
|
||||
},
|
||||
{
|
||||
label: `${formatInTimeZone(
|
||||
Date.now(),
|
||||
usedTimeZone,
|
||||
DateFormat.MONTH_FIRST,
|
||||
)}`,
|
||||
value: DateFormat.MONTH_FIRST,
|
||||
},
|
||||
{
|
||||
label: `${formatInTimeZone(
|
||||
Date.now(),
|
||||
usedTimeZone,
|
||||
DateFormat.DAY_FIRST,
|
||||
)}`,
|
||||
value: DateFormat.DAY_FIRST,
|
||||
},
|
||||
{
|
||||
label: `${formatInTimeZone(
|
||||
Date.now(),
|
||||
usedTimeZone,
|
||||
DateFormat.YEAR_FIRST,
|
||||
)}`,
|
||||
value: DateFormat.YEAR_FIRST,
|
||||
},
|
||||
]}
|
||||
onChange={onChange}
|
||||
/>
|
||||
);
|
||||
};
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
import { formatInTimeZone } from 'date-fns-tz';
|
||||
|
||||
import { TimeFormat } from '@/localization/constants/TimeFormat';
|
||||
import { detectTimeFormat } from '@/localization/utils/detectTimeFormat';
|
||||
import { detectTimeZone } from '@/localization/utils/detectTimeZone';
|
||||
import { Select } from '@/ui/input/components/Select';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
|
||||
type DateTimeSettingsTimeFormatSelectProps = {
|
||||
value: TimeFormat;
|
||||
onChange: (nextValue: TimeFormat) => void;
|
||||
timeZone: string;
|
||||
};
|
||||
|
||||
export const DateTimeSettingsTimeFormatSelect = ({
|
||||
onChange,
|
||||
timeZone,
|
||||
value,
|
||||
}: DateTimeSettingsTimeFormatSelectProps) => {
|
||||
const { t } = useLingui();
|
||||
const systemTimeZone = detectTimeZone();
|
||||
|
||||
const usedTimeZone = timeZone === 'system' ? systemTimeZone : timeZone;
|
||||
|
||||
const systemTimeFormat = TimeFormat[detectTimeFormat()];
|
||||
|
||||
const systemTimeFormatLabel = formatInTimeZone(
|
||||
Date.now(),
|
||||
usedTimeZone,
|
||||
systemTimeFormat,
|
||||
);
|
||||
|
||||
const hour24Label = formatInTimeZone(
|
||||
Date.now(),
|
||||
usedTimeZone,
|
||||
TimeFormat.HOUR_24,
|
||||
);
|
||||
|
||||
const hour12Label = formatInTimeZone(
|
||||
Date.now(),
|
||||
usedTimeZone,
|
||||
TimeFormat.HOUR_12,
|
||||
);
|
||||
|
||||
return (
|
||||
<Select
|
||||
dropdownId="datetime-settings-time-format"
|
||||
dropdownWidth={218}
|
||||
label={t`Time format`}
|
||||
dropdownWidthAuto
|
||||
fullWidth
|
||||
value={value}
|
||||
options={[
|
||||
{
|
||||
label: t`System Settings - ${systemTimeFormatLabel}`,
|
||||
value: TimeFormat.SYSTEM,
|
||||
},
|
||||
{
|
||||
label: t`24h (${hour24Label})`,
|
||||
value: TimeFormat.HOUR_24,
|
||||
},
|
||||
{
|
||||
label: t`12h (${hour12Label})`,
|
||||
value: TimeFormat.HOUR_12,
|
||||
},
|
||||
]}
|
||||
onChange={onChange}
|
||||
/>
|
||||
);
|
||||
};
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
import { detectTimeZone } from '@/localization/utils/detectTimeZone';
|
||||
import { findAvailableTimeZoneOption } from '@/localization/utils/findAvailableTimeZoneOption';
|
||||
import { AVAILABLE_TIMEZONE_OPTIONS } from '@/settings/experience/constants/AvailableTimezoneOptions';
|
||||
import { Select } from '@/ui/input/components/Select';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { SelectOption } from 'twenty-ui/input';
|
||||
|
||||
type DateTimeSettingsTimeZoneSelectProps = {
|
||||
value?: string;
|
||||
onChange: (nextValue: string) => void;
|
||||
};
|
||||
|
||||
export const DateTimeSettingsTimeZoneSelect = ({
|
||||
value = detectTimeZone(),
|
||||
onChange,
|
||||
}: DateTimeSettingsTimeZoneSelectProps) => {
|
||||
const systemTimeZone = detectTimeZone();
|
||||
|
||||
const systemTimeZoneOption = findAvailableTimeZoneOption(systemTimeZone);
|
||||
|
||||
return (
|
||||
<Select
|
||||
dropdownId="datetime-settings-time-zone"
|
||||
label={t`Time zone`}
|
||||
dropdownWidthAuto
|
||||
fullWidth
|
||||
value={value}
|
||||
options={[
|
||||
{
|
||||
label: isDefined(systemTimeZoneOption)
|
||||
? t`System settings`.concat(` - ${systemTimeZoneOption.label}`)
|
||||
: t`System settings`,
|
||||
value: 'system',
|
||||
},
|
||||
...(AVAILABLE_TIMEZONE_OPTIONS as SelectOption<string>[]),
|
||||
]}
|
||||
onChange={onChange}
|
||||
withSearchInput
|
||||
/>
|
||||
);
|
||||
};
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
import { getTimezoneOffset } from 'date-fns-tz';
|
||||
|
||||
import { AVAILABLE_TIME_ZONE_OPTIONS_BY_LABEL } from '@/settings/accounts/constants/AvailableTimezoneOptionsByLabel';
|
||||
import { AVAILABLE_TIME_ZONE_OPTIONS_BY_LABEL } from '@/settings/experience/constants/AvailableTimezoneOptionsByLabel';
|
||||
|
||||
export const AVAILABLE_TIMEZONE_OPTIONS = Object.values(
|
||||
AVAILABLE_TIME_ZONE_OPTIONS_BY_LABEL,
|
||||
@@ -0,0 +1,42 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { SELECT_DAY_DROPDOWN_ID } from '@/ui/input/components/internal/date/constants/SelectDayDropdownId';
|
||||
import { DayNameWithIndex } from '@/ui/input/components/internal/date/types/DayNameWithIndex';
|
||||
import { Select } from '@/ui/input/components/Select';
|
||||
import { SelectOption } from 'twenty-ui/input';
|
||||
|
||||
export const DaySelect = ({
|
||||
label,
|
||||
selectedDayIndex,
|
||||
onChange,
|
||||
dayList,
|
||||
}: {
|
||||
label: string;
|
||||
selectedDayIndex: number;
|
||||
onChange: (dayIndex: number | string) => void;
|
||||
dayList: DayNameWithIndex[];
|
||||
}) => {
|
||||
const options: SelectOption<string | number>[] = useMemo(() => {
|
||||
const days = dayList?.map<SelectOption<string | number>>(
|
||||
({ day, index }) => ({
|
||||
label: day,
|
||||
value: index,
|
||||
}),
|
||||
);
|
||||
|
||||
return days;
|
||||
}, [dayList]);
|
||||
|
||||
return (
|
||||
<Select
|
||||
fullWidth
|
||||
dropdownId={SELECT_DAY_DROPDOWN_ID}
|
||||
options={options}
|
||||
label={label}
|
||||
onChange={onChange}
|
||||
value={selectedDayIndex}
|
||||
dropdownWidth={218}
|
||||
dropdownWidthAuto
|
||||
/>
|
||||
);
|
||||
};
|
||||
+11
-1
@@ -4,6 +4,9 @@ import { lazy, Suspense, useContext } from 'react';
|
||||
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 '@/localization/constants/CalendarStartDay';
|
||||
import { detectCalendarStartDay } from '@/localization/utils/detectCalendarStartDay';
|
||||
import { AbsoluteDatePickerHeader } from '@/ui/input/components/internal/date/components/AbsoluteDatePickerHeader';
|
||||
import { DateTimeInput } from '@/ui/input/components/internal/date/components/DateTimeInput';
|
||||
import { RelativeDatePickerHeader } from '@/ui/input/components/internal/date/components/RelativeDatePickerHeader';
|
||||
@@ -17,6 +20,7 @@ import {
|
||||
import { useTheme } from '@emotion/react';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import 'react-datepicker/dist/react-datepicker.css';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { IconCalendarX } from 'twenty-ui/display';
|
||||
import {
|
||||
MenuItemLeftContent,
|
||||
@@ -343,7 +347,7 @@ export const DateTimePicker = ({
|
||||
|
||||
const { closeDropdown: closeDropdownMonthSelect } = useCloseDropdown();
|
||||
const { closeDropdown: closeDropdownYearSelect } = useCloseDropdown();
|
||||
|
||||
const currentWorkspaceMember = useRecoilValue(currentWorkspaceMemberState);
|
||||
const handleClear = () => {
|
||||
closeDropdowns();
|
||||
onClear?.();
|
||||
@@ -501,6 +505,12 @@ export const DateTimePicker = ({
|
||||
openToDate={hasDate ? dateToUse : new Date()}
|
||||
disabledKeyboardNavigation
|
||||
onChange={handleDateChange as any}
|
||||
calendarStartDay={
|
||||
currentWorkspaceMember?.calendarStartDay ===
|
||||
CalendarStartDay.SYSTEM
|
||||
? CalendarStartDay[detectCalendarStartDay()]
|
||||
: (currentWorkspaceMember?.calendarStartDay ?? undefined)
|
||||
}
|
||||
customInput={
|
||||
<DateTimeInput
|
||||
date={internalDate}
|
||||
|
||||
+1
@@ -0,0 +1 @@
|
||||
export const SELECT_DAY_DROPDOWN_ID = 'select-day-dropdown-Id';
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
export type DayNameWithIndex = {
|
||||
day: string;
|
||||
index: number;
|
||||
};
|
||||
+1
@@ -14,5 +14,6 @@ export const WORKSPACE_MEMBER_QUERY_FRAGMENT = gql`
|
||||
timeZone
|
||||
dateFormat
|
||||
timeFormat
|
||||
calendarStartDay
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -23,6 +23,7 @@ export type WorkspaceMember = {
|
||||
timeZone?: string | null;
|
||||
dateFormat?: WorkspaceMemberDateFormatEnum | null;
|
||||
timeFormat?: WorkspaceMemberTimeFormatEnum | null;
|
||||
calendarStartDay?: number | null;
|
||||
};
|
||||
|
||||
export type WorkspaceInvitation = {
|
||||
|
||||
Reference in New Issue
Block a user