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
+15 -3
View File
@@ -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),
},
+133 -3
View File
@@ -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<HTMLInputElement>(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<Set<string>>(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<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) {
return <RecipesSkeleton />
}
@@ -140,6 +176,17 @@ export default function RecipesPage() {
</span>
)}
</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>
@@ -155,6 +202,89 @@ export default function RecipesPage() {
/>
</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 */}
{showFilters && (
<div role="region" aria-label="Filters">
+27 -7
View File
@@ -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 {