diff --git a/resources/js/components/accounts/import-balances-drawer.tsx b/resources/js/components/accounts/import-balances-drawer.tsx index 4dad6d2f..b9309e04 100644 --- a/resources/js/components/accounts/import-balances-drawer.tsx +++ b/resources/js/components/accounts/import-balances-drawer.tsx @@ -18,6 +18,7 @@ import { parseDate, parseFile, } from '@/lib/file-parser'; +import { type SharedData } from '@/types'; import { type Account } from '@/types/account'; import { BalanceImportStep, @@ -28,6 +29,7 @@ import { import { DateFormat } from '@/types/import'; import type { UUID } from '@/types/uuid'; import { __ } from '@/utils/i18n'; +import { usePage } from '@inertiajs/react'; import { Check } from 'lucide-react'; import { useEffect, useState } from 'react'; import { toast } from 'sonner'; @@ -60,6 +62,7 @@ export function ImportBalancesDrawer({ accountId, onSuccess, }: ImportBalancesDrawerProps) { + const { locale } = usePage().props; const [isImporting, setIsImporting] = useState(false); const [importProgress, setImportProgress] = useState(0); const [importTotal, setImportTotal] = useState(0); @@ -240,6 +243,7 @@ export function ImportBalancesDrawer({ const detected = autoDetectDateFormat( data, autoMapping.balance_date, + locale, ); if (detected) { detectedFormat = detected; diff --git a/resources/js/components/transactions/import-transactions-drawer.tsx b/resources/js/components/transactions/import-transactions-drawer.tsx index 17c3617b..fa7b117c 100644 --- a/resources/js/components/transactions/import-transactions-drawer.tsx +++ b/resources/js/components/transactions/import-transactions-drawer.tsx @@ -22,6 +22,7 @@ import { import { getStoredKey } from '@/lib/key-storage'; import { evaluateRulesForNewTransaction } from '@/lib/rule-engine'; import { transactionSyncService } from '@/services/transaction-sync'; +import { type SharedData } from '@/types'; import { type Account, type Bank } from '@/types/account'; import { type AutomationRule } from '@/types/automation-rule'; import { type Category } from '@/types/category'; @@ -31,7 +32,6 @@ import { type ColumnMapping, type ImportState, } from '@/types/import'; -import { type SharedData } from '@/types'; import { __ } from '@/utils/i18n'; import { router, usePage } from '@inertiajs/react'; import { useEffect, useState } from 'react'; @@ -71,7 +71,7 @@ export function ImportTransactionsDrawer({ onImportComplete, }: ImportTransactionsDrawerProps) { const { isKeySet } = useEncryptionKey(); - const { features } = usePage().props; + const { features, locale } = usePage().props; const isPlaintext = features['plaintext-transactions']; const [isImporting, setIsImporting] = useState(false); const [importProgress, setImportProgress] = useState(0); @@ -187,6 +187,7 @@ export function ImportTransactionsDrawer({ const detected = autoDetectDateFormat( data, autoMapping.transaction_date, + locale, ); if (detected) { detectedFormat = detected; diff --git a/resources/js/lib/file-parser.test.ts b/resources/js/lib/file-parser.test.ts new file mode 100644 index 00000000..3b6b742c --- /dev/null +++ b/resources/js/lib/file-parser.test.ts @@ -0,0 +1,118 @@ +import { DateFormat } from '@/types/import'; +import { describe, expect, it } from 'vitest'; +import { autoDetectDateFormat, getLocaleDateFormat } from './file-parser'; + +describe('getLocaleDateFormat', () => { + it('returns null for undefined locale', () => { + expect(getLocaleDateFormat(undefined)).toBeNull(); + }); + + it('returns MM-DD-YYYY for en-US', () => { + expect(getLocaleDateFormat('en-US')).toBe(DateFormat.MonthDayYear); + }); + + it('returns DD-MM-YYYY for en-GB', () => { + expect(getLocaleDateFormat('en-GB')).toBe(DateFormat.DayMonthYear); + }); + + it('returns DD-MM-YYYY for es', () => { + expect(getLocaleDateFormat('es')).toBe(DateFormat.DayMonthYear); + }); + + it('returns DD-MM-YYYY for de', () => { + expect(getLocaleDateFormat('de')).toBe(DateFormat.DayMonthYear); + }); + + it('returns DD-MM-YYYY for fr', () => { + expect(getLocaleDateFormat('fr')).toBe(DateFormat.DayMonthYear); + }); + + it('handles underscored locales like en_US', () => { + expect(getLocaleDateFormat('en_US')).toBe(DateFormat.MonthDayYear); + }); +}); + +describe('autoDetectDateFormat', () => { + it('returns null for empty data', () => { + expect(autoDetectDateFormat([], 'date')).toBeNull(); + }); + + it('detects YYYY-MM-DD unambiguously', () => { + const data = [ + { date: '2024-01-15' }, + { date: '2024-02-20' }, + { date: '2024-03-25' }, + ]; + expect(autoDetectDateFormat(data, 'date')).toBe( + DateFormat.YearMonthDay, + ); + }); + + it('detects DD-MM-YYYY when day > 12 disambiguates', () => { + const data = [ + { date: '15/01/2024' }, + { date: '20/02/2024' }, + { date: '25/03/2024' }, + ]; + expect(autoDetectDateFormat(data, 'date')).toBe( + DateFormat.DayMonthYear, + ); + }); + + it('detects MM-DD-YYYY when day > 12 disambiguates', () => { + const data = [ + { date: '01/15/2024' }, + { date: '02/20/2024' }, + { date: '03/25/2024' }, + ]; + expect(autoDetectDateFormat(data, 'date')).toBe( + DateFormat.MonthDayYear, + ); + }); + + it('uses locale to break tie for ambiguous dates (en-GB prefers DD-MM-YYYY)', () => { + // All dates have day <= 12, so DD-MM-YYYY and MM-DD-YYYY both parse + const data = [ + { date: '05/03/2024' }, + { date: '06/04/2024' }, + { date: '07/05/2024' }, + ]; + expect(autoDetectDateFormat(data, 'date', 'en-GB')).toBe( + DateFormat.DayMonthYear, + ); + }); + + it('uses locale to break tie for ambiguous dates (en-US prefers MM-DD-YYYY)', () => { + const data = [ + { date: '05/03/2024' }, + { date: '06/04/2024' }, + { date: '07/05/2024' }, + ]; + expect(autoDetectDateFormat(data, 'date', 'en-US')).toBe( + DateFormat.MonthDayYear, + ); + }); + + it('uses locale to break tie for ambiguous dates (es prefers DD-MM-YYYY)', () => { + const data = [ + { date: '05/03/2024' }, + { date: '06/04/2024' }, + { date: '07/05/2024' }, + ]; + expect(autoDetectDateFormat(data, 'date', 'es')).toBe( + DateFormat.DayMonthYear, + ); + }); + + it('prefers unambiguous detection over locale', () => { + // Day > 12, so only DD-MM-YYYY parses correctly, even with en-US locale + const data = [ + { date: '15/01/2024' }, + { date: '20/02/2024' }, + { date: '25/03/2024' }, + ]; + expect(autoDetectDateFormat(data, 'date', 'en-US')).toBe( + DateFormat.DayMonthYear, + ); + }); +}); diff --git a/resources/js/lib/file-parser.ts b/resources/js/lib/file-parser.ts index 5abf30e3..84326886 100644 --- a/resources/js/lib/file-parser.ts +++ b/resources/js/lib/file-parser.ts @@ -178,9 +178,43 @@ export async function parseFile(file: File): Promise<{ }); } +/** + * Returns the preferred date format for a given locale. + * Most locales use DD-MM-YYYY, while US/Philippines/etc use MM-DD-YYYY. + */ +export function getLocaleDateFormat(locale?: string): DateFormat | null { + if (!locale) { + return null; + } + + const mdyLocales = ['en-US', 'en-PH', 'fil', 'ja', 'zh', 'ko', 'hu']; + const ymdLocales = [ + 'sv', + 'lt', + 'zh-CN', + 'zh-TW', + 'ja-JP', + 'ko-KR', + 'hu-HU', + ]; + + const normalized = locale.replace('_', '-'); + + if (ymdLocales.some((l) => normalized.startsWith(l))) { + return DateFormat.YearMonthDay; + } + + if (mdyLocales.some((l) => normalized.startsWith(l))) { + return DateFormat.MonthDayYear; + } + + return DateFormat.DayMonthYear; +} + export function autoDetectDateFormat( data: ParsedRow[], dateColumnName: string, + locale?: string, ): DateFormat | null { if (!data || data.length === 0 || !dateColumnName) { return null; @@ -216,10 +250,22 @@ export function autoDetectDateFormat( return null; } - const bestFormat = formats.find((format) => scores[format] === maxScore); - if (maxScore >= sampleSize * 0.8) { - return bestFormat || null; + const tiedFormats = formats.filter( + (format) => scores[format] === maxScore, + ); + + if (tiedFormats.length === 1) { + return tiedFormats[0]; + } + + // Use the user's locale to break ties between ambiguous formats + const localePreferred = getLocaleDateFormat(locale); + if (localePreferred && tiedFormats.includes(localePreferred)) { + return localePreferred; + } + + return tiedFormats[0]; } return null;