Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 73 additions & 22 deletions src/libs/CSVDateUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,15 @@ import {addDays, format, isValid, parse} from 'date-fns';
// Common date formats to try when parsing CSV dates
// Order matters - more specific/common formats first
const CSV_DATE_FORMATS = [
// Two-digit years come first because `yyyy` matches 1-4 digits and would read 01/20/24 as the year 24. `yy` reads at
// most two digits and maps them into the 100 years around today, so 01/20/2024 still falls through to `MM/dd/yyyy`.
'MM/dd/yy', // US two-digit year: 11/02/25
'dd/MM/yy', // European two-digit year: 02/11/25
'MM-dd-yy', // US two-digit year with dashes: 11-02-25
'dd-MM-yy', // European two-digit year with dashes: 02-11-25
'MMM d, yy', // Month name with two-digit year: Nov 2, 25
'MMMM d, yy', // Full month with two-digit year: November 2, 25
'd MMM yy', // European with month name and two-digit year: 2 Nov 25
'yyyy-MM-dd', // ISO format: 2025-11-02
'MM/dd/yyyy', // US format: 11/02/2025
'dd/MM/yyyy', // European format: 02/11/2025
Expand All @@ -20,6 +29,54 @@ const CSV_DATE_FORMATS = [
'yyyyMMdd', // Compact: 20251102
];

// Month and day bounds match what `new Date()` accepts, so values it rejected (such as 2026-13-01) are still rejected
const ISO_DATE_ONLY_REGEX = /^(\d{4})-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$/;

/**
* Parses a bare yyyy-MM-dd string in the local time zone. `new Date()` reads it as UTC midnight, which formats back to the
* previous day for anyone west of UTC. Days past the end of the month roll over the same way `new Date()` does.
*/
function parseISODateOnly(input: string): string | null {
const isoMatch = ISO_DATE_ONLY_REGEX.exec(input);
if (!isoMatch) {
return null;
}

const [, year, month, day] = isoMatch;

// setFullYear is used instead of the Date constructor, which maps years 0-99 to 1900-1999
const date = new Date(0, 0, 1);
date.setFullYear(Number(year), Number(month) - 1, Number(day));
return format(date, CONST.DATE.FNS_FORMAT_STRING);
}

/**
* Parses the input with the first matching CSV_DATE_FORMATS entry. This runs before `new Date()` so that web and native
* agree: Hermes's `new Date()` rejects strings such as 01/20/24, which V8 accepts.
*/
function parseWithDateFormats(input: string): string | null {
for (const dateFormat of CSV_DATE_FORMATS) {
const parsedDate = parse(input, dateFormat, new Date());

// A year below 1000 means `yyyy` matched a short year, so skip it rather than save a date in the first millennium
if (isValid(parsedDate) && parsedDate.getFullYear() >= 1000) {
return format(parsedDate, CONST.DATE.FNS_FORMAT_STRING);
}
}
return null;
}

/**
* Parses the input with `new Date()`, for formats that CSV_DATE_FORMATS does not cover
*/
function parseWithNativeDate(input: string): string | null {
const date = new Date(input);
if (isValid(date) && !Number.isNaN(date.getTime())) {
return format(date, CONST.DATE.FNS_FORMAT_STRING);
}
return null;
}

/**
* Parses a date string from various formats and returns it in yyyy-MM-dd format
*/
Expand All @@ -30,13 +87,9 @@ function parseCSVDate(input: string): string | null {

const trimmedInput = input.trim();

// Try parsing with common date formats using date-fns first. These are parsed in the local time zone, while `new Date()`
// reads a bare yyyy-MM-dd string as UTC midnight, which formats back to the previous day for anyone west of UTC.
for (const dateFormat of CSV_DATE_FORMATS) {
const parsedDate = parse(trimmedInput, dateFormat, new Date());
if (isValid(parsedDate)) {
return format(parsedDate, CONST.DATE.FNS_FORMAT_STRING);
}
const isoDate = parseISODateOnly(trimmedInput);
if (isoDate) {
return isoDate;
}

// Convert 5-digit Excel serials before new Date() treats them as years. We subtract 2 because Excel counts from 1900-01-01 and treats 1900 as a leap year.
Expand All @@ -51,28 +104,26 @@ function parseCSVDate(input: string): string | null {
}
}

// Fall back to native Date parsing (handles ISO date-times and other formats not listed above)
let date = new Date(trimmedInput);
if (isValid(date) && !Number.isNaN(date.getTime())) {
return format(date, CONST.DATE.FNS_FORMAT_STRING);
const formattedDate = parseWithDateFormats(trimmedInput) ?? parseWithNativeDate(trimmedInput);
if (formattedDate) {
return formattedDate;
}

// If the date didn't parse, try taking just the first 10 characters
if (trimmedInput.length > 10) {
const shortInput = trimmedInput.substring(0, 10);

// Formats run before native parsing here for the same time zone reason as above
for (const dateFormat of CSV_DATE_FORMATS) {
const parsedDate = parse(shortInput, dateFormat, new Date());
if (isValid(parsedDate)) {
return format(parsedDate, CONST.DATE.FNS_FORMAT_STRING);
}
const shortDate = parseISODateOnly(shortInput) ?? parseWithDateFormats(shortInput) ?? parseWithNativeDate(shortInput);
if (shortDate) {
return shortDate;
}
}

date = new Date(shortInput);
if (isValid(date) && !Number.isNaN(date.getTime())) {
return format(date, CONST.DATE.FNS_FORMAT_STRING);
}
// In a two-digit-year date followed by a time, such as 01/20/24 10:30, the first 10 characters include part of the
// time, so try the text before the first space too. `new Date()` is skipped here because V8 reads a lone number such
// as 2 as a date in 2001.
const spaceIndex = trimmedInput.indexOf(' ');
if (spaceIndex > 0) {
return parseWithDateFormats(trimmedInput.substring(0, spaceIndex));
}

return null;
Expand Down
49 changes: 49 additions & 0 deletions tests/unit/CSVDateUtils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,55 @@ describe('CSVDateUtils', () => {
expect(parseCSVDate('20251102')).toBe('2025-11-02');
});

it('parses two-digit years as 19xx or 20xx', () => {
// Given dates with a two-digit year, which is Excel's default short-date format on US locales.
// date-fns's `yyyy` token matches 1-4 digits, so these must match a `yy` format before any `yyyy` format.
// When they are parsed
// Then the year is in this century and the month and day are not swapped
expect(parseCSVDate('01/20/24')).toBe('2024-01-20');
expect(parseCSVDate('11/2/25')).toBe('2025-11-02');
expect(parseCSVDate('11-02-25')).toBe('2025-11-02');
expect(parseCSVDate('Nov 2, 25')).toBe('2025-11-02');
expect(parseCSVDate('2 Nov 25')).toBe('2025-11-02');
expect(parseCSVDate('20/01/24')).toBe('2024-01-20');
});

it('parses two-digit years when native Date parsing rejects them, as Hermes does', () => {
// Given a Date constructor that rejects every string, like Hermes does for strings that are not ISO 8601
const NativeDate = Date;
const dateSpy = jest.spyOn(global, 'Date').mockImplementation((value?: string | number | Date) => {
if (typeof value === 'string') {
return new NativeDate(Number.NaN);
}
return value === undefined ? new NativeDate() : new NativeDate(value);
});

// When dates with a two-digit year are parsed, along with a three-digit year that only a `yyyy` format matches
const results = ['01/20/24', '11-02-25', 'Nov 2, 25', '2 Nov 25', '20/01/24', '01/20/24 10:30', 'Nov 2, 025'].map(parseCSVDate);
dateSpy.mockRestore();

// Then the date-fns formats give the right year without native parsing, and a year below 1000 is rejected instead of saved
expect(results).toEqual(['2024-01-20', '2025-11-02', '2025-11-02', '2025-11-02', '2024-01-20', '2024-01-20', null]);
});

it('handles out-of-range ISO dates the same way as native parsing', () => {
// Given ISO dates that native parsing either rolls over or rejects
// When they are parsed in local time
// Then days past the end of the month roll over, and invalid months and days are still rejected
expect(parseCSVDate('2026-02-30')).toBe('2026-03-02');
expect(parseCSVDate('2026-04-31')).toBe('2026-05-01');
expect(parseCSVDate('2026-13-01')).toBeNull();
expect(parseCSVDate('2026-00-05')).toBeNull();
expect(parseCSVDate('2026-01-00')).toBeNull();
});

it('parses the ISO date at the start of a longer value', () => {
// Given an ISO date followed by text that native parsing rejects
// When it is parsed
// Then the first 10 characters are used as the date
expect(parseCSVDate('2024-01-15 posted')).toBe('2024-01-15');
});

it('parses Excel date serial numbers', () => {
// XLSX date cells arrive as serial numbers counted from 1900-01-01, offset by Excel's phantom 1900-02-29.
// These are formatted in local time, so a `toISOString()`-based implementation would report the previous
Expand Down
Loading