feat: sort dropdown on tag detail + filtered shop home (MPS-24)

Six sort options on category/tag pages, controlled by ?sort=:

  newest       Newest first        (default — created_timestamp desc)
  oldest       Oldest first        (created_timestamp asc)
  price_asc    Price low → high    (price_in_cents asc)
  price_desc   Price high → low    (price_in_cents desc)
  title        Alphabetical A→Z    (lowercased title)
  popular      Most popular (28d)  (PageSession views with visible_ms ≥ 7s)

Rendered as a <select> right-aligned above the grid. Capability-driven:
plain GET form works without JS (button appears via <noscript>), JS-on
auto-submits on change. Both shop_tag.j2 and home.j2 (filtered branch
only — lanes keep their editorial order) get the dropdown.

views/shop.py — new SORT_OPTIONS list + _sort_key_from_request +
_sort_products + _popular_view_counts helpers. Sort runs in Python on
the already-filtered list, so it's O(N log N) on the tagged subset
not the whole shop catalog. The 'popular' option fires one extra
PageSession aggregate query scoped to the filtered product_ids;
~tens of ms on indexed (shop_id, product_id, created_timestamp).

Doesn't address fox's bigger ask: Netflix-style truly-responsive
SERP layout with left nav + description-excerpt rows that works at
every resolution. That's a real redesign — proposing it next.
This commit is contained in:
russell@unturf.com 2026-05-15 14:14:17 -04:00
parent 18a8566023
commit 095d91bc68
No known key found for this signature in database
4 changed files with 156 additions and 1 deletions

View file

