Skip to content

Commit 59d9d10

Browse files
aduthtyxlaMaksVeterntsekourasannezazu
authored
Components: Normalize displayed dates to UTC time for DateTimePicker (#73444)
* Date: Fix incorrect typing on offset type * DateTimePicker: Normalize displayed dates to UTC time * Parse timezoneless input string as UTC * Add link to pull request in CHANGELOG.md * Add additional combination testing browser at UTC matches original bug behavior, adds additional assurances in how we're using UTC as a baseline for formatting and normalizing dates * Use toBeVisible over toBeInTheDocument with positive assertions More accurate to the user's experience * Add test coverage for inputToDate * Define timezone-mock as devDependencies of components package Co-authored-by: aduth <[email protected]> Co-authored-by: tyxla <[email protected]> Co-authored-by: MaksVeter <[email protected]> Co-authored-by: ntsekouras <[email protected]> Co-authored-by: annezazu <[email protected]> Co-authored-by: ciampo <[email protected]>
1 parent d6a7fa3 commit 59d9d10

File tree

10 files changed

+380
-21
lines changed

10 files changed

+380
-21
lines changed

package-lock.json

Lines changed: 17 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/components/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
- `ExternalLink`: Fix arrow direction for RTL languages. The external link arrow now correctly points to the top-left (↖) instead of top-right (↗) in RTL layouts. ([#73400](https://github.com/WordPress/gutenberg/pull/73400))
88
- Fixed an issue where the `Guide` component’s close button became invisible on hover when used on light backgrounds. The component's close button now relies on the default button hover effect, and the custom hover color is applied only within `welcome-guide` implementations to maintain consistency. ([#73220](https://github.com/WordPress/gutenberg/pull/73220))
9+
- `DateTimePicker`: Fixed timezone handling when selecting specific dates around changes in daylight savings time when browser and server timezone settings are not in sync, which would cause an incorrect date to be selected. ([#73444](https://github.com/WordPress/gutenberg/pull/73444))
910

1011
## 30.8.0 (2025-11-12)
1112

packages/components/package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
],
4545
"dependencies": {
4646
"@ariakit/react": "^0.4.15",
47+
"@date-fns/utc": "^2.1.1",
4748
"@emotion/cache": "^11.7.1",
4849
"@emotion/css": "^11.7.1",
4950
"@emotion/react": "^11.7.1",
@@ -90,6 +91,9 @@
9091
"remove-accents": "^0.5.0",
9192
"uuid": "^9.0.1"
9293
},
94+
"devDependencies": {
95+
"timezone-mock": "^1.3.6"
96+
},
9397
"peerDependencies": {
9498
"react": "^18.0.0",
9599
"react-dom": "^18.0.0"
Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
/**
2+
* External dependencies
3+
*/
4+
import { render, screen } from '@testing-library/react';
5+
import userEvent from '@testing-library/user-event';
6+
import timezoneMock from 'timezone-mock';
7+
8+
/**
9+
* WordPress dependencies
10+
*/
11+
import { getSettings, setSettings, type DateSettings } from '@wordpress/date';
12+
13+
/**
14+
* Internal dependencies
15+
*/
16+
import DateTimePicker from '..';
17+
18+
describe( 'DateTimePicker', () => {
19+
let originalSettings: DateSettings;
20+
beforeAll( () => {
21+
originalSettings = getSettings();
22+
setSettings( {
23+
...originalSettings,
24+
timezone: {
25+
offset: -5,
26+
offsetFormatted: '-5',
27+
string: 'America/New_York',
28+
abbr: 'EST',
29+
},
30+
} );
31+
} );
32+
33+
afterEach( () => {
34+
jest.restoreAllMocks();
35+
timezoneMock.unregister();
36+
} );
37+
38+
afterAll( () => {
39+
setSettings( originalSettings );
40+
} );
41+
42+
it( 'should display and select dates correctly when timezones match', async () => {
43+
const user = userEvent.setup();
44+
const onChange = jest.fn();
45+
46+
timezoneMock.register( 'US/Eastern' );
47+
48+
const { rerender } = render(
49+
<DateTimePicker
50+
currentDate="2025-11-15T00:00:00"
51+
onChange={ onChange }
52+
/>
53+
);
54+
55+
expect(
56+
screen.getByRole( 'button', {
57+
name: 'November 15, 2025. Selected',
58+
} )
59+
).toBeVisible();
60+
61+
onChange.mockImplementation( ( newDate ) => {
62+
rerender(
63+
<DateTimePicker currentDate={ newDate } onChange={ onChange } />
64+
);
65+
} );
66+
67+
await user.click(
68+
screen.getByRole( 'button', { name: 'November 20, 2025' } )
69+
);
70+
71+
expect( onChange ).toHaveBeenCalledWith( '2025-11-20T00:00:00' );
72+
expect(
73+
screen.getByRole( 'button', {
74+
name: 'November 20, 2025. Selected',
75+
} )
76+
).toBeVisible();
77+
} );
78+
79+
describe( 'timezone differences between browser and site', () => {
80+
describe.each( [
81+
{
82+
direction: 'browser behind site',
83+
timezone: 'US/Pacific' as const,
84+
time: '21:00:00', // Evening: shifts to next day UTC
85+
},
86+
{
87+
// Test a scenario where local time is UTC time, to verify that
88+
// using gmdateI18n (UTC) for formatting works correctly when
89+
// the browser's timezone already has no offset from UTC.
90+
direction: 'browser matches UTC (zero offset)',
91+
timezone: 'UTC' as const,
92+
time: '00:00:00',
93+
},
94+
{
95+
direction: 'browser ahead of site',
96+
timezone: 'Australia/Adelaide' as const,
97+
time: '00:00:00', // Midnight: shifts to previous day UTC
98+
},
99+
] )( '$direction', ( { timezone, time } ) => {
100+
describe.each( [
101+
{
102+
period: 'DST start',
103+
initialDate: `2025-03-10T${ time }`,
104+
initialButton: 'March 10, 2025. Selected',
105+
clickButton: 'March 11, 2025',
106+
expectedDay: 11,
107+
expectedDate: `2025-03-11T${ time }`,
108+
selectedButton: 'March 11, 2025. Selected',
109+
wrongMonthButton: 'February 28, 2025',
110+
},
111+
{
112+
period: 'DST end',
113+
initialDate: `2025-11-01T${ time }`,
114+
initialButton: 'November 1, 2025. Selected',
115+
clickButton: 'November 2, 2025',
116+
expectedDay: 2,
117+
expectedDate: `2025-11-02T${ time }`,
118+
selectedButton: 'November 2, 2025. Selected',
119+
wrongMonthButton: 'October 31, 2025',
120+
},
121+
] )(
122+
'$period',
123+
( {
124+
initialDate,
125+
initialButton,
126+
clickButton,
127+
expectedDay,
128+
expectedDate,
129+
selectedButton,
130+
wrongMonthButton,
131+
} ) => {
132+
it( 'should display and select dates correctly', async () => {
133+
const user = userEvent.setup();
134+
const onChange = jest.fn();
135+
136+
timezoneMock.register( timezone );
137+
138+
const { rerender } = render(
139+
<DateTimePicker
140+
currentDate={ initialDate }
141+
onChange={ onChange }
142+
/>
143+
);
144+
145+
// Calendar should not show dates from wrong month
146+
expect(
147+
screen.queryByRole( 'button', {
148+
name: wrongMonthButton,
149+
} )
150+
).not.toBeInTheDocument();
151+
152+
// Should show correct initial date as selected
153+
expect(
154+
screen.getByRole( 'button', {
155+
name: initialButton,
156+
} )
157+
).toBeVisible();
158+
159+
onChange.mockImplementation( ( newDate ) => {
160+
rerender(
161+
<DateTimePicker
162+
currentDate={ newDate }
163+
onChange={ onChange }
164+
/>
165+
);
166+
} );
167+
168+
await user.click(
169+
screen.getByRole( 'button', { name: clickButton } )
170+
);
171+
172+
expect( screen.getByLabelText( 'Day' ) ).toHaveValue(
173+
expectedDay
174+
);
175+
expect( onChange ).toHaveBeenCalledWith( expectedDate );
176+
expect(
177+
screen.getByRole( 'button', {
178+
name: selectedButton,
179+
} )
180+
).toBeVisible();
181+
} );
182+
}
183+
);
184+
} );
185+
} );
186+
187+
it( 'should preserve time when changing date', async () => {
188+
const user = userEvent.setup();
189+
const onChange = jest.fn();
190+
191+
timezoneMock.register( 'UTC' );
192+
193+
render(
194+
<DateTimePicker
195+
currentDate="2025-11-15T14:30:00"
196+
onChange={ onChange }
197+
/>
198+
);
199+
200+
await user.click(
201+
screen.getByRole( 'button', { name: 'November 20, 2025' } )
202+
);
203+
204+
expect( onChange ).toHaveBeenCalledWith( '2025-11-20T14:30:00' );
205+
} );
206+
} );

packages/components/src/date-time/date/index.tsx

Lines changed: 6 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ import type { KeyboardEventHandler } from 'react';
2222
*/
2323
import { __, _n, sprintf, isRTL } from '@wordpress/i18n';
2424
import { arrowLeft, arrowRight } from '@wordpress/icons';
25-
import { dateI18n, getSettings } from '@wordpress/date';
25+
import { getSettings, gmdateI18n } from '@wordpress/date';
2626
import { useState, useRef, useEffect } from '@wordpress/element';
2727

2828
/**
@@ -129,14 +129,8 @@ export function DatePicker( {
129129
size="compact"
130130
/>
131131
<NavigatorHeading level={ 3 }>
132-
<strong>
133-
{ dateI18n(
134-
'F',
135-
viewing,
136-
-viewing.getTimezoneOffset()
137-
) }
138-
</strong>{ ' ' }
139-
{ dateI18n( 'Y', viewing, -viewing.getTimezoneOffset() ) }
132+
<strong>{ gmdateI18n( 'F', viewing ) }</strong>{ ' ' }
133+
{ gmdateI18n( 'Y', viewing ) }
140134
</NavigatorHeading>
141135
<ViewNextMonthButton
142136
icon={ isRTL() ? arrowLeft : arrowRight }
@@ -161,7 +155,7 @@ export function DatePicker( {
161155
>
162156
{ calendar[ 0 ][ 0 ].map( ( day ) => (
163157
<DayOfWeek key={ day.toString() }>
164-
{ dateI18n( 'D', day, -day.getTimezoneOffset() ) }
158+
{ gmdateI18n( 'D', day ) }
165159
</DayOfWeek>
166160
) ) }
167161
{ calendar[ 0 ].map( ( week ) =>
@@ -320,18 +314,14 @@ function Day( {
320314
onClick={ onClick }
321315
onKeyDown={ onKeyDown }
322316
>
323-
{ dateI18n( 'j', day, -day.getTimezoneOffset() ) }
317+
{ gmdateI18n( 'j', day ) }
324318
</DayButton>
325319
);
326320
}
327321

328322
function getDayLabel( date: Date, isSelected: boolean, numEvents: number ) {
329323
const { formats } = getSettings();
330-
const localizedDate = dateI18n(
331-
formats.date,
332-
date,
333-
-date.getTimezoneOffset()
334-
);
324+
const localizedDate = gmdateI18n( formats.date, date );
335325
if ( isSelected && numEvents > 0 ) {
336326
return sprintf(
337327
// translators: 1: The calendar date. 2: Number of events on the calendar date.

0 commit comments

Comments
 (0)