Commit 5d684972d56 for woocommerce

commit 5d684972d569f90dac4478137f7499df0038841e
Author: Ahmed <ahmed.el.azzabi@automattic.com>
Date:   Wed Jun 24 15:39:47 2026 +0100

    Add localized timezone-aware formatting to Timeline (#65914)

    * fix(components): add Timeline timezone option

    Timeline always formatted dates with browser timezone behavior, which prevents consumers from rendering event dates relative to the WordPress site timezone.

    Add an optional timezone prop that defaults to browser behavior for backward compatibility and uses dateI18n when site timezone rendering is requested.

    Also cover the default and site timezone paths in Timeline tests and document the new prop.

    * fix(components): satisfy Timeline README lint

    CI expects ATX headings and unindented lists in markdown files.

    Update the Timeline README heading and final bullet formatting without changing the documented API.

    * fix(components): avoid spying on date exports

    The @wordpress/date exports are non-configurable in the Jest runtime, so spyOn cannot redefine them.

    Mock the module once with real default implementations, then override the mocked formatters only inside the timezone-specific Timeline tests.

    * fix(components): group Timeline items by selected timezone

    The Timeline timezone prop previously affected formatted labels and timestamps, but day/week/month grouping still used browser-local moment comparisons.

    Pass the timezone mode through to grouping, use WordPress site date keys for site-timezone grouping, and add a boundary test covering items that would split in browser time but share one site-timezone day.

    * fix(components): clarify Timeline timezone contract

    Timeline timezone now affects both grouping and display, so the prop docs should describe the full behavior.

    Also classify the package changelog as a minor add because this introduces a new public Timeline prop.

    * fix(components): avoid localizing Timeline timezone formatting

    The Timeline timezone prop should control timezone behavior without changing locale.

    Use @wordpress/date date() for site-timezone formatting instead of dateI18n(), keeping date and clock display controlled by the provided format strings.

    * fix(components): localize Timeline date formatting

    Timeline dates should be localized while preserving the selected timezone behavior.

    Use dateI18n for browser and site formatting, passing the browser IANA timezone when available and falling back to the previous format() behavior when it is not.

    * fix(components): document Timeline localization change

    Timeline now localizes rendered date text, which is a visible behavior change for existing consumers on non-English sites.

    Reframe the package changelog as an update, document the behavior change in the README, and pin the empty-string browser timezone fallback.

    * fix(components): refine Timeline date docs

    Keep README documentation focused on the current Timeline API contract and move behavior-change framing into the package changelog.

    * fix(components): strengthen Timeline timezone tests

    Use real WordPress date settings for the site-timezone grouping boundary test so it exercises date() keying instead of mocked keys.

    Also cover the Intl throw fallback, qualify the README fallback behavior, and document the ISO-week difference in site-timezone grouping.

    ---------

    Co-authored-by: Oleksandr Aratovskyi <79862886+oaratovskyi@users.noreply.github.com>

diff --git a/packages/js/components/changelog/add-timeline-timezone-prop b/packages/js/components/changelog/add-timeline-timezone-prop
new file mode 100644
index 00000000000..152ee69df27
--- /dev/null
+++ b/packages/js/components/changelog/add-timeline-timezone-prop
@@ -0,0 +1,4 @@
+Significance: minor
+Type: update
+
+Update Timeline date rendering from browser-local formatting to localized timezone-aware formatting.
diff --git a/packages/js/components/src/timeline/README.md b/packages/js/components/src/timeline/README.md
index f27300da7bc..563086d6f8d 100644
--- a/packages/js/components/src/timeline/README.md
+++ b/packages/js/components/src/timeline/README.md
@@ -1,5 +1,4 @@
-Timeline
-===
+# Timeline

 This is a timeline for displaying data, such as events, in chronological order.
 It accepts `items` for the timeline content and will order the data for you.
@@ -52,7 +51,11 @@ Name | Type | Default | Description
 `groupBy` | String | `'day'` | How the items should be grouped, one of `'day'`, `'week'`, or `'month'`
 `dateFormat` | String | `'F j, Y'` | PHP date format string used to format dates, see php.net/date
 `clockFormat` | String | `'g:ia'` | PHP clock format string used to format times, see php.net/date
+`timezone` | String | `'browser'` | Timezone mode used to group and render dates and times. Dates are localized using WordPress date settings. Use `'browser'` to use the user's browser timezone, or `'site'` to use the WordPress site timezone.

+### Date formatting
+
+Timeline localizes rendered dates and times using WordPress date settings. Use the `timezone` prop to choose whether dates are grouped and rendered in the user's browser timezone or the WordPress site timezone. When the browser timezone cannot be detected, `browser` mode uses non-localized browser formatting.

 ### `items` structure

@@ -67,4 +70,5 @@ Name | Type | Default | Description
 `hideTimestamp` | Bool | `false` | Allows the user to hide the timestamp associated with this event

 Icon color can be customized by adding 1 of 3 classes to the icon element: `is-success` (green), `is-warning` (yellow), and `is-error` (red)
-  - If no class is provided the icon will be gray
+
+- If no class is provided the icon will be gray
diff --git a/packages/js/components/src/timeline/index.js b/packages/js/components/src/timeline/index.js
index 289bcf36b9d..60a3c699864 100644
--- a/packages/js/components/src/timeline/index.js
+++ b/packages/js/components/src/timeline/index.js
@@ -4,14 +4,13 @@
 import clsx from 'clsx';
 import PropTypes from 'prop-types';
 import { __ } from '@wordpress/i18n';
-import { format } from '@wordpress/date';
 import { createElement } from '@wordpress/element';

 /**
  * Internal dependencies
  */
 import TimelineGroup from './timeline-group';
-import { sortByDateUsing, groupItemsUsing } from './util';
+import { formatTimelineDate, sortByDateUsing, groupItemsUsing } from './util';

 const Timeline = ( {
 	className = '',
@@ -22,6 +21,7 @@ const Timeline = ( {
 	dateFormat = __( 'F j, Y', 'woocommerce' ),
 	/* translators: PHP clock format string used to display times, see php.net/date. */
 	clockFormat = __( 'g:ia', 'woocommerce' ),
+	timezone = 'browser',
 } ) => {
 	const timelineClassName = clsx( 'woocommerce-timeline', className );

@@ -39,7 +39,7 @@ const Timeline = ( {
 	const addGroupTitles = ( group ) => {
 		return {
 			...group,
-			title: format( dateFormat, group.date ),
+			title: formatTimelineDate( dateFormat, group.date, timezone ),
 		};
 	};

@@ -47,7 +47,7 @@ const Timeline = ( {
 		<div className={ timelineClassName }>
 			<ul>
 				{ items
-					.reduce( groupItemsUsing( groupBy ), [] )
+					.reduce( groupItemsUsing( groupBy, timezone ), [] )
 					.map( addGroupTitles )
 					.sort( sortByDateUsing( orderBy ) )
 					.map( ( group ) => (
@@ -56,6 +56,7 @@ const Timeline = ( {
 							group={ group }
 							orderBy={ orderBy }
 							clockFormat={ clockFormat }
+							timezone={ timezone }
 						/>
 					) ) }
 			</ul>
@@ -116,6 +117,10 @@ Timeline.propTypes = {
 	 * The PHP clock format string used to format times, see php.net/date.
 	 */
 	clockFormat: PropTypes.string,
+	/**
+	 * Defines whether dates are grouped and displayed using the browser timezone or the WordPress site timezone.
+	 */
+	timezone: PropTypes.oneOf( [ 'browser', 'site' ] ),
 };

 export { orderByOptions, groupByOptions } from './util';
diff --git a/packages/js/components/src/timeline/test/index.js b/packages/js/components/src/timeline/test/index.js
index 017da0c891b..687e585502d 100644
--- a/packages/js/components/src/timeline/test/index.js
+++ b/packages/js/components/src/timeline/test/index.js
@@ -3,6 +3,7 @@
  * External dependencies
  */
 import { render } from '@testing-library/react';
+import { date as formatSiteDate, dateI18n, format } from '@wordpress/date';
 import { createElement } from '@wordpress/element';

 /**
@@ -12,7 +13,35 @@ import Timeline from '..';
 import mockData from './__mocks__/timeline-mock-data';
 import { groupItemsUsing, sortByDateUsing } from '../util.js';

+jest.mock( '@wordpress/date', () => {
+	const actualDateModule = jest.requireActual( '@wordpress/date' );
+
+	return {
+		...actualDateModule,
+		date: jest.fn( actualDateModule.date ),
+		dateI18n: jest.fn( actualDateModule.dateI18n ),
+		format: jest.fn( actualDateModule.format ),
+	};
+} );
+
 describe( 'Timeline', () => {
+	const actualDateModule = jest.requireActual( '@wordpress/date' );
+	const originalDateSettings = actualDateModule.getSettings();
+	const originalIntl = global.Intl;
+	const timezoneTestItem = {
+		...mockData[ 1 ],
+		date: new Date( Date.UTC( 2020, 0, 20, 23, 45 ) ),
+	};
+
+	afterEach( () => {
+		actualDateModule.setSettings( originalDateSettings );
+		global.Intl = originalIntl;
+		formatSiteDate.mockImplementation( actualDateModule.date );
+		dateI18n.mockImplementation( actualDateModule.dateI18n );
+		format.mockImplementation( actualDateModule.format );
+		jest.clearAllMocks();
+	} );
+
 	test( 'Empty snapshot', () => {
 		const { container } = render( <Timeline /> );
 		expect( container ).toMatchSnapshot();
@@ -23,6 +52,217 @@ describe( 'Timeline', () => {
 		expect( container ).toMatchSnapshot();
 	} );

+	test( 'uses browser timezone date formatting by default', () => {
+		global.Intl = {
+			DateTimeFormat: () => ( {
+				resolvedOptions: () => ( {
+					timeZone: 'Europe/London',
+				} ),
+			} ),
+		};
+		format.mockImplementation(
+			( dateFormat, date ) =>
+				`browser:${ dateFormat }:${ date.toISOString() }`
+		);
+		dateI18n.mockImplementation(
+			( dateFormat, date, timezone ) =>
+				`localized:${ timezone }:${ dateFormat }:${ date.toISOString() }`
+		);
+
+		const { container } = render(
+			<Timeline
+				items={ [ timezoneTestItem ] }
+				dateFormat="F j, Y"
+				clockFormat="g:ia"
+			/>
+		);
+
+		expect(
+			container.querySelector( '.woocommerce-timeline-group__title' )
+				.textContent
+		).toBe( 'localized:Europe/London:F j, Y:2020-01-20T23:45:00.000Z' );
+		expect(
+			container.querySelector( '.woocommerce-timeline-item__timestamp' )
+				.textContent
+		).toBe( 'localized:Europe/London:g:ia:2020-01-20T23:45:00.000Z' );
+	} );
+
+	test( 'uses site timezone date formatting when requested', () => {
+		format.mockImplementation(
+			( dateFormat, date ) =>
+				`browser:${ dateFormat }:${ date.toISOString() }`
+		);
+		dateI18n.mockImplementation(
+			( dateFormat, date ) =>
+				`site:${ dateFormat }:${ date.toISOString() }`
+		);
+
+		const { container } = render(
+			<Timeline
+				items={ [ timezoneTestItem ] }
+				dateFormat="F j, Y"
+				clockFormat="g:ia"
+				timezone="site"
+			/>
+		);
+
+		expect(
+			container.querySelector( '.woocommerce-timeline-group__title' )
+				.textContent
+		).toBe( 'site:F j, Y:2020-01-20T23:45:00.000Z' );
+		expect(
+			container.querySelector( '.woocommerce-timeline-item__timestamp' )
+				.textContent
+		).toBe( 'site:g:ia:2020-01-20T23:45:00.000Z' );
+	} );
+
+	test( 'falls back to browser timezone formatting when browser timezone is unavailable', () => {
+		global.Intl = {
+			DateTimeFormat: () => ( {
+				resolvedOptions: () => ( {} ),
+			} ),
+		};
+		format.mockImplementation(
+			( dateFormat, date ) =>
+				`browser:${ dateFormat }:${ date.toISOString() }`
+		);
+		dateI18n.mockImplementation(
+			( dateFormat, date, timezone ) =>
+				`localized:${ timezone }:${ dateFormat }:${ date.toISOString() }`
+		);
+
+		const { container } = render(
+			<Timeline
+				items={ [ timezoneTestItem ] }
+				dateFormat="F j, Y"
+				clockFormat="g:ia"
+			/>
+		);
+
+		expect(
+			container.querySelector( '.woocommerce-timeline-group__title' )
+				.textContent
+		).toBe( 'browser:F j, Y:2020-01-20T23:45:00.000Z' );
+		expect(
+			container.querySelector( '.woocommerce-timeline-item__timestamp' )
+				.textContent
+		).toBe( 'browser:g:ia:2020-01-20T23:45:00.000Z' );
+	} );
+
+	test( 'falls back to browser timezone formatting when browser timezone lookup throws', () => {
+		global.Intl = {
+			DateTimeFormat: () => {
+				throw new Error( 'Timezone unavailable' );
+			},
+		};
+		format.mockImplementation(
+			( dateFormat, date ) =>
+				`browser:${ dateFormat }:${ date.toISOString() }`
+		);
+		dateI18n.mockImplementation(
+			( dateFormat, date, timezone ) =>
+				`localized:${ timezone }:${ dateFormat }:${ date.toISOString() }`
+		);
+
+		const { container } = render(
+			<Timeline
+				items={ [ timezoneTestItem ] }
+				dateFormat="F j, Y"
+				clockFormat="g:ia"
+			/>
+		);
+
+		expect(
+			container.querySelector( '.woocommerce-timeline-group__title' )
+				.textContent
+		).toBe( 'browser:F j, Y:2020-01-20T23:45:00.000Z' );
+		expect(
+			container.querySelector( '.woocommerce-timeline-item__timestamp' )
+				.textContent
+		).toBe( 'browser:g:ia:2020-01-20T23:45:00.000Z' );
+	} );
+
+	test( 'falls back to browser timezone formatting when browser timezone is empty', () => {
+		global.Intl = {
+			DateTimeFormat: () => ( {
+				resolvedOptions: () => ( {
+					timeZone: '',
+				} ),
+			} ),
+		};
+		format.mockImplementation(
+			( dateFormat, date ) =>
+				`browser:${ dateFormat }:${ date.toISOString() }`
+		);
+		dateI18n.mockImplementation(
+			( dateFormat, date, timezone ) =>
+				`localized:${ timezone }:${ dateFormat }:${ date.toISOString() }`
+		);
+
+		const { container } = render(
+			<Timeline
+				items={ [ timezoneTestItem ] }
+				dateFormat="F j, Y"
+				clockFormat="g:ia"
+			/>
+		);
+
+		expect(
+			container.querySelector( '.woocommerce-timeline-group__title' )
+				.textContent
+		).toBe( 'browser:F j, Y:2020-01-20T23:45:00.000Z' );
+		expect(
+			container.querySelector( '.woocommerce-timeline-item__timestamp' )
+				.textContent
+		).toBe( 'browser:g:ia:2020-01-20T23:45:00.000Z' );
+	} );
+
+	test( 'groups items using site timezone when requested', () => {
+		const timezoneBoundaryItems = [
+			{
+				...mockData[ 1 ],
+				date: new Date( Date.UTC( 2020, 0, 20, 23, 0 ) ),
+			},
+			{
+				...mockData[ 2 ],
+				date: new Date( Date.UTC( 2020, 0, 21, 1, 0 ) ),
+			},
+		];
+
+		actualDateModule.setSettings( {
+			...originalDateSettings,
+			timezone: {
+				offset: '9',
+				offsetFormatted: '+09:00',
+				string: '',
+				abbr: '',
+			},
+		} );
+		dateI18n.mockImplementation( ( dateFormat, date ) => {
+			if ( dateFormat === 'F j, Y' ) {
+				return 'January 21, 2020';
+			}
+
+			return `site:${ dateFormat }:${ date.toISOString() }`;
+		} );
+
+		const { container } = render(
+			<Timeline
+				items={ timezoneBoundaryItems }
+				dateFormat="F j, Y"
+				clockFormat="g:ia"
+				timezone="site"
+			/>
+		);
+
+		const groupTitles = container.querySelectorAll(
+			'.woocommerce-timeline-group__title'
+		);
+
+		expect( groupTitles ).toHaveLength( 1 );
+		expect( groupTitles[ 0 ].textContent ).toBe( 'January 21, 2020' );
+	} );
+
 	describe( 'Timeline utilities', () => {
 		test( 'Sorts correctly', () => {
 			const jan21 = new Date( 2020, 0, 21 );
diff --git a/packages/js/components/src/timeline/timeline-group.js b/packages/js/components/src/timeline/timeline-group.js
index 09e4fdd3aac..40fdba09c7c 100644
--- a/packages/js/components/src/timeline/timeline-group.js
+++ b/packages/js/components/src/timeline/timeline-group.js
@@ -16,6 +16,7 @@ const TimelineGroup = ( {
 	className = '',
 	orderBy = 'desc',
 	clockFormat,
+	timezone,
 } ) => {
 	const groupClassName = clsx( 'woocommerce-timeline-group', className );
 	const itemsToTimlineItem = ( item, itemIndex ) => {
@@ -25,6 +26,7 @@ const TimelineGroup = ( {
 				key={ itemKey }
 				item={ item }
 				clockFormat={ clockFormat }
+				timezone={ timezone }
 			/>
 		);
 	};
@@ -101,6 +103,10 @@ TimelineGroup.propTypes = {
 	 * The PHP clock format string used to format times, see php.net/date.
 	 */
 	clockFormat: PropTypes.string,
+	/**
+	 * Defines whether item dates should be displayed in the browser timezone or the WordPress site timezone.
+	 */
+	timezone: PropTypes.oneOf( [ 'browser', 'site' ] ),
 };

 export default TimelineGroup;
diff --git a/packages/js/components/src/timeline/timeline-item.js b/packages/js/components/src/timeline/timeline-item.js
index b0ecdfd69d5..f9e8b581be6 100644
--- a/packages/js/components/src/timeline/timeline-item.js
+++ b/packages/js/components/src/timeline/timeline-item.js
@@ -2,13 +2,26 @@
  * External dependencies
  */
 import clsx from 'clsx';
-import { format } from '@wordpress/date';
 import PropTypes from 'prop-types';
 import { createElement } from '@wordpress/element';

-const TimelineItem = ( { item = {}, className = '', clockFormat } ) => {
+/**
+ * Internal dependencies
+ */
+import { formatTimelineDate } from './util';
+
+const TimelineItem = ( {
+	item = {},
+	className = '',
+	clockFormat,
+	timezone = 'browser',
+} ) => {
 	const itemClassName = clsx( 'woocommerce-timeline-item', className );
-	const itemTimeString = format( clockFormat, item.date );
+	const itemTimeString = formatTimelineDate(
+		clockFormat,
+		item.date,
+		timezone
+	);

 	return (
 		<li className={ itemClassName }>
@@ -65,11 +78,15 @@ TimelineItem.propTypes = {
 		 * Allows users to toggle the timestamp on or off.
 		 */
 		hideTimestamp: PropTypes.bool,
-		/**
-		 * The PHP clock format string used to format times, see php.net/date.
-		 */
-		clockFormat: PropTypes.string,
 	} ),
+	/**
+	 * The PHP clock format string used to format times, see php.net/date.
+	 */
+	clockFormat: PropTypes.string,
+	/**
+	 * Defines whether item dates should be displayed in the browser timezone or the WordPress site timezone.
+	 */
+	timezone: PropTypes.oneOf( [ 'browser', 'site' ] ),
 };

 export default TimelineItem;
diff --git a/packages/js/components/src/timeline/util.js b/packages/js/components/src/timeline/util.js
index 5f58b71cfc9..ce2d1ea536f 100644
--- a/packages/js/components/src/timeline/util.js
+++ b/packages/js/components/src/timeline/util.js
@@ -2,6 +2,7 @@
  * External dependencies
  */
 import moment from 'moment';
+import { date as formatSiteDate, dateI18n, format } from '@wordpress/date';

 const orderByOptions = {
 	ASC: 'asc',
@@ -29,29 +30,83 @@ const sortByDateUsing = ( orderBy ) => {
 	}
 };

-const groupItemsUsing = ( groupBy ) => ( groups, newItem ) => {
-	// Helper functions defined to make the logic a bit more readable.
-	const hasSameMoment = ( group, item ) => {
-		return moment( group.date ).isSame( moment( item.date ), groupBy );
-	};
-	const groupIndexExists = ( index ) => index >= 0;
-	const groupForItem = groups.findIndex( ( group ) =>
-		hasSameMoment( group, newItem )
+const siteGroupDateFormats = {
+	[ groupByOptions.DAY ]: 'Y-m-d',
+	// Site-timezone week grouping uses ISO week keys, while browser grouping
+	// preserves Moment's locale-aware week comparison behavior.
+	[ groupByOptions.WEEK ]: 'o-W',
+	[ groupByOptions.MONTH ]: 'Y-m',
+};
+
+const getSiteGroupKey = ( date, groupBy ) =>
+	formatSiteDate(
+		siteGroupDateFormats[ groupBy ] ||
+			siteGroupDateFormats[ groupByOptions.DAY ],
+		date
 	);

-	if ( ! groupIndexExists( groupForItem ) ) {
-		// Create new group for newItem.
-		return [
-			...groups,
-			{
-				date: newItem.date,
-				items: [ newItem ],
-			},
-		];
+const getBrowserTimezone = () => {
+	try {
+		return Intl.DateTimeFormat().resolvedOptions().timeZone;
+	} catch ( error ) {
+		return undefined;
+	}
+};
+
+const formatTimelineDate = ( dateFormat, dateValue, timezone = 'browser' ) => {
+	if ( timezone === 'site' ) {
+		return dateI18n( dateFormat, dateValue );
 	}

-	groups[ groupForItem ].items.push( newItem );
-	return groups;
+	const browserTimezone = getBrowserTimezone();
+
+	if ( browserTimezone ) {
+		return dateI18n( dateFormat, dateValue, browserTimezone );
+	}
+
+	// If the browser timezone is unavailable, preserve the previous behavior
+	// rather than falling back to dateI18n's default site timezone.
+	return format( dateFormat, dateValue );
 };

-export { groupByOptions, groupItemsUsing, orderByOptions, sortByDateUsing };
+const groupItemsUsing =
+	( groupBy, timezone = 'browser' ) =>
+	( groups, newItem ) => {
+		// Helper functions defined to make the logic a bit more readable.
+		const hasSameGroupKey = ( group, item ) => {
+			if ( timezone === 'site' ) {
+				return (
+					getSiteGroupKey( group.date, groupBy ) ===
+					getSiteGroupKey( item.date, groupBy )
+				);
+			}
+
+			return moment( group.date ).isSame( moment( item.date ), groupBy );
+		};
+		const groupIndexExists = ( index ) => index >= 0;
+		const groupForItem = groups.findIndex( ( group ) =>
+			hasSameGroupKey( group, newItem )
+		);
+
+		if ( ! groupIndexExists( groupForItem ) ) {
+			// Create new group for newItem.
+			return [
+				...groups,
+				{
+					date: newItem.date,
+					items: [ newItem ],
+				},
+			];
+		}
+
+		groups[ groupForItem ].items.push( newItem );
+		return groups;
+	};
+
+export {
+	formatTimelineDate,
+	groupByOptions,
+	groupItemsUsing,
+	orderByOptions,
+	sortByDateUsing,
+};