diff --git a/.changeset/swift-ears-flash.md b/.changeset/swift-ears-flash.md new file mode 100644 index 0000000000..e3699ba1b2 --- /dev/null +++ b/.changeset/swift-ears-flash.md @@ -0,0 +1,6 @@ +--- +'@commercetools-uikit/date-time-input': patch +'@commercetools-uikit/calendar-time-utils': patch +--- + +Enhanced `getMonthCalendarLabel` and `getYearCalendarLabel` functions to accept optional timezone parameter. When provided, these functions now interpret dates in the specified timezone context rather than UTC, fixing display issues for timezones that are significantly ahead of or behind UTC. diff --git a/packages/calendar-time-utils/src/calendar-time.spec.js b/packages/calendar-time-utils/src/calendar-time.spec.js index 3215390e09..549b4e1459 100644 --- a/packages/calendar-time-utils/src/calendar-time.spec.js +++ b/packages/calendar-time-utils/src/calendar-time.spec.js @@ -1,6 +1,9 @@ import { getLocalizedDateTimeFormatPattern, formatDefaultTime, + getMonthCalendarLabel, + getYearCalendarLabel, + getToday, } from './calendar-time'; import { warning } from '@commercetools-uikit/utils'; @@ -101,3 +104,98 @@ describe('formatDefaultTime', () => { ); }); }); + +describe('getMonthCalendarLabel', () => { + it('should return the month label without timezone', () => { + const date = '2025-01-01T10:00:00.000Z'; // January 1, 2025 in UTC + expect(getMonthCalendarLabel(date, 'en')).toBe('January'); + }); + + it('should return the month label with timezone that shows correct month when timezone conversion would change the day', () => { + // This represents January 1st, 2025 in Pacific/Kiritimati (UTC+14) + // When converted to UTC, this becomes December 31st, 2024 + const dateInKiritimati = '2024-12-31T10:00:00.000Z'; // This is January 1st in Pacific/Kiritimati + + // Without timezone, it would show December (wrong) + expect(getMonthCalendarLabel(dateInKiritimati, 'en')).toBe('December'); + + // With timezone, it should show January (correct) + expect( + getMonthCalendarLabel(dateInKiritimati, 'en', 'Pacific/Kiritimati') + ).toBe('January'); + }); + + it('should work with different locales and timezones', () => { + const dateInUTC = '2025-06-15T12:00:00.000Z'; // June 15, 2025 in UTC + + expect(getMonthCalendarLabel(dateInUTC, 'en', 'UTC')).toBe('June'); + expect(getMonthCalendarLabel(dateInUTC, 'de', 'UTC')).toBe('Juni'); + expect(getMonthCalendarLabel(dateInUTC, 'fr', 'UTC')).toBe('juin'); + }); +}); + +describe('getYearCalendarLabel', () => { + it('should return the year label without timezone', () => { + const date = '2025-01-01T10:00:00.000Z'; // January 1, 2025 in UTC + expect(getYearCalendarLabel(date, 'en')).toBe('2025'); + }); + + it('should return the year label with timezone that shows correct year when timezone conversion would change the day', () => { + // This represents January 1st, 2025 in Pacific/Kiritimati (UTC+14) + // When converted to UTC, this becomes December 31st, 2024 + const dateInKiritimati = '2024-12-31T10:00:00.000Z'; // This is January 1st, 2025 in Pacific/Kiritimati + + // Without timezone, it would show 2024 (wrong) + expect(getYearCalendarLabel(dateInKiritimati, 'en')).toBe('2024'); + + // With timezone, it should show 2025 (correct) + expect( + getYearCalendarLabel(dateInKiritimati, 'en', 'Pacific/Kiritimati') + ).toBe('2025'); + }); +}); + +describe('timezone edge cases', () => { + beforeEach(() => { + // Mock the current date to January 1st, 2025 in Pacific/Kiritimati + const mockDate = new Date('2025-01-01T02:00:00+14:00'); // 2025-01-01 02:00 in UTC+14 = 2024-12-31 12:00 in UTC + jest.spyOn(Date, 'now').mockReturnValue(mockDate.getTime()); + + // Mock moment to return this specific date when getting "now" + jest.doMock('moment-timezone', () => { + const actualMoment = jest.requireActual('moment-timezone'); + return { + ...actualMoment, + tz: jest.fn().mockImplementation((...args) => { + if (args[0] === 'Pacific/Kiritimati' && args.length === 1) { + // Return January 1st, 2025 in that timezone + return actualMoment.tz('2025-01-01 02:00', 'Pacific/Kiritimati'); + } + return actualMoment.tz.apply(actualMoment, args); + }), + }; + }); + }); + + afterEach(() => { + jest.restoreAllMocks(); + jest.dontMock('moment-timezone'); + }); + + it('should display correct month when today in timezone converts to yesterday in UTC', () => { + // Test the scenario from the bug report: + // It's January 1st in Pacific/Kiritimati, but December 31st in UTC + + const today = getToday('Pacific/Kiritimati'); + + // The month label should show January when using timezone + expect(getMonthCalendarLabel(today, 'en', 'Pacific/Kiritimati')).toBe( + 'January' + ); + + // The year label should show 2025 when using timezone + expect(getYearCalendarLabel(today, 'en', 'Pacific/Kiritimati')).toBe( + '2025' + ); + }); +}); diff --git a/packages/calendar-time-utils/src/calendar-time.ts b/packages/calendar-time-utils/src/calendar-time.ts index 5ba1d7be83..b9d49d5346 100644 --- a/packages/calendar-time-utils/src/calendar-time.ts +++ b/packages/calendar-time-utils/src/calendar-time.ts @@ -119,10 +119,22 @@ export const getWeekdayNames = (locale: string) => { export const getStartOf = (day: MomentInput, timeZone: string) => moment.tz(day, timeZone).startOf('day').toISOString(); -export const getMonthCalendarLabel = (day: MomentInput, locale: string) => - moment(day, moment.ISO_8601, locale).format('MMMM'); -export const getYearCalendarLabel = (day: MomentInput, locale: string) => - moment(day, moment.ISO_8601, locale).format('YYYY'); +export const getMonthCalendarLabel = ( + day: MomentInput, + locale: string, + timeZone?: string +) => + timeZone + ? moment.tz(day, timeZone).locale(locale).format('MMMM') + : moment(day, moment.ISO_8601, locale).format('MMMM'); +export const getYearCalendarLabel = ( + day: MomentInput, + locale: string, + timeZone?: string +) => + timeZone + ? moment.tz(day, timeZone).locale(locale).format('YYYY') + : moment(day, moment.ISO_8601, locale).format('YYYY'); export const isSameDay = (a: MomentInput, b: MomentInput) => moment(a).isSame(b, 'day'); export const getCalendarDayLabel = (day: MomentInput, timeZone: string) => diff --git a/packages/components/inputs/date-time-input/src/date-time-input.spec.js b/packages/components/inputs/date-time-input/src/date-time-input.spec.js index 220189605f..3dbd0fa15e 100644 --- a/packages/components/inputs/date-time-input/src/date-time-input.spec.js +++ b/packages/components/inputs/date-time-input/src/date-time-input.spec.js @@ -197,6 +197,82 @@ describe('date picker defaultDaySelectionTime prop', () => { }); }); +describe('timezone edge cases', () => { + beforeEach(() => { + // Mock the current date to January 1st in a timezone ahead of UTC + // such that "today" in that timezone is "yesterday" in UTC + jest.useFakeTimers(); + // Set system time to January 1st, 2025 02:00 in Pacific/Kiritimati (UTC+14) + // This represents 2024-12-31 12:00:00.000Z in UTC + jest.setSystemTime(new Date('2024-12-31T12:00:00.000Z')); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('should display the correct month when timezone is ahead of UTC and would show wrong month', () => { + // Render DateTimeInput with Pacific/Kiritimati timezone (UTC+14) + // On January 1st in this timezone, UTC shows December 31st + const { getByLabelText } = renderDateTimeInput({ + timeZone: 'Pacific/Kiritimati', + value: '', // No initial value, so calendar will show "today" + }); + + const dateInput = getByLabelText('Date'); + fireEvent.click(dateInput); + + // The calendar should show January (correct month in the timezone) + // not December (which would be wrong - the UTC month) + expect(screen.getByText('January')).toBeInTheDocument(); + expect(screen.queryByText('December')).not.toBeInTheDocument(); + + // The year should also be correct (2025, not 2024) + expect(screen.getByText('2025')).toBeInTheDocument(); + }); + + it('should highlight the correct day as today when timezone causes date shift', () => { + const { getByLabelText } = renderDateTimeInput({ + timeZone: 'Pacific/Kiritimati', + value: '', + }); + + const dateInput = getByLabelText('Date'); + fireEvent.click(dateInput); + + // In Pacific/Kiritimati timezone, it should be January 1st + // We should find a calendar day with "1" that is marked as today + const todayElement = screen.getByText('1'); + expect(todayElement).toBeInTheDocument(); + + // Verify the calendar is showing January 2025 (not December 2024) + expect(screen.getByText('January')).toBeInTheDocument(); + expect(screen.getByText('2025')).toBeInTheDocument(); + }); + + it('should display the correct month when value is set and timezone conversion changes the month', () => { + // This represents July 1st, 2025 in Pacific/Kiritimati (UTC+14) + // When converted to UTC, this becomes June 30th, 2025 + const valueInKiritimati = '2025-06-30T10:00:00.000Z'; // This is July 1st in Pacific/Kiritimati + + const { getByLabelText } = renderDateTimeInput({ + timeZone: 'Pacific/Kiritimati', + value: valueInKiritimati, + }); + + const dateInput = getByLabelText('Date'); + fireEvent.click(dateInput); + + // The calendar should show July (correct month in the timezone) + // not June (which would be wrong - the UTC month) + expect(screen.getByText('July')).toBeInTheDocument(); + expect(screen.queryByText('June')).not.toBeInTheDocument(); + + // The year should also be correct (2025) + expect(screen.getByText('2025')).toBeInTheDocument(); + }); +}); + it('should only emit valid datetimes from manually entered datestrings', async () => { // Render the input with an initial value renderDateTimeInput({ diff --git a/packages/components/inputs/date-time-input/src/date-time-input.tsx b/packages/components/inputs/date-time-input/src/date-time-input.tsx index c3e1854021..db156878cd 100644 --- a/packages/components/inputs/date-time-input/src/date-time-input.tsx +++ b/packages/components/inputs/date-time-input/src/date-time-input.tsx @@ -363,7 +363,7 @@ class DateTimeInput extends Component< calendarDate: this.props.value === '' ? getToday(this.props.timeZone) - : this.props.value, + : getStartOf(this.props.value, this.props.timeZone), }; } @@ -587,11 +587,13 @@ class DateTimeInput extends Component< this.jumpMonths(-1)} onTodayClick={this.showToday}