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:
parent
48b4b7bd01
commit
a19a8d52ec
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue