feat(ui): Sprint 12 — F8 Spoonacular search (web-search toggle + import)

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 (<div
  role="region" aria-label="Web recipe search"
  aria-busy={webLoading}>). 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.
This commit is contained in:
2026-06-05 16:31:39 -07:00
parent dac1364c29
commit 11b4595cf7
7 changed files with 517 additions and 14 deletions
+308
View File
@@ -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),
}
+7
View File
@@ -37,6 +37,13 @@ class Settings(BaseSettings):
OLLAMA_API_KEY: Optional[str] = None OLLAMA_API_KEY: Optional[str] = None
OLLAMA_MODEL: str = "kimi-k2.6:cloud" 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_1: Optional[str] = None
FAMILY_EMAIL_2: Optional[str] = None FAMILY_EMAIL_2: Optional[str] = None
RECIPES_EMAIL: Optional[str] = None RECIPES_EMAIL: Optional[str] = None
+3
View File
@@ -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 meal_plans as meal_plans_api
from app.api import feedback as feedback_api from app.api import feedback as feedback_api
from app.api import orchestrate as orchestrate_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(auth.router, prefix="/api/auth", tags=["auth"])
app.include_router(profile.router, prefix="/api/profile", tags=["profile"]) 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.admin_router)
app.include_router(meal_plans_api.public_router) app.include_router(meal_plans_api.public_router)
app.include_router(feedback_api.router, prefix="/api/feedback", tags=["feedback"]) 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"])
+23
View File
@@ -390,3 +390,26 @@ class ShoppingListResponse(BaseModel):
total_estimated_cost: float total_estimated_cost: float
sale_items_count: int sale_items_count: int
by_aisle: dict[str, List[ShoppingListItem]] 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"
+15 -3
View File
@@ -26,12 +26,24 @@ export const mealPlannerApi = {
recipes: { recipes: {
list: (params?: any) => api.get('/recipes', { params }), 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}`), get: (id: string) => api.get(`/recipes/${id}`),
create: (data: any) => api.post('/recipes', data), create: (data: any) => api.post('/recipes', data),
delete: (id: string) => api.delete(`/recipes/${id}`), 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), createIngredient: (data: any) => api.post('/ingredients', data),
}, },
+133 -3
View File
@@ -1,11 +1,13 @@
import { useState, useCallback, useRef } from 'react' 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 { Link } from 'react-router-dom'
import { import {
Search, SlidersHorizontal, CookingPot, Clock, Users, Sparkles Search, SlidersHorizontal, CookingPot, Clock, Users, Sparkles,
Globe, Loader2, Check
} from 'lucide-react' } from 'lucide-react'
import { mealPlannerApi } from '../api' 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 { Button } from '../components/ui/Button'
import { Input } from '../components/ui/Input' import { Input } from '../components/ui/Input'
import { Badge } from '../components/ui/Badge' import { Badge } from '../components/ui/Badge'
@@ -44,11 +46,21 @@ const PROTEIN_OPTIONS = [
] ]
export default function RecipesPage() { export default function RecipesPage() {
const queryClient = useQueryClient()
const [q, setQ] = useState('') const [q, setQ] = useState('')
const searchInputRef = useRef<HTMLInputElement>(null) const searchInputRef = useRef<HTMLInputElement>(null)
useFocusSearchOnShortcut(searchInputRef) useFocusSearchOnShortcut(searchInputRef)
const [debouncedQ, setDebouncedQ] = useState('') const [debouncedQ, setDebouncedQ] = useState('')
const [showFilters, setShowFilters] = useState(false) 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<Set<string>>(new Set())
// Pending (form) vs applied (query) state so users can stage changes // Pending (form) vs applied (query) state so users can stage changes
// and commit them with Apply, with Reset clearing pending back to applied. // 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), 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<RecipeSearchHit[]>({
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) { if (isLoading && !data) {
return <RecipesSkeleton /> return <RecipesSkeleton />
} }
@@ -140,6 +176,17 @@ export default function RecipesPage() {
</span> </span>
)} )}
</Button> </Button>
{/* Sprint 12: "Search the web" toggle. aria-pressed reflects
state; the panel renders below the local search bar. */}
<Button
variant={searchWeb ? 'primary' : 'secondary'}
size="sm"
icon={<Globe className="w-4 h-4" />}
onClick={() => setSearchWeb(!searchWeb)}
aria-pressed={searchWeb}
>
Search the web
</Button>
</div> </div>
</div> </div>
@@ -155,6 +202,89 @@ export default function RecipesPage() {
/> />
</div> </div>
{/* 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 && (
<div role="region" aria-label="Web recipe search" aria-busy={webLoading}>
<Card className="border-2 border-primary-200 bg-primary-50/30">
<CardBody>
<div className="flex items-center gap-2 mb-3">
<Globe className="w-4 h-4 text-primary-600" />
<h2 className="text-sm font-semibold text-surface-900">
Search the web Spoonacular
</h2>
{webLoading && <Loader2 className="w-4 h-4 animate-spin text-primary-600" />}
</div>
{debouncedQ.length < 2 ? (
<p className="text-sm text-surface-500">
Type at least 2 characters in the search bar above to find recipes from the web.
</p>
) : webError ? (
<p className="text-sm text-red-600">
Search failed. The Spoonacular API may be rate-limited or the backend
has exhausted its daily quota (free tier: 150 points/day).
</p>
) : (webHits || []).length === 0 && !webLoading ? (
<p className="text-sm text-surface-500">No results for "{debouncedQ}".</p>
) : (
<ul className="grid grid-cols-1 md:grid-cols-2 gap-3">
{(webHits || []).map((hit) => {
const isImported = importedExternalIds.has(hit.external_id)
const isImporting = importMutation.isPending && importMutation.variables?.external_id === hit.external_id
return (
<li
key={hit.external_id}
className="flex gap-3 p-3 rounded-lg border border-surface-200 bg-white"
>
{hit.image_url ? (
<img
src={hit.image_url}
alt=""
className="w-16 h-16 rounded-md object-cover flex-shrink-0"
/>
) : (
<div className="w-16 h-16 rounded-md bg-surface-100 flex-shrink-0" />
)}
<div className="flex-1 min-w-0">
<h3 className="text-sm font-semibold text-surface-900 line-clamp-2">{hit.name}</h3>
<div className="flex flex-wrap gap-1 mt-1">
{hit.cuisine_tags.slice(0, 2).map((c) => (
<Badge key={c} variant="neutral" className="text-[10px] px-1.5 py-0">{c}</Badge>
))}
{hit.dietary_tags.slice(0, 1).map((d) => (
<Badge key={d} variant="success" className="text-[10px] px-1.5 py-0">{d}</Badge>
))}
</div>
<div className="mt-2 flex items-center gap-2">
{hit.prep_time_minutes != null && (
<span className="text-xs text-surface-500 inline-flex items-center gap-1">
<Clock className="w-3 h-3" />
{hit.prep_time_minutes + (hit.cook_time_minutes || 0)} min
</span>
)}
<Button
size="sm"
variant={isImported ? 'secondary' : 'primary'}
disabled={isImported || isImporting}
onClick={() => importMutation.mutate(hit)}
icon={isImported ? <Check className="w-3 h-3" /> : isImporting ? <Loader2 className="w-3 h-3 animate-spin" /> : <Sparkles className="w-3 h-3" />}
>
{isImported ? 'Imported' : isImporting ? 'Importing…' : 'Import'}
</Button>
</div>
</div>
</li>
)
})}
</ul>
)}
</CardBody>
</Card>
</div>
)}
{/* Filters */} {/* Filters */}
{showFilters && ( {showFilters && (
<div role="region" aria-label="Filters"> <div role="region" aria-label="Filters">
+27 -7
View File
@@ -65,14 +65,34 @@ export interface Recipe {
} }
export interface RecipeIngredient { export interface RecipeIngredient {
ingredient_id?: string ingredient_id: string
name: string name?: string
qty?: number qty: number
quantity?: number
unit?: string unit?: string
is_optional: boolean notes?: string
notes?: string | null // Optional fields populated by some code paths (MealDetail.tsx).
ingredient?: { name?: string; aisle?: string } // 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 { export interface MealPlan {