fix(ui): compact recipe cards, batch ingredient classifier queries
Some checks are pending
CI / Backend (Python) (push) Waiting to run
CI / Frontend (Vue) (push) Waiting to run
Mirror / mirror (push) Waiting to run

Recipe cards were rendering full directions, all nutrition chips,
prep notes, swap candidates, and grocery links inline in the grid —
making each card tall enough to push the second row below the fold at
3-column widths. Cards now show title, match/complexity/time badges,
up to 4 pantry ingredient chips, missing count, and calorie hint.
Full detail remains in RecipeDetailPanel on "Make this".

ElementClassifier.classify_batch() was issuing N separate DB queries
(one per pantry item). Replaced with a single WHERE name IN (...)
query + heuristic fallback for misses — same result, one round-trip.
This commit is contained in:
pyr0ball 2026-04-27 14:56:00 -07:00
parent d5a4b14400
commit 640fcefa9e
2 changed files with 65 additions and 147 deletions

View file

@ -93,7 +93,18 @@ class ElementClassifier:
return self._heuristic_profile(name) return self._heuristic_profile(name)
def classify_batch(self, names: list[str]) -> list[IngredientProfile]: def classify_batch(self, names: list[str]) -> list[IngredientProfile]:
return [self.classify(n) for n in names] """Classify multiple names in one DB round-trip, falling back to heuristics."""
if not names:
return []
normalised = [n.lower().strip() for n in names]
c = self._store._cp
placeholders = ",".join("?" * len(normalised))
rows = self._store._fetch_all(
f"SELECT * FROM {c}ingredient_profiles WHERE name IN ({placeholders})",
tuple(normalised),
)
by_name = {r["name"]: self._row_to_profile(r) for r in rows}
return [by_name.get(n) or self._heuristic_profile(n) for n in normalised]
def identify_gaps(self, profiles: list[IngredientProfile]) -> list[str]: def identify_gaps(self, profiles: list[IngredientProfile]) -> list[str]:
"""Return element names that have no coverage in the given profile list.""" """Return element names that have no coverage in the given profile list."""

View file

