Commit 3c67ef70d85 for woocommerce

commit 3c67ef70d85bb41664fc7a46000e2bf5b8cd24b0
Author: Peter Petrov <peter.petrov89@gmail.com>
Date:   Thu Aug 20 10:59:50 2026 +0300

    Fix Analytics date range label in locales with digits in month names (#67804)

    * Fix Analytics date range label in locales with digits in month names

    Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

    * Keep the day format token when building the same-month range label

    * Cover the non-Latin digit locale in the range label tests

    * Keep backslash escaped literals out of the day token swap

    ---------

    Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

diff --git a/packages/js/date/changelog/fix-53303-day-range-in-localized-format b/packages/js/date/changelog/fix-53303-day-range-in-localized-format
new file mode 100644
index 00000000000..4806e36d438
--- /dev/null
+++ b/packages/js/date/changelog/fix-53303-day-range-in-localized-format
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Build the same-month range label by substituting the day token in the date format, so month names that contain digits are no longer corrupted (#53303).
diff --git a/packages/js/date/src/index.ts b/packages/js/date/src/index.ts
index 74620e3fdb0..727e88913ba 100644
--- a/packages/js/date/src/index.ts
+++ b/packages/js/date/src/index.ts
@@ -133,6 +133,51 @@ export function toMoment( format: string, str: unknown ) {
 	throw new Error( 'toMoment requires a string to be passed as an argument' );
 }

+/**
+ * Swaps the day of month token of a moment format string for an escaped literal.
+ *
+ * Substituting in the format instead of in the formatted date keeps the value
+ * away from the rest of the localized output: Japanese renders October as
+ * "10月", where replacing the day "1" lands on the month instead, and locales
+ * with non-Latin digits never match a Latin day number at all.
+ *
+ * @param {string}   format      - localized date string format
+ * @param {Function} replacement - builds the literal text to render in place of
+ *                               the day, from the token it replaces
+ * @return {string|null} - format string, or null when it holds no day token
+ */
+function replaceDayToken(
+	format: string,
+	replacement: ( dayToken: string ) => string
+) {
+	let replaced = false;
+	// Backslash escapes and bracketed sections are moment's literals, so a "D"
+	// inside one is text.
+	const dayRangeFormat = format.replace(
+		/\\.|\[[^\]]*\]|D+o?/g,
+		( token ) => {
+			// Runs longer than "DD" are day of year tokens, not day of month.
+			const dayDigits = token.endsWith( 'o' )
+				? token.length - 1
+				: token.length;
+
+			if (
+				replaced ||
+				token.startsWith( '[' ) ||
+				token.startsWith( '\\' ) ||
+				dayDigits > 2
+			) {
+				return token;
+			}
+
+			replaced = true;
+			return `[${ replacement( token ) }]`;
+		}
+	);
+
+	return replaced ? dayRangeFormat : null;
+}
+
 /**
  * Given two dates, derive a string representation
  *
@@ -150,13 +195,23 @@ export function getRangeLabel( after: moment.Moment, before: moment.Moment ) {
 	if ( isSameDay ) {
 		return after.format( fullDateFormat );
 	} else if ( isSameMonth ) {
-		const afterDate = after.date();
-		return after
-			.format( fullDateFormat )
-			.replace(
-				String( afterDate ),
-				`${ afterDate } - ${ before.date() }`
-			);
+		// Formatting each day through the token it replaces keeps whatever the
+		// format asked for, such as the zero padding of "DD" or the ordinal of "Do".
+		const dayRangeFormat = replaceDayToken(
+			fullDateFormat,
+			( dayToken ) =>
+				`${ after.format( dayToken ) } - ${ before.format( dayToken ) }`
+		);
+
+		// No day of month token to swap: the format either omits the day, or
+		// renders one through an aggregate token such as "LL" that is not
+		// scanned. Either way the shared month is as much of the range as this
+		// format can carry.
+		if ( dayRangeFormat === null ) {
+			return after.format( fullDateFormat );
+		}
+
+		return after.format( dayRangeFormat );
 	} else if ( isSameYear ) {
 		const monthDayFormat = __( 'MMM D', 'woocommerce' );
 		return `${ after.format( monthDayFormat ) } - ${ before.format(
diff --git a/packages/js/date/src/test/index.ts b/packages/js/date/src/test/index.ts
index 52088bc4ff7..f23f58f953d 100644
--- a/packages/js/date/src/test/index.ts
+++ b/packages/js/date/src/test/index.ts
@@ -2,12 +2,19 @@
  * External dependencies
  */
 import moment from 'moment';
+import { __ } from '@wordpress/i18n';
 import {
 	format as formatDate,
 	getSettings as getDateSettings,
 	setSettings as setDateSettings,
 } from '@wordpress/date';
 import { timeFormat as d3TimeFormat } from 'd3-time-format';
+
+jest.mock( '@wordpress/i18n', () => ( {
+	...jest.requireActual( '@wordpress/i18n' ),
+	__: jest.fn( ( text: string ) => text ),
+} ) );
+
 /**
  * Internal dependencies
  */
@@ -820,6 +827,191 @@ describe( 'getRangeLabel', () => {
 		);
 		expect( label ).toBe( 'Apr 1, 2017 - May 15, 2018' );
 	} );
+
+	it( 'should fall back to the shared month when the format holds no day token', () => {
+		( __ as jest.Mock ).mockReturnValueOnce( 'YYYY年M月' );
+
+		const label = getRangeLabel(
+			moment( '2024-10-01' ),
+			moment( '2024-10-31' )
+		);
+
+		expect( label ).toBe( '2024年10月' );
+	} );
+
+	it( 'should keep the zero padding a "DD" format asks for', () => {
+		// Mirrors the Serbian translation of the format.
+		( __ as jest.Mock ).mockReturnValueOnce( 'DD. MMM YYYY.' );
+
+		const label = getRangeLabel(
+			moment( '2018-04-01' ),
+			moment( '2018-04-15' )
+		);
+
+		expect( label ).toBe( '01 - 15. Apr 2018.' );
+	} );
+
+	it( 'should keep the ordinal a "Do" format asks for', () => {
+		( __ as jest.Mock ).mockReturnValueOnce( 'MMM Do, YYYY' );
+
+		const label = getRangeLabel(
+			moment( '2018-04-01' ),
+			moment( '2018-04-15' )
+		);
+
+		expect( label ).toBe( 'Apr 1st - 15th, 2018' );
+	} );
+
+	it( 'should leave a bracketed day literal alone', () => {
+		( __ as jest.Mock ).mockReturnValueOnce( '[Day] MMM D, YYYY' );
+
+		const label = getRangeLabel(
+			moment( '2018-04-01' ),
+			moment( '2018-04-15' )
+		);
+
+		expect( label ).toBe( 'Day Apr 1 - 15, 2018' );
+	} );
+
+	it( 'should leave a backslash escaped day literal alone', () => {
+		( __ as jest.Mock ).mockReturnValueOnce( '\\D MMM D, YYYY' );
+
+		const label = getRangeLabel(
+			moment( '2018-04-01' ),
+			moment( '2018-04-15' )
+		);
+
+		expect( label ).toBe( 'D Apr 1 - 15, 2018' );
+	} );
+
+	it( 'should not mistake day of year tokens for the day of month', () => {
+		( __ as jest.Mock ).mockReturnValueOnce( 'DDDD, YYYY' );
+
+		expect(
+			getRangeLabel( moment( '2018-04-01' ), moment( '2018-04-15' ) )
+		).toBe( '091, 2018' );
+
+		( __ as jest.Mock ).mockReturnValueOnce( 'DDDo, YYYY' );
+
+		expect(
+			getRangeLabel( moment( '2018-04-01' ), moment( '2018-04-15' ) )
+		).toBe( '91st, 2018' );
+	} );
+
+	describe( 'with a locale whose month names contain digits', () => {
+		// Mirrors moment's Japanese locale, which renders October as "10月".
+		const monthNames = Array.from(
+			{ length: 12 },
+			( _, index ) => `${ index + 1 }月`
+		);
+		let originalLocale: string;
+
+		beforeAll( () => {
+			originalLocale = moment.locale();
+			moment.defineLocale( 'digit-months', {
+				months: monthNames,
+				monthsShort: monthNames,
+			} );
+			moment.locale( 'digit-months' );
+		} );
+
+		afterAll( () => {
+			moment.locale( originalLocale );
+		} );
+
+		it( 'should not expand the day range inside the month name', () => {
+			const label = getRangeLabel(
+				moment( '2024-10-01' ),
+				moment( '2024-10-31' )
+			);
+			expect( label ).toBe( '10月 1 - 31, 2024' );
+		} );
+
+		it( 'should not expand the day range inside a month name sharing the day digits', () => {
+			const label = getRangeLabel(
+				moment( '2024-12-12' ),
+				moment( '2024-12-31' )
+			);
+			expect( label ).toBe( '12月 12 - 31, 2024' );
+		} );
+
+		it( 'should leave single day, cross month and cross year labels alone', () => {
+			expect(
+				getRangeLabel( moment( '2024-10-01' ), moment( '2024-10-01' ) )
+			).toBe( '10月 1, 2024' );
+			expect(
+				getRangeLabel( moment( '2024-10-01' ), moment( '2024-12-31' ) )
+			).toBe( '10月 1 - 12月 31, 2024' );
+			expect(
+				getRangeLabel( moment( '2023-10-01' ), moment( '2024-12-31' ) )
+			).toBe( '10月 1, 2023 - 12月 31, 2024' );
+		} );
+	} );
+
+	describe( 'with a locale that renders non-Latin digits', () => {
+		// Mirrors moment's Arabic locale, which maps digits when formatting.
+		const arabicDigits = [
+			'٠',
+			'١',
+			'٢',
+			'٣',
+			'٤',
+			'٥',
+			'٦',
+			'٧',
+			'٨',
+			'٩',
+		];
+		const monthNames = [
+			'يناير',
+			'فبراير',
+			'مارس',
+			'أبريل',
+			'مايو',
+			'يونيو',
+			'يوليو',
+			'أغسطس',
+			'سبتمبر',
+			'أكتوبر',
+			'نوفمبر',
+			'ديسمبر',
+		];
+		let originalLocale: string;
+
+		beforeAll( () => {
+			originalLocale = moment.locale();
+			moment.defineLocale( 'non-latin-digits', {
+				months: monthNames,
+				monthsShort: monthNames,
+				postformat: ( value: string ) =>
+					value.replace(
+						/\d/g,
+						( digit ) => arabicDigits[ Number( digit ) ]
+					),
+			} );
+			moment.locale( 'non-latin-digits' );
+		} );
+
+		afterAll( () => {
+			moment.locale( originalLocale );
+		} );
+
+		it( 'should keep both ends of the range', () => {
+			const label = getRangeLabel(
+				moment( '2024-10-01' ),
+				moment( '2024-10-31' )
+			);
+			expect( label ).toBe( 'أكتوبر ١ - ٣١, ٢٠٢٤' );
+		} );
+
+		it( 'should leave a single day label alone', () => {
+			const label = getRangeLabel(
+				moment( '2024-10-01' ),
+				moment( '2024-10-01' )
+			);
+			expect( label ).toBe( 'أكتوبر ١, ٢٠٢٤' );
+		} );
+	} );
 } );

 describe( 'loadLocaleData', () => {
diff --git a/plugins/woocommerce/changelog/fix-53303-day-range-in-localized-format b/plugins/woocommerce/changelog/fix-53303-day-range-in-localized-format
new file mode 100644
index 00000000000..eaf323fca18
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-53303-day-range-in-localized-format
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Fix the Analytics date range label showing a garbled date in languages whose month names contain digits, such as Japanese.