Fix: Use locale to disambiguate date format during CSV import (#107)

## Summary
- When CSV dates are ambiguous (e.g. `05/03/2024` parses as both
DD-MM-YYYY and MM-DD-YYYY), the auto-detection heuristic now uses the
user's locale preference as a tie-breaker instead of relying on array
order
- Adds `getLocaleDateFormat()` helper that maps locale strings to their
expected date format (US → MM-DD-YYYY, most others → DD-MM-YYYY, CJK →
YYYY-MM-DD)
- Passes locale from Inertia shared props to both transaction and
balance import flows

Closes #100

## Test plan
- [x] Added 15 Vitest tests for `getLocaleDateFormat` and
`autoDetectDateFormat` with locale tie-breaking
- [ ] Manual test: Import CSV with ambiguous dates (day ≤ 12) using a
non-US locale, verify DD-MM-YYYY is preferred
- [ ] Manual test: Import CSV with unambiguous dates (day > 12), verify
locale does NOT override correct detection
This commit is contained in:
Víctor Falcón 2026-02-12 09:08:52 +01:00 committed by GitHub
parent 48b4b7bd01
commit a19a8d52ec
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 174 additions and 5 deletions

View File

@ -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<SharedData>().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;

View File

@ -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<SharedData>().props;
const { features, locale } = usePage<SharedData>().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;

View File

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

View File

@ -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;