@ -534,160 +534,65 @@
<p v-else>We didn't find matches at this level. Try <button class="btn btn-ghost btn-sm" @click="recipesStore.level = 1; handleSuggest()">Level 1 Use What I Have</button> or adjust your filters.</p> <p v-else>We didn't find matches at this level. Try <button class="btn btn-ghost btn-sm" @click="recipesStore.level = 1; handleSuggest()">Level 1 Use What I Have</button> or adjust your filters.</p>
</div> </div>
<!-- Recipe Cards --> <!-- Recipe Cards compact summary; full detail opens in RecipeDetailPanel -->
<div class="grid-auto mb-md"> <div class="grid-auto mb-md">
<div <div
v-for="recipe in filteredSuggestions" v-for="recipe in filteredSuggestions"
:key="recipe.id" :key="recipe.id"
class="card slide-up" class="card recipe-card-compact slide-up"
role="article"
> >
<!-- Header row --> <!-- Title + actions -->
<div class="flex-between mb-sm"> <div class="flex-between mb-xs" style="gap: var(--spacing-xs)">
<h3 class="text-lg font-bold recipe-title">{{ recipe.title }}</h3> <h3 class="text-base font-bold recipe-title" style="flex:1; min-width:0">{{ recipe.title }}</h3>
<div class="flex flex-wrap gap-xs" style="align-items:center"> <div class="flex gap-xs" style="align-items:center; flex-shrink:0">
<span class="status-badge status-success">{{ recipe.match_count }} matched</span>
<span v-if="recipe.complexity" :class="['status-badge', `complexity-${recipe.complexity}`]">{{ recipe.complexity }}</span>
<span v-if="recipe.estimated_time_min" class="status-badge status-neutral">~{{ recipe.estimated_time_min }}m</span>
<span class="status-badge status-info">Level {{ recipe.level }}</span>
<span v-if="recipe.is_wildcard" class="status-badge status-info">Wildcard</span>
<span <span
v-if="recipe.id" v-if="recipe.id"
:class="['btn-icon', 'btn-bookmark', { active: savedStore.isSaved(recipe.id) }]" :class="['btn-icon', 'btn-bookmark', { active: savedStore.isSaved(recipe.id) }]"
:aria-label="savedStore.isSaved(recipe.id) ? 'Saved: ' + recipe.title : recipe.title" :aria-label="savedStore.isSaved(recipe.id) ? 'Saved: ' + recipe.title : 'Save ' + recipe.title"
:title="savedStore.isSaved(recipe.id) ? 'Saved' : 'Not saved'" :title="savedStore.isSaved(recipe.id) ? 'Saved' : 'Save'"
>{{ savedStore.isSaved(recipe.id) ? '★' : '☆' }}</span> >{{ savedStore.isSaved(recipe.id) ? '★' : '☆' }}</span>
<button <button
v-if="recipe.id" v-if="recipe.id"
class="btn-icon btn-dismiss" class="btn-icon btn-dismiss"
@click="recipesStore.dismiss(recipe.id)" @click.stop="recipesStore.dismiss(recipe.id)"
:aria-label="'Hide recipe: ' + recipe.title" :aria-label="'Hide recipe: ' + recipe.title"
></button> ></button>
</div> </div>
</div> </div>
<!-- Notes --> <!-- Badges row -->
<p v-if="recipe.notes" class="text-sm text-secondary mb-sm">{{ recipe.notes }}</p> <div class="flex flex-wrap gap-xs mb-sm">
<span class="status-badge status-success">{{ recipe.match_count }} matched</span>
<!-- Matched ingredients (what you already have) --> <span v-if="recipe.complexity" :class="['status-badge', `complexity-${recipe.complexity}`]">{{ recipe.complexity }}</span>
<div v-if="recipe.matched_ingredients?.length > 0" class="ingredient-section mb-sm"> <span v-if="recipe.estimated_time_min" class="status-badge status-neutral">~{{ recipe.estimated_time_min }}m</span>
<p class="text-sm font-semibold ingredient-label ingredient-label-have">From your pantry:</p> <span class="status-badge status-info">L{{ recipe.level }}</span>
<div class="flex flex-wrap gap-xs mt-xs"> <span v-if="recipe.is_wildcard" class="status-badge status-info">Wildcard</span>
<span
v-for="ing in recipe.matched_ingredients"
:key="ing"
class="ingredient-chip ingredient-chip-have"
>{{ ing }}</span>
</div>
</div> </div>
<!-- Nutrition chips --> <!-- Pantry ingredients preview (max 4 chips) -->
<div v-if="recipe.nutrition" class="nutrition-chips mb-sm"> <div v-if="recipe.matched_ingredients?.length > 0" class="flex flex-wrap gap-xs mb-xs">
<span v-if="recipe.nutrition.calories != null" class="nutrition-chip"> <span
<span aria-hidden="true">🔥</span><span class="sr-only">Calories:</span> {{ Math.round(recipe.nutrition.calories) }} kcal v-for="ing in recipe.matched_ingredients.slice(0, 4)"
</span> :key="ing"
<span v-if="recipe.nutrition.fat_g != null" class="nutrition-chip"> class="ingredient-chip ingredient-chip-have"
<span aria-hidden="true">🧈</span><span class="sr-only">Fat:</span> {{ recipe.nutrition.fat_g.toFixed(1) }}g fat >{{ ing }}</span>
</span> <span
<span v-if="recipe.nutrition.protein_g != null" class="nutrition-chip"> v-if="recipe.matched_ingredients.length > 4"
<span aria-hidden="true">💪</span><span class="sr-only">Protein:</span> {{ recipe.nutrition.protein_g.toFixed(1) }}g protein class="ingredient-chip ingredient-chip-have chip-overflow"
</span> >+{{ recipe.matched_ingredients.length - 4 }} more</span>
<span v-if="recipe.nutrition.carbs_g != null" class="nutrition-chip">
<span aria-hidden="true">🌾</span><span class="sr-only">Carbs:</span> {{ recipe.nutrition.carbs_g.toFixed(1) }}g carbs
</span>
<span v-if="recipe.nutrition.fiber_g != null" class="nutrition-chip">
<span aria-hidden="true">🌿</span><span class="sr-only">Fiber:</span> {{ recipe.nutrition.fiber_g.toFixed(1) }}g fiber
</span>
<span v-if="recipe.nutrition.sugar_g != null" class="nutrition-chip nutrition-chip-sugar">
<span aria-hidden="true">🍬</span><span class="sr-only">Sugar:</span> {{ recipe.nutrition.sugar_g.toFixed(1) }}g sugar
</span>
<span v-if="recipe.nutrition.sodium_mg != null" class="nutrition-chip">
<span aria-hidden="true">🧂</span><span class="sr-only">Sodium:</span> {{ Math.round(recipe.nutrition.sodium_mg) }}mg sodium
</span>
<span v-if="recipe.nutrition.servings != null" class="nutrition-chip nutrition-chip-servings">
<span aria-hidden="true">🍽</span><span class="sr-only">Servings:</span> {{ recipe.nutrition.servings }} serving{{ recipe.nutrition.servings !== 1 ? 's' : '' }}
</span>
<span v-if="recipe.nutrition.estimated" class="nutrition-chip nutrition-chip-estimated" title="Estimated from ingredient profiles">
~ estimated
</span>
</div> </div>
<!-- Missing ingredients --> <!-- Missing count + calorie hint -->
<div v-if="recipe.missing_ingredients.length > 0" class="mb-sm"> <p class="text-sm text-secondary mb-sm recipe-card-meta">
<p class="text-sm font-semibold text-secondary">To complete this recipe:</p> <span v-if="recipe.missing_ingredients.length > 0">+{{ recipe.missing_ingredients.length }} needed</span>
<div class="flex flex-wrap gap-xs mt-xs"> <span v-if="recipe.missing_ingredients.length > 0 && recipe.nutrition?.calories != null"> · </span>
<span <span v-if="recipe.nutrition?.calories != null">{{ Math.round(recipe.nutrition.calories) }} kcal</span>
v-for="ing in recipe.missing_ingredients" </p>
:key="ing"
class="status-badge status-info"
>{{ ing }}</span>
</div>
</div>
<!-- Grocery links for this recipe's missing ingredients --> <!-- Primary action -->
<div v-if="groceryLinksForRecipe(recipe).length > 0" class="mb-sm">
<p class="text-sm font-semibold">Buy online:</p>
<div class="flex flex-wrap gap-xs mt-xs">
<a
v-for="link in groceryLinksForRecipe(recipe)"
:key="link.ingredient + link.retailer"
:href="link.url"
target="_blank"
rel="noopener noreferrer"
class="grocery-link status-badge status-info"
title="This is an affiliate link"
>
{{ link.ingredient }} @ {{ link.retailer }}
</a>
</div>
</div>
<!-- Prep notes -->
<div v-if="recipe.prep_notes && recipe.prep_notes.length > 0" class="prep-notes mb-sm">
<p class="text-sm font-semibold">Before you start:</p>
<ul class="prep-notes-list mt-xs">
<li v-for="note in recipe.prep_notes" :key="note" class="text-sm prep-note-item">
{{ note }}
</li>
</ul>
</div>
<!-- Directions always visible; this is the content people came for -->
<div v-if="recipe.directions.length > 0" class="directions-section">
<p class="text-sm font-semibold directions-label">Steps</p>
<ol class="directions-list mt-xs">
<li v-for="(step, idx) in recipe.directions" :key="idx" class="text-sm direction-step">
{{ step }}
</li>
</ol>
</div>
<!-- Swap candidates collapsible shown after directions per M8 -->
<details
v-if="recipe.swap_candidates.length > 0"
class="collapsible mb-sm"
@toggle="(e: Event) => toggleSwapOpen(recipe.id, (e.target as HTMLDetailsElement).open)"
>
<summary class="text-sm font-semibold collapsible-summary" :aria-expanded="openSwapIds.has(recipe.id)">
Possible swaps ({{ recipe.swap_candidates.length }})
</summary>
<div class="card-secondary mt-xs">
<div
v-for="swap in recipe.swap_candidates"
:key="swap.original_name + swap.substitute_name"
class="swap-row text-sm"
>
<span class="font-semibold">{{ swap.original_name }}</span>
<span class="text-muted"> </span>
<span class="font-semibold">{{ swap.substitute_name }}</span>
<span v-if="swap.constraint_label" class="status-badge status-info ml-xs">{{ swap.constraint_label }}</span>
<p v-if="swap.explanation" class="text-muted mt-xs">{{ swap.explanation }}</p>
</div>
</div>
</details>
<!-- Primary action: open detail panel -->
<div class="card-actions"> <div class="card-actions">
<button class="btn btn-primary btn-make" @click="openRecipe(recipe)"> <button class="btn btn-primary btn-sm btn-make" @click="openRecipe(recipe)">
Make this Make this
</button> </button>
</div> </div>
@ -874,13 +779,6 @@ const dietaryOpen = ref(false)
const advancedOpen = ref(false) const advancedOpen = ref(false)
// Per-recipe swap section open tracking (Set of recipe IDs whose swap <details> are open) // Per-recipe swap section open tracking (Set of recipe IDs whose swap <details> are open)
// Uses reassignment instead of .add()/.delete() so Vue's ref() detects the change
const openSwapIds = ref<Set<number>>(new Set())
function toggleSwapOpen(recipeId: number, isOpen: boolean) {
const next = new Set(openSwapIds.value)
isOpen ? next.add(recipeId) : next.delete(recipeId)
openSwapIds.value = next
}
// Human-readable level labels for filter chips (avoids "Lv1" etc. for screen readers) // Human-readable level labels for filter chips (avoids "Lv1" etc. for screen readers)
const levelLabels: Record<number, string> = { const levelLabels: Record<number, string> = {
@ -1102,13 +1000,6 @@ const secondaryPantryItems = computed<Record<string, string>>(() => {
return result return result
}) })
// Grocery links relevant to a specific recipe's missing ingredients
function groceryLinksForRecipe(recipe: RecipeSuggestion): GroceryLink[] {
if (!recipesStore.result) return []
return recipesStore.result.grocery_links.filter((link) =>
recipe.missing_ingredients.includes(link.ingredient)
)
}
// Tag input helpers constraints // Tag input helpers constraints
function addConstraint(value: string) { function addConstraint(value: string) {
@ -1764,7 +1655,7 @@ details[open] .collapsible-summary::before {
.card-actions { .card-actions {
border-top: 1px solid var(--color-border); border-top: 1px solid var(--color-border);
padding-top: var(--spacing-sm); padding-top: var(--spacing-sm);
margin-top: var(--spacing-sm); margin-top: auto;
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
} }
@ -1774,6 +1665,22 @@ details[open] .collapsible-summary::before {
padding: var(--spacing-xs) var(--spacing-md); padding: var(--spacing-xs) var(--spacing-md);
} }
/* Compact recipe card — summary only; detail in RecipeDetailPanel */
.recipe-card-compact {
display: flex;
flex-direction: column;
padding: var(--spacing-sm) var(--spacing-md);
}
.recipe-card-meta {
min-height: 1.25em; /* prevent layout shift when both fields absent */
}
.chip-overflow {
opacity: 0.75;
font-style: italic;
}
.spotlight-card { .spotlight-card {
border: 2px solid var(--color-primary); border: 2px solid var(--color-primary);
background: linear-gradient(135deg, var(--color-bg-elevated) 0%, rgba(232, 168, 32, 0.06) 100%); background: linear-gradient(135deg, var(--color-bg-elevated) 0%, rgba(232, 168, 32, 0.06) 100%);