fix(import): honor selected date format for CSV imports (#494)

## Problem

Importing a CSV with `DD/MM/YYYY` dates (e.g. `02/06/2026`) parsed them
as `MM/DD/YYYY`, and the **Date Format** selector either didn't appear
or had no effect — the preview showed the same wrong date regardless of
the chosen format.

## Root cause

The `xlsx` parser coerced CSV date-like strings into Excel **serial
numbers** at read time, guessing the format itself. `parseDate` then hit
its numeric short-circuit and **ignored the selected format entirely**,
so the Date Format radio never changed the preview.

On top of that, ambiguous dates (valid as both DD/MM and MM/DD) were
resolved silently by browser locale, and a saved import config force-hid
the selector — so a wrong guess couldn't be corrected.

## Changes

- **`parseFile` reads with `raw: true`** — CSV cells stay as their
original strings; `parseDate` applies the chosen format. Native
`.xls/.xlsx` dates still arrive as numbers and use the serial path.
- **`detectDateFormat()`** now returns `{ format, ambiguous }`. When
multiple formats parse the sample equally, the importer keeps the Date
Format selector visible (defaulting to the locale/saved best guess) —
even when a saved config exists — so a previously-saved wrong format can
be fixed. `autoDetectDateFormat()` kept as a thin wrapper.
- Applied to both the transactions and account-balances import drawers.
- **UI:** moved the Date Format selector above the preview and switched
the mapping form from broken Tailwind v4 `space-y`/`space-x` to
`flex`/`gap`.

## Tests

- New `parseFile` regression test: `02/06/2026` stays a string and
parses to June (DD-MM) vs February (MM-DD).
- New `detectDateFormat` tests for the `ambiguous` flag.
- All 39 `file-parser` tests pass; lint/format clean.
This commit is contained in:
Víctor Falcón 2026-06-05 15:10:48 +02:00 committed by GitHub
parent 300188f135
commit 744d874464
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 208 additions and 109 deletions

View File

@ -14,7 +14,7 @@ import {
} from '@/lib/balance-import-config-storage';
import { getCsrfToken } from '@/lib/csrf';
import {
autoDetectDateFormat,
detectDateFormat,
parseAmount,
parseDate,
parseFile,
@ -265,15 +265,17 @@ export function ImportBalancesDrawer({
let detectedFormat = DateFormat.YearMonthDay;
let formatDetected = false;
let formatAmbiguous = false;
if (autoMapping.balance_date) {
const detected = autoDetectDateFormat(
const detected = detectDateFormat(
data,
autoMapping.balance_date,
locale,
);
if (detected) {
detectedFormat = detected;
formatDetected = true;
detectedFormat = detected.format;
formatAmbiguous = detected.ambiguous;
formatDetected = !detected.ambiguous;
}
}
@ -300,7 +302,10 @@ export function ImportBalancesDrawer({
if (isValidMapping(savedConfig.columnMapping)) {
finalMapping = savedConfig.columnMapping;
finalDateFormat = savedConfig.dateFormat;
formatDetected = true;
// Keep the saved format as the default, but still show
// the selector when the dates are ambiguous so a
// previously-saved wrong format can be corrected.
formatDetected = !formatAmbiguous;
}
}
}

View File

@ -181,8 +181,8 @@ export function ImportStepMapping({
return (
<div className="flex flex-col gap-6">
<div className="space-y-4">
<div className="space-y-2">
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<Label htmlFor="date-column">
{__('Transaction Date')}{' '}
<span className="text-destructive">*</span>
@ -213,7 +213,7 @@ export function ImportStepMapping({
</Select>
</div>
<div className="space-y-2">
<div className="flex flex-col gap-2">
<Label htmlFor="description-column-0">
{__('Description')}
<span className="text-destructive">*</span>
@ -246,7 +246,7 @@ export function ImportStepMapping({
</SelectContent>
</Select>
) : (
<div className="space-y-3">
<div className="flex flex-col gap-3">
{descriptionColumns.map((column, columnIndex) => (
<div
key={columnIndex}
@ -315,7 +315,7 @@ export function ImportStepMapping({
)}
</div>
<div className="space-y-2">
<div className="flex flex-col gap-2">
<Label htmlFor="amount-column">
{__('Amount')}
<span className="text-destructive">*</span>
@ -344,7 +344,7 @@ export function ImportStepMapping({
</Select>
</div>
<div className="space-y-2">
<div className="flex flex-col gap-2">
<Label htmlFor="balance-column">
{__('Balance (Optional)')}
</Label>
@ -380,7 +380,7 @@ export function ImportStepMapping({
</Select>
{calculateBalancesAvailable && (
<div className="space-y-3 pt-2">
<div className="flex flex-col gap-3 pt-2">
<div className="flex items-start gap-2">
<Checkbox
id="calculate-balances"
@ -397,7 +397,7 @@ export function ImportStepMapping({
}
className="mt-0.5"
/>
<div className="space-y-1">
<div className="flex flex-col gap-1">
<Label
htmlFor="calculate-balances"
className={`cursor-pointer font-normal ${checkboxDisabled ? 'opacity-50' : ''}`}
@ -415,7 +415,7 @@ export function ImportStepMapping({
</div>
{effectiveCalculate && latestDate && (
<div className="space-y-2 rounded-md border bg-muted/30 p-3">
<div className="flex flex-col gap-2 rounded-md border bg-muted/30 p-3">
<Label htmlFor="reference-balance">
{__('Balance on')}{' '}
{formatRelativeDate(latestDate, locale)}{' '}
@ -444,7 +444,7 @@ export function ImportStepMapping({
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<div className="flex flex-col gap-2">
<Label htmlFor="creditor-column">
{__('Creditor name (Optional)')}
</Label>
@ -480,7 +480,7 @@ export function ImportStepMapping({
</Select>
</div>
<div className="space-y-2">
<div className="flex flex-col gap-2">
<Label htmlFor="debtor-column">
{__('Debtor name (Optional)')}
</Label>
@ -517,12 +517,77 @@ export function ImportStepMapping({
</div>
</div>
{!dateFormatDetected && (
<div className="flex flex-col gap-3 rounded-lg border p-4">
<Label>{__('Date Format')}</Label>
<RadioGroup
value={dateFormat}
onValueChange={(value) =>
onDateFormatChange(value as DateFormat)
}
>
<div className="flex items-center gap-2">
<RadioGroupItem
value={DateFormat.YearMonthDay}
id="format-ymd"
/>
<Label
htmlFor="format-ymd"
className="cursor-pointer font-normal"
>
{__('YYYY-MM-DD (e.g., 2024-12-31)')}
</Label>
</div>
<div className="flex items-center gap-2">
<RadioGroupItem
value={DateFormat.MonthDayYear}
id="format-mdy"
/>
<Label
htmlFor="format-mdy"
className="cursor-pointer font-normal"
>
{__('MM-DD-YYYY (e.g., 12-31-2024)')}
</Label>
</div>
<div className="flex items-center gap-2">
<RadioGroupItem
value={DateFormat.DayMonthYear}
id="format-dmy"
/>
<Label
htmlFor="format-dmy"
className="cursor-pointer font-normal"
>
{__('DD-MM-YYYY (e.g., 31-12-2024)')}
</Label>
</div>
<div className="flex items-center gap-2">
<RadioGroupItem
value={DateFormat.YearMonthDayCompact}
id="format-ymd-compact"
/>
<Label
htmlFor="format-ymd-compact"
className="cursor-pointer font-normal"
>
{__('YYYYMMDD (e.g., 20241231)')}
</Label>
</div>
</RadioGroup>
</div>
)}
{baseMappingValid && previewTransactions.length > 0 && (
<div className="space-y-4 rounded-lg border bg-muted/30 p-4">
<div className="flex flex-col gap-4 rounded-lg border bg-muted/30 p-4">
<Label className="pl-2 text-xs font-light tracking-widest uppercase opacity-50">
{__('Preview (first 3 rows)')}
</Label>
<div className="space-y-2 pt-2">
<div className="flex flex-col gap-2 pt-2">
{previewTransactions.map((transaction, index) => (
<div
key={index}
@ -544,71 +609,6 @@ export function ImportStepMapping({
</div>
</div>
)}
{!dateFormatDetected && (
<div className="space-y-3 rounded-lg border p-4">
<Label>{__('Date Format')}</Label>
<RadioGroup
value={dateFormat}
onValueChange={(value) =>
onDateFormatChange(value as DateFormat)
}
>
<div className="flex items-center space-x-2">
<RadioGroupItem
value={DateFormat.YearMonthDay}
id="format-ymd"
/>
<Label
htmlFor="format-ymd"
className="cursor-pointer font-normal"
>
{__('YYYY-MM-DD (e.g., 2024-12-31)')}
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem
value={DateFormat.MonthDayYear}
id="format-mdy"
/>
<Label
htmlFor="format-mdy"
className="cursor-pointer font-normal"
>
{__('MM-DD-YYYY (e.g., 12-31-2024)')}
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem
value={DateFormat.DayMonthYear}
id="format-dmy"
/>
<Label
htmlFor="format-dmy"
className="cursor-pointer font-normal"
>
{__('DD-MM-YYYY (e.g., 31-12-2024)')}
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem
value={DateFormat.YearMonthDayCompact}
id="format-ymd-compact"
/>
<Label
htmlFor="format-ymd-compact"
className="cursor-pointer font-normal"
>
{__('YYYYMMDD (e.g., 20241231)')}
</Label>
</div>
</RadioGroup>
</div>
)}
</div>
<div className="flex justify-between">

View File

@ -208,17 +208,18 @@ export function ImportTransactionsDrawer({
let detectedFormat = DateFormat.YearMonthDay;
let formatDetected = false;
let formatAmbiguous = false;
if (autoMapping.transaction_date) {
const { autoDetectDateFormat } =
await import('@/lib/file-parser');
const detected = autoDetectDateFormat(
const { detectDateFormat } = await import('@/lib/file-parser');
const detected = detectDateFormat(
data,
autoMapping.transaction_date,
locale,
);
if (detected) {
detectedFormat = detected;
formatDetected = true;
detectedFormat = detected.format;
formatAmbiguous = detected.ambiguous;
formatDetected = !detected.ambiguous;
}
}
@ -248,7 +249,10 @@ export function ImportTransactionsDrawer({
if (isValidMapping(savedConfig.columnMapping)) {
finalMapping = savedConfig.columnMapping;
finalDateFormat = savedConfig.dateFormat;
formatDetected = true;
// Keep the saved format as the default, but still show
// the selector when the dates are ambiguous so a
// previously-saved wrong format can be corrected.
formatDetected = !formatAmbiguous;
}
}
}

View File

@ -7,8 +7,11 @@ import {
calculateBalancesFromTransactions,
collectBalancesToImport,
convertRowsToTransactions,
detectDateFormat,
getLatestTransactionDate,
getLocaleDateFormat,
parseDate,
parseFile,
} from './file-parser';
describe('getLocaleDateFormat', () => {
@ -316,6 +319,48 @@ describe('autoDetectDateFormat', () => {
});
});
describe('detectDateFormat', () => {
it('returns null for empty data', () => {
expect(detectDateFormat([], 'date')).toBeNull();
});
it('flags unambiguous detection as not ambiguous', () => {
const data = [
{ date: '15/01/2024' },
{ date: '20/02/2024' },
{ date: '25/03/2024' },
];
expect(detectDateFormat(data, 'date')).toEqual({
format: DateFormat.DayMonthYear,
ambiguous: false,
});
});
it('flags locale-resolved ties as ambiguous (en-US)', () => {
const data = [
{ date: '05/03/2024' },
{ date: '06/04/2024' },
{ date: '07/05/2024' },
];
expect(detectDateFormat(data, 'date', 'en-US')).toEqual({
format: DateFormat.MonthDayYear,
ambiguous: true,
});
});
it('flags locale-resolved ties as ambiguous (es)', () => {
const data = [
{ date: '02/06/2026' },
{ date: '05/03/2024' },
{ date: '07/05/2024' },
];
expect(detectDateFormat(data, 'date', 'es')).toEqual({
format: DateFormat.DayMonthYear,
ambiguous: true,
});
});
});
describe('getLatestTransactionDate', () => {
const mapping: ColumnMapping = {
transaction_date: 'date',
@ -463,3 +508,33 @@ describe('collectBalancesToImport', () => {
expect(balances.size).toBe(0);
});
});
describe('parseFile', () => {
it('keeps CSV date cells as their original strings instead of coercing them to date serials', async () => {
const csv = [
'Fecha,Concepto,Importe',
'02/06/2026,Internet,-104576',
'03/06/2026,Groceries,-2500',
].join('\n');
const file = new File([csv], 'transactions.csv', { type: 'text/csv' });
const { data } = await parseFile(file);
expect(data[0].Fecha).toBe('02/06/2026');
expect(typeof data[0].Fecha).toBe('string');
// Because the raw string is preserved, the chosen format still drives
// parsing: DD-MM-YYYY -> June, MM-DD-YYYY -> February.
const asDmy = parseDate(
data[0].Fecha as string,
DateFormat.DayMonthYear,
);
const asMdy = parseDate(
data[0].Fecha as string,
DateFormat.MonthDayYear,
);
expect(asDmy?.getMonth()).toBe(5);
expect(asMdy?.getMonth()).toBe(1);
});
});

View File

@ -62,12 +62,18 @@ export async function parseFile(file: File): Promise<{
return;
}
const workbook = XLSX.read(data, { type: 'binary' });
// raw: true keeps text-based cells (CSV) as their original
// strings instead of letting the parser guess and coerce
// date-like values into Excel serial numbers. parseDate then
// applies the user-selected format. Native spreadsheet dates
// (.xls/.xlsx) still arrive as numbers and use the serial path.
const workbook = XLSX.read(data, { type: 'binary', raw: true });
const firstSheetName = workbook.SheetNames[0];
const worksheet = workbook.Sheets[firstSheetName];
const jsonData = XLSX.utils.sheet_to_json(worksheet, {
header: 1,
raw: true,
}) as unknown[][];
if (jsonData.length === 0) {
@ -211,11 +217,16 @@ export function getLocaleDateFormat(locale?: string): DateFormat | null {
return DateFormat.DayMonthYear;
}
export function autoDetectDateFormat(
export interface DateFormatDetection {
format: DateFormat;
ambiguous: boolean;
}
export function detectDateFormat(
data: ParsedRow[],
dateColumnName: string,
locale?: string,
): DateFormat | null {
): DateFormatDetection | null {
if (!data || data.length === 0 || !dateColumnName) {
return null;
}
@ -248,29 +259,33 @@ export function autoDetectDateFormat(
const maxScore = Math.max(...Object.values(scores));
if (maxScore === 0) {
if (maxScore === 0 || maxScore < sampleSize * 0.8) {
return null;
}
if (maxScore >= sampleSize * 0.8) {
const tiedFormats = formats.filter(
(format) => scores[format] === maxScore,
);
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];
if (tiedFormats.length === 1) {
return { format: tiedFormats[0], ambiguous: false };
}
return null;
// Multiple formats parse the sample equally well (e.g. 02/06/2026 is valid
// as both DD/MM and MM/DD). Pick the locale-preferred format as the default
// but flag it as ambiguous so the caller can let the user confirm.
const localePreferred = getLocaleDateFormat(locale);
if (localePreferred && tiedFormats.includes(localePreferred)) {
return { format: localePreferred, ambiguous: true };
}
return { format: tiedFormats[0], ambiguous: true };
}
export function autoDetectDateFormat(
data: ParsedRow[],
dateColumnName: string,
locale?: string,
): DateFormat | null {
return detectDateFormat(data, dateColumnName, locale)?.format ?? null;
}
export function autoDetectColumns(headers: string[]): ColumnMapping {