diff --git a/app/services/recipe/element_classifier.py b/app/services/recipe/element_classifier.py index 850c001..76bfd76 100644 --- a/app/services/recipe/element_classifier.py +++ b/app/services/recipe/element_classifier.py @@ -93,7 +93,18 @@ class ElementClassifier: return self._heuristic_profile(name) 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]: """Return element names that have no coverage in the given profile list.""" diff --git a/frontend/src/components/RecipesView.vue b/frontend/src/components/RecipesView.vue index 119dfc6..79547f6 100644 --- a/frontend/src/components/RecipesView.vue +++ b/frontend/src/components/RecipesView.vue @@ -534,160 +534,65 @@

We didn't find matches at this level. Try or adjust your filters.

- +
- -
-

{{ recipe.title }}

-
- {{ recipe.match_count }} matched - {{ recipe.complexity }} - ~{{ recipe.estimated_time_min }}m - Level {{ recipe.level }} - Wildcard + +
+

{{ recipe.title }}

+
{{ savedStore.isSaved(recipe.id) ? '★' : '☆' }}
- -

{{ recipe.notes }}

- - -
-

From your pantry:

-
- {{ ing }} -
+ +
+ {{ recipe.match_count }} matched + {{ recipe.complexity }} + ~{{ recipe.estimated_time_min }}m + L{{ recipe.level }} + Wildcard
- -
- - Calories: {{ Math.round(recipe.nutrition.calories) }} kcal - - - Fat: {{ recipe.nutrition.fat_g.toFixed(1) }}g fat - - - Protein: {{ recipe.nutrition.protein_g.toFixed(1) }}g protein - - - Carbs: {{ recipe.nutrition.carbs_g.toFixed(1) }}g carbs - - - Fiber: {{ recipe.nutrition.fiber_g.toFixed(1) }}g fiber - - - Sugar: {{ recipe.nutrition.sugar_g.toFixed(1) }}g sugar - - - Sodium: {{ Math.round(recipe.nutrition.sodium_mg) }}mg sodium - - - Servings: {{ recipe.nutrition.servings }} serving{{ recipe.nutrition.servings !== 1 ? 's' : '' }} - - - ~ estimated - + +
+ {{ ing }} + +{{ recipe.matched_ingredients.length - 4 }} more
- -
-

To complete this recipe:

-
- {{ ing }} -
-
+ +

+ +{{ recipe.missing_ingredients.length }} needed + · + {{ Math.round(recipe.nutrition.calories) }} kcal +

- - - - -
-

Before you start:

-
    -
  • - {{ note }} -
  • -
-
- - -
-

Steps

-
    -
  1. - {{ step }} -
  2. -
-
- - -
- - Possible swaps ({{ recipe.swap_candidates.length }}) - -
-
- {{ swap.original_name }} - - {{ swap.substitute_name }} - {{ swap.constraint_label }} -

{{ swap.explanation }}

-
-
-
- - +
-
@@ -874,13 +779,6 @@ const dietaryOpen = ref(false) const advancedOpen = ref(false) // Per-recipe swap section open tracking (Set of recipe IDs whose swap
are open) -// Uses reassignment instead of .add()/.delete() so Vue's ref() detects the change -const openSwapIds = ref>(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) const levelLabels: Record = { @@ -1102,13 +1000,6 @@ const secondaryPantryItems = computed>(() => { 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 function addConstraint(value: string) { @@ -1764,7 +1655,7 @@ details[open] .collapsible-summary::before { .card-actions { border-top: 1px solid var(--color-border); padding-top: var(--spacing-sm); - margin-top: var(--spacing-sm); + margin-top: auto; display: flex; justify-content: flex-end; } @@ -1774,6 +1665,22 @@ details[open] .collapsible-summary::before { 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 { border: 2px solid var(--color-primary); background: linear-gradient(135deg, var(--color-bg-elevated) 0%, rgba(232, 168, 32, 0.06) 100%);