Commit 232f8c942f7 for woocommerce

commit 232f8c942f78d0caefc6187f84251a9ec2756488
Author: Peter Petrov <peter.petrov89@gmail.com>
Date:   Thu Aug 27 14:03:47 2026 +0300

    Keep the Analytics range end when the date format uses a localized moment token (#67875)

    * Expand localized moment format tokens before building the day range label

    * Match moment's six pass limit when expanding localized format tokens

    * Cover backslash escaped localized format tokens in the range label tests

    * Stop the expansion helper docblock from overclaiming its return value

    * Drop the unused capture groups from the localized token pattern

    * Keep the day format token so inflecting locales render the genitive month name

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

    * Render the month name before the day swap so the label keeps start-date tokens

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

    * Escape the whole token a backslash precedes, not just its first character

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

    * Cover the genitive short month name of a MMM format

    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

    * Cover a localized format token that expands to another localized token

    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

    * Note why weekday names are left for moment to render

    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

    * Cap the escaped day run at moment's longest day token

    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

    * Cover the genitive month name behind a localized format token

    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

    * Cover the escaped day run cap against moment's tokenization

    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

    * Cover a bracketed month literal in the range label

    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

    * Restore the day of year case to the null fallback comment

    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

    * Escape weekday names so substituted brackets cannot flip their form

    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

    ---------

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

diff --git a/packages/js/date/src/index.ts b/packages/js/date/src/index.ts
index 727e88913ba..3d14066b500 100644
--- a/packages/js/date/src/index.ts
+++ b/packages/js/date/src/index.ts
@@ -133,6 +133,105 @@ export function toMoment( format: string, str: unknown ) {
 	throw new Error( 'toMoment requires a string to be passed as an argument' );
 }

+/**
+ * Expands moment's localized format tokens ("L", "LL", "ll", ...) into the
+ * underlying format the locale defines for them.
+ *
+ * Moment resolves those tokens only while formatting, so a day rendered through
+ * one is invisible to the day token scan below and the range end would be
+ * dropped. This mirrors moment's own expansion, including its pass limit, so
+ * the expanded format renders exactly what the original one would.
+ *
+ * @param {string}        format     - localized date string format
+ * @param {moment.Locale} localeData - locale the format will be rendered with
+ * @return {string} - format string with its localized tokens expanded, leaving
+ *                      escaped and bracketed ones as the literals they are
+ */
+function expandLocalizedFormat( format: string, localeData: moment.Locale ) {
+	// Bracketed sections and backslash escapes are moment's literals, so an "L"
+	// inside one is text; matching them first leaves them untouched, as
+	// `longDateFormat` has no entry for them.
+	const localizedTokens = /\[[^[]*\]|\\?(?:LTS|LT|LL?L?L?|l{1,4})/g;
+	let expanded = format;
+	// An expansion can itself hold localized tokens; moment allows six passes.
+	let passes = 6;
+
+	while ( passes-- > 0 ) {
+		localizedTokens.lastIndex = 0;
+
+		if ( ! localizedTokens.test( expanded ) ) {
+			break;
+		}
+
+		expanded = expanded.replace(
+			localizedTokens,
+			( token ) =>
+				localeData.longDateFormat(
+					token as moment.LongDateFormatKey
+				) || token
+		);
+	}
+
+	return expanded;
+}
+
+/**
+ * Renders the month and weekday names of a moment format string into escaped
+ * literals.
+ *
+ * Moment picks the grammatical form of both names by pattern-testing the
+ * format string while rendering: month choosers look for a day token next to
+ * the month one, and Ukrainian renders the genitive weekday whenever a
+ * bracketed literal sits before "dddd" - exactly the shape the substitutions
+ * here leave behind. Months and weekdays are the only tokens moment resolves
+ * against the format, so rendering every name in one pass, against the format
+ * as the locale received it, settles each choice before any substitution can
+ * flip one.
+ *
+ * @param {string}        format     - localized date string format
+ * @param {moment.Moment} date       - date whose month and weekday to render
+ * @param {moment.Locale} localeData - locale the format will be rendered with
+ * @return {string} - format string with its month and weekday tokens escaped
+ */
+function escapeNameTokens(
+	format: string,
+	date: moment.Moment,
+	localeData: moment.Locale
+) {
+	// Backslash escapes and bracketed sections are moment's literals, so an
+	// "M" or "d" inside one is text. A backslash escapes the whole token that
+	// follows it; the escaped alternatives mirror moment's own tokens. "MM",
+	// "M", "Mo", "do" and "d" render digits, which carry no grammar.
+	return format.replace(
+		/\\(?:Mo|MM?M?M?|ddd?d?|do?)|\\.|\[[^\]]*\]|M{3,4}|d{2,4}/g,
+		( token ) => {
+			if ( token.startsWith( 'M' ) ) {
+				const name =
+					token.length === 4
+						? localeData.months( date, format )
+						: localeData.monthsShort( date, format );
+
+				return `[${ name }]`;
+			}
+
+			if ( ! token.startsWith( 'd' ) ) {
+				return token;
+			}
+
+			if ( token.length === 4 ) {
+				return `[${ localeData.weekdays( date, format ) }]`;
+			}
+
+			const name =
+				token.length === 3
+					? localeData.weekdaysShort( date )
+					: localeData.weekdaysMin( date );
+
+			return `[${ name }]`;
+		}
+	);
+}
+
 /**
  * Swaps the day of month token of a moment format string for an escaped literal.
  *
@@ -152,9 +251,11 @@ function replaceDayToken(
 ) {
 	let replaced = false;
 	// Backslash escapes and bracketed sections are moment's literals, so a "D"
-	// inside one is text.
+	// inside one is text. A backslash escapes the whole token that follows it,
+	// not just its first character; the escaped alternatives mirror moment's
+	// own day tokens, so a longer run of "D"s leaves the rest live.
 	const dayRangeFormat = format.replace(
-		/\\.|\[[^\]]*\]|D+o?/g,
+		/\\(?:Do|DDDo|DD?D?D?)|\\.|\[[^\]]*\]|D+o?/g,
 		( token ) => {
 			// Runs longer than "DD" are day of year tokens, not day of month.
 			const dayDigits = token.endsWith( 'o' )
@@ -197,16 +298,22 @@ export function getRangeLabel( after: moment.Moment, before: moment.Moment ) {
 	} else if ( isSameMonth ) {
 		// 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".
+		// Everything else still renders from `after`, so a weekday, week number
+		// or time in the format stays the one the range starts on.
+		const localeData = after.localeData();
 		const dayRangeFormat = replaceDayToken(
-			fullDateFormat,
+			escapeNameTokens(
+				expandLocalizedFormat( fullDateFormat, localeData ),
+				after,
+				localeData
+			),
 			( 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.
+		// No day of month token to swap: the format either omits the day or
+		// holds only a day of year token, which is left alone. Either way the
+		// shared month is as much of the range as this format can carry.
 		if ( dayRangeFormat === null ) {
 			return after.format( fullDateFormat );
 		}
diff --git a/packages/js/date/src/test/index.ts b/packages/js/date/src/test/index.ts
index f23f58f953d..b6a790d5ed3 100644
--- a/packages/js/date/src/test/index.ts
+++ b/packages/js/date/src/test/index.ts
@@ -839,6 +839,53 @@ describe( 'getRangeLabel', () => {
 		expect( label ).toBe( '2024年10月' );
 	} );

+	it( 'should keep the range when the format uses a localized format token', () => {
+		( __ as jest.Mock ).mockReturnValueOnce( 'LL' );
+
+		const label = getRangeLabel(
+			moment( '2024-10-01' ),
+			moment( '2024-10-31' )
+		);
+
+		expect( label ).toBe( 'October 1 - 31, 2024' );
+	} );
+
+	it( 'should keep the range when the format uses an abbreviated localized format token', () => {
+		( __ as jest.Mock ).mockReturnValueOnce( 'll' );
+
+		const label = getRangeLabel(
+			moment( '2024-10-01' ),
+			moment( '2024-10-31' )
+		);
+
+		expect( label ).toBe( 'Oct 1 - 31, 2024' );
+	} );
+
+	it( 'should leave a bracketed localized format token alone', () => {
+		( __ as jest.Mock ).mockReturnValueOnce( '[LL] MMM D, YYYY' );
+
+		const label = getRangeLabel(
+			moment( '2024-10-01' ),
+			moment( '2024-10-31' )
+		);
+
+		expect( label ).toBe( 'LL Oct 1 - 31, 2024' );
+	} );
+
+	it( 'should leave a backslash escaped localized format token alone', () => {
+		( __ as jest.Mock ).mockReturnValueOnce( '\\LL MMM D, YYYY' );
+
+		expect(
+			getRangeLabel( moment( '2024-10-01' ), moment( '2024-10-31' ) )
+		).toBe( 'LL Oct 1 - 31, 2024' );
+
+		( __ as jest.Mock ).mockReturnValueOnce( '\\L\\L MMM D, YYYY' );
+
+		expect(
+			getRangeLabel( moment( '2024-10-01' ), moment( '2024-10-31' ) )
+		).toBe( 'LL Oct 1 - 31, 2024' );
+	} );
+
 	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.' );
@@ -873,6 +920,17 @@ describe( 'getRangeLabel', () => {
 		expect( label ).toBe( 'Day Apr 1 - 15, 2018' );
 	} );

+	it( 'should leave a bracketed month literal alone', () => {
+		( __ as jest.Mock ).mockReturnValueOnce( '[MMMM] MMM D, YYYY' );
+
+		const label = getRangeLabel(
+			moment( '2018-04-01' ),
+			moment( '2018-04-15' )
+		);
+
+		expect( label ).toBe( 'MMMM Apr 1 - 15, 2018' );
+	} );
+
 	it( 'should leave a backslash escaped day literal alone', () => {
 		( __ as jest.Mock ).mockReturnValueOnce( '\\D MMM D, YYYY' );

@@ -1012,6 +1070,268 @@ describe( 'getRangeLabel', () => {
 			expect( label ).toBe( 'أكتوبر ١, ٢٠٢٤' );
 		} );
 	} );
+	it( 'should leave a whole backslash escaped token alone', () => {
+		( __ as jest.Mock ).mockReturnValueOnce( '\\MMMM MMM D, YYYY' );
+
+		expect(
+			getRangeLabel( moment( '2024-10-01' ), moment( '2024-10-31' ) )
+		).toBe( 'MMMM Oct 1 - 31, 2024' );
+
+		( __ as jest.Mock ).mockReturnValueOnce( '\\DD MMM D, YYYY' );
+
+		expect(
+			getRangeLabel( moment( '2024-10-01' ), moment( '2024-10-31' ) )
+		).toBe( 'DD Oct 1 - 31, 2024' );
+
+		( __ as jest.Mock ).mockReturnValueOnce( '\\DDDo MMM D, YYYY' );
+
+		expect(
+			getRangeLabel( moment( '2024-10-01' ), moment( '2024-10-31' ) )
+		).toBe( 'DDDo Oct 1 - 31, 2024' );
+	} );
+
+	it( 'should escape no more of a backslashed day run than moment does', () => {
+		// Moment reads "\DDDDD" as an escaped "DDDD" and a live day of month
+		// token, so the fifth "D" still carries the range.
+		( __ as jest.Mock ).mockReturnValueOnce( '\\DDDDD MMM YYYY' );
+
+		expect(
+			getRangeLabel( moment( '2024-10-01' ), moment( '2024-10-31' ) )
+		).toBe( 'DDDD1 - 31 Oct 2024' );
+	} );
+
+	it( 'should render a weekday from the start of the range', () => {
+		( __ as jest.Mock ).mockReturnValueOnce( 'ddd, MMM D, YYYY' );
+
+		// Oct 1 2024 is a Tuesday, Oct 31 a Thursday.
+		expect(
+			getRangeLabel( moment( '2024-10-01' ), moment( '2024-10-31' ) )
+		).toBe( 'Tue, Oct 1 - 31, 2024' );
+	} );
+
+	it( 'should render a week number from the start of the range', () => {
+		( __ as jest.Mock ).mockReturnValueOnce( 'MMM D, YYYY [w]w' );
+
+		expect(
+			getRangeLabel( moment( '2024-10-01' ), moment( '2024-10-31' ) )
+		).toBe( 'Oct 1 - 31, 2024 w40' );
+	} );
+
+	describe( 'with a locale that inflects the month name', () => {
+		// Moment picks the genitive month name over the nominative one by
+		// testing the format string for a day token next to the month one, and
+		// locales disagree on how: some rely on moment's own regex, some ship a
+		// stricter one, and some replace the month names with a function that
+		// tests the format itself. Each is covered here because a format string
+		// that stops matching renders the wrong grammatical form.
+		const genitive = Array.from(
+			{ length: 12 },
+			( _, index ) => `month${ index + 1 }-genitive`
+		);
+		const nominative = Array.from(
+			{ length: 12 },
+			( _, index ) => `month${ index + 1 }-nominative`
+		);
+		let originalLocale: string;
+
+		beforeAll( () => {
+			originalLocale = moment.locale();
+		} );
+
+		afterEach( () => {
+			moment.locale( originalLocale );
+		} );
+
+		it( "should keep the genitive month name of moment's own format test", () => {
+			moment.defineLocale( 'inflected-months', {
+				months: { format: genitive, standalone: nominative },
+				monthsShort: { format: genitive, standalone: nominative },
+			} );
+			( __ as jest.Mock ).mockReturnValueOnce( 'D MMMM YYYY' );
+
+			expect(
+				getRangeLabel( moment( '2024-10-01' ), moment( '2024-10-31' ) )
+			).toBe( '1 - 31 month10-genitive 2024' );
+		} );
+
+		it( 'should keep the genitive month name of a locale that allows only whitespace before the month', () => {
+			// Mirrors the Catalan locale's stricter `isFormat`.
+			moment.defineLocale( 'inflected-months-strict', {
+				months: {
+					format: genitive,
+					standalone: nominative,
+					isFormat: /D[oD]?(\s)+MMMM/,
+				},
+				monthsShort: { format: genitive, standalone: nominative },
+			} );
+			( __ as jest.Mock ).mockReturnValueOnce( 'D MMMM YYYY' );
+
+			expect(
+				getRangeLabel( moment( '2024-10-01' ), moment( '2024-10-31' ) )
+			).toBe( '1 - 31 month10-genitive 2024' );
+		} );
+
+		it( 'should keep the genitive month name of a locale that tests the format itself', () => {
+			// Mirrors the Polish locale, which resolves month names in code.
+			moment.defineLocale( 'inflected-months-fn', {
+				months: ( monthMoment, format ) =>
+					( /D MMMM/.test( format || '' ) ? genitive : nominative )[
+						monthMoment.month()
+					],
+				monthsShort: { format: genitive, standalone: nominative },
+			} );
+			( __ as jest.Mock ).mockReturnValueOnce( 'D MMMM YYYY' );
+
+			expect(
+				getRangeLabel( moment( '2024-10-01' ), moment( '2024-10-31' ) )
+			).toBe( '1 - 31 month10-genitive 2024' );
+		} );
+
+		it( 'should keep the genitive short month name of a "MMM" format', () => {
+			const shortGenitive = Array.from(
+				{ length: 12 },
+				( _, index ) => `short${ index + 1 }-genitive`
+			);
+			const shortNominative = Array.from(
+				{ length: 12 },
+				( _, index ) => `short${ index + 1 }-nominative`
+			);
+			moment.defineLocale( 'inflected-months-abbreviated', {
+				months: { format: genitive, standalone: nominative },
+				monthsShort: {
+					format: shortGenitive,
+					standalone: shortNominative,
+				},
+			} );
+			( __ as jest.Mock ).mockReturnValueOnce( 'D MMM YYYY' );
+
+			expect(
+				getRangeLabel( moment( '2024-10-01' ), moment( '2024-10-31' ) )
+			).toBe( '1 - 31 short10-genitive 2024' );
+		} );
+
+		it( 'should keep the genitive month name behind a localized format token', () => {
+			// The WOOAIRR-105 shape itself: the translation resolves to a
+			// localized token, and only its expansion reveals the day sitting
+			// next to the month.
+			moment.defineLocale( 'inflected-months-localized', {
+				months: { format: genitive, standalone: nominative },
+				monthsShort: { format: genitive, standalone: nominative },
+				longDateFormat: {
+					LT: 'HH:mm',
+					LTS: 'HH:mm:ss',
+					L: 'DD/MM/YYYY',
+					LL: 'D MMMM YYYY',
+					LLL: 'D MMMM YYYY HH:mm',
+					LLLL: 'dddd, D MMMM YYYY HH:mm',
+				},
+			} );
+			( __ as jest.Mock ).mockReturnValueOnce( 'LL' );
+
+			expect(
+				getRangeLabel( moment( '2024-10-01' ), moment( '2024-10-31' ) )
+			).toBe( '1 - 31 month10-genitive 2024' );
+		} );
+
+		it( 'should keep the nominative month name when the format holds no day token', () => {
+			moment.defineLocale( 'inflected-months-standalone', {
+				months: { format: genitive, standalone: nominative },
+				monthsShort: { format: genitive, standalone: nominative },
+			} );
+			( __ as jest.Mock ).mockReturnValueOnce( 'MMMM YYYY' );
+
+			expect(
+				getRangeLabel( moment( '2024-10-01' ), moment( '2024-10-31' ) )
+			).toBe( 'month10-nominative 2024' );
+		} );
+	} );
+
+	describe( 'with a locale that inflects the weekday name', () => {
+		// Mirrors the Ukrainian locale, which renders the genitive weekday
+		// whenever a bracketed literal precedes "dddd" - exactly the shape the
+		// month and day substitutions leave behind.
+		const weekdayGenitive = Array.from(
+			{ length: 7 },
+			( _, index ) => `weekday${ index }-genitive`
+		);
+		const weekdayNominative = Array.from(
+			{ length: 7 },
+			( _, index ) => `weekday${ index }-nominative`
+		);
+		const weekdays = ( dayMoment: moment.Moment, format?: string ) =>
+			( /\] ?dddd/.test( format || '' )
+				? weekdayGenitive
+				: weekdayNominative )[ dayMoment.day() ];
+		let originalLocale: string;
+
+		beforeAll( () => {
+			originalLocale = moment.locale();
+		} );
+
+		afterEach( () => {
+			moment.locale( originalLocale );
+		} );
+
+		it( 'should keep the nominative weekday after the month name', () => {
+			moment.defineLocale( 'inflected-weekdays', { weekdays } );
+			( __ as jest.Mock ).mockReturnValueOnce( 'MMM dddd D YYYY' );
+
+			expect(
+				getRangeLabel( moment( '2024-10-01' ), moment( '2024-10-31' ) )
+			).toBe( 'Oct weekday2-nominative 1 - 31 2024' );
+		} );
+
+		it( 'should keep the nominative weekday after the day of month', () => {
+			moment.defineLocale( 'inflected-weekdays-after-day', { weekdays } );
+			( __ as jest.Mock ).mockReturnValueOnce( 'D dddd MMM YYYY' );
+
+			expect(
+				getRangeLabel( moment( '2024-10-01' ), moment( '2024-10-31' ) )
+			).toBe( '1 - 31 weekday2-nominative Oct 2024' );
+		} );
+
+		it( 'should keep the genitive weekday of a format that asks for it', () => {
+			moment.defineLocale( 'inflected-weekdays-literal', { weekdays } );
+			( __ as jest.Mock ).mockReturnValueOnce( '[у] dddd, MMM D, YYYY' );
+
+			expect(
+				getRangeLabel( moment( '2024-10-01' ), moment( '2024-10-31' ) )
+			).toBe( 'у weekday2-genitive, Oct 1 - 31, 2024' );
+		} );
+	} );
+
+	describe( 'with a locale that nests localized format tokens', () => {
+		// `loadLocaleData` below builds "LLL" from a translation that still
+		// holds "LT", so on a real site an expansion can itself hold a
+		// localized token and a single pass is not enough.
+		let originalLocale: string;
+
+		beforeAll( () => {
+			originalLocale = moment.locale();
+		} );
+
+		afterEach( () => {
+			moment.locale( originalLocale );
+		} );
+
+		it( 'should expand a localized format token that expands to another', () => {
+			moment.defineLocale( 'nested-long-formats', {
+				longDateFormat: {
+					LT: 'HH:mm',
+					LTS: 'HH:mm:ss',
+					L: 'MM/DD/YYYY',
+					LL: 'LLL',
+					LLL: 'MMMM D, YYYY',
+					LLLL: 'dddd, MMMM D, YYYY',
+				},
+			} );
+			( __ as jest.Mock ).mockReturnValueOnce( 'LL' );
+
+			expect(
+				getRangeLabel( moment( '2024-10-01' ), moment( '2024-10-31' ) )
+			).toBe( 'October 1 - 31, 2024' );
+		} );
+	} );
 } );

 describe( 'loadLocaleData', () => {