Public Access
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:
@@ -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),
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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"])
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -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),
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user