Skip to content

Feature : Price Filter - #2

Draft
Loubal70 wants to merge 30 commits into
mainfrom
feat/price-filter
Draft

Loubal70 wants to merge 30 commits into
mainfrom
feat/price-filter

Conversation

@Loubal70

Copy link
Copy Markdown
Member

No description provided.

Comparing `scheduled_date_local` to a WooCommerce meta timestamp, which is
UTC, shows an offset that does not exist — it is the site's own offset. It
produced a false conclusion while scoping the price filter: a window where
`is_on_sale()` says yes and `_price` still holds the regular price. There is
none; WooCommerce schedules the flip at the exact boundary.
Frames R-43 / Q-08 before any code. Nothing is decided.

Two things correct the R-43 entry, both measured on the test catalogue: its
argument that no variable product exists is no longer true (8 of them, plus 2
grouped), and its proposal of price buckets does not follow the platform —
WooCommerce filters min/max on an interval per product, with an overlap test.

The blocking defect: for a variable or grouped product only the lowest price
reaches the index, so a 40-70 filter misses a product sold from 28 to 62. Ten
products of seventy-six.

Also framed: the scheduled-sale chain does hold (Action Scheduler flips the
price, which saves the product, which reindexes — verified on a past sale),
the stock of a variation, which a live facet already contradicts, and what
FacetWP and the Algolia WooCommerce connector do.
Seven decisions taken with Louis, two deferred with the stock work. The shape
follows WooCommerce: a min/max overlap on an interval indexed per product,
bounds from facetStats, an on-sale flag frozen at index time like the `onsale`
column of wc_product_meta_lookup, and its tax conversion.

The derived fields live under a `price` field of the module's own, beside
`card` and `facets`. None of them exists in postmeta, so presenting them as
metas would mislead the next reader.

R-109 closed by AmphiBee/MeiliScout@26eb035: saving a product dropped its metas
from the index, silently, because the meta keys were only resolved on the batch
paths. Found while checking a scheduled sale.
A variable or grouped product carries one `_price` row per distinct child
price. Only the lowest reached the index, so a 40-70 filter missed a product
sold from 28 to 62 — ten of seventy-six here — and "price, high to low" ranked
it at its cheapest variation.

`price.min` and `price.max` are the ends of those rows, which `sync_price()`
writes sorted: the same reading `wc_product_meta_lookup` does. They live under
a `price` field of the module's own, beside `card` and `facets`, because none
of the three exists in `postmeta`.

`price.onsale` comes from `is_on_sale()` rather than from the formula of the
lookup column. Measured on the catalogue: 20 against 15, and the five it
differs on are all variable or grouped — `sync_price()` deletes `_sale_price`
from the parent, so the column is wrong exactly where the method is overridden
to read the children. Reproducing it would reproduce a defect.

Sorting follows: ascending on the low end, descending on the high one.

The `allow-plugins` entry arrived with a `composer update`, and it is kept on
purpose: the module requires meiliscout, which pulls meilisearch-php, which
pulls php-http/discovery, so a standalone `composer install` here needs the
consent too.
`Modules/` sits outside the docroot, so the browser reads a copy of the
module's CSS and JavaScript. A copy older than its source leaves the page
reading rules nobody holds, and nothing said so.

`meilifacets:check-assets` names every file that is missing or older than
the module's own, and gives the command that republishes them.
…ject asks for

A listing can carry a price filter made of a two-handle slider, two number
fields, or both — `PriceFilter` takes the parts, one view renders those it
was given, in a fixed order. The pair of inputs always travels, shown or
hidden: the browser client reads the parameter names and the current bounds
from them.

Filtering tests the overlap of a product's own interval with the range asked
for, the way WooCommerce does, so a variable product is found through any of
its variations. Bounds come from the engine's `facetStats` and follow the
current filtering; a range the new bounds no longer meet is dropped rather
than clamped onto an empty sliver.

`NativeFiltering` stops WooCommerce filtering the main query in parallel,
which it would otherwise do from the same `$_GET` names with its own tax
conversion.

`Contract::VERSION` stays at 1. The seven hooks are added, never renamed:
an override written against the previous contract keeps working, and
refusing to start would cost the whole listing to protect a part the theme
never rendered. The rule on the constant now says so.
`check-parameters` refused `min_price` and `max_price`, which are at once
the module's own defaults and names WooCommerce reads straight from `$_GET`.
Keeping them is the point: a link written for WooCommerce drives the module
without translation.

