diff --git a/frontend/components/Task/TaskDetails/TaskDeferUntilCard.tsx b/frontend/components/Task/TaskDetails/TaskDeferUntilCard.tsx index 953963b..c74896d 100644 --- a/frontend/components/Task/TaskDetails/TaskDeferUntilCard.tsx +++ b/frontend/components/Task/TaskDetails/TaskDeferUntilCard.tsx @@ -1,9 +1,13 @@ -import React, { useMemo } from 'react'; +import React from 'react'; import { useTranslation } from 'react-i18next'; import { ClockIcon } from '@heroicons/react/24/outline'; import TaskDeferUntilSection from '../TaskForm/TaskDeferUntilSection'; import { Task } from '../../../entities/Task'; -import { resolveUserLocale } from '../../../utils/localeUtils'; +import { + formatDateTimeByCountry, + getUserTimezone, +} from '../../../utils/dateUtils'; +import { getCountryFromTimezone } from '../../../utils/localeUtils'; interface TaskDeferUntilCardProps { task: Task; @@ -24,23 +28,16 @@ const TaskDeferUntilCard: React.FC = ({ onSave, onCancel, }) => { - const { t, i18n } = useTranslation(); - const displayLocale = useMemo( - () => resolveUserLocale(i18n.language), - [i18n.language] - ); + const { t } = useTranslation(); const getDeferUntilDisplay = (deferUntil: string) => { const date = new Date(deferUntil); if (Number.isNaN(date.getTime())) return null; - const formattedDateTime = date.toLocaleString(displayLocale, { - day: '2-digit', - month: '2-digit', - year: 'numeric', - hour: '2-digit', - minute: '2-digit', - }); + // Format datetime based on user's timezone-derived country + const timezone = getUserTimezone(); + const country = getCountryFromTimezone(timezone); + const formattedDateTime = formatDateTimeByCountry(date, country); const now = new Date(); const diffMs = date.getTime() - now.getTime(); diff --git a/frontend/components/Task/TaskDetails/TaskDueDateCard.tsx b/frontend/components/Task/TaskDetails/TaskDueDateCard.tsx index 4d281ec..8c75d68 100644 --- a/frontend/components/Task/TaskDetails/TaskDueDateCard.tsx +++ b/frontend/components/Task/TaskDetails/TaskDueDateCard.tsx @@ -1,4 +1,4 @@ -import React, { useMemo } from 'react'; +import React from 'react'; import { useTranslation } from 'react-i18next'; import { CalendarIcon, @@ -6,8 +6,12 @@ import { } from '@heroicons/react/24/outline'; import TaskDueDateSection from '../TaskForm/TaskDueDateSection'; import { Task } from '../../../entities/Task'; -import { parseDateString } from '../../../utils/dateUtils'; -import { resolveUserLocale } from '../../../utils/localeUtils'; +import { + parseDateString, + formatDateByCountry, + getUserTimezone, +} from '../../../utils/dateUtils'; +import { getCountryFromTimezone } from '../../../utils/localeUtils'; interface TaskDueDateCardProps { task: Task; @@ -28,21 +32,16 @@ const TaskDueDateCard: React.FC = ({ onSave, onCancel, }) => { - const { t, i18n } = useTranslation(); - const displayLocale = useMemo( - () => resolveUserLocale(i18n.language), - [i18n.language] - ); + const { t } = useTranslation(); const getDueDateDisplay = (dueDate: string) => { const date = parseDateString(dueDate); if (!date) return null; - const formattedDate = date.toLocaleDateString(displayLocale, { - day: '2-digit', - month: '2-digit', - year: 'numeric', - }); + // Format date based on user's timezone-derived country + const timezone = getUserTimezone(); + const country = getCountryFromTimezone(timezone); + const formattedDate = formatDateByCountry(date, country); const today = new Date(); today.setHours(0, 0, 0, 0); diff --git a/frontend/utils/dateUtils.test.ts b/frontend/utils/dateUtils.test.ts new file mode 100644 index 0000000..ae591f3 --- /dev/null +++ b/frontend/utils/dateUtils.test.ts @@ -0,0 +1,249 @@ +import { formatDateByCountry, formatDateTimeByCountry } from './dateUtils'; + +describe('formatDateByCountry', () => { + // Test date: March 15, 2026 at 10:30 AM + const testDate = new Date('2026-03-15T10:30:00'); + + describe('DD/MM/YYYY format (European and most global)', () => { + it('formats dates in DD/MM/YYYY for Greece', () => { + expect(formatDateByCountry(testDate, 'GR')).toBe('15/03/2026'); + }); + + it('formats dates in DD/MM/YYYY for United Kingdom', () => { + expect(formatDateByCountry(testDate, 'GB')).toBe('15/03/2026'); + }); + + it('formats dates in DD/MM/YYYY for France', () => { + expect(formatDateByCountry(testDate, 'FR')).toBe('15/03/2026'); + }); + + it('formats dates in DD/MM/YYYY for Germany', () => { + expect(formatDateByCountry(testDate, 'DE')).toBe('15/03/2026'); + }); + + it('formats dates in DD/MM/YYYY for Italy', () => { + expect(formatDateByCountry(testDate, 'IT')).toBe('15/03/2026'); + }); + + it('formats dates in DD/MM/YYYY for Spain', () => { + expect(formatDateByCountry(testDate, 'ES')).toBe('15/03/2026'); + }); + + it('formats dates in DD/MM/YYYY for Australia', () => { + expect(formatDateByCountry(testDate, 'AU')).toBe('15/03/2026'); + }); + + it('formats dates in DD/MM/YYYY for India', () => { + expect(formatDateByCountry(testDate, 'IN')).toBe('15/03/2026'); + }); + + it('formats dates in DD/MM/YYYY for Brazil', () => { + expect(formatDateByCountry(testDate, 'BR')).toBe('15/03/2026'); + }); + + it('formats dates in DD/MM/YYYY for South Africa', () => { + expect(formatDateByCountry(testDate, 'ZA')).toBe('15/03/2026'); + }); + }); + + describe('MM/DD/YYYY format (North America)', () => { + it('formats dates in MM/DD/YYYY for United States', () => { + expect(formatDateByCountry(testDate, 'US')).toBe('03/15/2026'); + }); + + it('formats dates in MM/DD/YYYY for Canada', () => { + expect(formatDateByCountry(testDate, 'CA')).toBe('03/15/2026'); + }); + + it('formats dates in MM/DD/YYYY for Philippines', () => { + expect(formatDateByCountry(testDate, 'PH')).toBe('03/15/2026'); + }); + }); + + describe('YYYY/MM/DD format (East Asia)', () => { + it('formats dates in YYYY/MM/DD for Japan', () => { + expect(formatDateByCountry(testDate, 'JP')).toBe('2026/03/15'); + }); + + it('formats dates in YYYY/MM/DD for South Korea', () => { + expect(formatDateByCountry(testDate, 'KR')).toBe('2026/03/15'); + }); + + it('formats dates in YYYY/MM/DD for China', () => { + expect(formatDateByCountry(testDate, 'CN')).toBe('2026/03/15'); + }); + + it('formats dates in YYYY/MM/DD for Taiwan', () => { + expect(formatDateByCountry(testDate, 'TW')).toBe('2026/03/15'); + }); + }); + + describe('Default and edge cases', () => { + it('uses DD/MM/YYYY as default format when country is null', () => { + expect(formatDateByCountry(testDate, null)).toBe('15/03/2026'); + }); + + it('uses DD/MM/YYYY as default format when country is undefined', () => { + expect(formatDateByCountry(testDate, undefined)).toBe('15/03/2026'); + }); + + it('uses DD/MM/YYYY as default format when country is unknown', () => { + expect(formatDateByCountry(testDate, 'UNKNOWN')).toBe('15/03/2026'); + }); + + it('uses DD/MM/YYYY as default format for empty string', () => { + expect(formatDateByCountry(testDate, '')).toBe('15/03/2026'); + }); + }); + + describe('Date boundaries', () => { + it('formats dates with single-digit day correctly', () => { + const date = new Date('2026-03-05T10:00:00'); + expect(formatDateByCountry(date, 'GR')).toBe('05/03/2026'); + expect(formatDateByCountry(date, 'US')).toBe('03/05/2026'); + expect(formatDateByCountry(date, 'JP')).toBe('2026/03/05'); + }); + + it('formats dates with single-digit month correctly', () => { + const date = new Date('2026-01-15T10:00:00'); + expect(formatDateByCountry(date, 'GR')).toBe('15/01/2026'); + expect(formatDateByCountry(date, 'US')).toBe('01/15/2026'); + expect(formatDateByCountry(date, 'JP')).toBe('2026/01/15'); + }); + + it('formats end of month dates correctly', () => { + const date = new Date('2026-12-31T10:00:00'); + expect(formatDateByCountry(date, 'GR')).toBe('31/12/2026'); + expect(formatDateByCountry(date, 'US')).toBe('12/31/2026'); + expect(formatDateByCountry(date, 'JP')).toBe('2026/12/31'); + }); + + it('formats start of year dates correctly', () => { + const date = new Date('2026-01-01T10:00:00'); + expect(formatDateByCountry(date, 'GR')).toBe('01/01/2026'); + expect(formatDateByCountry(date, 'US')).toBe('01/01/2026'); + expect(formatDateByCountry(date, 'JP')).toBe('2026/01/01'); + }); + }); +}); + +describe('formatDateTimeByCountry', () => { + // Test datetime: March 15, 2026 at 14:30 (2:30 PM) + const testDate = new Date('2026-03-15T14:30:00'); + + describe('DD/MM/YYYY HH:MM format (European and most global)', () => { + it('formats datetime in DD/MM/YYYY HH:MM for Greece', () => { + expect(formatDateTimeByCountry(testDate, 'GR')).toBe( + '15/03/2026 14:30' + ); + }); + + it('formats datetime in DD/MM/YYYY HH:MM for United Kingdom', () => { + expect(formatDateTimeByCountry(testDate, 'GB')).toBe( + '15/03/2026 14:30' + ); + }); + + it('formats datetime in DD/MM/YYYY HH:MM for Germany', () => { + expect(formatDateTimeByCountry(testDate, 'DE')).toBe( + '15/03/2026 14:30' + ); + }); + + it('formats datetime in DD/MM/YYYY HH:MM for Australia', () => { + expect(formatDateTimeByCountry(testDate, 'AU')).toBe( + '15/03/2026 14:30' + ); + }); + }); + + describe('MM/DD/YYYY HH:MM format (North America)', () => { + it('formats datetime in MM/DD/YYYY HH:MM for United States', () => { + expect(formatDateTimeByCountry(testDate, 'US')).toBe( + '03/15/2026 14:30' + ); + }); + + it('formats datetime in MM/DD/YYYY HH:MM for Canada', () => { + expect(formatDateTimeByCountry(testDate, 'CA')).toBe( + '03/15/2026 14:30' + ); + }); + }); + + describe('YYYY/MM/DD HH:MM format (East Asia)', () => { + it('formats datetime in YYYY/MM/DD HH:MM for Japan', () => { + expect(formatDateTimeByCountry(testDate, 'JP')).toBe( + '2026/03/15 14:30' + ); + }); + + it('formats datetime in YYYY/MM/DD HH:MM for South Korea', () => { + expect(formatDateTimeByCountry(testDate, 'KR')).toBe( + '2026/03/15 14:30' + ); + }); + + it('formats datetime in YYYY/MM/DD HH:MM for China', () => { + expect(formatDateTimeByCountry(testDate, 'CN')).toBe( + '2026/03/15 14:30' + ); + }); + }); + + describe('Time formatting', () => { + it('uses 24-hour format for all countries', () => { + const morning = new Date('2026-03-15T09:15:00'); + const afternoon = new Date('2026-03-15T15:45:00'); + const midnight = new Date('2026-03-15T00:00:00'); + const noon = new Date('2026-03-15T12:00:00'); + + expect(formatDateTimeByCountry(morning, 'US')).toBe( + '03/15/2026 09:15' + ); + expect(formatDateTimeByCountry(afternoon, 'US')).toBe( + '03/15/2026 15:45' + ); + expect(formatDateTimeByCountry(midnight, 'US')).toBe( + '03/15/2026 00:00' + ); + expect(formatDateTimeByCountry(noon, 'US')).toBe( + '03/15/2026 12:00' + ); + }); + + it('formats minutes with leading zero', () => { + const date = new Date('2026-03-15T14:05:00'); + expect(formatDateTimeByCountry(date, 'GR')).toBe( + '15/03/2026 14:05' + ); + }); + + it('formats hours with leading zero for single digits', () => { + const date = new Date('2026-03-15T08:30:00'); + expect(formatDateTimeByCountry(date, 'GR')).toBe( + '15/03/2026 08:30' + ); + }); + }); + + describe('Default and edge cases', () => { + it('uses DD/MM/YYYY HH:MM as default format when country is null', () => { + expect(formatDateTimeByCountry(testDate, null)).toBe( + '15/03/2026 14:30' + ); + }); + + it('uses DD/MM/YYYY HH:MM as default format when country is undefined', () => { + expect(formatDateTimeByCountry(testDate, undefined)).toBe( + '15/03/2026 14:30' + ); + }); + + it('uses DD/MM/YYYY HH:MM as default format when country is unknown', () => { + expect(formatDateTimeByCountry(testDate, 'UNKNOWN')).toBe( + '15/03/2026 14:30' + ); + }); + }); +}); diff --git a/frontend/utils/dateUtils.ts b/frontend/utils/dateUtils.ts index d1722d5..6b096c8 100644 --- a/frontend/utils/dateUtils.ts +++ b/frontend/utils/dateUtils.ts @@ -293,3 +293,141 @@ export const isTaskOverdueInTodayPlan = (task: { // are likely to have been sitting there for a while return createdDate.getTime() < yesterday.getTime(); }; + +/** + * Maps ISO 3166-1 country codes to date format patterns. + * Used to ensure consistent date formatting based on regional conventions, + * independent of browser locale support. + * + * @returns Object mapping country code to date-fns format string + */ +const getCountryDateFormats = (): Record => ({ + // DD/MM/YYYY format (most common globally - European standard) + AT: 'dd/MM/yyyy', // Austria + BE: 'dd/MM/yyyy', // Belgium + BG: 'dd/MM/yyyy', // Bulgaria + CH: 'dd/MM/yyyy', // Switzerland + CY: 'dd/MM/yyyy', // Cyprus + CZ: 'dd/MM/yyyy', // Czech Republic + DE: 'dd/MM/yyyy', // Germany + DK: 'dd/MM/yyyy', // Denmark + EE: 'dd/MM/yyyy', // Estonia + ES: 'dd/MM/yyyy', // Spain + FI: 'dd/MM/yyyy', // Finland + FR: 'dd/MM/yyyy', // France + GB: 'dd/MM/yyyy', // United Kingdom + GR: 'dd/MM/yyyy', // Greece + HR: 'dd/MM/yyyy', // Croatia + IE: 'dd/MM/yyyy', // Ireland + IS: 'dd/MM/yyyy', // Iceland + IT: 'dd/MM/yyyy', // Italy + LT: 'dd/MM/yyyy', // Lithuania + LU: 'dd/MM/yyyy', // Luxembourg + LV: 'dd/MM/yyyy', // Latvia + MT: 'dd/MM/yyyy', // Malta + NL: 'dd/MM/yyyy', // Netherlands + NO: 'dd/MM/yyyy', // Norway + PL: 'dd/MM/yyyy', // Poland + PT: 'dd/MM/yyyy', // Portugal + RO: 'dd/MM/yyyy', // Romania + SE: 'dd/MM/yyyy', // Sweden + SI: 'dd/MM/yyyy', // Slovenia + SK: 'dd/MM/yyyy', // Slovakia + TR: 'dd/MM/yyyy', // Turkey + RU: 'dd/MM/yyyy', // Russia + + // DD/MM/YYYY format (Africa) + DZ: 'dd/MM/yyyy', // Algeria + EG: 'dd/MM/yyyy', // Egypt + GH: 'dd/MM/yyyy', // Ghana + KE: 'dd/MM/yyyy', // Kenya + MA: 'dd/MM/yyyy', // Morocco + NG: 'dd/MM/yyyy', // Nigeria + TN: 'dd/MM/yyyy', // Tunisia + ZA: 'dd/MM/yyyy', // South Africa + + // DD/MM/YYYY format (Asia/Pacific) + AU: 'dd/MM/yyyy', // Australia + BD: 'dd/MM/yyyy', // Bangladesh + FJ: 'dd/MM/yyyy', // Fiji + GU: 'dd/MM/yyyy', // Guam + HK: 'dd/MM/yyyy', // Hong Kong + ID: 'dd/MM/yyyy', // Indonesia + IN: 'dd/MM/yyyy', // India + MY: 'dd/MM/yyyy', // Malaysia + NZ: 'dd/MM/yyyy', // New Zealand + PK: 'dd/MM/yyyy', // Pakistan + SG: 'dd/MM/yyyy', // Singapore + TH: 'dd/MM/yyyy', // Thailand + + // DD/MM/YYYY format (Middle East) + AE: 'dd/MM/yyyy', // United Arab Emirates + IL: 'dd/MM/yyyy', // Israel + IR: 'dd/MM/yyyy', // Iran + SA: 'dd/MM/yyyy', // Saudi Arabia + + // DD/MM/YYYY format (Americas - except US/CA) + AR: 'dd/MM/yyyy', // Argentina + BR: 'dd/MM/yyyy', // Brazil + CL: 'dd/MM/yyyy', // Chile + CO: 'dd/MM/yyyy', // Colombia + MX: 'dd/MM/yyyy', // Mexico + PE: 'dd/MM/yyyy', // Peru + VE: 'dd/MM/yyyy', // Venezuela + + // MM/DD/YYYY format (North America and some others) + US: 'MM/dd/yyyy', // United States + CA: 'MM/dd/yyyy', // Canada + PH: 'MM/dd/yyyy', // Philippines + + // YYYY/MM/DD format (East Asia - ISO-like format) + CN: 'yyyy/MM/dd', // China + JP: 'yyyy/MM/dd', // Japan + KR: 'yyyy/MM/dd', // South Korea + TW: 'yyyy/MM/dd', // Taiwan +}); + +/** + * Formats a date using country-specific format pattern. + * This provides consistent date formatting independent of browser locale support. + * + * @param date - Date to format + * @param country - ISO 3166-1 country code (optional) + * @returns Formatted date string (e.g., "15/03/2026" for Greece, "03/15/2026" for US) + */ +export const formatDateByCountry = ( + date: Date, + country?: string | null +): string => { + const formats = getCountryDateFormats(); + + // Use country-specific format if available, otherwise default to DD/MM/YYYY + const formatPattern = + country && formats[country] ? formats[country] : 'dd/MM/yyyy'; + + return format(date, formatPattern); +}; + +/** + * Formats a datetime using country-specific date format + 24-hour time. + * This provides consistent datetime formatting independent of browser locale support. + * + * @param date - Date to format + * @param country - ISO 3166-1 country code (optional) + * @returns Formatted datetime string (e.g., "15/03/2026 14:30" for Greece) + */ +export const formatDateTimeByCountry = ( + date: Date, + country?: string | null +): string => { + const formats = getCountryDateFormats(); + + // Use country-specific format if available, otherwise default to DD/MM/YYYY + const datePattern = + country && formats[country] ? formats[country] : 'dd/MM/yyyy'; + + // Combine date pattern with 24-hour time format + const datetimePattern = `${datePattern} HH:mm`; + + return format(date, datetimePattern); +};