Skip to content

Commit e38175f

Browse files
fix: parse holiday date strings as local time to prevent timezone shift
When holiday dates are provided as ISO date strings (YYYY-MM-DD), the previous implementation used `new Date(string)` which parses them as UTC midnight. This caused holidays to display on the wrong day in timezones west of UTC. For example, "2025-01-01" would be parsed as 2025-01-01T00:00:00.000Z, which when formatted in PST (UTC-8) becomes December 31st, 2024. This fix introduces `parseHolidayDateString()` which parses ISO date strings by extracting year/month/day components and creating a Date object in local time, ensuring holidays display on the intended date regardless of timezone. Fixes #6105 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 8279f2f commit e38175f

4 files changed

Lines changed: 129 additions & 2 deletions

File tree

src/date_utils.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1329,6 +1329,39 @@ export function arraysAreEqual<T>(array1: T[], array2: T[]): boolean {
13291329
return array1.every((value, index) => value === array2[index]);
13301330
}
13311331

1332+
/**
1333+
* Parses an ISO date string (YYYY-MM-DD) as local midnight.
1334+
*
1335+
* This is necessary because `new Date("YYYY-MM-DD")` parses the date as UTC midnight,
1336+
* which can cause the date to shift to the previous day when displayed in timezones
1337+
* west of UTC. For example, "2024-01-15" parsed as UTC midnight becomes
1338+
* January 14th at 4pm in PST (UTC-8).
1339+
*
1340+
* @param dateString - An ISO date string in YYYY-MM-DD format
1341+
* @returns A Date object representing local midnight, or null if invalid
1342+
*/
1343+
export function parseHolidayDateString(dateString: string): Date | null {
1344+
const dateRegex = /^\d{4}-\d{2}-\d{2}$/;
1345+
if (!dateRegex.test(dateString)) {
1346+
return null;
1347+
}
1348+
1349+
const [year, month, day] = dateString.split("-").map(Number);
1350+
if (
1351+
year === undefined ||
1352+
month === undefined ||
1353+
day === undefined ||
1354+
isNaN(year) ||
1355+
isNaN(month) ||
1356+
isNaN(day)
1357+
) {
1358+
return null;
1359+
}
1360+
1361+
const date = new Date(year, month - 1, day);
1362+
return isValidDate(date) ? date : null;
1363+
}
1364+
13321365
export interface HolidayItem {
13331366
date: Date;
13341367
holidayName: string;

src/index.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ import {
4545
isYearDisabled,
4646
safeMultipleDatesFormat,
4747
getHolidaysMap,
48+
parseHolidayDateString,
4849
isDateBefore,
4950
getStartOfDay,
5051
getEndOfDay,
@@ -410,10 +411,13 @@ export class DatePicker extends Component<DatePickerProps, DatePickerState> {
410411
: newDate();
411412

412413
// Convert the date from string format to standard Date format
414+
// Uses parseHolidayDateString to parse ISO date strings (YYYY-MM-DD) as local
415+
// midnight instead of UTC midnight, preventing dates from shifting to the
416+
// previous day in timezones west of UTC. See issue #6105.
413417
modifyHolidays = () =>
414418
this.props.holidays?.reduce<HolidayItem[]>((accumulator, holiday) => {
415-
const date = new Date(holiday.date);
416-
if (!isValid(date)) {
419+
const date = parseHolidayDateString(holiday.date);
420+
if (!date) {
417421
return accumulator;
418422
}
419423

src/test/date_utils_test.test.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ import {
5050
safeDateFormat,
5151
getHolidaysMap,
5252
arraysAreEqual,
53+
parseHolidayDateString,
5354
startOfMinute,
5455
isDateBefore,
5556
getMidnightDate,
@@ -1436,6 +1437,60 @@ describe("date_utils", () => {
14361437
});
14371438
});
14381439

1440+
describe("parseHolidayDateString", () => {
1441+
it("should parse a valid ISO date string as local midnight", () => {
1442+
const result = parseHolidayDateString("2024-01-15");
1443+
expect(result).not.toBeNull();
1444+
expect(result!.getFullYear()).toBe(2024);
1445+
expect(result!.getMonth()).toBe(0); // January is 0
1446+
expect(result!.getDate()).toBe(15);
1447+
expect(result!.getHours()).toBe(0);
1448+
expect(result!.getMinutes()).toBe(0);
1449+
});
1450+
1451+
it("should parse date correctly regardless of how new Date() would parse it", () => {
1452+
// This test documents the bug that this function fixes.
1453+
// new Date("2024-01-15") parses as UTC midnight, which can shift
1454+
// to a different day in western timezones.
1455+
const dateString = "2024-01-15";
1456+
const result = parseHolidayDateString(dateString);
1457+
1458+
// The result should always represent January 15th in local time
1459+
expect(result!.getDate()).toBe(15);
1460+
expect(result!.getMonth()).toBe(0);
1461+
expect(result!.getFullYear()).toBe(2024);
1462+
});
1463+
1464+
it("should return null for invalid date format", () => {
1465+
expect(parseHolidayDateString("invalid-date")).toBeNull();
1466+
expect(parseHolidayDateString("2024/01/15")).toBeNull();
1467+
expect(parseHolidayDateString("01-15-2024")).toBeNull();
1468+
expect(parseHolidayDateString("2024-1-15")).toBeNull();
1469+
expect(parseHolidayDateString("2024-01-5")).toBeNull();
1470+
});
1471+
1472+
it("should return null for empty string", () => {
1473+
expect(parseHolidayDateString("")).toBeNull();
1474+
});
1475+
1476+
it("should handle edge dates correctly", () => {
1477+
// New Year's Day
1478+
const newYear = parseHolidayDateString("2024-01-01");
1479+
expect(newYear!.getDate()).toBe(1);
1480+
expect(newYear!.getMonth()).toBe(0);
1481+
1482+
// New Year's Eve
1483+
const newYearEve = parseHolidayDateString("2024-12-31");
1484+
expect(newYearEve!.getDate()).toBe(31);
1485+
expect(newYearEve!.getMonth()).toBe(11);
1486+
1487+
// Leap year date
1488+
const leapDay = parseHolidayDateString("2024-02-29");
1489+
expect(leapDay!.getDate()).toBe(29);
1490+
expect(leapDay!.getMonth()).toBe(1);
1491+
});
1492+
});
1493+
14391494
describe("isSameMinute", () => {
14401495
it("should return true if two dates are within the same minute", () => {
14411496
const d1 = new Date(2020, 10, 10, 10, 10, 10); // Nov 10, 2020 10:10:10

src/test/datepicker_test.test.tsx

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5461,6 +5461,41 @@ describe("DatePicker", () => {
54615461
expect(container.querySelector(".react-datepicker")).not.toBeNull();
54625462
});
54635463

5464+
it("should apply holiday class to correct date regardless of timezone (issue #6105)", () => {
5465+
// This test verifies that holidays specified as ISO date strings (YYYY-MM-DD)
5466+
// are displayed on the correct date in all timezones.
5467+
// The bug was that "2024-01-15" parsed as UTC midnight would display
5468+
// on January 14th for users in timezones west of UTC.
5469+
const holidays = [{ date: "2024-01-15", holidayName: "Test Holiday" }];
5470+
5471+
const { container } = render(
5472+
<DatePicker
5473+
selected={new Date(2024, 0, 15)} // January 15, 2024 in local time
5474+
onChange={() => {}}
5475+
holidays={holidays}
5476+
inline
5477+
/>,
5478+
);
5479+
5480+
// Find the day element for January 15th
5481+
const jan15 = container.querySelector(
5482+
".react-datepicker__day--015:not(.react-datepicker__day--outside-month)",
5483+
);
5484+
expect(jan15).not.toBeNull();
5485+
expect(jan15?.classList.contains("react-datepicker__day--holidays")).toBe(
5486+
true,
5487+
);
5488+
5489+
// Verify January 14th does NOT have the holiday class
5490+
const jan14 = container.querySelector(
5491+
".react-datepicker__day--014:not(.react-datepicker__day--outside-month)",
5492+
);
5493+
expect(jan14).not.toBeNull();
5494+
expect(jan14?.classList.contains("react-datepicker__day--holidays")).toBe(
5495+
false,
5496+
);
5497+
});
5498+
54645499
it("should handle deferFocusInput and cancelFocusInput", () => {
54655500
jest.useFakeTimers();
54665501

0 commit comments

Comments
 (0)