feat(billing): refacto billing (#14243)

… prices for metered billing

---------

Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
Co-authored-by: Félix Malfait <felix@twenty.com>
This commit is contained in:
Antoine Moreaux
2025-09-19 11:25:53 +02:00
committed by GitHub
parent 163890f6c8
commit 43e0cd5d05
351 changed files with 16091 additions and 6101 deletions
@@ -1,73 +0,0 @@
import { detectNumberFormat } from '../detectNumberFormat';
// Mock navigator.language
Object.defineProperty(navigator, 'language', {
writable: true,
value: 'en-US',
});
describe('detectNumberFormat', () => {
beforeEach(() => {
// Reset to default
Object.defineProperty(navigator, 'language', {
writable: true,
value: 'en-US',
});
});
it('should detect COMMAS_AND_DOT format for en-US locale', () => {
Object.defineProperty(navigator, 'language', {
writable: true,
value: 'en-US',
});
expect(detectNumberFormat()).toBe('COMMAS_AND_DOT');
});
it('should detect SPACES_AND_COMMA format for fr-FR locale', () => {
Object.defineProperty(navigator, 'language', {
writable: true,
value: 'fr-FR',
});
expect(detectNumberFormat()).toBe('SPACES_AND_COMMA');
});
it('should detect DOTS_AND_COMMA format for de-DE locale', () => {
Object.defineProperty(navigator, 'language', {
writable: true,
value: 'de-DE',
});
expect(detectNumberFormat()).toBe('DOTS_AND_COMMA');
});
it('should detect APOSTROPHE_AND_DOT format for de-CH locale', () => {
Object.defineProperty(navigator, 'language', {
writable: true,
value: 'de-CH',
});
expect(detectNumberFormat()).toBe('APOSTROPHE_AND_DOT');
});
it('should fallback to COMMAS_AND_DOT for unknown patterns', () => {
Object.defineProperty(navigator, 'language', {
writable: true,
value: 'ja-JP', // Uses different separators not in our patterns
});
expect(detectNumberFormat()).toBe('COMMAS_AND_DOT');
});
it('should handle invalid locale gracefully', () => {
Object.defineProperty(navigator, 'language', {
writable: true,
value: 'invalid-locale',
});
expect(detectNumberFormat()).toBe('COMMAS_AND_DOT');
});
it('should handle missing navigator.language', () => {
Object.defineProperty(navigator, 'language', {
writable: true,
value: undefined,
});
expect(detectNumberFormat()).toBe('COMMAS_AND_DOT');
});
});
@@ -1,22 +1,30 @@
import { detectDateFormat } from '@/localization/utils/detectDateFormat';
import { TimeFormat } from '@/localization/constants/TimeFormat';
import { detectDateFormat } from '@/localization/utils/detection/detectDateFormat';
import { formatDateISOStringToDateTimeSimplified } from '@/localization/utils/formatDateISOStringToDateTimeSimplified';
import { formatInTimeZone } from 'date-fns-tz';
// Mock the imported modules
jest.mock('@/localization/utils/detectDateFormat');
jest.mock('@/localization/utils/detection/detectDateFormat');
jest.mock('date-fns-tz');
const mockDetectDateFormat = detectDateFormat as jest.MockedFunction<
typeof detectDateFormat
>;
const mockFormatInTimeZone = formatInTimeZone as jest.MockedFunction<
typeof formatInTimeZone
>;
describe('formatDateISOStringToDateTimeSimplified', () => {
const mockDate = new Date('2023-08-15T10:30:00Z');
const mockTimeZone = 'America/New_York';
const mockTimeFormat = 'HH:mm';
const mockTimeFormat = TimeFormat.HOUR_24;
beforeEach(() => {
jest.resetAllMocks();
});
it('should format the date correctly when DATE_FORMAT is MONTH_FIRST', () => {
detectDateFormat.mockReturnValue('MONTH_FIRST');
formatInTimeZone.mockReturnValue('Oct 15 · 06:30');
mockDetectDateFormat.mockReturnValue('MONTH_FIRST');
mockFormatInTimeZone.mockReturnValue('Oct 15 · 06:30');
const result = formatDateISOStringToDateTimeSimplified(
mockDate,
@@ -24,8 +32,8 @@ describe('formatDateISOStringToDateTimeSimplified', () => {
mockTimeFormat,
);
expect(detectDateFormat).toHaveBeenCalled();
expect(formatInTimeZone).toHaveBeenCalledWith(
expect(mockDetectDateFormat).toHaveBeenCalled();
expect(mockFormatInTimeZone).toHaveBeenCalledWith(
mockDate,
mockTimeZone,
'MMM d · HH:mm',
@@ -34,8 +42,8 @@ describe('formatDateISOStringToDateTimeSimplified', () => {
});
it('should format the date correctly when DATE_FORMAT is DAY_FIRST', () => {
detectDateFormat.mockReturnValue('DAY_FIRST');
formatInTimeZone.mockReturnValue('15 Oct · 06:30');
mockDetectDateFormat.mockReturnValue('DAY_FIRST');
mockFormatInTimeZone.mockReturnValue('15 Oct · 06:30');
const result = formatDateISOStringToDateTimeSimplified(
mockDate,
@@ -43,8 +51,8 @@ describe('formatDateISOStringToDateTimeSimplified', () => {
mockTimeFormat,
);
expect(detectDateFormat).toHaveBeenCalled();
expect(formatInTimeZone).toHaveBeenCalledWith(
expect(mockDetectDateFormat).toHaveBeenCalled();
expect(mockFormatInTimeZone).toHaveBeenCalledWith(
mockDate,
mockTimeZone,
'd MMM · HH:mm',
@@ -53,16 +61,16 @@ describe('formatDateISOStringToDateTimeSimplified', () => {
});
it('should use the provided time format', () => {
detectDateFormat.mockReturnValue('MONTH_FIRST');
formatInTimeZone.mockReturnValue('Oct 15 · 6:30 AM');
mockDetectDateFormat.mockReturnValue('MONTH_FIRST');
mockFormatInTimeZone.mockReturnValue('Oct 15 · 6:30 AM');
const result = formatDateISOStringToDateTimeSimplified(
mockDate,
mockTimeZone,
'h:mm aa',
TimeFormat.HOUR_12,
);
expect(formatInTimeZone).toHaveBeenCalledWith(
expect(mockFormatInTimeZone).toHaveBeenCalledWith(
mockDate,
mockTimeZone,
'MMM d · h:mm aa',
@@ -71,8 +79,8 @@ describe('formatDateISOStringToDateTimeSimplified', () => {
});
it('should handle different time zones', () => {
detectDateFormat.mockReturnValue('MONTH_FIRST');
formatInTimeZone.mockReturnValue('Oct 16 · 02:30');
mockDetectDateFormat.mockReturnValue('MONTH_FIRST');
mockFormatInTimeZone.mockReturnValue('Oct 16 · 02:30');
const result = formatDateISOStringToDateTimeSimplified(
mockDate,
@@ -80,7 +88,7 @@ describe('formatDateISOStringToDateTimeSimplified', () => {
mockTimeFormat,
);
expect(formatInTimeZone).toHaveBeenCalledWith(
expect(mockFormatInTimeZone).toHaveBeenCalledWith(
mockDate,
'Asia/Tokyo',
'MMM d · HH:mm',
@@ -1,17 +0,0 @@
import { type DateFormat } from '@/localization/constants/DateFormat';
export const detectDateFormat = (): keyof typeof DateFormat => {
const date = new Date();
const formatter = new Intl.DateTimeFormat(navigator.language);
const parts = formatter.formatToParts(date);
const partOrder = parts
.filter((part) => ['year', 'month', 'day'].includes(part.type))
.map((part) => part.type);
if (partOrder[0] === 'month') return 'MONTH_FIRST';
if (partOrder[0] === 'day') return 'DAY_FIRST';
if (partOrder[0] === 'year') return 'YEAR_FIRST';
return 'MONTH_FIRST';
};
@@ -1,41 +0,0 @@
import { NumberFormat } from '@/localization/constants/NumberFormat';
const SPACE_CHARS = new Set([' ', '\u00A0', '\u202F']); // space, non-breaking space, narrow no-break space
const APOSTROPHE_CHARS = new Set(["'", '\u2019']); // apostrophe, right single quotation mark
const FORMAT_PATTERNS = new Map<string, keyof typeof NumberFormat>([
[',|.', NumberFormat.COMMAS_AND_DOT],
['.|,', NumberFormat.DOTS_AND_COMMA],
['space|,', NumberFormat.SPACES_AND_COMMA],
['apostrophe|.', NumberFormat.APOSTROPHE_AND_DOT],
]);
export const detectNumberFormat = (): keyof typeof NumberFormat => {
const testNumber = 1234567.89;
let language = navigator?.language || 'en-US';
let formatter: Intl.NumberFormat;
try {
formatter = new Intl.NumberFormat(language);
} catch {
formatter = new Intl.NumberFormat('en-US');
}
const parts = formatter.formatToParts(testNumber);
const thousandSeparator =
parts.find((part) => part.type === 'group')?.value || '';
const decimalSeparator =
parts.find((part) => part.type === 'decimal')?.value || '';
let thousandCategory: string;
if (SPACE_CHARS.has(thousandSeparator)) {
thousandCategory = 'space';
} else if (APOSTROPHE_CHARS.has(thousandSeparator)) {
thousandCategory = 'apostrophe';
} else {
thousandCategory = thousandSeparator;
}
const pattern = `${thousandCategory}|${decimalSeparator}`;
return FORMAT_PATTERNS.get(pattern) || NumberFormat.COMMAS_AND_DOT;
};
@@ -1,14 +0,0 @@
import { type TimeFormat } from '@/localization/constants/TimeFormat';
import { isDefined } from 'twenty-shared/utils';
export const detectTimeFormat = (): keyof typeof TimeFormat => {
const isHour12 = Intl.DateTimeFormat(navigator.language, {
hour: 'numeric',
}).resolvedOptions().hour12;
if (isDefined(isHour12) && isHour12) {
return 'HOUR_12';
}
return 'HOUR_24';
};
@@ -1,6 +0,0 @@
/**
* Detects the user's time zone.
* @returns a IANA time zone
*/
export const detectTimeZone = () =>
Intl.DateTimeFormat().resolvedOptions().timeZone;
@@ -0,0 +1,122 @@
import { detectCalendarStartDay } from '@/localization/utils/detection/detectCalendarStartDay';
// Mock navigator.language
Object.defineProperty(navigator, 'language', {
writable: true,
value: 'en-US',
});
describe('detectCalendarStartDay', () => {
let originalIntlLocale: typeof Intl.Locale;
beforeEach(() => {
// Reset to default
Object.defineProperty(navigator, 'language', {
writable: true,
value: 'en-US',
});
// Store original and mock Intl.Locale to force fallback logic
originalIntlLocale = global.Intl.Locale;
(global.Intl as any).Locale = jest.fn().mockImplementation(() => {
throw new Error('Force fallback');
});
});
afterEach(() => {
// Restore original Intl.Locale
(global.Intl as any).Locale = originalIntlLocale;
});
it('should return SUNDAY for en-US locale', () => {
Object.defineProperty(navigator, 'language', {
writable: true,
value: 'en-US',
});
expect(detectCalendarStartDay()).toBe('SUNDAY');
});
it('should return MONDAY for German locale', () => {
Object.defineProperty(navigator, 'language', {
writable: true,
value: 'de-DE',
});
expect(detectCalendarStartDay()).toBe('MONDAY');
});
it('should return MONDAY for French locale', () => {
Object.defineProperty(navigator, 'language', {
writable: true,
value: 'fr-FR',
});
expect(detectCalendarStartDay()).toBe('MONDAY');
});
it('should return MONDAY for British English', () => {
Object.defineProperty(navigator, 'language', {
writable: true,
value: 'en-GB',
});
expect(detectCalendarStartDay()).toBe('MONDAY');
});
it('should return SATURDAY for Arabic locale', () => {
Object.defineProperty(navigator, 'language', {
writable: true,
value: 'ar-SA',
});
expect(detectCalendarStartDay()).toBe('SATURDAY');
});
it('should return SATURDAY for Hebrew locale', () => {
Object.defineProperty(navigator, 'language', {
writable: true,
value: 'he-IL',
});
expect(detectCalendarStartDay()).toBe('SATURDAY');
});
it('should return SUNDAY for unknown locale', () => {
Object.defineProperty(navigator, 'language', {
writable: true,
value: 'xx-XX',
});
expect(detectCalendarStartDay()).toBe('SUNDAY');
});
it('should handle missing navigator.language', () => {
Object.defineProperty(navigator, 'language', {
writable: true,
value: undefined,
});
expect(detectCalendarStartDay()).toBe('SUNDAY');
});
it('should handle Intl.Locale with weekInfo support', () => {
// Temporarily restore original and mock with weekInfo
(global.Intl as any).Locale = jest.fn().mockImplementation(() => ({
weekInfo: { firstDay: 1 }, // Monday
}));
expect(detectCalendarStartDay()).toBe('MONDAY');
});
it('should handle Intl.Locale with Saturday firstDay', () => {
// Temporarily restore original and mock with weekInfo
(global.Intl as any).Locale = jest.fn().mockImplementation(() => ({
weekInfo: { firstDay: 6 }, // Saturday
}));
expect(detectCalendarStartDay()).toBe('SATURDAY');
});
it('should handle Intl.Locale throwing error', () => {
// This test uses the default mock that throws an error
Object.defineProperty(navigator, 'language', {
writable: true,
value: 'de-DE',
});
expect(detectCalendarStartDay()).toBe('MONDAY');
});
});
@@ -1,4 +1,4 @@
import { detectDateFormat } from '@/localization/utils/detectDateFormat';
import { detectDateFormat } from '@/localization/utils/detection/detectDateFormat';
describe('detectDateFormat', () => {
it('should return MONTH_FIRST if the detected format starts with month', () => {
@@ -0,0 +1,221 @@
import { detectNumberFormat } from '@/localization/utils/detection/detectNumberFormat';
// Mock navigator.language
Object.defineProperty(navigator, 'language', {
writable: true,
value: 'en-US',
});
describe('detectNumberFormat', () => {
beforeEach(() => {
// Reset to default
Object.defineProperty(navigator, 'language', {
writable: true,
value: 'en-US',
});
});
it('should detect COMMAS_AND_DOT format for en-US locale', () => {
Object.defineProperty(navigator, 'language', {
writable: true,
value: 'en-US',
});
expect(detectNumberFormat()).toBe('COMMAS_AND_DOT');
});
it('should detect SPACES_AND_COMMA format for fr-FR locale', () => {
Object.defineProperty(navigator, 'language', {
writable: true,
value: 'fr-FR',
});
expect(detectNumberFormat()).toBe('SPACES_AND_COMMA');
});
it('should detect DOTS_AND_COMMA format for de-DE locale', () => {
Object.defineProperty(navigator, 'language', {
writable: true,
value: 'de-DE',
});
expect(detectNumberFormat()).toBe('DOTS_AND_COMMA');
});
it('should detect APOSTROPHE_AND_DOT format for de-CH locale', () => {
Object.defineProperty(navigator, 'language', {
writable: true,
value: 'de-CH',
});
expect(detectNumberFormat()).toBe('APOSTROPHE_AND_DOT');
});
it('should fallback to COMMAS_AND_DOT for unknown patterns', () => {
Object.defineProperty(navigator, 'language', {
writable: true,
value: 'ja-JP', // Uses different separators not in our patterns
});
expect(detectNumberFormat()).toBe('COMMAS_AND_DOT');
});
it('should handle invalid locale gracefully', () => {
Object.defineProperty(navigator, 'language', {
writable: true,
value: 'invalid-locale',
});
expect(detectNumberFormat()).toBe('COMMAS_AND_DOT');
});
it('should handle missing navigator.language', () => {
Object.defineProperty(navigator, 'language', {
writable: true,
value: undefined,
});
expect(detectNumberFormat()).toBe('COMMAS_AND_DOT');
});
it('should detect SPACES_AND_COMMA format for locales using regular space separator', () => {
Object.defineProperty(navigator, 'language', {
writable: true,
value: 'nb-NO', // Norwegian uses space as thousand separator
});
expect(detectNumberFormat()).toBe('SPACES_AND_COMMA');
});
it('should detect SPACES_AND_COMMA format for locales using non-breaking space separator', () => {
// Mock formatToParts to return non-breaking space
const originalNumberFormat = Intl.NumberFormat;
(global.Intl as any).NumberFormat = Object.assign(
jest.fn().mockImplementation(() => ({
formatToParts: () => [
{ type: 'integer', value: '1' },
{ type: 'group', value: '\u00A0' }, // non-breaking space
{ type: 'integer', value: '234' },
{ type: 'group', value: '\u00A0' },
{ type: 'integer', value: '567' },
{ type: 'decimal', value: ',' },
{ type: 'fraction', value: '89' },
],
})),
{ supportedLocalesOf: jest.fn() },
);
expect(detectNumberFormat()).toBe('SPACES_AND_COMMA');
global.Intl.NumberFormat = originalNumberFormat;
});
it('should detect SPACES_AND_COMMA format for locales using narrow no-break space separator', () => {
// Mock formatToParts to return narrow no-break space
const originalNumberFormat = Intl.NumberFormat;
(global.Intl as any).NumberFormat = Object.assign(
jest.fn().mockImplementation(() => ({
formatToParts: () => [
{ type: 'integer', value: '1' },
{ type: 'group', value: '\u202F' }, // narrow no-break space
{ type: 'integer', value: '234' },
{ type: 'group', value: '\u202F' },
{ type: 'integer', value: '567' },
{ type: 'decimal', value: ',' },
{ type: 'fraction', value: '89' },
],
})),
{ supportedLocalesOf: jest.fn() },
);
expect(detectNumberFormat()).toBe('SPACES_AND_COMMA');
global.Intl.NumberFormat = originalNumberFormat;
});
it('should detect APOSTROPHE_AND_DOT format for locales using regular apostrophe separator', () => {
// Mock formatToParts to return apostrophe
const originalNumberFormat = Intl.NumberFormat;
// @ts-expect-error - Mocking for test
global.Intl.NumberFormat = jest.fn().mockImplementation(() => ({
formatToParts: () => [
{ type: 'integer', value: '1' },
{ type: 'group', value: "'" }, // apostrophe
{ type: 'integer', value: '234' },
{ type: 'group', value: "'" },
{ type: 'integer', value: '567' },
{ type: 'decimal', value: '.' },
{ type: 'fraction', value: '89' },
],
}));
expect(detectNumberFormat()).toBe('APOSTROPHE_AND_DOT');
global.Intl.NumberFormat = originalNumberFormat;
});
it('should detect APOSTROPHE_AND_DOT format for locales using right single quotation mark separator', () => {
// Mock formatToParts to return right single quotation mark
const originalNumberFormat = Intl.NumberFormat;
// @ts-expect-error - Mocking for test
global.Intl.NumberFormat = jest.fn().mockImplementation(() => ({
formatToParts: () => [
{ type: 'integer', value: '1' },
{ type: 'group', value: '\u2019' }, // right single quotation mark
{ type: 'integer', value: '234' },
{ type: 'group', value: '\u2019' },
{ type: 'integer', value: '567' },
{ type: 'decimal', value: '.' },
{ type: 'fraction', value: '89' },
],
}));
expect(detectNumberFormat()).toBe('APOSTROPHE_AND_DOT');
global.Intl.NumberFormat = originalNumberFormat;
});
it('should handle formatToParts throwing an error', () => {
const originalNumberFormat = Intl.NumberFormat;
// @ts-expect-error - Mocking for test
global.Intl.NumberFormat = jest.fn().mockImplementation(() => ({
formatToParts: () => {
throw new Error('formatToParts failed');
},
}));
expect(detectNumberFormat()).toBe('COMMAS_AND_DOT');
global.Intl.NumberFormat = originalNumberFormat;
});
it('should handle Intl.NumberFormat constructor throwing an error', () => {
const originalNumberFormat = Intl.NumberFormat;
// @ts-expect-error - Mocking for test
global.Intl.NumberFormat = jest.fn().mockImplementation(() => {
throw new Error('NumberFormat constructor failed');
});
expect(detectNumberFormat()).toBe('COMMAS_AND_DOT');
global.Intl.NumberFormat = originalNumberFormat;
});
it('should handle formatToParts returning parts without group or decimal types', () => {
const originalNumberFormat = Intl.NumberFormat;
// @ts-expect-error - Mocking for test
global.Intl.NumberFormat = jest.fn().mockImplementation(() => ({
formatToParts: () => [
{ type: 'integer', value: '1234567' },
{ type: 'fraction', value: '89' },
],
}));
expect(detectNumberFormat()).toBe('COMMAS_AND_DOT');
global.Intl.NumberFormat = originalNumberFormat;
});
it('should handle unknown pattern combinations', () => {
// Mock formatToParts to return an unknown pattern
const originalNumberFormat = Intl.NumberFormat;
// @ts-expect-error - Mocking for test
global.Intl.NumberFormat = jest.fn().mockImplementation(() => ({
formatToParts: () => [
{ type: 'integer', value: '1' },
{ type: 'group', value: '|' }, // unknown separator
{ type: 'integer', value: '234' },
{ type: 'decimal', value: ':' }, // unknown decimal
{ type: 'fraction', value: '89' },
],
}));
expect(detectNumberFormat()).toBe('COMMAS_AND_DOT');
global.Intl.NumberFormat = originalNumberFormat;
});
});
@@ -1,4 +1,4 @@
import { detectTimeFormat } from '@/localization/utils/detectTimeFormat';
import { detectTimeFormat } from '@/localization/utils/detection/detectTimeFormat';
describe('detectTimeFormat', () => {
it('should return HOUR_12 if the hour format is 12-hour', () => {
@@ -0,0 +1,44 @@
import { detectTimeZone } from '@/localization/utils/detection/detectTimeZone';
describe('detectTimeZone', () => {
it('should return the system timezone', () => {
const result = detectTimeZone();
expect(typeof result).toBe('string');
expect(result.length).toBeGreaterThan(0);
});
it('should return UTC when Intl.DateTimeFormat throws error', () => {
const originalDateTimeFormat = global.Intl.DateTimeFormat;
// @ts-expect-error - Mocking for test
global.Intl.DateTimeFormat = jest.fn().mockImplementation(() => {
throw new Error('DateTimeFormat not supported');
});
expect(detectTimeZone()).toBe('UTC');
global.Intl.DateTimeFormat = originalDateTimeFormat;
});
it('should return UTC when resolvedOptions throws error', () => {
const originalDateTimeFormat = global.Intl.DateTimeFormat;
// @ts-expect-error - Mocking for test
global.Intl.DateTimeFormat = jest.fn().mockImplementation(() => ({
resolvedOptions: () => {
throw new Error('resolvedOptions failed');
},
}));
expect(detectTimeZone()).toBe('UTC');
global.Intl.DateTimeFormat = originalDateTimeFormat;
});
it('should return specific timezone when mocked', () => {
const originalDateTimeFormat = global.Intl.DateTimeFormat;
// @ts-expect-error - Mocking for test
global.Intl.DateTimeFormat = jest.fn().mockImplementation(() => ({
resolvedOptions: () => ({ timeZone: 'America/New_York' }),
}));
expect(detectTimeZone()).toBe('America/New_York');
global.Intl.DateTimeFormat = originalDateTimeFormat;
});
});
@@ -8,7 +8,7 @@ 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);
const locale = new Intl.Locale(navigator?.language || 'en-US');
// Check if the weekInfo property is available (newer browsers)
if (
@@ -37,7 +37,7 @@ export const detectCalendarStartDay = (): keyof typeof CalendarStartDay => {
}
// Fallback: Use a heuristic based on common locale patterns
const language = navigator.language.toLowerCase();
const language = (navigator?.language || 'en-US').toLowerCase();
// Most European countries, Australia, New Zealand start with Monday
if (
@@ -0,0 +1,21 @@
import { type DateFormat } from '@/localization/constants/DateFormat';
export const detectDateFormat = (): keyof typeof DateFormat => {
try {
const date = new Date();
const formatter = new Intl.DateTimeFormat(navigator?.language || 'en-US');
const parts = formatter.formatToParts(date);
const partOrder = parts
.filter((part) => ['year', 'month', 'day'].includes(part.type))
.map((part) => part.type);
if (partOrder[0] === 'month') return 'MONTH_FIRST';
if (partOrder[0] === 'day') return 'DAY_FIRST';
if (partOrder[0] === 'year') return 'YEAR_FIRST';
return 'MONTH_FIRST';
} catch {
return 'MONTH_FIRST';
}
};
@@ -0,0 +1,39 @@
import { NumberFormat } from '@/localization/constants/NumberFormat';
const SPACE_CHARS = new Set([' ', '\u00A0', '\u202F']); // space, non-breaking space, narrow no-break space
const APOSTROPHE_CHARS = new Set(["'", '\u2019']); // apostrophe, right single quotation mark
const FORMAT_PATTERNS = new Map<string, keyof typeof NumberFormat>([
[',|.', NumberFormat.COMMAS_AND_DOT],
['.|,', NumberFormat.DOTS_AND_COMMA],
['space|,', NumberFormat.SPACES_AND_COMMA],
['apostrophe|.', NumberFormat.APOSTROPHE_AND_DOT],
]);
export const detectNumberFormat = (): keyof typeof NumberFormat => {
try {
const testNumber = 1234567.89;
const language = navigator?.language || 'en-US';
const formatter = new Intl.NumberFormat(language);
const parts = formatter.formatToParts(testNumber);
const thousandSeparator =
parts.find((part) => part.type === 'group')?.value || '';
const decimalSeparator =
parts.find((part) => part.type === 'decimal')?.value || '';
let thousandCategory: string;
if (SPACE_CHARS.has(thousandSeparator)) {
thousandCategory = 'space';
} else if (APOSTROPHE_CHARS.has(thousandSeparator)) {
thousandCategory = 'apostrophe';
} else {
thousandCategory = thousandSeparator;
}
const pattern = `${thousandCategory}|${decimalSeparator}`;
return FORMAT_PATTERNS.get(pattern) || NumberFormat.COMMAS_AND_DOT;
} catch {
return NumberFormat.COMMAS_AND_DOT;
}
};
@@ -0,0 +1,18 @@
import { type TimeFormat } from '@/localization/constants/TimeFormat';
import { isDefined } from 'twenty-shared/utils';
export const detectTimeFormat = (): keyof typeof TimeFormat => {
try {
const isHour12 = Intl.DateTimeFormat(navigator?.language || 'en-US', {
hour: 'numeric',
}).resolvedOptions().hour12;
if (isDefined(isHour12) && isHour12) {
return 'HOUR_12';
}
return 'HOUR_24';
} catch {
return 'HOUR_24';
}
};
@@ -0,0 +1,11 @@
/**
* Detects the user's time zone.
* @returns a IANA time zone
*/
export const detectTimeZone = (): string => {
try {
return Intl.DateTimeFormat().resolvedOptions().timeZone;
} catch {
return 'UTC';
}
};
@@ -0,0 +1,50 @@
import { DateFormat } from '@/localization/constants/DateFormat';
import { getDateFormatFromWorkspaceDateFormat } from '@/localization/utils/format-preferences/getDateFormatFromWorkspaceDateFormat';
import { WorkspaceMemberDateFormatEnum } from '~/generated/graphql';
// Mock detectDateFormat
jest.mock('@/localization/utils/detection/detectDateFormat', () => ({
detectDateFormat: jest.fn(() => 'MONTH_FIRST'),
}));
describe('getDateFormatFromWorkspaceDateFormat', () => {
it('should return MONTH_FIRST for MONTH_FIRST workspace format', () => {
expect(
getDateFormatFromWorkspaceDateFormat(
WorkspaceMemberDateFormatEnum.MONTH_FIRST,
),
).toBe(DateFormat.MONTH_FIRST);
});
it('should return DAY_FIRST for DAY_FIRST workspace format', () => {
expect(
getDateFormatFromWorkspaceDateFormat(
WorkspaceMemberDateFormatEnum.DAY_FIRST,
),
).toBe(DateFormat.DAY_FIRST);
});
it('should return YEAR_FIRST for YEAR_FIRST workspace format', () => {
expect(
getDateFormatFromWorkspaceDateFormat(
WorkspaceMemberDateFormatEnum.YEAR_FIRST,
),
).toBe(DateFormat.YEAR_FIRST);
});
it('should return detected format for SYSTEM workspace format', () => {
expect(
getDateFormatFromWorkspaceDateFormat(
WorkspaceMemberDateFormatEnum.SYSTEM,
),
).toBe(DateFormat.MONTH_FIRST);
});
it('should return MONTH_FIRST for unknown format', () => {
expect(
getDateFormatFromWorkspaceDateFormat(
'UNKNOWN' as WorkspaceMemberDateFormatEnum,
),
).toBe(DateFormat.MONTH_FIRST);
});
});
@@ -0,0 +1,42 @@
import { TimeFormat } from '@/localization/constants/TimeFormat';
import { getTimeFormatFromWorkspaceTimeFormat } from '@/localization/utils/format-preferences/getTimeFormatFromWorkspaceTimeFormat';
import { WorkspaceMemberTimeFormatEnum } from '~/generated/graphql';
// Mock detectTimeFormat
jest.mock('@/localization/utils/detection/detectTimeFormat', () => ({
detectTimeFormat: jest.fn(() => 'HOUR_12'),
}));
describe('getTimeFormatFromWorkspaceTimeFormat', () => {
it('should return HOUR_24 for HOUR_24 workspace format', () => {
expect(
getTimeFormatFromWorkspaceTimeFormat(
WorkspaceMemberTimeFormatEnum.HOUR_24,
),
).toBe(TimeFormat.HOUR_24);
});
it('should return HOUR_12 for HOUR_12 workspace format', () => {
expect(
getTimeFormatFromWorkspaceTimeFormat(
WorkspaceMemberTimeFormatEnum.HOUR_12,
),
).toBe(TimeFormat.HOUR_12);
});
it('should return detected format for SYSTEM workspace format', () => {
expect(
getTimeFormatFromWorkspaceTimeFormat(
WorkspaceMemberTimeFormatEnum.SYSTEM,
),
).toBe(TimeFormat.HOUR_12);
});
it('should return HOUR_24 for unknown format', () => {
expect(
getTimeFormatFromWorkspaceTimeFormat(
'UNKNOWN' as WorkspaceMemberTimeFormatEnum,
),
).toBe(TimeFormat.HOUR_24);
});
});
@@ -0,0 +1,19 @@
import { type CurrentWorkspaceMember } from '@/auth/states/currentWorkspaceMemberState';
import { CalendarStartDay } from '@/localization/constants/CalendarStartDay';
import { detectCalendarStartDay } from '@/localization/utils/detection/detectCalendarStartDay';
import { type WorkspaceMember } from '@/workspace-member/types/WorkspaceMember';
export const getCalendarStartDayFromWorkspaceMember = (
workspaceMember: WorkspaceMember | CurrentWorkspaceMember,
): CalendarStartDay => {
const calendarStartDay = workspaceMember.calendarStartDay;
if (
calendarStartDay === CalendarStartDay.SYSTEM ||
calendarStartDay == null
) {
return CalendarStartDay[detectCalendarStartDay()];
}
return calendarStartDay as CalendarStartDay;
};
@@ -1,5 +1,5 @@
import { DateFormat } from '@/localization/constants/DateFormat';
import { detectDateFormat } from '@/localization/utils/detectDateFormat';
import { detectDateFormat } from '@/localization/utils/detection/detectDateFormat';
import { WorkspaceMemberDateFormatEnum } from '~/generated/graphql';
export const getDateFormatFromWorkspaceDateFormat = (
@@ -0,0 +1,22 @@
import { type CurrentWorkspaceMember } from '@/auth/states/currentWorkspaceMemberState';
import { DateFormat } from '@/localization/constants/DateFormat';
import { detectDateFormat } from '@/localization/utils/detection/detectDateFormat';
import { type WorkspaceMember } from '@/workspace-member/types/WorkspaceMember';
import { WorkspaceMemberDateFormatEnum } from '~/generated/graphql';
export const getDateFormatFromWorkspaceMember = (
workspaceMember: WorkspaceMember | CurrentWorkspaceMember,
): DateFormat => {
switch (workspaceMember.dateFormat) {
case WorkspaceMemberDateFormatEnum.SYSTEM:
return DateFormat[detectDateFormat()];
case WorkspaceMemberDateFormatEnum.MONTH_FIRST:
return DateFormat.MONTH_FIRST;
case WorkspaceMemberDateFormatEnum.DAY_FIRST:
return DateFormat.DAY_FIRST;
case WorkspaceMemberDateFormatEnum.YEAR_FIRST:
return DateFormat.YEAR_FIRST;
default:
return DateFormat[detectDateFormat()];
}
};
@@ -0,0 +1,20 @@
import { type CurrentWorkspaceMember } from '@/auth/states/currentWorkspaceMemberState';
import { type WorkspaceMemberFormatPreferences } from '@/localization/states/workspaceMemberFormatPreferencesState';
import { getCalendarStartDayFromWorkspaceMember } from '@/localization/utils/format-preferences/getCalendarStartDayFromWorkspaceMember';
import { getDateFormatFromWorkspaceMember } from '@/localization/utils/format-preferences/getDateFormatFromWorkspaceMember';
import { getNumberFormatFromWorkspaceMember } from '@/localization/utils/format-preferences/getNumberFormatFromWorkspaceMember';
import { getTimeFormatFromWorkspaceMember } from '@/localization/utils/format-preferences/getTimeFormatFromWorkspaceMember';
import { getTimeZoneFromWorkspaceMember } from '@/localization/utils/format-preferences/getTimeZoneFromWorkspaceMember';
import { type WorkspaceMember } from '@/workspace-member/types/WorkspaceMember';
export const getFormatPreferencesFromWorkspaceMember = (
workspaceMember: WorkspaceMember | CurrentWorkspaceMember,
): WorkspaceMemberFormatPreferences => {
return {
timeZone: getTimeZoneFromWorkspaceMember(workspaceMember),
dateFormat: getDateFormatFromWorkspaceMember(workspaceMember),
timeFormat: getTimeFormatFromWorkspaceMember(workspaceMember),
numberFormat: getNumberFormatFromWorkspaceMember(workspaceMember),
calendarStartDay: getCalendarStartDayFromWorkspaceMember(workspaceMember),
};
};
@@ -0,0 +1,24 @@
import { type CurrentWorkspaceMember } from '@/auth/states/currentWorkspaceMemberState';
import { NumberFormat } from '@/localization/constants/NumberFormat';
import { detectNumberFormat } from '@/localization/utils/detection/detectNumberFormat';
import { type WorkspaceMember } from '@/workspace-member/types/WorkspaceMember';
import { WorkspaceMemberNumberFormatEnum } from '~/generated/graphql';
export const getNumberFormatFromWorkspaceMember = (
workspaceMember: WorkspaceMember | CurrentWorkspaceMember,
): NumberFormat => {
switch (workspaceMember.numberFormat) {
case WorkspaceMemberNumberFormatEnum.SYSTEM:
return NumberFormat[detectNumberFormat()];
case WorkspaceMemberNumberFormatEnum.COMMAS_AND_DOT:
return NumberFormat.COMMAS_AND_DOT;
case WorkspaceMemberNumberFormatEnum.SPACES_AND_COMMA:
return NumberFormat.SPACES_AND_COMMA;
case WorkspaceMemberNumberFormatEnum.DOTS_AND_COMMA:
return NumberFormat.DOTS_AND_COMMA;
case WorkspaceMemberNumberFormatEnum.APOSTROPHE_AND_DOT:
return NumberFormat.APOSTROPHE_AND_DOT;
default:
return NumberFormat[detectNumberFormat()];
}
};
@@ -1,5 +1,5 @@
import { NumberFormat } from '@/localization/constants/NumberFormat';
import { detectNumberFormat } from '@/localization/utils/detectNumberFormat';
import { detectNumberFormat } from '@/localization/utils/detection/detectNumberFormat';
import { WorkspaceMemberNumberFormatEnum } from '~/generated/graphql';
export const getNumberFormatFromWorkspaceNumberFormat = (
@@ -0,0 +1,20 @@
import { type CurrentWorkspaceMember } from '@/auth/states/currentWorkspaceMemberState';
import { TimeFormat } from '@/localization/constants/TimeFormat';
import { detectTimeFormat } from '@/localization/utils/detection/detectTimeFormat';
import { type WorkspaceMember } from '@/workspace-member/types/WorkspaceMember';
import { WorkspaceMemberTimeFormatEnum } from '~/generated/graphql';
export const getTimeFormatFromWorkspaceMember = (
workspaceMember: WorkspaceMember | CurrentWorkspaceMember,
): TimeFormat => {
switch (workspaceMember.timeFormat) {
case WorkspaceMemberTimeFormatEnum.SYSTEM:
return TimeFormat[detectTimeFormat()];
case WorkspaceMemberTimeFormatEnum.HOUR_24:
return TimeFormat.HOUR_24;
case WorkspaceMemberTimeFormatEnum.HOUR_12:
return TimeFormat.HOUR_12;
default:
return TimeFormat[detectTimeFormat()];
}
};
@@ -1,5 +1,5 @@
import { TimeFormat } from '@/localization/constants/TimeFormat';
import { detectTimeFormat } from '@/localization/utils/detectTimeFormat';
import { detectTimeFormat } from '@/localization/utils/detection/detectTimeFormat';
import { WorkspaceMemberTimeFormatEnum } from '~/generated/graphql';
export const getTimeFormatFromWorkspaceTimeFormat = (
@@ -0,0 +1,11 @@
import { type CurrentWorkspaceMember } from '@/auth/states/currentWorkspaceMemberState';
import { detectTimeZone } from '@/localization/utils/detection/detectTimeZone';
import { type WorkspaceMember } from '@/workspace-member/types/WorkspaceMember';
export const getTimeZoneFromWorkspaceMember = (
workspaceMember: WorkspaceMember | CurrentWorkspaceMember,
): string => {
return workspaceMember.timeZone === 'system' || !workspaceMember.timeZone
? detectTimeZone()
: workspaceMember.timeZone;
};
@@ -0,0 +1,39 @@
import { type CurrentWorkspaceMember } from '@/auth/states/currentWorkspaceMemberState';
import { type WorkspaceMemberFormatPreferences } from '@/localization/states/workspaceMemberFormatPreferencesState';
import { getWorkspaceDateFormatFromDateFormat } from '@/localization/utils/format-preferences/getWorkspaceDateFormatFromDateFormat';
import { getWorkspaceNumberFormatFromNumberFormat } from '@/localization/utils/format-preferences/getWorkspaceNumberFormatFromNumberFormat';
import { getWorkspaceTimeFormatFromTimeFormat } from '@/localization/utils/format-preferences/getWorkspaceTimeFormatFromTimeFormat';
export const getWorkspaceMemberUpdateFromFormatPreferences = (
preferences: Partial<WorkspaceMemberFormatPreferences>,
): Partial<CurrentWorkspaceMember> => {
const update: Partial<CurrentWorkspaceMember> = {};
if (preferences.timeZone !== undefined) {
update.timeZone = preferences.timeZone;
}
if (preferences.dateFormat !== undefined) {
update.dateFormat = getWorkspaceDateFormatFromDateFormat(
preferences.dateFormat,
);
}
if (preferences.timeFormat !== undefined) {
update.timeFormat = getWorkspaceTimeFormatFromTimeFormat(
preferences.timeFormat,
);
}
if (preferences.numberFormat !== undefined) {
update.numberFormat = getWorkspaceNumberFormatFromNumberFormat(
preferences.numberFormat,
);
}
if (preferences.calendarStartDay !== undefined) {
update.calendarStartDay = preferences.calendarStartDay;
}
return update;
};
@@ -1,6 +1,6 @@
import { DATE_FORMAT_WITHOUT_YEAR } from '@/localization/constants/DateFormatWithoutYear';
import { type TimeFormat } from '@/localization/constants/TimeFormat';
import { detectDateFormat } from '@/localization/utils/detectDateFormat';
import { detectDateFormat } from '@/localization/utils/detection/detectDateFormat';
import { formatInTimeZone } from 'date-fns-tz';
export const formatDateISOStringToDateTimeSimplified = (