Commit f0c8876ecd7 for woocommerce

commit f0c8876ecd7c85e2fdaf7750715efddc46a6ae3a
Author: Tung Du <dinhtungdu@gmail.com>
Date:   Tue Jul 28 14:22:06 2026 +0700

    Add sale date fields to the Product Quick edit form (#66931)

    * Revert "Fix Quick Edit keeping expired sale dates when prices change (#66726)"

    This reverts commit 0447abbd99924b72ab4f43c67e2379a027ff5bfa.

    * Revert "Fix Quick Edit removing scheduled sale dates (#66648)"

    This reverts commit f21f1796ae96f1f2783825f97cce4285b8fcf4a8.

    * Add sale dates to product Quick Edit

    * chore: changelog

    * fix: unit test

diff --git a/plugins/woocommerce/changelog/fix-29040-quick-edit-sale-dates b/plugins/woocommerce/changelog/fix-29040-quick-edit-sale-dates
deleted file mode 100644
index 92e9a467f6d..00000000000
--- a/plugins/woocommerce/changelog/fix-29040-quick-edit-sale-dates
+++ /dev/null
@@ -1,4 +0,0 @@
-Significance: patch
-Type: fix
-
-Preserve scheduled sale dates when updating product prices with Quick Edit.
diff --git a/plugins/woocommerce/changelog/fix-66718-quick-edit-expired-sale-dates b/plugins/woocommerce/changelog/fix-66718-quick-edit-expired-sale-dates
deleted file mode 100644
index 497b4e70355..00000000000
--- a/plugins/woocommerce/changelog/fix-66718-quick-edit-expired-sale-dates
+++ /dev/null
@@ -1,4 +0,0 @@
-Significance: patch
-Type: fix
-
-Clear expired scheduled sale dates during Quick Edit so a new sale price takes effect immediately.
diff --git a/plugins/woocommerce/changelog/fix-wc-66768-quick-edit-sale-dates b/plugins/woocommerce/changelog/fix-wc-66768-quick-edit-sale-dates
new file mode 100644
index 00000000000..6eea5f286a3
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-wc-66768-quick-edit-sale-dates
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Add explicit sale date fields to product Quick Edit and save schedules consistently with the full product editor.
diff --git a/plugins/woocommerce/changelog/wc-66768-quick-edit-sale-dates b/plugins/woocommerce/changelog/wc-66768-quick-edit-sale-dates
new file mode 100644
index 00000000000..cc68e4d77a1
--- /dev/null
+++ b/plugins/woocommerce/changelog/wc-66768-quick-edit-sale-dates
@@ -0,0 +1,4 @@
+Significance: patch
+Type: add
+
+Show sale date fields in the product quick edit form.
diff --git a/plugins/woocommerce/client/legacy/js/admin/quick-edit.js b/plugins/woocommerce/client/legacy/js/admin/quick-edit.js
index a4faa6e391c..eee4e8880e2 100644
--- a/plugins/woocommerce/client/legacy/js/admin/quick-edit.js
+++ b/plugins/woocommerce/client/legacy/js/admin/quick-edit.js
@@ -1,6 +1,29 @@
 /*global inlineEditPost, woocommerce_admin, woocommerce_quick_edit */
 jQuery(
 	function( $ ) {
+		function init_sale_datepickers( $row ) {
+			var $date_from = $row.find( 'input[name="_sale_price_dates_from"]' ),
+				$date_to   = $row.find( 'input[name="_sale_price_dates_to"]' );
+
+			function sync_date_limits() {
+				$date_from.datepicker( 'option', 'maxDate', $date_to.datepicker( 'getDate' ) );
+				$date_to.datepicker( 'option', 'minDate', $date_from.datepicker( 'getDate' ) );
+			}
+
+			$date_from.add( $date_to ).datepicker( {
+				defaultDate: '',
+				dateFormat: 'yy-mm-dd',
+				numberOfMonths: 1,
+				showButtonPanel: true,
+				onSelect: function() {
+					sync_date_limits();
+					$( this ).trigger( 'change' );
+				}
+			} );
+
+			sync_date_limits();
+		}
+
 		$( '#the-list' ).on(
 			'click',
 			'.editinline',
@@ -14,24 +37,26 @@ jQuery(

 				var $wc_inline_data = $( '#woocommerce_inline_' + post_id );

-				var sku        = $wc_inline_data.find( '.sku' ).text(),
-				regular_price  = $wc_inline_data.find( '.regular_price' ).text(),
-				sale_price     = $wc_inline_data.find( '.sale_price ' ).text(),
-				weight         = $wc_inline_data.find( '.weight' ).text(),
-				length         = $wc_inline_data.find( '.length' ).text(),
-				width          = $wc_inline_data.find( '.width' ).text(),
-				height         = $wc_inline_data.find( '.height' ).text(),
-				shipping_class = $wc_inline_data.find( '.shipping_class' ).text(),
-				visibility     = $wc_inline_data.find( '.visibility' ).text(),
-				stock_status   = $wc_inline_data.find( '.stock_status' ).text(),
-				stock          = $wc_inline_data.find( '.stock' ).text(),
-				featured       = $wc_inline_data.find( '.featured' ).text(),
-				manage_stock   = $wc_inline_data.find( '.manage_stock' ).text(),
-				menu_order     = $wc_inline_data.find( '.menu_order' ).text(),
-				tax_status     = $wc_inline_data.find( '.tax_status' ).text(),
-				tax_class      = $wc_inline_data.find( '.tax_class' ).text(),
-				backorders     = $wc_inline_data.find( '.backorders' ).text(),
-				product_type   = $wc_inline_data.find( '.product_type' ).text();
+				var sku          = $wc_inline_data.find( '.sku' ).text(),
+				regular_price    = $wc_inline_data.find( '.regular_price' ).text(),
+				sale_price       = $wc_inline_data.find( '.sale_price ' ).text(),
+				sale_date_from   = $wc_inline_data.find( '.sale_price_dates_from' ).text(),
+				sale_date_to     = $wc_inline_data.find( '.sale_price_dates_to' ).text(),
+				weight           = $wc_inline_data.find( '.weight' ).text(),
+				length           = $wc_inline_data.find( '.length' ).text(),
+				width            = $wc_inline_data.find( '.width' ).text(),
+				height           = $wc_inline_data.find( '.height' ).text(),
+				shipping_class   = $wc_inline_data.find( '.shipping_class' ).text(),
+				visibility       = $wc_inline_data.find( '.visibility' ).text(),
+				stock_status     = $wc_inline_data.find( '.stock_status' ).text(),
+				stock            = $wc_inline_data.find( '.stock' ).text(),
+				featured         = $wc_inline_data.find( '.featured' ).text(),
+				manage_stock     = $wc_inline_data.find( '.manage_stock' ).text(),
+				menu_order       = $wc_inline_data.find( '.menu_order' ).text(),
+				tax_status       = $wc_inline_data.find( '.tax_status' ).text(),
+				tax_class        = $wc_inline_data.find( '.tax_class' ).text(),
+				backorders       = $wc_inline_data.find( '.backorders' ).text(),
+				product_type     = $wc_inline_data.find( '.product_type' ).text();

 				var formatted_regular_price = regular_price.replace( '.', woocommerce_admin.mon_decimal_point ),
 				formatted_sale_price        = sale_price.replace( '.', woocommerce_admin.mon_decimal_point );
@@ -45,6 +70,11 @@ jQuery(
 				$( 'input[name="_sku"]', '.inline-edit-row' ).val( sku );
 				$( 'input[name="_regular_price"]', '.inline-edit-row' ).val( formatted_regular_price );
 				$( 'input[name="_sale_price"]', '.inline-edit-row' ).val( formatted_sale_price );
+				$( 'input[name="_sale_price_dates_from"]', '.inline-edit-row' ).val( sale_date_from );
+				$( 'input[name="_sale_price_dates_to"]', '.inline-edit-row' ).val( sale_date_to );
+				setTimeout( function() {
+					init_sale_datepickers( $( '#edit-' + post_id ) );
+				}, 0 );
 				$( 'input[name="_weight"]', '.inline-edit-row' ).val( weight );
 				$( 'input[name="_length"]', '.inline-edit-row' ).val( length );
 				$( 'input[name="_width"]', '.inline-edit-row' ).val( width );
diff --git a/plugins/woocommerce/includes/admin/class-wc-admin-assets.php b/plugins/woocommerce/includes/admin/class-wc-admin-assets.php
index 2c74470553a..54367f172b0 100644
--- a/plugins/woocommerce/includes/admin/class-wc-admin-assets.php
+++ b/plugins/woocommerce/includes/admin/class-wc-admin-assets.php
@@ -519,7 +519,7 @@ if ( ! class_exists( 'WC_Admin_Assets', false ) ) :

 			// Products.
 			if ( in_array( $screen_id, array( 'edit-product' ) ) ) {
-				wp_enqueue_script( 'woocommerce_quick-edit', WC()->plugin_url() . '/assets/js/admin/quick-edit' . $suffix . '.js', array( 'jquery', 'woocommerce_admin' ), $version );
+				wp_enqueue_script( 'woocommerce_quick-edit', WC()->plugin_url() . '/assets/js/admin/quick-edit' . $suffix . '.js', array( 'jquery', 'jquery-ui-datepicker', 'woocommerce_admin' ), $version, false );

 				$params = array(
 					'strings' => array(
diff --git a/plugins/woocommerce/includes/admin/class-wc-admin-post-types.php b/plugins/woocommerce/includes/admin/class-wc-admin-post-types.php
index e54dd0b1717..adcd8af7472 100644
--- a/plugins/woocommerce/includes/admin/class-wc-admin-post-types.php
+++ b/plugins/woocommerce/includes/admin/class-wc-admin-post-types.php
@@ -450,37 +450,54 @@ class WC_Admin_Post_Types {
 		$product->set_featured( isset( $request_data['_featured'] ) );

 		if ( $product->is_type( ProductType::SIMPLE ) || $product->is_type( ProductType::EXTERNAL ) ) {
-			// The Quick Edit form is prefilled with view-context (filtered) prices, so detecting
-			// an actual edit requires comparing the submission against those same values.
-			$old_regular_price = $this->normalize_price_for_comparison( $product->get_regular_price() );
-			$old_sale_price    = $this->normalize_price_for_comparison( $product->get_sale_price() );

 			if ( isset( $request_data['_regular_price'] ) ) {
 				// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash
-				$new_regular_price = ( '' === $request_data['_regular_price'] ) ? '' : wc_format_decimal( $request_data['_regular_price'] );
-				$product->set_regular_price( $new_regular_price );
+				$regular_price = ( '' === $request_data['_regular_price'] ) ? '' : wc_format_decimal( $request_data['_regular_price'] );
+				$product->set_regular_price( $regular_price );
 			}

 			if ( isset( $request_data['_sale_price'] ) ) {
 				// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash
-				$new_sale_price = ( '' === $request_data['_sale_price'] ) ? '' : wc_format_decimal( $request_data['_sale_price'] );
-				$product->set_sale_price( $new_sale_price );
+				$sale_price = ( '' === $request_data['_sale_price'] ) ? '' : wc_format_decimal( $request_data['_sale_price'] );
+				$product->set_sale_price( $sale_price );
 			}

-			$regular_price = $product->get_regular_price( 'edit' );
-			$sale_price    = $product->get_sale_price( 'edit' );
+			// Match the full product editor's date parsing and site-timezone behavior.
+			if ( isset( $request_data['_sale_price_dates_from'] ) ) {
+				$date_on_sale_from = '';
+				if ( is_string( $request_data['_sale_price_dates_from'] ) ) {
+					/**
+					 * Sanitized sale start date.
+					 *
+					 * @var string $date_on_sale_from
+					 */
+					$date_on_sale_from = wc_clean( wp_unslash( $request_data['_sale_price_dates_from'] ) );
+				}

-			// Only a submitted field can be a changed field.
-			$regular_price_changed = isset( $request_data['_regular_price'] ) && $this->normalize_price_for_comparison( $regular_price ) !== $old_regular_price;
-			$sale_price_changed    = isset( $request_data['_sale_price'] ) && $this->normalize_price_for_comparison( $sale_price ) !== $old_sale_price;
+				if ( ! empty( $date_on_sale_from ) ) {
+					$date_on_sale_from = date( 'Y-m-d 00:00:00', (int) strtotime( $date_on_sale_from ) ); // phpcs:ignore WordPress.DateTime.RestrictedFunctions.date_date
+				}

-			$price_changed  = $regular_price_changed || $sale_price_changed;
-			$sale_date_to   = $product->get_date_on_sale_to( 'edit' );
-			$sale_has_ended = $sale_date_to && $sale_date_to->getTimestamp() < time();
+				$product->set_date_on_sale_from( $date_on_sale_from );
+			}

-			if ( $price_changed && ( '' === $sale_price || $sale_price >= $regular_price || $sale_has_ended ) ) {
-				$product->set_date_on_sale_to( '' );
-				$product->set_date_on_sale_from( '' );
+			if ( isset( $request_data['_sale_price_dates_to'] ) ) {
+				$date_on_sale_to = '';
+				if ( is_string( $request_data['_sale_price_dates_to'] ) ) {
+					/**
+					 * Sanitized sale end date.
+					 *
+					 * @var string $date_on_sale_to
+					 */
+					$date_on_sale_to = wc_clean( wp_unslash( $request_data['_sale_price_dates_to'] ) );
+				}
+
+				if ( ! empty( $date_on_sale_to ) ) {
+					$date_on_sale_to = date( 'Y-m-d 23:59:59', (int) strtotime( $date_on_sale_to ) ); // phpcs:ignore WordPress.DateTime.RestrictedFunctions.date_date
+				}
+
+				$product->set_date_on_sale_to( $date_on_sale_to );
 			}
 		}

@@ -524,20 +541,6 @@ class WC_Admin_Post_Types {
 		do_action( 'woocommerce_product_quick_edit_save', $product );
 	}

-	/**
-	 * Normalize a price value for change detection during Quick Edit.
-	 *
-	 * View-context prices pass through the woocommerce_product_get_regular_price and
-	 * woocommerce_product_get_sale_price filters, which may return non-scalar values
-	 * or differently formatted numbers.
-	 *
-	 * @param mixed $price Raw price value.
-	 * @return string Normalized price string.
-	 */
-	private function normalize_price_for_comparison( $price ): string {
-		return wc_format_decimal( is_scalar( $price ) ? (string) $price : '', false, true );
-	}
-
 	/**
 	 * Bulk edit.
 	 *
diff --git a/plugins/woocommerce/includes/admin/list-tables/class-wc-admin-list-table-products.php b/plugins/woocommerce/includes/admin/list-tables/class-wc-admin-list-table-products.php
index fad7a879c64..35eb8117ba7 100644
--- a/plugins/woocommerce/includes/admin/list-tables/class-wc-admin-list-table-products.php
+++ b/plugins/woocommerce/includes/admin/list-tables/class-wc-admin-list-table-products.php
@@ -263,6 +263,19 @@ class WC_Admin_List_Table_Products extends WC_Admin_List_Table {
 				'<div class="cogs_value">' . esc_html( $this->object->get_cogs_value() ?? '0' ) . '</div>' :
 				'';

+		/**
+		 * Product represented by the current list-table row.
+		 * Narrow the inherited object type without adding a PHPStan baseline entry.
+		 * In future we should correct the type of $this->object.
+		 *
+		 * @var WC_Product $product
+		 */
+		$product        = $this->object;
+		$sale_date_from = $product->get_date_on_sale_from( 'edit' );
+		$sale_date_to   = $product->get_date_on_sale_to( 'edit' );
+		$sale_date_from = $sale_date_from ? date_i18n( 'Y-m-d', $sale_date_from->getOffsetTimestamp() ) : '';
+		$sale_date_to   = $sale_date_to ? date_i18n( 'Y-m-d', $sale_date_to->getOffsetTimestamp() ) : '';
+
 		// phpcs:disable WordPress.Security.EscapeOutput.OutputNotEscaped -- the COGS value is already escaped.
 		/* Custom inline data for woocommerce. */
 		echo '
@@ -272,6 +285,8 @@ class WC_Admin_List_Table_Products extends WC_Admin_List_Table {
 				<div class="global_unique_id">' . esc_html( $this->object->get_global_unique_id() ) . '</div>
 				<div class="regular_price">' . esc_html( $this->object->get_regular_price() ) . '</div>
 				<div class="sale_price">' . esc_html( $this->object->get_sale_price() ) . '</div>
+				<div class="sale_price_dates_from">' . esc_html( $sale_date_from ) . '</div>
+				<div class="sale_price_dates_to">' . esc_html( $sale_date_to ) . '</div>
 				<div class="weight">' . esc_html( $this->object->get_weight() ) . '</div>
 				<div class="length">' . esc_html( $this->object->get_length() ) . '</div>
 				<div class="width">' . esc_html( $this->object->get_width() ) . '</div>
diff --git a/plugins/woocommerce/includes/admin/views/html-quick-edit-product.php b/plugins/woocommerce/includes/admin/views/html-quick-edit-product.php
index 62c74d68f46..e5e6785b727 100644
--- a/plugins/woocommerce/includes/admin/views/html-quick-edit-product.php
+++ b/plugins/woocommerce/includes/admin/views/html-quick-edit-product.php
@@ -46,6 +46,30 @@ defined( 'ABSPATH' ) || exit;
 				</span>
 			</label>
 			<br class="clear" />
+			<?php
+			/**
+			 * Hook to customize the regular expression that validates dates entered in the WooCommerce admin editors.
+			 *
+			 * @since 3.0.0
+			 *
+			 * @param string $pattern Default pattern to use.
+			 */
+			$date_input_html_pattern = apply_filters( 'woocommerce_date_input_html_pattern', '[0-9]{4}-(0[1-9]|1[012])-(0[1-9]|1[0-9]|2[0-9]|3[01])' );
+			?>
+			<label>
+				<span class="title"><?php esc_html_e( 'Sale start', 'woocommerce' ); ?></span>
+				<span class="input-text-wrap">
+					<input type="text" name="_sale_price_dates_from" class="text sale_price_dates_from" placeholder="<?php echo esc_attr_x( 'From&hellip;', 'placeholder', 'woocommerce' ); ?> YYYY-MM-DD" value="" maxlength="10" pattern="<?php echo esc_attr( $date_input_html_pattern ); ?>">
+				</span>
+			</label>
+			<br class="clear" />
+			<label>
+				<span class="title"><?php esc_html_e( 'Sale end', 'woocommerce' ); ?></span>
+				<span class="input-text-wrap">
+					<input type="text" name="_sale_price_dates_to" class="text sale_price_dates_to" placeholder="<?php echo esc_attr_x( 'To&hellip;', 'placeholder', 'woocommerce' ); ?> YYYY-MM-DD" value="" maxlength="10" pattern="<?php echo esc_attr( $date_input_html_pattern ); ?>">
+				</span>
+			</label>
+			<br class="clear" />
 		</div>

 		<?php if ( wc_get_container()->get( CostOfGoodsSoldController::class )->feature_is_enabled() ) : ?>
diff --git a/plugins/woocommerce/tests/php/includes/admin/class-wc-admin-post-types-test.php b/plugins/woocommerce/tests/php/includes/admin/class-wc-admin-post-types-test.php
index f73f89e9ae5..a9220c36d20 100644
--- a/plugins/woocommerce/tests/php/includes/admin/class-wc-admin-post-types-test.php
+++ b/plugins/woocommerce/tests/php/includes/admin/class-wc-admin-post-types-test.php
@@ -7,6 +7,8 @@

 declare( strict_types = 1 );

+use Automattic\WooCommerce\Enums\ProductType;
+
 /**
  * Class WC_Admin_Post_Types_Test.
  */
@@ -60,310 +62,235 @@ class WC_Admin_Post_Types_Test extends WC_Unit_Test_Case {
 	}

 	/**
-	 * @testdox Quick Edit preserves sale dates when a valid product price changes.
-	 * @dataProvider valid_price_changes_provider
+	 * @testdox Quick Edit explicitly saves active, future, expired, and empty sale schedules for supported product types.
+	 * @dataProvider explicit_schedule_provider
 	 *
-	 * @param int    $start_offset Sale start offset from the current time.
-	 * @param int    $end_offset Sale end offset from the current time.
-	 * @param string $new_regular_price New regular price.
-	 * @param string $new_sale_price New sale price.
+	 * @param string      $product_type Product type.
+	 * @param string|null $initial_from Initial sale start date.
+	 * @param string|null $initial_to Initial sale end date.
+	 * @param string      $submitted_from Submitted sale start date.
+	 * @param string      $submitted_to Submitted sale end date.
+	 * @param string|null $expected_from Expected sale start date.
+	 * @param string|null $expected_to Expected sale end date.
 	 */
-	public function test_quick_edit_preserves_sale_dates_when_prices_change( int $start_offset, int $end_offset, string $new_regular_price, string $new_sale_price ): void {
-		$now                = time();
-		$product            = WC_Helper_Product::create_simple_product();
-		$sale_date_from     = gmdate( 'Y-m-d H:i:s', $now + $start_offset );
-		$sale_date_to       = gmdate( 'Y-m-d H:i:s', $now + $end_offset );
-		$original_sale_from = wc_string_to_datetime( $sale_date_from );
-		$original_sale_to   = wc_string_to_datetime( $sale_date_to );
-
+	public function test_quick_edit_saves_explicit_sale_schedule(
+		string $product_type,
+		?string $initial_from,
+		?string $initial_to,
+		string $submitted_from,
+		string $submitted_to,
+		?string $expected_from,
+		?string $expected_to
+	): void {
+		$product = $this->create_product( $product_type );
 		$product->set_regular_price( '100' );
 		$product->set_sale_price( '80' );
-		$product->set_date_on_sale_from( $sale_date_from );
-		$product->set_date_on_sale_to( $sale_date_to );
+		$product->set_date_on_sale_from( $initial_from );
+		$product->set_date_on_sale_to( $initial_to );
 		$product->save();

-		$_REQUEST = array(
-			'woocommerce_quick_edit'       => '1',
-			'woocommerce_quick_edit_nonce' => wp_create_nonce( 'woocommerce_quick_edit_nonce' ),
-			'_regular_price'               => $new_regular_price,
-			'_sale_price'                  => $new_sale_price,
-			'_stock_status'                => 'instock',
+		$this->quick_edit(
+			$product,
+			array(
+				'_regular_price'         => '100',
+				'_sale_price'            => '70',
+				'_sale_price_dates_from' => $submitted_from,
+				'_sale_price_dates_to'   => $submitted_to,
+			)
 		);

-		$this->sut->bulk_and_quick_edit_save_post( $product->get_id(), get_post( $product->get_id() ) );
-
-		$updated_product   = wc_get_product( $product->get_id() );
-		$updated_sale_from = $updated_product->get_date_on_sale_from( 'edit' );
-		$updated_sale_to   = $updated_product->get_date_on_sale_to( 'edit' );
+		$updated_product = wc_get_product( $product->get_id() );

-		$this->assertSame( $new_regular_price, $updated_product->get_regular_price( 'edit' ), 'Quick Edit should persist the regular price.' );
-		$this->assertSame( $new_sale_price, $updated_product->get_sale_price( 'edit' ), 'Quick Edit should persist the sale price.' );
-		$this->assertInstanceOf( WC_DateTime::class, $updated_sale_from, 'Quick Edit should preserve the sale start date.' );
-		$this->assertInstanceOf( WC_DateTime::class, $updated_sale_to, 'Quick Edit should preserve the sale end date.' );
-		$this->assertSame( $original_sale_from->getTimestamp(), $updated_sale_from->getTimestamp(), 'The sale start date should remain unchanged.' );
-		$this->assertSame( $original_sale_to->getTimestamp(), $updated_sale_to->getTimestamp(), 'The sale end date should remain unchanged.' );
+		$this->assertSame( '70', $updated_product->get_sale_price( 'edit' ), 'Quick Edit should persist the submitted sale price.' );
+		$this->assert_date( $expected_from, $updated_product->get_date_on_sale_from( 'edit' ), 'start' );
+		$this->assert_date( $expected_to, $updated_product->get_date_on_sale_to( 'edit' ), 'end' );
 	}

 	/**
-	 * @testdox Quick Edit clears sale dates when a price change ends the sale.
-	 * @dataProvider sale_ending_price_changes_provider
+	 * Provides sale schedule states and supported product types.
 	 *
-	 * @param string $new_regular_price New regular price.
-	 * @param string $new_sale_price New sale price.
+	 * @return array<string, array{string, ?string, ?string, string, string, ?string, ?string}>
 	 */
-	public function test_quick_edit_clears_sale_dates_when_sale_ends( string $new_regular_price, string $new_sale_price ): void {
-		$product = WC_Helper_Product::create_simple_product();
-
-		$product->set_regular_price( '100' );
-		$product->set_sale_price( '80' );
-		$product->set_date_on_sale_from( gmdate( 'Y-m-d H:i:s', time() - DAY_IN_SECONDS ) );
-		$product->set_date_on_sale_to( gmdate( 'Y-m-d H:i:s', time() + DAY_IN_SECONDS ) );
-		$product->save();
-
-		$_REQUEST = array(
-			'woocommerce_quick_edit'       => '1',
-			'woocommerce_quick_edit_nonce' => wp_create_nonce( 'woocommerce_quick_edit_nonce' ),
-			'_regular_price'               => $new_regular_price,
-			'_sale_price'                  => $new_sale_price,
-			'_stock_status'                => 'instock',
+	public static function explicit_schedule_provider(): array {
+		return array(
+			'active simple schedule'   => array( ProductType::SIMPLE, '2000-01-01 00:00:00', '2099-12-31 23:59:59', '2000-01-01', '2099-12-31', '2000-01-01 00:00:00', '2099-12-31 23:59:59' ),
+			'future external schedule' => array( ProductType::EXTERNAL, '2098-01-01 00:00:00', '2098-12-31 23:59:59', '2098-02-01', '2098-11-30', '2098-02-01 00:00:00', '2098-11-30 23:59:59' ),
+			'expired simple schedule'  => array( ProductType::SIMPLE, '2000-01-01 00:00:00', '2001-01-01 23:59:59', '2000-01-01', '2001-01-01', '2000-01-01 00:00:00', '2001-01-01 23:59:59' ),
+			'empty external schedule'  => array( ProductType::EXTERNAL, null, null, '', '', null, null ),
 		);
-
-		$this->sut->bulk_and_quick_edit_save_post( $product->get_id(), get_post( $product->get_id() ) );
-
-		$updated_product = wc_get_product( $product->get_id() );
-
-		$this->assertSame( '', $updated_product->get_sale_price( 'edit' ), 'Quick Edit should leave the product without a sale price.' );
-		$this->assertNull( $updated_product->get_date_on_sale_from( 'edit' ), 'Quick Edit should clear the start date when the sale ends.' );
-		$this->assertNull( $updated_product->get_date_on_sale_to( 'edit' ), 'Quick Edit should clear the end date when the sale ends.' );
 	}

 	/**
-	 * @testdox Quick Edit clears expired sale dates when prices change so the new sale takes effect.
-	 * @dataProvider expired_schedule_price_changes_provider
-	 *
-	 * @param string $new_regular_price New regular price.
-	 * @param string $new_sale_price New sale price.
+	 * @testdox Quick Edit preserves explicit sale dates when only prices are submitted.
 	 */
-	public function test_quick_edit_clears_expired_sale_dates_when_prices_change( string $new_regular_price, string $new_sale_price ): void {
-		$product = WC_Helper_Product::create_simple_product();
-
+	public function test_quick_edit_price_only_request_preserves_sale_dates(): void {
+		$product = $this->create_product( ProductType::SIMPLE );
 		$product->set_regular_price( '100' );
 		$product->set_sale_price( '80' );
-		$product->set_date_on_sale_from( gmdate( 'Y-m-d H:i:s', time() - 3 * DAY_IN_SECONDS ) );
-		$product->set_date_on_sale_to( gmdate( 'Y-m-d H:i:s', time() - DAY_IN_SECONDS ) );
+		$product->set_date_on_sale_from( '2000-01-01 00:00:00' );
+		$product->set_date_on_sale_to( '2001-01-01 23:59:59' );
 		$product->save();

-		$_REQUEST = array(
-			'woocommerce_quick_edit'       => '1',
-			'woocommerce_quick_edit_nonce' => wp_create_nonce( 'woocommerce_quick_edit_nonce' ),
-			'_regular_price'               => $new_regular_price,
-			'_sale_price'                  => $new_sale_price,
-			'_stock_status'                => 'instock',
+		$this->quick_edit(
+			$product,
+			array(
+				'_regular_price' => '90',
+				'_sale_price'    => '70',
+			)
 		);

-		$this->sut->bulk_and_quick_edit_save_post( $product->get_id(), get_post( $product->get_id() ) );
-
 		$updated_product = wc_get_product( $product->get_id() );

-		$this->assertSame( $new_regular_price, $updated_product->get_regular_price( 'edit' ), 'Quick Edit should persist the regular price.' );
-		$this->assertSame( $new_sale_price, $updated_product->get_sale_price( 'edit' ), 'Quick Edit should persist the sale price.' );
-		$this->assertNull( $updated_product->get_date_on_sale_from( 'edit' ), 'Quick Edit should clear the expired sale start date.' );
-		$this->assertNull( $updated_product->get_date_on_sale_to( 'edit' ), 'Quick Edit should clear the expired sale end date.' );
-		$this->assertTrue( $updated_product->is_on_sale( 'edit' ), 'The product should be on sale once the expired schedule is cleared.' );
-		$this->assertSame( $new_sale_price, $updated_product->get_price( 'edit' ), 'The active price should be the new sale price.' );
+		$this->assertSame( '90', $updated_product->get_regular_price( 'edit' ), 'Quick Edit should persist the regular price.' );
+		$this->assertSame( '70', $updated_product->get_sale_price( 'edit' ), 'Quick Edit should persist the sale price.' );
+		$this->assert_date( '2000-01-01 00:00:00', $updated_product->get_date_on_sale_from( 'edit' ), 'start' );
+		$this->assert_date( '2001-01-01 23:59:59', $updated_product->get_date_on_sale_to( 'edit' ), 'end' );
 	}

 	/**
-	 * @testdox Quick Edit preserves expired sale dates when prices are not edited, even if display filters reformat prices.
+	 * @testdox Quick Edit updates sale dates without requiring price fields.
 	 */
-	public function test_quick_edit_preserves_expired_sale_dates_when_prices_not_edited(): void {
-		// Simulates extensions (currency switchers, wholesale, dynamic pricing) that filter
-		// prices in view context, reshaping the values the Quick Edit form is prefilled with.
-		$format_price = function ( $value ) {
-			return '' === $value || null === $value ? $value : number_format( (float) $value, 2, '.', '' );
-		};
-		add_filter( 'woocommerce_product_get_regular_price', $format_price );
-		add_filter( 'woocommerce_product_get_sale_price', $format_price );
-
-		$product = WC_Helper_Product::create_simple_product();
-
+	public function test_quick_edit_date_only_request_updates_sale_dates(): void {
+		$product = $this->create_product( ProductType::SIMPLE );
 		$product->set_regular_price( '100' );
 		$product->set_sale_price( '80' );
-		$product->set_date_on_sale_from( gmdate( 'Y-m-d H:i:s', time() - 3 * DAY_IN_SECONDS ) );
-		$product->set_date_on_sale_to( gmdate( 'Y-m-d H:i:s', time() - DAY_IN_SECONDS ) );
 		$product->save();

-		// A stock-only Quick Edit: the price inputs hold the filtered display values, submitted back unchanged.
-		$_REQUEST = array(
-			'woocommerce_quick_edit'       => '1',
-			'woocommerce_quick_edit_nonce' => wp_create_nonce( 'woocommerce_quick_edit_nonce' ),
-			'_regular_price'               => '100.00',
-			'_sale_price'                  => '80.00',
-			'_stock_status'                => 'outofstock',
+		$this->quick_edit(
+			$product,
+			array(
+				'_sale_price_dates_from' => '2098-03-04',
+				'_sale_price_dates_to'   => '2098-04-05',
+			)
 		);

-		$this->sut->bulk_and_quick_edit_save_post( $product->get_id(), get_post( $product->get_id() ) );
-
-		remove_filter( 'woocommerce_product_get_regular_price', $format_price );
-		remove_filter( 'woocommerce_product_get_sale_price', $format_price );
-
 		$updated_product = wc_get_product( $product->get_id() );

-		$this->assertSame( '100.00', $updated_product->get_regular_price( 'edit' ), 'The submitted display-formatted regular price is what gets persisted.' );
-		$this->assertSame( '80.00', $updated_product->get_sale_price( 'edit' ), 'The submitted display-formatted sale price is what gets persisted.' );
-		$this->assertInstanceOf( WC_DateTime::class, $updated_product->get_date_on_sale_from( 'edit' ), 'Quick Edit should preserve the expired sale start date when no price was edited.' );
-		$this->assertInstanceOf( WC_DateTime::class, $updated_product->get_date_on_sale_to( 'edit' ), 'Quick Edit should preserve the expired sale end date when no price was edited.' );
-		$this->assertFalse( $updated_product->is_on_sale( 'edit' ), 'The expired sale should not be reactivated by an edit that did not touch prices.' );
+		$this->assertSame( '100', $updated_product->get_regular_price( 'edit' ), 'Quick Edit should preserve the regular price.' );
+		$this->assertSame( '80', $updated_product->get_sale_price( 'edit' ), 'Quick Edit should preserve the sale price.' );
+		$this->assert_date( '2098-03-04 00:00:00', $updated_product->get_date_on_sale_from( 'edit' ), 'start' );
+		$this->assert_date( '2098-04-05 23:59:59', $updated_product->get_date_on_sale_to( 'edit' ), 'end' );
 	}

 	/**
-	 * @testdox Quick Edit preserves expired sale dates when a converting display filter round-trips unedited prices.
+	 * @testdox Quick Edit intentionally clears submitted empty sale dates.
 	 */
-	public function test_quick_edit_preserves_expired_sale_dates_with_converting_filter(): void {
-		// Simulates a value-converting filter (currency switcher, dynamic pricing) that is
-		// active during both the form render and the save request.
-		$convert_price = function ( $value ) {
-			return '' === $value || null === $value ? $value : (string) ( 2 * (float) $value );
-		};
-		add_filter( 'woocommerce_product_get_regular_price', $convert_price );
-		add_filter( 'woocommerce_product_get_sale_price', $convert_price );
-
-		$product = WC_Helper_Product::create_simple_product();
-
-		$product->set_regular_price( '100' );
-		$product->set_sale_price( '80' );
-		$product->set_date_on_sale_from( gmdate( 'Y-m-d H:i:s', time() - 3 * DAY_IN_SECONDS ) );
-		$product->set_date_on_sale_to( gmdate( 'Y-m-d H:i:s', time() - DAY_IN_SECONDS ) );
+	public function test_quick_edit_clears_sale_dates(): void {
+		$product = $this->create_product( ProductType::SIMPLE );
+		$product->set_date_on_sale_from( '2000-01-01 00:00:00' );
+		$product->set_date_on_sale_to( '2099-12-31 23:59:59' );
 		$product->save();

-		// A stock-only Quick Edit: the form displayed the converted values and submits them back unchanged.
-		$_REQUEST = array(
-			'woocommerce_quick_edit'       => '1',
-			'woocommerce_quick_edit_nonce' => wp_create_nonce( 'woocommerce_quick_edit_nonce' ),
-			'_regular_price'               => '200',
-			'_sale_price'                  => '160',
-			'_stock_status'                => 'outofstock',
+		$this->quick_edit(
+			$product,
+			array(
+				'_sale_price_dates_from' => '',
+				'_sale_price_dates_to'   => '',
+			)
 		);

-		$this->sut->bulk_and_quick_edit_save_post( $product->get_id(), get_post( $product->get_id() ) );
-
-		remove_filter( 'woocommerce_product_get_regular_price', $convert_price );
-		remove_filter( 'woocommerce_product_get_sale_price', $convert_price );
-
 		$updated_product = wc_get_product( $product->get_id() );

-		$this->assertInstanceOf( WC_DateTime::class, $updated_product->get_date_on_sale_from( 'edit' ), 'Quick Edit should preserve the expired sale start date under a converting display filter.' );
-		$this->assertInstanceOf( WC_DateTime::class, $updated_product->get_date_on_sale_to( 'edit' ), 'Quick Edit should preserve the expired sale end date under a converting display filter.' );
-		$this->assertFalse( $updated_product->is_on_sale( 'edit' ), 'The expired sale should not be reactivated by a round-trip of converted display prices.' );
+		$this->assertNull( $updated_product->get_date_on_sale_from( 'edit' ), 'Quick Edit should clear the sale start date.' );
+		$this->assertNull( $updated_product->get_date_on_sale_to( 'edit' ), 'Quick Edit should clear the sale end date.' );
 	}

 	/**
-	 * @testdox Quick Edit survives a display filter returning a non-scalar value.
+	 * @testdox Quick Edit normalizes invalid sale dates like the full product editor.
 	 */
-	public function test_quick_edit_survives_non_scalar_price_filter(): void {
-		$break_price = function () {
-			return new stdClass();
-		};
-		add_filter( 'woocommerce_product_get_regular_price', $break_price );
-		add_filter( 'woocommerce_product_get_sale_price', $break_price );
-
-		$product = WC_Helper_Product::create_simple_product();
-
-		$product->set_regular_price( '100' );
-		$product->set_sale_price( '80' );
-		$product->save();
-
-		$_REQUEST = array(
-			'woocommerce_quick_edit'       => '1',
-			'woocommerce_quick_edit_nonce' => wp_create_nonce( 'woocommerce_quick_edit_nonce' ),
-			'_regular_price'               => '90',
-			'_sale_price'                  => '70',
-			'_stock_status'                => 'instock',
+	public function test_quick_edit_matches_full_editor_invalid_date_behavior(): void {
+		$product = $this->create_product( ProductType::SIMPLE );
+
+		$this->quick_edit(
+			$product,
+			array(
+				'_sale_price_dates_from' => 'invalid-date',
+				'_sale_price_dates_to'   => 'invalid-date',
+			)
 		);

-		$this->sut->bulk_and_quick_edit_save_post( $product->get_id(), get_post( $product->get_id() ) );
-
-		remove_filter( 'woocommerce_product_get_regular_price', $break_price );
-		remove_filter( 'woocommerce_product_get_sale_price', $break_price );
-
 		$updated_product = wc_get_product( $product->get_id() );

-		$this->assertSame( '90', $updated_product->get_regular_price( 'edit' ), 'Submitted prices should persist even when a display filter returns a non-scalar value.' );
-		$this->assertSame( '70', $updated_product->get_sale_price( 'edit' ), 'Submitted prices should persist even when a display filter returns a non-scalar value.' );
+		// Like the full editor, an invalid start becomes timestamp 0, which WC_Data treats as empty on reload.
+		$this->assert_date( null, $updated_product->get_date_on_sale_from( 'edit' ), 'start' );
+		$this->assert_date( '1970-01-01 23:59:59', $updated_product->get_date_on_sale_to( 'edit' ), 'end' );
 	}

 	/**
-	 * @testdox Quick Edit preserves expired sale dates when price fields are absent from the request.
+	 * @testdox Quick Edit stores sale boundaries in the site timezone.
 	 */
-	public function test_quick_edit_preserves_expired_sale_dates_when_price_fields_not_submitted(): void {
-		$format_price = function ( $value ) {
-			return '' === $value || null === $value ? $value : number_format( (float) $value, 2, '.', '' );
-		};
-		add_filter( 'woocommerce_product_get_regular_price', $format_price );
-		add_filter( 'woocommerce_product_get_sale_price', $format_price );
-
-		$product = WC_Helper_Product::create_simple_product();
-
-		$product->set_regular_price( '100' );
-		$product->set_sale_price( '80' );
-		$product->set_date_on_sale_from( gmdate( 'Y-m-d H:i:s', time() - 3 * DAY_IN_SECONDS ) );
-		$product->set_date_on_sale_to( gmdate( 'Y-m-d H:i:s', time() - DAY_IN_SECONDS ) );
-		$product->save();
-
-		$_REQUEST = array(
-			'woocommerce_quick_edit'       => '1',
-			'woocommerce_quick_edit_nonce' => wp_create_nonce( 'woocommerce_quick_edit_nonce' ),
-			'_stock_status'                => 'instock',
-		);
-
-		$this->sut->bulk_and_quick_edit_save_post( $product->get_id(), get_post( $product->get_id() ) );
-
-		remove_filter( 'woocommerce_product_get_regular_price', $format_price );
-		remove_filter( 'woocommerce_product_get_sale_price', $format_price );
-
-		$updated_product = wc_get_product( $product->get_id() );
-
-		$this->assertSame( '100', $updated_product->get_regular_price( 'edit' ), 'An absent regular price field should leave the stored regular price untouched.' );
-		$this->assertSame( '80', $updated_product->get_sale_price( 'edit' ), 'An absent sale price field should leave the stored sale price untouched.' );
-		$this->assertInstanceOf( WC_DateTime::class, $updated_product->get_date_on_sale_from( 'edit' ), 'Quick Edit should preserve the expired sale start date when no price fields were submitted.' );
-		$this->assertInstanceOf( WC_DateTime::class, $updated_product->get_date_on_sale_to( 'edit' ), 'Quick Edit should preserve the expired sale end date when no price fields were submitted.' );
-		$this->assertFalse( $updated_product->is_on_sale( 'edit' ), 'The expired sale should not be reactivated by a request without price fields.' );
+	public function test_quick_edit_uses_site_timezone_for_sale_dates(): void {
+		$original_timezone = get_option( 'timezone_string' );
+		update_option( 'timezone_string', 'America/New_York' );
+
+		try {
+			$product = $this->create_product( ProductType::EXTERNAL );
+			$this->quick_edit(
+				$product,
+				array(
+					'_sale_price_dates_from' => '2098-06-01',
+					'_sale_price_dates_to'   => '2098-06-30',
+				)
+			);
+
+			$updated_product = wc_get_product( $product->get_id() );
+			$start_date      = $updated_product->get_date_on_sale_from( 'edit' );
+			$end_date        = $updated_product->get_date_on_sale_to( 'edit' );
+		} finally {
+			update_option( 'timezone_string', $original_timezone );
+		}
+
+		$this->assertInstanceOf( WC_DateTime::class, $start_date, 'Quick Edit should set the sale start date.' );
+		$this->assertInstanceOf( WC_DateTime::class, $end_date, 'Quick Edit should set the sale end date.' );
+		$this->assertSame( '2098-06-01 00:00:00', $start_date->date( 'Y-m-d H:i:s' ), 'The sale should start at the beginning of the local day.' );
+		$this->assertSame( '2098-06-30 23:59:59', $end_date->date( 'Y-m-d H:i:s' ), 'The sale should end at the end of the local day.' );
+		$this->assertSame( 'America/New_York', $start_date->getTimezone()->getName(), 'The start date should use the site timezone.' );
+		$this->assertSame( 'America/New_York', $end_date->getTimezone()->getName(), 'The end date should use the site timezone.' );
 	}

 	/**
-	 * Provides valid price changes for active and future sale schedules.
+	 * Create a supported product type.
 	 *
-	 * @return array<string, array{int, int, string, string}>
+	 * @param string $product_type Product type.
+	 * @return WC_Product
 	 */
-	public static function valid_price_changes_provider(): array {
-		return array(
-			'active schedule with sale price change'    => array( -DAY_IN_SECONDS, DAY_IN_SECONDS, '100', '70' ),
-			'future schedule with sale price change'    => array( DAY_IN_SECONDS, 2 * DAY_IN_SECONDS, '100', '70' ),
-			'active schedule with regular price change' => array( -DAY_IN_SECONDS, DAY_IN_SECONDS, '90', '80' ),
-		);
+	private function create_product( string $product_type ): WC_Product {
+		return ProductType::EXTERNAL === $product_type ? WC_Helper_Product::create_external_product() : WC_Helper_Product::create_simple_product();
 	}

 	/**
-	 * Provides price changes for a product whose scheduled sale has already ended.
+	 * Submit a Quick Edit request.
 	 *
-	 * @return array<string, array{string, string}>
+	 * @param WC_Product          $product Product to edit.
+	 * @param array<string,mixed> $request_data Request fields.
 	 */
-	public static function expired_schedule_price_changes_provider(): array {
-		return array(
-			'expired schedule with new sale price'       => array( '100', '70' ),
-			'expired schedule with regular price change' => array( '90', '80' ),
+	private function quick_edit( WC_Product $product, array $request_data ): void {
+		$_REQUEST = array_merge(
+			array(
+				'woocommerce_quick_edit'       => '1',
+				'woocommerce_quick_edit_nonce' => wp_create_nonce( 'woocommerce_quick_edit_nonce' ),
+				'_stock_status'                => 'instock',
+			),
+			$request_data
 		);
+
+		$this->sut->bulk_and_quick_edit_save_post( $product->get_id(), get_post( $product->get_id() ) );
 	}

 	/**
-	 * Provides price changes that end an active sale.
+	 * Assert a stored sale date.
 	 *
-	 * @return array<string, array{string, string}>
+	 * @param string|null      $expected Expected local date, or null.
+	 * @param WC_DateTime|null $actual Stored date.
+	 * @param string           $boundary Boundary name for assertion messages.
 	 */
-	public static function sale_ending_price_changes_provider(): array {
-		return array(
-			'regular price no longer above sale price' => array( '70', '80' ),
-			'sale price removed'                       => array( '100', '' ),
-		);
+	private function assert_date( ?string $expected, ?WC_DateTime $actual, string $boundary ): void {
+		if ( null === $expected ) {
+			$this->assertNull( $actual, "Quick Edit should leave the sale {$boundary} date empty." );
+			return;
+		}
+
+		$this->assertInstanceOf( WC_DateTime::class, $actual, "Quick Edit should set the sale {$boundary} date." );
+		$this->assertSame( $expected, $actual->date( 'Y-m-d H:i:s' ), "Quick Edit should set the expected sale {$boundary} boundary." );
 	}
 }