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.