Fix date format inconsistency in Task detail screen (#956)

* Fix date format inconsistency in Task detail screen (#938)

Replace browser-dependent toLocaleDateString() with explicit country-based
date formatting to ensure consistent date formats based on user's timezone.

Problem:
- User with English language + Greek timezone saw MM/DD/YYYY format
- Expected DD/MM/YYYY format based on timezone/country
- Browser's Intl.DateTimeFormat had incomplete locale support for
  combined locales like "en-GR"

Solution:
- Add country-to-format mapping in dateUtils.ts (60+ countries)
- New formatDateByCountry() for dates (DD/MM/YYYY, MM/DD/YYYY, YYYY/MM/DD)
- New formatDateTimeByCountry() for datetimes with 24h time
- Update TaskDueDateCard and TaskDeferUntilCard to use new functions
- Uses date-fns for consistent cross-browser formatting

Testing:
- Added 40 comprehensive test cases covering all format types
- Verified with Greece (DD/MM), US (MM/DD), Japan (YYYY/MM/DD)
- All tests passing

Fixes #938

* chore: remove unused import in dateUtils.ts
This commit is contained in:
Chris 2026-03-21 18:47:33 +02:00 committed by GitHub
parent 84d30b5230
commit 2444e36f47
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 410 additions and 27 deletions

View file

@ -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<TaskDeferUntilCardProps> = ({
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();

View file

@ -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<TaskDueDateCardProps> = ({
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);

View file

@ -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'
);
});
});
});

View file

@ -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<string, string> => ({
// 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);
};