feat(browse): sort recipes by pantry match percentage

Adds 'Best match' sort button to the recipe browser. When selected, recipes are
ordered by the fraction of their ingredients that are in the user's pantry.

- store.py: _browse_by_match() pushes match_pct computation into SQL via json_each()
  so ORDER BY can sort the full result set before LIMIT/OFFSET pagination
- recipes.py: extends sort pattern validation to accept 'match'; falls back to
  default when no pantry_items provided
- RecipeBrowserPanel.vue: adds 'Best match' button (disabled when pantry empty);
  watcher auto-engages match sort when pantry goes from empty to non-empty
This commit is contained in:
pyr0ball 2026-04-21 15:04:34 -07:00
parent 1a7a94a344
commit 1ac7e3d76a
3 changed files with 126 additions and 16 deletions

View file

@ -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}'.")

View file

@ -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" (AZ) | "alpha_desc" (ZA).
q: optional title substring filter (case-insensitive LIKE).
sort: "default" (corpus order) | "alpha" (AZ) | "alpha_desc" (ZA)
| "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,

View file

@ -103,6 +103,12 @@
@click="setSort('alpha_desc')"
title="Alphabetical Z→A"
>ZA</button>
<button
:class="['btn', 'btn-secondary', 'sort-btn', { active: sortOrder === 'match' }]"
:disabled="pantryCount === 0"
@click="setSort('match')"
:title="pantryCount > 0 ? 'Sort by pantry match %' : 'Add items to pantry to sort by match'"
>Best match</button>
</div>
</div>
@ -184,7 +190,7 @@
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { ref, computed, onMounted, watch } from 'vue'
import { browserAPI, type BrowserDomain, type BrowserCategory, type BrowserSubcategory, type BrowserRecipe } from '../services/api'
import { useSavedRecipesStore } from '../stores/savedRecipes'
import { useInventoryStore } from '../stores/inventory'
@ -212,7 +218,7 @@ const loadingDomains = ref(false)
const loadingRecipes = ref(false)
const savingRecipe = ref<BrowserRecipe | null>(null)
const searchQuery = ref('')
const sortOrder = ref<'default' | 'alpha' | 'alpha_desc'>('default')
const sortOrder = ref<'default' | 'alpha' | 'alpha_desc' | 'match'>('default')
let searchDebounce: ReturnType<typeof setTimeout> | null = null
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize)))
@ -258,13 +264,23 @@ function onSearchInput() {
}, 350)
}
function setSort(s: 'default' | 'alpha' | 'alpha_desc') {
function setSort(s: 'default' | 'alpha' | 'alpha_desc' | 'match') {
if (sortOrder.value === s) return
sortOrder.value = s
page.value = 1
loadRecipes()
}
// When pantry items first become available while browsing, auto-engage match sort.
// When pantry empties out mid-session, drop back to default so the button disables cleanly.
watch(pantryCount, (newCount, oldCount) => {
if (newCount > 0 && oldCount === 0 && activeCategory.value) {
setSort('match')
} else if (newCount === 0 && sortOrder.value === 'match') {
setSort('default')
}
})
async function selectDomain(domainId: string) {
activeDomain.value = domainId
activeCategory.value = null