@ -1430,6 +1430,39 @@ a.tag-chip-active:hover {
background: var(--dark-button-bg-hover, #3a4658);
}
/* Sort dropdown — shown on tag detail and filtered shop home above
the grid. Right-aligned on a single row at desktop, full width on
mobile (label above select). Grid only — no flexbox. */
form.serp-sort-form {
display: grid;
grid-template-columns: auto auto auto;
justify-content: end;
align-items: center;
gap: var(--space-2, 8px);
margin: 0 0 var(--space-3, 12px) 0;
padding: 0 var(--space-1, 4px);
}
form.serp-sort-form .serp-sort-label {
color: var(--text-muted, #666);
font-size: var(--type-body-sm-size, 0.875rem);
}
form.serp-sort-form select {
padding: var(--space-1, 4px) var(--space-2, 8px);
border-radius: var(--radius-sm, 4px);
border: 1px solid var(--border-color, #dee2e6);
background: var(--surface-color, #fff);
color: var(--text-color, #222);
font-size: var(--type-body-sm-size, 0.875rem);
}
@media (max-width: 600px) {
/* On narrow screens, label sits above the select instead of
crowding the row. Still right-aligned. */
form.serp-sort-form {
grid-template-columns: 1fr;
justify-items: end;
}
}
/* Sectioned-lanes layout — one lane per top tag. Netflix-style
horizontal row with scroll-snap. */
section.tag-lane {

View file

@ -129,6 +129,22 @@
when no tag filter is active. #}
{% set grid_products = filtered_products if filtered_products is defined and filtered_products is not none else products %}
{% if grid_products %}
{% if active_tag and sort_options is defined %}
{# Sort dropdown — only shown on filtered views (when a tag is
active). Lane home doesn't show this; lanes have a fixed editorial
order. Capability-driven: plain GET form works without JS,
auto-submits on change with JS. The ?tag= param is preserved. #}
<form method="get" action="" class="serp-sort-form">
<input type="hidden" name="tag" value="{{ active_tag.slug }}" />
<label for="serp-sort" class="serp-sort-label">Sort by</label>
<select id="serp-sort" name="sort" onchange="this.form.submit()">
{% for key, label in sort_options %}
<option value="{{ key }}"{% if key == sort_key %} selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
<noscript><button type="submit" class="mps-button mps-button-small">Apply</button></noscript>
</form>
{% endif %}
<section class="serp{% if request.shop.grid_lanes_enabled %} grid-lanes-enabled{% endif %}" data-tag-grid>
{% for product in grid_products %}

View file

@ -22,6 +22,18 @@
</nav>
{% endif %}
{# Sort dropdown — capability-driven: works as a plain GET form
without JS, auto-submits on change with JS. #}
<form method="get" action="" class="serp-sort-form">
<label for="serp-sort" class="serp-sort-label">Sort by</label>
<select id="serp-sort" name="sort" onchange="this.form.submit()">
{% for key, label in sort_options %}
<option value="{{ key }}"{% if key == sort_key %} selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
<noscript><button type="submit" class="mps-button mps-button-small">Apply</button></noscript>
</form>
<section class="serp{% if request.shop.grid_lanes_enabled %} grid-lanes-enabled{% endif %}" data-tag-grid>
{% for product in products %}
{% if product.is_ready %}

View file

@ -217,6 +217,86 @@ def get_shop_from_matchdict(request, prefetched_shop=None):
return get_shop_by_id(request.dbsession, request.matchdict["shop_id"])
# MPS-24: sort options surfaced as a <select> dropdown on tag detail
# pages and any filtered grid view. Keep the keys short — they appear
# in URL query strings (?sort=newest). Order in this list controls the
# order in the dropdown UI.
SORT_OPTIONS = [
("newest", "Newest first"),
("oldest", "Oldest first"),
("price_asc", "Price: low to high"),
("price_desc", "Price: high to low"),
("title", "Alphabetical (A→Z)"),
("popular", "Most popular (28d)"),
]
SORT_KEYS = {k for k, _ in SORT_OPTIONS}
DEFAULT_SORT = "newest"
def _sort_key_from_request(request):
"""Read ?sort= from the request, falling back to default if absent
or unknown. Lower-cased + stripped to be forgiving."""
raw = (request.params.get("sort") or "").strip().lower()
return raw if raw in SORT_KEYS else DEFAULT_SORT
def _popular_view_counts(dbsession, shop_id, product_ids, days=28):
"""Return {product_id: view_count} over the last `days`. Used by the
'popular' sort option. Filters by visible_ms >= 7000 to match the
same 'real view' definition used in analytics, and excludes owner
traffic. One query, returns a dict for cheap in-Python sort."""
if not product_ids:
return {}
from sqlalchemy import func, and_, or_
from ..models.page_session import PageSession
from ..models.meta import now_timestamp
cutoff_ms = now_timestamp() - days * 24 * 60 * 60 * 1000
rows = (
dbsession.query(
PageSession.product_id,
func.count().label("cnt"),
)
.filter(
PageSession.shop_id == shop_id,
PageSession.product_id.in_(product_ids),
PageSession.visible_ms >= 7000,
PageSession.created_timestamp > cutoff_ms,
or_(PageSession.is_owner == None, PageSession.is_owner == False), # noqa: E711,E712
)
.group_by(PageSession.product_id)
.all()
)
return {r.product_id: r.cnt for r in rows}
def _sort_products(products, sort_key, dbsession=None, shop_id=None):
"""Sort an in-Python list of Product objects by sort_key. Stable —
products without the sort attribute keep their original order
relative to each other. Falls back to created_timestamp desc for
any unknown key."""
if not products:
return products
if sort_key == "oldest":
return sorted(products, key=lambda p: p.created_timestamp or 0)
if sort_key == "price_asc":
return sorted(products, key=lambda p: p.price_in_cents or 0)
if sort_key == "price_desc":
return sorted(products, key=lambda p: p.price_in_cents or 0, reverse=True)
if sort_key == "title":
return sorted(products, key=lambda p: (p.title or "").lower())
if sort_key == "popular":
if dbsession is None or shop_id is None:
return products
counts = _popular_view_counts(
dbsession, shop_id, [p.id for p in products]
)
# Sort by view count desc; products with zero views keep their
# incoming order at the end via a stable secondary key.
return sorted(products, key=lambda p: -counts.get(p.id, 0))
# newest (default) and anything unknown
return sorted(products, key=lambda p: -(p.created_timestamp or 0))
def _build_home_layout_context(request, shop, products):
"""
MPS-24: derive home-layout context (chips/lanes/featured) for a shop home
@ -237,11 +317,14 @@ def _build_home_layout_context(request, shop, products):
else:
products_list = list(products)
sort_key = _sort_key_from_request(request)
ctx = {
"home_chips": [],
"home_lanes": [],
"active_tag": None,
"filtered_products": products_list,
"sort_key": sort_key,
"sort_options": SORT_OPTIONS,
}
if shop is None:
return ctx
@ -271,9 +354,13 @@ def _build_home_layout_context(request, shop, products):
.all()
}
if products_list is not None:
ctx["filtered_products"] = [
filtered = [
p for p in products_list if p.id in tagged_ids
]
ctx["filtered_products"] = _sort_products(
filtered, sort_key,
dbsession=request.dbsession, shop_id=shop.id,
)
# Sectioned lanes only when layout == 2 and the shopper hasn't already
# filtered to one tag (filtering trumps lanes — single grid in that case).
@ -1974,6 +2061,11 @@ def shop_tag_detail(request):
.all()
}
filtered = [p for p in products if p.id in tagged_ids]
sort_key = _sort_key_from_request(request)
filtered = _sort_products(
filtered, sort_key,
dbsession=request.dbsession, shop_id=shop.id,
)
ctx = {
"products": filtered,
"active_tag": tag,
@ -1984,6 +2076,8 @@ def shop_tag_detail(request):
),
"home_lanes": [],
"filtered_products": filtered,
"sort_key": sort_key,
"sort_options": SORT_OPTIONS,
}
return ctx