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:
parent
300188f135
commit
744d874464
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in New Issue