The guard now reports them without blocking, and only while the bound still
answers to the name. A project that renames one gives up the compatibility
and the excuse together: should a taxonomy then take the freed name, it
blocks again.
`prix.md` carries the nine decisions the batch took: min/max rather than
brackets, the interval indexed per product, bounds from `facetStats`, the
derived fields under a `price` key, WooCommerce's tax conversion, and
`min_price`/`max_price` as renameable defaults.

The register closes R-113 (the price views lived outside `components/`,
where the README promises overriding), R-116 (the rule on the contract put
adding a hook on the same footing as removing one) and R-117, and opens
R-114 and R-115. `configuration.md` documents the three price sub-views with
the props each receives, and why the bounds keep WooCommerce's names.
`architecture.md` names the version mechanism instead of a literal, and
lists the seven price hooks it had been missing.

`CLAUDE.md` gains the reason `config/config.php` cannot hold an overridable
setting: nwidart merges it over the project's, recursively.
The price half of batch 4 shipped, so R-43 closes on evidence rather than on
a claim: a product only matches `price.min <= 28 AND price.max >= 62` if its
whole interval is indexed, and one does. The defect the entry named — only
the lowest price indexed for a variable product — is gone.

Q-08 asked whether to serve simple products early or wait for variations.
D-b removed the choice by indexing the interval first, so the filter was
right on day one for all 76 products. Availability stays deferred under D-f,
per-variation stock under D-g; `lots.md` says which half of batch 4 landed
and which acceptance point is still unmet.
…ine's

`faceting.maxValuesPerFacet` was left at Meilisearch's default of 100, the
only one of the three ceilings that could not be found by reading the
module's code. It cuts `facetDistribution` by global count — before a facet
narrows to the aisle being looked at, so a value that is rare shop-wide may
be the only one that mattered on its page.

The module now writes it from `engine.max_facet_values`, carried by
`EngineLimits` beside `reachable_hits`, which already travels from the
provider down to the indexable.

`ResolvedListing::valuesOf()` reports `FacetTruncated` when a distribution
stops exactly on that ceiling — the last moment the raw distribution is in
hand, since `FacetValues::of()` then narrows and slices it. It reports on
100 as well: between a release and its reindexing the index still cuts where
the engine does while the module already asks for more. The threshold comes
from the same object that is written to the index, never a second copy.
@Loubal70 Loubal70 self-assigned this Sep 17, 2026

@Loubal70 Loubal70 left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Three findings on the price filter, each checked against the module and the WooCommerce source.

Comment thread app/Search/PriceQuery.php

public function clause(ListingState $state): string
{
return FilterExpression::overlapping(PriceTax::excluding($state->price));

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Server and browser disagree on the range when a shop stores prices excluding tax and displays them including tax.

PriceTax::excluding() takes the tax off the bounds here, but resources/assets/ts/price/price-query.ts builds its clause from the raw bounds — while its docblock says it runs « the same test the server writes ». Neither side adds the tax to the min/max the slider shows either, which WooCommerce's own price widget does (class-wc-widget-price-filter.php:101-107).

With 20 % tax:

  • loading ?max_price=50 directly filters on a stored price ≤ 41.67, while setting 50 with the slider filters on ≤ 50 — the same URL lists different products depending on how it was reached;
  • the slider's top is the tax-free maximum (100), which the server then brings down to 83.33, dropping the most expensive products.

*/
final readonly class NativeFiltering
{
#[Filter('woocommerce_enable_post_clause_filtering')]

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This turns WooCommerce's native filtering off on every main product query, not only where a MeiliFacets listing renders.

woocommerce_enable_post_clause_filtering gates price filtering (class-wc-query.php:787) and attribute filtering (src/Internal/ProductAttributesLookup/Filterer.php:70) alike, and nothing here checks that a listing is on the page — ListingPage only tests is_archive() || is_search(). The docblock's « inert on every other listing » does not hold.

A product archive or search page that keeps WooCommerce's loop with its price filter or layered-nav widgets would ignore min_price, max_price and filter_*, with no error.

Comment on lines +37 to +39
$prices = (array) get_post_meta($post->ID, '_price', false);

if ($prices === []) {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A product with no price is indexed at 0.

Saving a product with an empty regular price writes _price = '' (class-wc-product-data-store-cpt.php:876). get_post_meta(..., false) then returns [''], which passes the [] === check, and (float) '' is 0.0 — so « A product with no price has none » is not what happens.

Such a product (« price on request ») matches every range with an upper bound, ?max_price=10 included, and pulls the measured minimum to 0, so the slider always starts at 0. Dropping empty values before the check fixes it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant