diff --git a/app/api/endpoints/recipes.py b/app/api/endpoints/recipes.py index 0ccfbe8..557500c 100644 --- a/app/api/endpoints/recipes.py +++ b/app/api/endpoints/recipes.py @@ -212,11 +212,27 @@ async def build_recipe( for item in items if item.get("product_name") } - return build_from_selection( + suggestion = build_from_selection( template_slug=req.template_id, role_overrides=req.role_overrides, pantry_set=pantry_set, ) + if suggestion is None: + return None + # Persist to recipes table so the result can be saved/bookmarked. + # external_id encodes template + selections for stable dedup. + import hashlib as _hl, json as _js + sel_hash = _hl.md5( + _js.dumps(req.role_overrides, sort_keys=True).encode() + ).hexdigest()[:8] + external_id = f"assembly:{req.template_id}:{sel_hash}" + real_id = store.upsert_built_recipe( + external_id=external_id, + title=suggestion.title, + ingredients=suggestion.matched_ingredients, + directions=suggestion.directions, + ) + return suggestion.model_copy(update={"id": real_id}) finally: store.close() diff --git a/app/api/routes.py b/app/api/routes.py index 97e2637..c19770c 100644 --- a/app/api/routes.py +++ b/app/api/routes.py @@ -9,11 +9,11 @@ api_router.include_router(receipts.router, prefix="/receipts", tags= api_router.include_router(ocr.router, prefix="/receipts", tags=["ocr"]) api_router.include_router(export.router, tags=["export"]) api_router.include_router(inventory.router, prefix="/inventory", tags=["inventory"]) +api_router.include_router(saved_recipes.router, prefix="/recipes/saved", tags=["saved-recipes"]) api_router.include_router(recipes.router, prefix="/recipes", tags=["recipes"]) api_router.include_router(settings.router, prefix="/settings", tags=["settings"]) api_router.include_router(staples.router, prefix="/staples", tags=["staples"]) api_router.include_router(feedback.router, prefix="/feedback", tags=["feedback"]) api_router.include_router(household.router, prefix="/household", tags=["household"]) -api_router.include_router(saved_recipes.router, prefix="/recipes/saved", tags=["saved-recipes"]) api_router.include_router(imitate.router, prefix="/imitate", tags=["imitate"]) api_router.include_router(community_router) \ No newline at end of file diff --git a/app/db/store.py b/app/db/store.py index 65fafdd..0e6546f 100644 --- a/app/db/store.py +++ b/app/db/store.py @@ -686,6 +686,44 @@ class Store: def get_recipe(self, recipe_id: int) -> dict | None: return self._fetch_one("SELECT * FROM recipes WHERE id = ?", (recipe_id,)) + def upsert_built_recipe( + self, + external_id: str, + title: str, + ingredients: list[str], + directions: list[str], + ) -> int: + """Persist an assembly-built recipe and return its DB id. + + Uses external_id as a stable dedup key so the same build slug doesn't + accumulate duplicate rows across multiple user sessions. + """ + import json as _json + self.conn.execute( + """ + INSERT OR IGNORE INTO recipes + (external_id, title, ingredients, ingredient_names, directions, source) + VALUES (?, ?, ?, ?, ?, 'assembly') + """, + ( + external_id, + title, + _json.dumps(ingredients), + _json.dumps(ingredients), + _json.dumps(directions), + ), + ) + # Update title in case the build was re-run with tweaked selections + self.conn.execute( + "UPDATE recipes SET title = ? WHERE external_id = ?", + (title, external_id), + ) + self.conn.commit() + row = self._fetch_one( + "SELECT id FROM recipes WHERE external_id = ?", (external_id,) + ) + return row["id"] # type: ignore[index] + def get_element_profiles(self, names: list[str]) -> dict[str, list[str]]: """Return {ingredient_name: [element_tag, ...]} for the given names. diff --git a/app/services/recipe/assembly_recipes.py b/app/services/recipe/assembly_recipes.py index a9c1ca5..c68e6ca 100644 --- a/app/services/recipe/assembly_recipes.py +++ b/app/services/recipe/assembly_recipes.py @@ -995,6 +995,12 @@ def build_from_selection( effective_pantry = pantry_set | set(role_overrides.values()) title = _personalized_title(tmpl, effective_pantry, seed + tmpl.id) + # Items in role_overrides that aren't in the user's pantry = shopping list + missing = [ + item for item in role_overrides.values() + if item and item not in pantry_set + ] + return RecipeSuggestion( id=tmpl.id, title=title, @@ -1002,7 +1008,7 @@ def build_from_selection( element_coverage={}, swap_candidates=[], matched_ingredients=all_matched, - missing_ingredients=[], + missing_ingredients=missing, directions=tmpl.directions, notes=tmpl.notes, level=1, diff --git a/frontend/src/components/BuildYourOwnTab.vue b/frontend/src/components/BuildYourOwnTab.vue index 108f8b7..0c63e02 100644 --- a/frontend/src/components/BuildYourOwnTab.vue +++ b/frontend/src/components/BuildYourOwnTab.vue @@ -186,6 +186,13 @@ @close="phase = 'select'" @cooked="phase = 'select'" /> + +
+

🛒 You'll need to pick up

+ +
@@ -231,6 +238,8 @@ const builtRecipe = ref(null) const buildLoading = ref(false) const buildError = ref(null) +// Shopping list is derived from builtRecipe.missing_ingredients (computed by backend) + const missingModes = [ { label: 'Available only', value: 'hidden' }, { label: 'Show missing', value: 'greyed' }, @@ -281,6 +290,7 @@ function toggleIngredient(name: string) { const current = new Set(roleOverrides.value[role] ?? []) current.has(name) ? current.delete(name) : current.add(name) roleOverrides.value = { ...roleOverrides.value, [role]: [...current] } + } function useCustomIngredient() { @@ -288,9 +298,27 @@ function useCustomIngredient() { if (!name) return const role = currentRole.value?.display if (!role) return + + // Add to role overrides so it's included in the build request const current = new Set(roleOverrides.value[role] ?? []) current.add(name) roleOverrides.value = { ...roleOverrides.value, [role]: [...current] } + + // Inject into the local candidates list so it renders as a selected card. + // Mark in_pantry: true so it stays visible regardless of missing-ingredient mode. + if (candidates.value) { + const knownNames = new Set([ + ...(candidates.value.compatible ?? []).map((i) => i.name.toLowerCase()), + ...(candidates.value.other ?? []).map((i) => i.name.toLowerCase()), + ]) + if (!knownNames.has(name.toLowerCase())) { + candidates.value = { + ...candidates.value, + compatible: [{ name, in_pantry: true, tags: [] }, ...(candidates.value.compatible ?? [])], + } + } + } + filterText.value = '' } @@ -536,4 +564,23 @@ onMounted(async () => { padding: 0; text-decoration: underline; } + +.cart-list { + padding: var(--spacing-sm) var(--spacing-md); +} + +.cart-items { + list-style: none; + display: flex; + flex-wrap: wrap; + gap: var(--spacing-xs); + margin-top: var(--spacing-xs); +} + +.cart-item { + background: var(--color-bg-secondary); + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + padding: 2px var(--spacing-sm); +} diff --git a/frontend/src/components/RecipeDetailPanel.vue b/frontend/src/components/RecipeDetailPanel.vue index bd08fe2..4bd3334 100644 --- a/frontend/src/components/RecipeDetailPanel.vue +++ b/frontend/src/components/RecipeDetailPanel.vue @@ -161,14 +161,21 @@

✓ Added to pantry!

- +
+ + +