diff --git a/app/api/endpoints/recipes.py b/app/api/endpoints/recipes.py
index ee65761..20b70d5 100644
--- a/app/api/endpoints/recipes.py
+++ b/app/api/endpoints/recipes.py
@@ -245,14 +245,15 @@ async def browse_recipes(
pantry_items: Annotated[str | None, Query()] = None,
subcategory: Annotated[str | None, Query()] = None,
q: Annotated[str | None, Query(max_length=200)] = None,
- sort: Annotated[str, Query(pattern="^(default|alpha|alpha_desc)$")] = "default",
+ sort: Annotated[str, Query(pattern="^(default|alpha|alpha_desc|match)$")] = "default",
session: CloudUser = Depends(get_session),
) -> dict:
"""Return a paginated list of recipes for a domain/category.
Pass pantry_items as a comma-separated string to receive match_pct badges.
Pass subcategory to narrow within a category that has subcategories.
- Pass q to filter by title substring. Pass sort for ordering (default/alpha/alpha_desc).
+ Pass q to filter by title substring. Pass sort for ordering (default/alpha/alpha_desc/match).
+ sort=match orders by pantry coverage DESC; falls back to default when no pantry_items.
"""
if domain not in DOMAINS:
raise HTTPException(status_code=404, detail=f"Unknown domain '{domain}'.")
diff --git a/app/db/store.py b/app/db/store.py
index d4e74fc..6066ed9 100644
--- a/app/db/store.py
+++ b/app/db/store.py
@@ -1161,21 +1161,33 @@ class Store:
is provided. match_pct is the fraction of ingredient_names covered by
the pantry set — computed deterministically, no LLM needed.
- q: optional title substring filter (case-insensitive LIKE).
- sort: "default" (corpus order) | "alpha" (A→Z) | "alpha_desc" (Z→A).
+ q: optional title substring filter (case-insensitive LIKE).
+ sort: "default" (corpus order) | "alpha" (A→Z) | "alpha_desc" (Z→A)
+ | "match" (pantry coverage DESC — falls back to default when no pantry).
"""
if keywords is not None and not keywords:
return {"recipes": [], "total": 0, "page": page}
offset = (page - 1) * page_size
c = self._cp
+ pantry_set = {p.lower() for p in pantry_items} if pantry_items else None
+
+ # "match" sort requires pantry items; fall back gracefully when absent.
+ effective_sort = sort if (sort != "match" or pantry_set) else "default"
order_clause = {
"alpha": "ORDER BY title ASC",
"alpha_desc": "ORDER BY title DESC",
- }.get(sort, "ORDER BY id ASC")
+ }.get(effective_sort, "ORDER BY id ASC")
q_param = f"%{q.strip()}%" if q and q.strip() else None
+
+ # ── match sort: push match_pct computation into SQL so ORDER BY works ──
+ if effective_sort == "match" and pantry_set:
+ return self._browse_by_match(
+ keywords, page, page_size, offset, pantry_set, q_param, c
+ )
+
cols = (
f"SELECT id, title, category, keywords, ingredient_names,"
f" calories, fat_g, protein_g, sodium_mg FROM {c}recipes"
@@ -1217,26 +1229,107 @@ class Store:
(match_expr, page_size, offset),
)
- pantry_set = {p.lower() for p in pantry_items} if pantry_items else None
recipes = []
for r in rows:
entry = {
- "id": r["id"],
- "title": r["title"],
- "category": r["category"],
- "match_pct": None,
+ "id": r["id"],
+ "title": r["title"],
+ "category": r["category"],
+ "match_pct": None,
}
if pantry_set:
names = r.get("ingredient_names") or []
if names:
- matched = sum(
- 1 for n in names if n.lower() in pantry_set
- )
+ matched = sum(1 for n in names if n.lower() in pantry_set)
entry["match_pct"] = round(matched / len(names), 3)
recipes.append(entry)
return {"recipes": recipes, "total": total, "page": page}
+ def _browse_by_match(
+ self,
+ keywords: list[str] | None,
+ page: int,
+ page_size: int,
+ offset: int,
+ pantry_set: set[str],
+ q_param: str | None,
+ c: str,
+ ) -> dict:
+ """Browse recipes sorted by pantry match percentage, computed in SQL.
+
+ Uses json_each() to count how many of each recipe's ingredient_names
+ appear in the pantry set, then sorts highest-first. match_pct is
+ already present in the SQL result so no Python post-processing needed.
+ """
+ pantry_list = sorted(pantry_set)
+ ph = ",".join("?" * len(pantry_list))
+
+ # Subquery computes match fraction inline so ORDER BY can use it.
+ match_col = (
+ f"(SELECT CAST(COUNT(*) AS REAL)"
+ f" / NULLIF(json_array_length(r.ingredient_names), 0)"
+ f" FROM json_each(r.ingredient_names) AS j"
+ f" WHERE LOWER(j.value) IN ({ph}))"
+ )
+
+ base_cols = (
+ f"SELECT r.id, r.title, r.category, r.keywords, r.ingredient_names,"
+ f" r.calories, r.fat_g, r.protein_g, r.sodium_mg,"
+ f" {match_col} AS match_pct"
+ f" FROM {c}recipes r"
+ )
+
+ if keywords is None:
+ fts_where = "LOWER(r.title) LIKE LOWER(?)" if q_param else "1=1"
+ count_params: tuple = (q_param,) if q_param else ()
+ total = self.conn.execute(
+ f"SELECT COUNT(*) FROM {c}recipes r WHERE {fts_where}", count_params
+ ).fetchone()[0]
+ data_params = (*pantry_list, *(count_params), page_size, offset)
+ where_clause = f"WHERE {fts_where}" if fts_where != "1=1" else ""
+ sql = (
+ f"{base_cols} {where_clause}"
+ f" ORDER BY match_pct DESC NULLS LAST, r.id ASC"
+ f" LIMIT ? OFFSET ?"
+ )
+ else:
+ match_expr = self._browser_fts_query(keywords)
+ fts_sub = (
+ f"r.id IN (SELECT rowid FROM {c}recipe_browser_fts"
+ f" WHERE recipe_browser_fts MATCH ?)"
+ )
+ if q_param:
+ total = self.conn.execute(
+ f"SELECT COUNT(*) FROM {c}recipes r"
+ f" WHERE {fts_sub} AND LOWER(r.title) LIKE LOWER(?)",
+ (match_expr, q_param),
+ ).fetchone()[0]
+ where_clause = f"WHERE {fts_sub} AND LOWER(r.title) LIKE LOWER(?)"
+ data_params = (*pantry_list, match_expr, q_param, page_size, offset)
+ else:
+ total = self._count_recipes_for_keywords(keywords)
+ where_clause = f"WHERE {fts_sub}"
+ data_params = (*pantry_list, match_expr, page_size, offset)
+ sql = (
+ f"{base_cols} {where_clause}"
+ f" ORDER BY match_pct DESC NULLS LAST, r.id ASC"
+ f" LIMIT ? OFFSET ?"
+ )
+
+ self.conn.row_factory = sqlite3.Row
+ rows = self.conn.execute(sql, data_params).fetchall()
+ recipes = []
+ for r in rows:
+ row = dict(r)
+ recipes.append({
+ "id": row["id"],
+ "title": row["title"],
+ "category": row["category"],
+ "match_pct": round(row["match_pct"], 3) if row["match_pct"] is not None else None,
+ })
+ return {"recipes": recipes, "total": total, "page": page}
+
def log_browser_telemetry(
self,
domain: str,
diff --git a/frontend/src/components/RecipeBrowserPanel.vue b/frontend/src/components/RecipeBrowserPanel.vue
index 28aa39b..363b7e3 100644
--- a/frontend/src/components/RecipeBrowserPanel.vue
+++ b/frontend/src/components/RecipeBrowserPanel.vue
@@ -103,6 +103,12 @@
@click="setSort('alpha_desc')"
title="Alphabetical Z→A"
>Z→A
+
@@ -184,7 +190,7 @@