feat: title search and sort controls in recipe browser
Some checks are pending
CI / Backend (Python) (push) Waiting to run
CI / Frontend (Vue) (push) Waiting to run
Mirror / mirror (push) Waiting to run

Adds minimal sort/search to the recipe browser for cognitive access diversity —
linear scanners, alphabet browsers, and keyword diggers each get a different
way in without duplicating the full search tab.

- browse_recipes: q (LIKE title filter) + sort (default/alpha/alpha_desc)
- API endpoint: q/sort query params with validation
- Frontend: debounced search input (350ms) + sort pills (Default/A→Z/Z→A)
- Search and sort reset on domain/category change
- _all path supports q+sort; keyword-FTS path adds AND filter on top
This commit is contained in:
pyr0ball 2026-04-18 22:14:36 -07:00
parent e7ba305e63
commit 5385adc52a
5 changed files with 139 additions and 27 deletions

View file

@ -168,12 +168,15 @@ async def browse_recipes(
page_size: Annotated[int, Query(ge=1, le=100)] = 20,
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",
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).
"""
if domain not in DOMAINS:
raise HTTPException(status_code=404, detail=f"Unknown domain '{domain}'.")
@ -209,6 +212,8 @@ async def browse_recipes(
page=page,
page_size=page_size,
pantry_items=pantry_list,
q=q or None,
sort=sort,
)
store.log_browser_telemetry(
domain=domain,

View file

@ -1116,6 +1116,8 @@ class Store:
page: int,
page_size: int,
pantry_items: list[str] | None = None,
q: str | None = None,
sort: str = "default",
) -> dict:
"""Return a page of recipes matching the keyword set.
@ -1123,6 +1125,9 @@ class Store:
Each recipe row includes match_pct (float | None) when pantry_items
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).
"""
if keywords is not None and not keywords:
return {"recipes": [], "total": 0, "page": page}
@ -1130,35 +1135,50 @@ class Store:
offset = (page - 1) * page_size
c = self._cp
order_clause = {
"alpha": "ORDER BY title ASC",
"alpha_desc": "ORDER BY title DESC",
}.get(sort, "ORDER BY id ASC")
q_param = f"%{q.strip()}%" if q and q.strip() else None
cols = (
f"SELECT id, title, category, keywords, ingredient_names,"
f" calories, fat_g, protein_g, sodium_mg FROM {c}recipes"
)
if keywords is None:
# "All" browse — unfiltered paginated scan.
if q_param:
total = self.conn.execute(
f"SELECT COUNT(*) FROM {c}recipes WHERE LOWER(title) LIKE LOWER(?)",
(q_param,),
).fetchone()[0]
rows = self._fetch_all(
f"{cols} WHERE LOWER(title) LIKE LOWER(?) {order_clause} LIMIT ? OFFSET ?",
(q_param, page_size, offset),
)
else:
total = self.conn.execute(f"SELECT COUNT(*) FROM {c}recipes").fetchone()[0]
rows = self._fetch_all(
f"""
SELECT id, title, category, keywords, ingredient_names,
calories, fat_g, protein_g, sodium_mg
FROM {c}recipes
ORDER BY id ASC
LIMIT ? OFFSET ?
""",
f"{cols} {order_clause} LIMIT ? OFFSET ?",
(page_size, offset),
)
else:
match_expr = self._browser_fts_query(keywords)
fts_sub = f"id IN (SELECT rowid FROM {c}recipe_browser_fts WHERE recipe_browser_fts MATCH ?)"
if q_param:
total = self.conn.execute(
f"SELECT COUNT(*) FROM {c}recipes WHERE {fts_sub} AND LOWER(title) LIKE LOWER(?)",
(match_expr, q_param),
).fetchone()[0]
rows = self._fetch_all(
f"{cols} WHERE {fts_sub} AND LOWER(title) LIKE LOWER(?) {order_clause} LIMIT ? OFFSET ?",
(match_expr, q_param, page_size, offset),
)
else:
# Reuse cached count — avoids a second index scan on every page turn.
total = self._count_recipes_for_keywords(keywords)
rows = self._fetch_all(
f"""
SELECT id, title, category, keywords, ingredient_names,
calories, fat_g, protein_g, sodium_mg
FROM {c}recipes
WHERE id IN (
SELECT rowid FROM {c}recipe_browser_fts
WHERE recipe_browser_fts MATCH ?
)
ORDER BY id ASC
LIMIT ? OFFSET ?
""",
f"{cols} WHERE {fts_sub} {order_clause} LIMIT ? OFFSET ?",
(match_expr, page_size, offset),
)

View file

@ -78,6 +78,34 @@
<div v-if="loadingRecipes" class="text-secondary text-sm">Loading recipes</div>
<template v-else>
<!-- Search + sort controls -->
<div class="browser-controls flex gap-sm mb-sm flex-wrap align-center">
<input
v-model="searchQuery"
@input="onSearchInput"
type="search"
placeholder="Filter by title…"
class="browser-search"
/>
<div class="sort-btns flex gap-xs">
<button
:class="['btn', 'btn-secondary', 'sort-btn', { active: sortOrder === 'default' }]"
@click="setSort('default')"
title="Corpus order"
>Default</button>
<button
:class="['btn', 'btn-secondary', 'sort-btn', { active: sortOrder === 'alpha' }]"
@click="setSort('alpha')"
title="Alphabetical A→Z"
>AZ</button>
<button
:class="['btn', 'btn-secondary', 'sort-btn', { active: sortOrder === 'alpha_desc' }]"
@click="setSort('alpha_desc')"
title="Alphabetical Z→A"
>ZA</button>
</div>
</div>
<div class="results-header flex-between mb-sm">
<span class="text-sm text-secondary">
{{ total }} recipes
@ -183,6 +211,9 @@ const pageSize = 20
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')
let searchDebounce: ReturnType<typeof setTimeout> | null = null
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize)))
const allCountsZero = computed(() =>
@ -219,12 +250,29 @@ onMounted(async () => {
if (!savedStore.savedIds.size) savedStore.load()
})
function onSearchInput() {
if (searchDebounce) clearTimeout(searchDebounce)
searchDebounce = setTimeout(() => {
page.value = 1
loadRecipes()
}, 350)
}
function setSort(s: 'default' | 'alpha' | 'alpha_desc') {
if (sortOrder.value === s) return
sortOrder.value = s
page.value = 1
loadRecipes()
}
async function selectDomain(domainId: string) {
activeDomain.value = domainId
activeCategory.value = null
recipes.value = []
total.value = 0
page.value = 1
searchQuery.value = ''
sortOrder.value = 'default'
categories.value = await browserAPI.listCategories(domainId)
// Auto-select the most-populated category so content appears immediately.
// Skip when all counts are 0 (corpus not seeded) no point loading an empty result.
@ -247,6 +295,8 @@ async function selectCategory(category: string) {
activeSubcategory.value = null
subcategories.value = []
page.value = 1
searchQuery.value = ''
sortOrder.value = 'default'
// Fetch subcategories in the background when the category supports them,
// then immediately start loading recipes at the full-category level.
@ -286,6 +336,8 @@ async function loadRecipes() {
? pantryItems.value.join(',')
: undefined,
subcategory: activeSubcategory.value ?? undefined,
q: searchQuery.value.trim() || undefined,
sort: sortOrder.value !== 'default' ? sortOrder.value : undefined,
}
)
recipes.value = result.recipes
@ -378,6 +430,38 @@ async function doUnsave(recipeId: number) {
color: white;
}
.browser-controls {
align-items: center;
}
.browser-search {
flex: 1;
min-width: 120px;
max-width: 260px;
padding: var(--spacing-xs) var(--spacing-sm);
font-size: var(--font-size-sm);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-bg);
color: var(--color-text);
}
.browser-search:focus {
outline: none;
border-color: var(--color-primary);
}
.sort-btn {
font-size: var(--font-size-xs, 0.75rem);
padding: 2px var(--spacing-sm);
}
.sort-btn.active {
background: var(--color-primary);
color: white;
border-color: var(--color-primary);
}
.recipe-grid {
display: flex;
flex-direction: column;

View file

@ -924,6 +924,8 @@ export const browserAPI = {
page_size?: number
pantry_items?: string
subcategory?: string
q?: string
sort?: string
}): Promise<BrowserResult> {
const response = await api.get(`/recipes/browse/${domain}/${encodeURIComponent(category)}`, { params })
return response.data

View file

@ -111,6 +111,7 @@
justify-content: flex-end;
align-items: center;
}
.align-center { align-items: center; }
.flex-responsive {
display: flex;