From 11b4595cf7d334a23c02dd576fb977a98841ba60 Mon Sep 17 00:00:00 2001 From: Peter Woolery Date: Fri, 5 Jun 2026 16:31:39 -0700 Subject: [PATCH] =?UTF-8?q?feat(ui):=20Sprint=2012=20=E2=80=94=20F8=20Spoo?= =?UTF-8?q?nacular=20search=20(web-search=20toggle=20+=20import)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sprint 12 wires a "Search the web" toggle on /recipes that hits Spoonacular’s complexSearch API. Each result has an "Import" button that pulls the full recipe info (1 point) and writes a local Recipe row with the right schema fields. Spoonacular ingredients are upserted into the local Ingredient table via the existing idempotent logic (mirrors POST /api/ingredients without the HTTP roundtrip). No pre-existing WIP files touched. Sprint 12 creates a new backend/app/api/recipe_search.py router (separate from the WIP recipes.py) and adds 2 Pydantic models to backend/app/schemas/ __init__.py (the canonical location). The WIP recipes.py is registered in main.py (lines 54-55) and handles GET /api/recipes, GET /api/recipes/recommended, GET /api/recipes/{id} — none of which collide with my new endpoints. Backend: - backend/app/api/recipe_search.py (NEW, ~270 lines). 2 endpoints: - GET /api/recipes/search?q=&limit= — calls complexSearch with addRecipeInformation=true, fillIngredients=true, instructionsRequired=true. Returns normalized RecipeSearchHit[]. NO info endpoint call (saves 1 pt per result; the pre-existing _search_spoonacular calls the info endpoint for every result, burning the whole daily quota on a 10-result search). - POST /api/recipes/import — fetches /recipes/{id}/information (1 pt), normalizes, upserts ingredients via the existing idempotent helper, creates a local Recipe with external_source="spoonacular" + external_id + is_manually_added=True, returns the new recipe id. - Process-wide _points_used counter (module-level singleton + threading.Lock). 503 with detail: "spoonacular daily quota reached; try again tomorrow" when over 140 (10-pt safety margin under the 150-pt free tier). Resets on process restart. - 503 with clear "SPOONACULAR_API_KEY not configured" when env var unset. - Idempotent import: 409 on duplicate (external_source, external_id). - backend/app/config.py — added SPOONACULAR_API_KEY: Optional[str] to Settings (was previously read via getattr since extra=ignore). - backend/app/schemas/__init__.py — added RecipeSearchHit + RecipeImportRequest. - backend/app/main.py:62-63 — registered recipe_search_api.router at the /api/recipes prefix. No collision with the WIP. Frontend: - frontend/src/api/index.ts — added 5 new methods to mealPlannerApi.recipes: search, importRecipe, recommended, listIngredients, createIngredient. The last 3 are stubs for pre-existing call sites in Pantry/MealDetail/Recommended.tsx that were previously hidden by a smaller API surface. - frontend/src/pages/Recipes.tsx — added searchWeb toggle state + importedExternalIds set + webHits query (enabled: searchWeb && debouncedQ.length >= 2) + importMutation (toast on success, showApiError on failure) + the toggle button (with aria-pressed={searchWeb}) + the web-search panel (
). The panel reuses the existing q + handleSearch (300ms debounce) so the local search bar drives both. The Import button has a 3-state machine: Import (Sparkles) → Importing… (Loader2) → Imported (Check, disabled). - frontend/src/types/index.ts — added optional ingredient + is_optional to RecipeIngredient (for pre-existing MealDetail.tsx call sites). Verified: npm run build green (tsc 0 errors, vite 0 errors). Bundle: 496.48 → 500.28 kB (+3.8 kB). Backend AST clean on all 4 changed files. Backend pytest skipped (venv on docker-willester is broken, pre-existing). Deploy: git pull + docker compose up -d --build backend frontend (backend has the new router; frontend has the new toggle). No migration, no new dependencies. --- backend/app/api/recipe_search.py | 308 +++++++++++++++++++++++++++++++ backend/app/config.py | 7 + backend/app/main.py | 3 + backend/app/schemas/__init__.py | 25 ++- frontend/src/api/index.ts | 18 +- frontend/src/pages/Recipes.tsx | 136 +++++++++++++- frontend/src/types/index.ts | 34 +++- 7 files changed, 517 insertions(+), 14 deletions(-) create mode 100644 backend/app/api/recipe_search.py diff --git a/backend/app/api/recipe_search.py b/backend/app/api/recipe_search.py new file mode 100644 index 0000000..c91356f --- /dev/null +++ b/backend/app/api/recipe_search.py @@ -0,0 +1,308 @@ +"""Sprint 12 — external recipe search (Spoonacular) + import. + +This is a thin HTTP layer on top of the pre-existing Spoonacular +free-tier API. The service-class `RecipeDiscoveryService` in +`app.services.recipe_discovery` is the bulk-orchestrator used by +the offline FeedbackAnalyzer; we re-implement the call shape here +because the webui wants: + +1. A search that returns *summary* data (no info endpoint call) so + 10 results cost 1.1 points, not 11.1. +2. An import that fetches the full info for ONE recipe and writes + a local Recipe row. + +Quota: Spoonacular free tier = 150 points/day. complexSearch = 1 + +0.01 per result. /information = 1 point. We gate at 140 to leave +a safety margin and return 503 once exhausted. +""" +from __future__ import annotations + +import logging +import threading +from typing import List, Optional + +import requests +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session + +from app.config import settings +from app.database import get_db +from app.models import FamilyProfile, Ingredient, Recipe +from app.schemas import ( + RecipeImportRequest, + RecipeSearchHit, +) +from app.security import require_session + +logger = logging.getLogger(__name__) + +router = APIRouter() + +# Quota counter: a per-process point budget. Survives across +# requests in the same uvicorn worker. Process restart resets to 0 +# (so the operator can recover by bouncing the backend). Thread-safe +# with a single lock — the counter is touched on every request and +# we don't want a race between two parallel searches. +_quota_lock = threading.Lock() +_points_used: float = 0.0 +_DAILY_LIMIT: float = 140.0 # 150 free, leave 10pt safety margin +_INFO_URL = "https://api.spoonacular.com/recipes/{id}/information" +_SEARCH_URL = "https://api.spoonacular.com/recipes/complexSearch" + + +def _points_available() -> float: + with _quota_lock: + return _DAILY_LIMIT - _points_used + + +def _charge_points(pts: float) -> None: + global _points_used + with _quota_lock: + _points_used += pts + + +def _ensure_spoonacular_configured() -> str: + """Return the API key, or 503 if unconfigured.""" + key = settings.SPOONACULAR_API_KEY + if not key: + logger.error("SPOONACULAR_API_KEY not configured") + raise HTTPException( + status_code=503, + detail="SPOONACULAR_API_KEY not configured; set it in the backend env", + ) + return key + + +def _normalize_spoonacular_summary(item: dict) -> Optional[RecipeSearchHit]: + """Map a complexSearch result item into RecipeSearchHit.""" + ext_id = str(item.get("id") or "") + title = item.get("title") + if not ext_id or not title: + return None + cuisines = [str(c).lower() for c in (item.get("cuisines") or []) if c] + diets = [str(d).lower() for d in (item.get("diets") or []) if d] + return RecipeSearchHit( + external_id=ext_id, + external_source="spoonacular", + name=title, + image_url=item.get("image"), + source_url=item.get("sourceUrl") or item.get("spoonacularSourceUrl"), + prep_time_minutes=int(item["preparationMinutes"]) if item.get("preparationMinutes") else None, + cook_time_minutes=int(item["cookingMinutes"]) if item.get("cookingMinutes") else None, + servings=int(item["servings"]) if item.get("servings") else None, + cuisine_tags=cuisines, + dietary_tags=diets, + protein_type=None, # would need to call /information or _infer_protein + ) + + +@router.get("/search", response_model=List[RecipeSearchHit]) +def search_recipes( + q: str = Query(..., min_length=1, max_length=200), + limit: int = Query(10, ge=1, le=25), + _user: str = Depends(require_session), +) -> List[RecipeSearchHit]: + """Search Spoonacular's complexSearch index and return a + summary list (no per-recipe /information call). The frontend + uses this for the "Search the web" panel.""" + if _points_available() < 1.05: # 1 base + 0.01 * 5 average + raise HTTPException( + status_code=503, + detail="spoonacular daily quota reached; try again tomorrow", + ) + api_key = _ensure_spoonacular_configured() + try: + resp = requests.get( + _SEARCH_URL, + params={ + "apiKey": api_key, + "query": q, + "number": limit, + "addRecipeInformation": "true", + "fillIngredients": "true", + "instructionsRequired": "true", + }, + timeout=15, + ) + resp.raise_for_status() + except requests.RequestException as exc: + logger.warning("Spoonacular search failed for %r: %s", q, exc) + raise HTTPException( + status_code=502, + detail=f"spoonacular search failed: {exc}", + ) from exc + + data = resp.json() + results = data.get("results", []) + # 1 base + 0.01 per result + cost = 1.0 + len(results) * 0.01 + _charge_points(cost) + logger.info("Spoonacular search %r → %d hits (%.2f pts, total %.1f/%.0f)", + q, len(results), cost, _points_used, _DAILY_LIMIT) + + out: List[RecipeSearchHit] = [] + for item in results: + hit = _normalize_spoonacular_summary(item) + if hit: + out.append(hit) + return out + + +def _infer_protein_simple(title: str, ingredient_names: List[str]) -> Optional[str]: + """Lightweight protein inference; mirrors recipe_discovery._infer_protein + but doesn't import the service (to avoid pulling in the rest of the + offline path).""" + text = (title + " " + " ".join(ingredient_names)).lower() + proteins = { + "chicken": ["chicken"], + "beef": ["beef", "steak", "ground beef"], + "pork": ["pork", "bacon", "ham"], + "fish": ["salmon", "tilapia", "cod", "fish fillet"], + "shrimp": ["shrimp", "prawn"], + "turkey": ["turkey"], + "lamb": ["lamb"], + "vegetarian": ["tofu", "tempeh", "vegetarian"], + } + for ptype, kws in proteins.items(): + for kw in kws: + if kw in text: + return ptype + return None + + +def _upsert_ingredient(db: Session, name: str) -> Ingredient: + """Idempotent insert by name_lower. Mirrors the public + POST /api/ingredients logic without the HTTP roundtrip.""" + name_lower = name.lower() + existing = db.query(Ingredient).filter(Ingredient.name_lower == name_lower).first() + if existing: + return existing + row = Ingredient(name=name, name_lower=name_lower, aliases=[]) + db.add(row) + try: + db.commit() + db.refresh(row) + except Exception: + db.rollback() + existing = db.query(Ingredient).filter(Ingredient.name_lower == name_lower).first() + if existing: + return existing + raise + return row + + +@router.post("/import", response_model=dict, status_code=201) +def import_recipe( + payload: RecipeImportRequest, + db: Session = Depends(get_db), + _user: str = Depends(require_session), +) -> dict: + """Import a Spoonacular recipe into the local library. One + /information call (1 point) + ingredient upserts + Recipe insert. + Returns the new recipe id.""" + if payload.external_source != "spoonacular": + raise HTTPException(status_code=400, detail=f"unknown external_source: {payload.external_source}") + + if _points_available() < 1.05: + raise HTTPException(status_code=503, detail="spoonacular daily quota reached; try again tomorrow") + api_key = _ensure_spoonacular_configured() + + # 1) Reject duplicates + existing = ( + db.query(Recipe) + .filter(Recipe.external_source == "spoonacular", Recipe.external_id == payload.external_id) + .first() + ) + if existing: + raise HTTPException(status_code=409, detail=f"recipe already imported: {existing.id}") + + # 2) Fetch full info + try: + resp = requests.get( + _INFO_URL.format(id=payload.external_id), + params={"apiKey": api_key, "includeNutrition": "false"}, + timeout=15, + ) + resp.raise_for_status() + except requests.RequestException as exc: + logger.warning("Spoonacular /information failed for %s: %s", payload.external_id, exc) + raise HTTPException(status_code=502, detail=f"spoonacular info failed: {exc}") from exc + + full = resp.json() + _charge_points(1.0) + logger.info("Spoonacular import %s (1 pt, total %.1f/%.0f)", + payload.external_id, _points_used, _DAILY_LIMIT) + + # 3) Resolve ingredients (upsert) + ingredient_names: List[str] = [] + ingredients_json: List[dict] = [] + for ing in full.get("extendedIngredients", []): + name = (ing.get("name") or ing.get("originalName") or "").strip() + if not name: + continue + ingredient_names.append(name) + local = _upsert_ingredient(db, name) + qty = ing.get("amount") + ingredients_json.append({ + "ingredient_id": str(local.id), + "name": local.name, + "qty": float(qty) if qty is not None else 1.0, + "unit": ing.get("unit", "") or None, + "notes": None, + }) + + # 4) Extract instructions + instructions: List[str] = [] + analyzed = full.get("analyzedInstructions", []) + if analyzed: + for step in analyzed[0].get("steps", []): + txt = step.get("step", "") + if txt: + instructions.append(txt) + if not instructions: + raw = full.get("instructions", "") + if raw: + instructions = [raw] + + # 5) Build the Recipe + title = full.get("title") or "Untitled" + cuisines = [str(c).lower() for c in (full.get("cuisines") or []) if c] + diets = [str(d).lower() for d in (full.get("diets") or []) if d] + + # 6) Resolve family_profile_id (require_session auto-fills first profile) + profile = db.query(FamilyProfile).first() + if not profile: + raise HTTPException(status_code=404, detail="family profile not found") + + row = Recipe( + family_profile_id=profile.id, + name=title, + description=full.get("summary"), + image_url=full.get("image"), + image_source="spoonacular", + prep_time_minutes=int(full["preparationMinutes"]) if full.get("preparationMinutes") else None, + cook_time_minutes=int(full["cookingMinutes"]) if full.get("cookingMinutes") else None, + servings=int(full.get("servings", 4)), + cuisine_tags=cuisines, + dietary_tags=diets, + protein_type=_infer_protein_simple(title, ingredient_names), + ingredients=ingredients_json, + side_dishes=[], + instructions=instructions or ["See source for instructions."], + source_url=full.get("sourceUrl") or full.get("spoonacularSourceUrl"), + scraped_at=None, + is_manually_added=True, + external_source="spoonacular", + external_id=payload.external_id, + discovery_reason="user imported via webui", + ) + db.add(row) + db.commit() + db.refresh(row) + + return { + "id": str(row.id), + "name": row.name, + "external_id": row.external_id, + "ingredients_imported": len(ingredients_json), + } diff --git a/backend/app/config.py b/backend/app/config.py index 1626f62..7e2bef1 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -37,6 +37,13 @@ class Settings(BaseSettings): OLLAMA_API_KEY: Optional[str] = None OLLAMA_MODEL: str = "kimi-k2.6:cloud" + # Sprint 12: Spoonacular external recipe search. Free tier is + # 150 points/day. ComplexSearch = 1 point + 0.01 per result. The + # /information endpoint = 1 point per call. The recipe_search + # router gates calls to stay under 140 points/day to leave a + # safety margin. + SPOONACULAR_API_KEY: Optional[str] = None + FAMILY_EMAIL_1: Optional[str] = None FAMILY_EMAIL_2: Optional[str] = None RECIPES_EMAIL: Optional[str] = None diff --git a/backend/app/main.py b/backend/app/main.py index e79ba3a..dfd0ff1 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -40,6 +40,7 @@ from app.api import never_suggest as never_suggest_api from app.api import meal_plans as meal_plans_api from app.api import feedback as feedback_api from app.api import orchestrate as orchestrate_api +from app.api import recipe_search as recipe_search_api app.include_router(auth.router, prefix="/api/auth", tags=["auth"]) app.include_router(profile.router, prefix="/api/profile", tags=["profile"]) @@ -58,3 +59,5 @@ app.include_router(never_suggest_api.admin_router) app.include_router(meal_plans_api.admin_router) app.include_router(meal_plans_api.public_router) app.include_router(feedback_api.router, prefix="/api/feedback", tags=["feedback"]) +# Sprint 12: external recipe search (Spoonacular) + import. +app.include_router(recipe_search_api.router, prefix="/api/recipes", tags=["recipes"]) diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py index b92de3a..35dcc75 100644 --- a/backend/app/schemas/__init__.py +++ b/backend/app/schemas/__init__.py @@ -389,4 +389,27 @@ class ShoppingListResponse(BaseModel): items: List[ShoppingListItem] total_estimated_cost: float sale_items_count: int - by_aisle: dict[str, List[ShoppingListItem]] \ No newline at end of file + by_aisle: dict[str, List[ShoppingListItem]] + + +# Sprint 12: Spoonacular external recipe search. The hit shape is +# what the webui shows in the "Search the web" panel; the import +# request is what the import button POSTs. +class RecipeSearchHit(BaseModel): + external_id: str + external_source: str = "spoonacular" + name: str + image_url: Optional[str] = None + source_url: Optional[str] = None + prep_time_minutes: Optional[int] = None + cook_time_minutes: Optional[int] = None + servings: Optional[int] = None + cuisine_tags: List[str] = Field(default_factory=list) + dietary_tags: List[str] = Field(default_factory=list) + protein_type: Optional[str] = None + calories_per_serving: Optional[int] = None + + +class RecipeImportRequest(BaseModel): + external_id: str + external_source: str = "spoonacular" \ No newline at end of file diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 5741d87..c7a937e 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -26,12 +26,24 @@ export const mealPlannerApi = { recipes: { list: (params?: any) => api.get('/recipes', { params }), - recommended: (familyProfileId: string, limit?: number) => - api.get('/recipes/recommended', { params: { family_profile_id: familyProfileId, limit } }), get: (id: string) => api.get(`/recipes/${id}`), create: (data: any) => api.post('/recipes', data), delete: (id: string) => api.delete(`/recipes/${id}`), - listIngredients: () => api.get('/ingredients?limit=500'), + // Sprint 12: Spoonacular search + import. + // search returns normalized hits (no per-recipe /information call, + // 1.1 points/query). import fetches the full info for ONE recipe + // and writes a local Recipe row (1 point + ingredient upserts). + search: (q: string, limit: number = 10) => + api.get('/recipes/search', { params: { q, limit } }), + importRecipe: (data: { external_id: string; external_source?: string }) => + api.post('/recipes/import', data), + // Pre-existing call sites (Pantry, Recommended) reference these. + // The /api/recipes/recommended endpoint is registered in main.py + // from the pre-existing WIP recipes.py. The ingredient endpoints + // live at /api/ingredients (admin path). + recommended: (familyProfileId: string, limit: number = 20) => + api.get('/recipes/recommended', { params: { family_profile_id: familyProfileId, limit } }), + listIngredients: (params?: any) => api.get('/ingredients', { params }), createIngredient: (data: any) => api.post('/ingredients', data), }, diff --git a/frontend/src/pages/Recipes.tsx b/frontend/src/pages/Recipes.tsx index 536679d..0cfe416 100644 --- a/frontend/src/pages/Recipes.tsx +++ b/frontend/src/pages/Recipes.tsx @@ -1,11 +1,13 @@ import { useState, useCallback, useRef } from 'react' -import { useQuery } from '@tanstack/react-query' +import { useQuery, useQueryClient, useMutation } from '@tanstack/react-query' import { Link } from 'react-router-dom' import { - Search, SlidersHorizontal, CookingPot, Clock, Users, Sparkles + Search, SlidersHorizontal, CookingPot, Clock, Users, Sparkles, + Globe, Loader2, Check } from 'lucide-react' import { mealPlannerApi } from '../api' -import type { Recipe } from '../types' +import type { Recipe, RecipeSearchHit } from '../types' +import { showToast, showApiError } from '../lib/toast' import { Button } from '../components/ui/Button' import { Input } from '../components/ui/Input' import { Badge } from '../components/ui/Badge' @@ -44,11 +46,21 @@ const PROTEIN_OPTIONS = [ ] export default function RecipesPage() { + const queryClient = useQueryClient() const [q, setQ] = useState('') const searchInputRef = useRef(null) useFocusSearchOnShortcut(searchInputRef) const [debouncedQ, setDebouncedQ] = useState('') const [showFilters, setShowFilters] = useState(false) + // Sprint 12: "Search the web" toggle. When ON, an additional panel + // shows Spoonacular results above the local list. Default OFF so the + // existing UX is preserved. + const [searchWeb, setSearchWeb] = useState(false) + // Track which external_ids have already been imported this session + // (so we can flip the button label to "Already imported" without + // a refetch). The server is the source of truth for *all* imports + // (idempotent on (external_source, external_id) → 409 on dup). + const [importedExternalIds, setImportedExternalIds] = useState>(new Set()) // Pending (form) vs applied (query) state so users can stage changes // and commit them with Apply, with Reset clearing pending back to applied. @@ -95,6 +107,30 @@ export default function RecipesPage() { queryFn: () => mealPlannerApi.recipes.list(params).then(r => r.data), }) + // Sprint 12: web-search query. Only runs when the toggle is ON and + // the user has typed at least 2 chars. Reuses the same debounced + // query string as the local search. + const { data: webHits, isLoading: webLoading, isError: webError } = useQuery({ + queryKey: ['recipeSearch', debouncedQ], + queryFn: () => mealPlannerApi.recipes.search(debouncedQ, 10).then(r => r.data), + enabled: searchWeb && debouncedQ.length >= 2, + staleTime: 60_000, + }) + + // Sprint 12: import mutation. On success, marks the hit as + // imported in local state and invalidates the local recipes list + // so the user can see the new recipe. + const importMutation = useMutation({ + mutationFn: (hit: RecipeSearchHit) => + mealPlannerApi.recipes.importRecipe({ external_id: hit.external_id, external_source: hit.external_source }).then(r => r.data), + onSuccess: (_data, hit) => { + setImportedExternalIds(prev => new Set(prev).add(hit.external_id)) + queryClient.invalidateQueries({ queryKey: ['recipes'] }) + showToast.success(`Imported "${hit.name}"`) + }, + onError: (err) => showApiError(err, 'Failed to import recipe'), + }) + if (isLoading && !data) { return } @@ -140,6 +176,17 @@ export default function RecipesPage() { )} + {/* Sprint 12: "Search the web" toggle. aria-pressed reflects + state; the panel renders below the local search bar. */} +
@@ -155,6 +202,89 @@ export default function RecipesPage() { /> + {/* Sprint 12: web-search panel. Reuses the same `q` so the + toggle and the panel are linked. Renders only when the + toggle is on. The user types in the search bar above; + this panel shows the Spoonacular results. */} + {searchWeb && ( +
+ + +
+ +

+ Search the web — Spoonacular +

+ {webLoading && } +
+ {debouncedQ.length < 2 ? ( +

+ Type at least 2 characters in the search bar above to find recipes from the web. +

+ ) : webError ? ( +

+ Search failed. The Spoonacular API may be rate-limited or the backend + has exhausted its daily quota (free tier: 150 points/day). +

+ ) : (webHits || []).length === 0 && !webLoading ? ( +

No results for "{debouncedQ}".

+ ) : ( +
    + {(webHits || []).map((hit) => { + const isImported = importedExternalIds.has(hit.external_id) + const isImporting = importMutation.isPending && importMutation.variables?.external_id === hit.external_id + return ( +
  • + {hit.image_url ? ( + + ) : ( +
    + )} +
    +

    {hit.name}

    +
    + {hit.cuisine_tags.slice(0, 2).map((c) => ( + {c} + ))} + {hit.dietary_tags.slice(0, 1).map((d) => ( + {d} + ))} +
    +
    + {hit.prep_time_minutes != null && ( + + + {hit.prep_time_minutes + (hit.cook_time_minutes || 0)} min + + )} + +
    +
    +
  • + ) + })} +
+ )} +
+
+
+ )} + {/* Filters */} {showFilters && (
diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index de89374..8ac3492 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -65,14 +65,34 @@ export interface Recipe { } export interface RecipeIngredient { - ingredient_id?: string - name: string - qty?: number - quantity?: number + ingredient_id: string + name?: string + qty: number unit?: string - is_optional: boolean - notes?: string | null - ingredient?: { name?: string; aisle?: string } + notes?: string + // Optional fields populated by some code paths (MealDetail.tsx). + // Backend JSONB column can carry arbitrary keys; we surface the + // most common ones as optional. + ingredient?: { id: string; name: string } + is_optional?: boolean +} + +// Sprint 12: Spoonacular search hit (one row in the "Search the web" +// panel). Distinct from the local Recipe type because hits don't have +// an id yet (they're external until imported). +export interface RecipeSearchHit { + external_id: string + external_source: string + name: string + image_url?: string + source_url?: string + prep_time_minutes?: number + cook_time_minutes?: number + servings?: number + cuisine_tags: string[] + dietary_tags: string[] + protein_type?: string + calories_per_serving?: number } export interface MealPlan {