From 38a9dbf274c749cb68026cbd68c375668baa1736 Mon Sep 17 00:00:00 2001 From: Michael Salzmann Date: Tue, 8 Jul 2025 09:02:48 +0200 Subject: [PATCH 1/3] feat(calendar-time-utils): add timezone-aware month and year label functions with tests for edge cases --- .../src/calendar-time.spec.js | 98 +++++++++++++++++++ .../calendar-time-utils/src/calendar-time.ts | 20 +++- .../src/date-time-input.spec.js | 54 ++++++++++ .../date-time-input/src/date-time-input.tsx | 6 +- 4 files changed, 172 insertions(+), 6 deletions(-) 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..0b0981d2a4 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,60 @@ 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 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..6b720d36f2 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 @@ -587,11 +587,13 @@ class DateTimeInput extends Component< this.jumpMonths(-1)} onTodayClick={this.showToday} From 9c95832bdd9a3e2f3201ba82649fd360e902ea7f Mon Sep 17 00:00:00 2001 From: Michael Salzmann Date: Tue, 8 Jul 2025 09:07:23 +0200 Subject: [PATCH 2/3] feat(calendar-time-utils): enhance calendar label functions to support optional timezone parameter for improved date display --- .changeset/swift-ears-flash.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/swift-ears-flash.md 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. From 56411a6a2cfa340b2e3b733c2e7692eea430f9db Mon Sep 17 00:00:00 2001 From: Michael Salzmann Date: Tue, 8 Jul 2025 09:11:51 +0200 Subject: [PATCH 3/3] feat(date-time-input): add test for timezone conversion affecting month display --- .../src/date-time-input.spec.js | 22 +++++++++++++++++++ .../date-time-input/src/date-time-input.tsx | 2 +- 2 files changed, 23 insertions(+), 1 deletion(-) 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 0b0981d2a4..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 @@ -249,6 +249,28 @@ describe('timezone edge cases', () => { 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 () => { 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 6b720d36f2..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), }; }