feat(meals): enforce three lower-calorie dinners with media
CI / backend (pytest + alembic) (push) Canceled after 0s
CI / frontend (build) (push) Canceled after 0s

This commit is contained in:
2026-09-01 10:11:36 -07:00
parent 7838c49721
commit 2695ba0232
85 changed files with 8103 additions and 795 deletions
+16 -11
View File
@@ -9,6 +9,15 @@ export function getIngressBasePath() {
}
export const APP_BASE_PATH = getIngressBasePath()
export function resolveAssetUrl(url: string) {
const basePath = getIngressBasePath()
if (!url.startsWith('/') || !basePath || url.startsWith(`${basePath}/`)) {
return url
}
return `${basePath}${url}`
}
const API_BASE = import.meta.env.VITE_API_URL || `${APP_BASE_PATH}/api`
const api = axios.create({
@@ -38,14 +47,6 @@ export const mealPlannerApi = {
get: (id: string) => api.get(`/recipes/${id}`),
create: (data: any) => api.post('/recipes', data),
delete: (id: string) => api.delete(`/recipes/${id}`),
// 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
@@ -85,6 +86,11 @@ export const mealPlannerApi = {
api.post(`/meals/${mealPlanId}/fill-empty-slots`, { meal_types: mealTypes }),
},
mealPlans: {
generate: (weekStartDate: string) =>
api.post('/meal-plans/generate', { week_start_date: weekStartDate }),
},
pantry: {
list: () => api.get('/pantry'),
add: (data: any) => api.post('/pantry', data),
@@ -130,9 +136,8 @@ export const mealPlannerApi = {
},
// Sprint 13: F9-lite — free-text meal-plan synthesis via Ollama
// Cloud. The prompt + week_start are sent to /api/llm/plan; the
// backend calls kimi-k2.6:cloud, parses the picks, creates the
// plan, fills the rest from the library, returns the plan id.
// Cloud. The canonical planner validates any LLM preferences and
// falls back to its own three-dinner selection when needed.
llm: {
plan: (data: { prompt: string; week_start: string }) =>
api.post('/llm/plan', data),
+7 -7
View File
@@ -30,8 +30,8 @@ const fakeResponse = (overrides: Partial<{
reasoning: string | null
}> = {}) => ({
plan_id: '8afe516e-f92a-42b9-ab53-a88fe3537f40',
picked_count: 7,
filled_count: 14,
picked_count: 3,
filled_count: 0,
failed_count: 0,
reasoning: 'Picked Italian vegetarian dishes from the library.',
...overrides,
@@ -61,7 +61,7 @@ describe('mealPlannerApi.llm.plan (Sprint 16 contract test)', () => {
it('Case 8b: response.plan_id is a valid UUID', async () => {
vi.spyOn(mealPlannerApi.llm, 'plan').mockResolvedValueOnce({
data: fakeResponse({ picked_count: 0, filled_count: 21, reasoning: null }),
data: fakeResponse({ picked_count: 0, filled_count: 3, reasoning: null }),
} as any)
const result = await mealPlannerApi.llm.plan({
@@ -72,9 +72,9 @@ describe('mealPlannerApi.llm.plan (Sprint 16 contract test)', () => {
expect(result.data.plan_id).toMatch(UUID_RE)
})
it('Case 8c: response counts are non-negative integers and sum to ≤ 21 (one week)', async () => {
it('Case 8c: response counts are non-negative integers and sum to ≤ 3 dinners', async () => {
vi.spyOn(mealPlannerApi.llm, 'plan').mockResolvedValueOnce({
data: fakeResponse({ picked_count: 10, filled_count: 11, failed_count: 0 }),
data: fakeResponse({ picked_count: 2, filled_count: 1, failed_count: 0 }),
} as any)
const result = await mealPlannerApi.llm.plan({
@@ -89,12 +89,12 @@ describe('mealPlannerApi.llm.plan (Sprint 16 contract test)', () => {
expect(picked_count).toBeGreaterThanOrEqual(0)
expect(filled_count).toBeGreaterThanOrEqual(0)
expect(failed_count).toBeGreaterThanOrEqual(0)
expect(picked_count + filled_count + failed_count).toBeLessThanOrEqual(21)
expect(picked_count + filled_count + failed_count).toBeLessThanOrEqual(3)
})
it('Case 8d: reasoning is string or null (handles both the success and library-fills-everything cases)', async () => {
vi.spyOn(mealPlannerApi.llm, 'plan').mockResolvedValueOnce({
data: fakeResponse({ picked_count: 21, filled_count: 0, reasoning: 'All picks from library.' }),
data: fakeResponse({ picked_count: 3, filled_count: 0, reasoning: 'All picks from library.' }),
} as any)
const result = await mealPlannerApi.llm.plan({
+101
View File
@@ -0,0 +1,101 @@
import { cleanup, render, screen } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { MemoryRouter } from 'react-router-dom'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mealPlannerApi } from '../api'
import type { MealPlan } from '../types'
import Dashboard from './Dashboard'
const INGRESS_PATH = '/api/hassio_ingress/test-ingress'
const mealPlan = {
id: 'plan-1',
family_profile_id: 'family-1',
week_start_date: '2026-08-31',
status: 'pending_approval',
items: [
{
id: 'item-1',
meal_plan_id: 'plan-1',
recipe_id: 'recipe-1',
day_of_week: 1,
meal_type: 'dinner',
approval_status: 'pending',
used_pantry_items: [],
recipe: {
id: 'recipe-1',
name: 'Sheet Pan Gnocchi',
servings: 4,
cuisine_tags: [],
dietary_tags: [],
ingredients: [],
instructions: [],
is_manually_added: false,
calories_per_serving: 430,
image_url: '/static/images/foo.png',
},
},
],
} satisfies MealPlan
vi.mock('@hello-pangea/dnd', () => ({
DragDropContext: ({ children }: { children: React.ReactNode }) => <>{children}</>,
Droppable: ({ children }: { children: (provided: unknown, snapshot: unknown) => React.ReactNode }) =>
children({ innerRef: vi.fn(), droppableProps: {}, placeholder: null }, { isDraggingOver: false }),
Draggable: ({ children }: { children: (provided: unknown, snapshot: unknown) => React.ReactNode }) =>
children(
{ innerRef: vi.fn(), draggableProps: { style: {} }, dragHandleProps: {} },
{ isDragging: false },
),
}))
const getPlanned = vi.spyOn(mealPlannerApi.meals, 'getPlanned')
beforeEach(() => {
const happyWindow = window as typeof window & { happyDOM: { setURL: (url: string) => void } }
happyWindow.happyDOM.setURL(`http://localhost${INGRESS_PATH}`)
getPlanned.mockResolvedValue({ data: mealPlan, headers: {} } as never)
})
afterEach(() => {
cleanup()
getPlanned.mockClear()
})
function renderDashboard() {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
})
return render(
<QueryClientProvider client={queryClient}>
<MemoryRouter>
<Dashboard />
</MemoryRouter>
</QueryClientProvider>,
)
}
describe('Dashboard meal card', () => {
it('renders a recipe calorie count per serving', async () => {
renderDashboard()
await screen.findByText('Sheet Pan Gnocchi')
expect(screen.getByText('430 kcal')).not.toBeNull()
})
it('resolves root-relative recipe images through the Home Assistant ingress path', async () => {
const { getIngressBasePath, resolveAssetUrl } = await import('../api')
expect(window.location.pathname).toBe(INGRESS_PATH)
expect(getIngressBasePath()).toBe(INGRESS_PATH)
expect(resolveAssetUrl('/static/images/foo.png')).toBe(`${INGRESS_PATH}/static/images/foo.png`)
renderDashboard()
const image = await screen.findByRole('img', { name: 'Sheet Pan Gnocchi' })
expect(image.getAttribute('src')).toBe(`${INGRESS_PATH}/static/images/foo.png`)
})
})
+32 -95
View File
@@ -8,7 +8,6 @@ import {
import toast from 'react-hot-toast'
import { showToast, showApiError } from '../lib/toast'
import { upcomingMonday, parseIsoDate, shiftIsoDate, formatIsoDate } from '../lib/utils'
import { ChevronDown } from 'lucide-react'
import {
DragDropContext,
Droppable,
@@ -20,7 +19,7 @@ import {
type DraggableStateSnapshot,
type DroppableStateSnapshot,
} from '@hello-pangea/dnd'
import { mealPlannerApi } from '../api'
import { mealPlannerApi, resolveAssetUrl } from '../api'
import type { MealPlan, MealPlanItem, SuggestedSides } from '../types'
import { Badge } from '../components/ui/Badge'
import { Card, CardBody, CardHeader } from '../components/ui/Card'
@@ -31,7 +30,7 @@ import { WeekRangeNav } from '../components/WeekRangeNav'
const DAY_NAMES = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
const FULL_DAY_NAMES = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday']
const MEAL_TYPES = ['breakfast', 'lunch', 'dinner'] as const
const MEAL_TYPES = ['dinner'] as const
function getSuggestedSides(item: MealPlanItem): SuggestedSides | null {
const value = item.components?.suggested_sides
@@ -95,7 +94,7 @@ function MealCard({ item, dragHandleProps, isDragging, onApprove: _onApprove, on
{item.recipe?.image_url ? (
<img
src={item.recipe.image_url}
src={resolveAssetUrl(item.recipe.image_url)}
alt={item.recipe.name}
className="w-10 h-10 md:w-14 md:h-14 object-cover rounded-lg flex-shrink-0"
/>
@@ -114,6 +113,11 @@ function MealCard({ item, dragHandleProps, isDragging, onApprove: _onApprove, on
{totalTime > 0 && `${totalTime} min · `}
{item.recipe?.servings} servings
</p>
{item.recipe?.calories_per_serving != null && (
<p className="text-[11px] font-medium text-surface-600 mt-0.5">
{item.recipe.calories_per_serving} kcal
</p>
)}
{sideText && (
<p className="mt-1 rounded-lg bg-primary-50 px-2 py-1 text-[11px] leading-snug text-primary-800">
{sideText}
@@ -381,29 +385,15 @@ export default function Dashboard() {
setSearchParams(next === upcomingMonday() ? {} : { week: next }, { replace: true })
}
const [planningWeek, setPlanningWeek] = useState(false)
const [planMenuOpen, setPlanMenuOpen] = useState(false)
const [generatingFirstPlan, setGeneratingFirstPlan] = useState(false)
async function handlePlanWeek(mealTypes: string[]) {
async function handlePlanWeek() {
if (!mealPlan || planningWeek) return
setPlanMenuOpen(false)
setPlanningWeek(true)
try {
const res = await mealPlannerApi.meals.fillEmptySlots(mealPlan.id, mealTypes)
const data = res.data as { filled: unknown[]; failed: { reason: string }[] }
const filledCount = data.filled.length
const failedCount = data.failed.length
const label = mealTypes.length === 1 && mealTypes[0] === 'dinner' ? 'dinners' : 'meal slots'
if (filledCount === 0 && failedCount === 0) {
showToast.success(`No empty ${label} to fill`)
} else if (failedCount === 0) {
showToast.success(`Planned ${filledCount} ${label}`)
} else {
const reason = data.failed[0]?.reason ?? 'Unknown'
showToast.error(
`Planned ${filledCount} of ${filledCount + failedCount} ${label} — ${failedCount} failed (e.g. ${reason})`,
)
}
const res = await mealPlannerApi.mealPlans.generate(weekStart)
const count = (res.data as { items?: unknown[] }).items?.length ?? 0
showToast.success(`Planned ${count} dinners`)
queryClient.invalidateQueries({ queryKey: ['mealPlan', weekStart] })
} catch (err) {
showApiError(err, 'Failed to plan the week')
@@ -425,8 +415,8 @@ export default function Dashboard() {
// Sprint 13: handler invoked from the prompt modal's submit
// button. Branches on `promptMode`. Library mode is the Sprint 11
// create-then-fill flow; LLM mode POSTs /api/llm/plan and lets the
// backend do the synthesis.
// canonical planner flow; LLM mode can express preferences but the
// same planner validates and persists the final three dinners.
async function handlePromptSubmit() {
if (promptBusy) return
if (promptMode === 'llm' && !promptText.trim()) {
@@ -447,41 +437,13 @@ export default function Dashboard() {
}
}
// Library path (Sprint 11, extracted into its own function).
// Library path delegates directly to the canonical planner.
async function generateFromLibrary() {
setGeneratingFirstPlan(true)
try {
let planId: string | undefined
try {
const res = await mealPlannerApi.meals.create({
week_start_date: weekStart,
status: 'draft',
items: [],
})
planId = (res.data as { id?: string } | undefined)?.id
} catch (createErr: unknown) {
const existing = await mealPlannerApi.meals.getPlanned(weekStart)
planId = (existing.data as { id?: string } | undefined)?.id
if (!planId) throw createErr
}
if (!planId) {
showToast.error('Failed to create meal plan')
return
}
const fillRes = await mealPlannerApi.meals.fillEmptySlots(planId, ['breakfast', 'lunch', 'dinner'])
const data = fillRes.data as { filled: unknown[]; failed: { reason: string }[] }
const filledCount = data.filled.length
const failedCount = data.failed.length
if (filledCount === 0 && failedCount === 0) {
showToast.success('Plan created — no recipes to add yet')
} else if (failedCount === 0) {
showToast.success(`Planned ${filledCount} meals`)
} else {
const reason = data.failed[0]?.reason ?? 'Unknown'
showToast.error(
`Planned ${filledCount} of ${filledCount + failedCount} meals — ${failedCount} failed (e.g. ${reason})`,
)
}
const res = await mealPlannerApi.mealPlans.generate(weekStart)
const count = (res.data as { items?: unknown[] }).items?.length ?? 0
showToast.success(`Planned ${count} dinners`)
queryClient.invalidateQueries({ queryKey: ['mealPlan', weekStart] })
} catch (err) {
showApiError(err, 'Failed to generate meal plan')
@@ -492,7 +454,7 @@ export default function Dashboard() {
// LLM path (Sprint 13). POSTs /api/llm/plan. The backend returns
// {plan_id, picked_count, filled_count, failed_count}. Toast
// shows the picked/filled split; on 503 (no OLLAMA_API_KEY), the
// shows whether all three preferences passed planner constraints; on 503, the
// showApiError toast surfaces the clear backend message.
async function generateFromLLM() {
setGeneratingFirstPlan(true)
@@ -502,11 +464,11 @@ export default function Dashboard() {
const total = data.picked_count + data.filled_count
if (data.failed_count > 0) {
showToast.error(
`Planned ${total} meals (LLM picked ${data.picked_count}, library filled ${data.filled_count}; ${data.failed_count} failed)`,
`Planned ${total} dinners (${data.failed_count} could not be filled)`,
)
} else {
showToast.success(
`Planned ${total} meals (LLM picked ${data.picked_count}, library filled the rest)`,
`Planned ${total} dinners (${data.picked_count} matched your request)`,
)
}
queryClient.invalidateQueries({ queryKey: ['mealPlan', weekStart] })
@@ -645,8 +607,9 @@ export default function Dashboard() {
)
}
const dinnerItems = mealPlan.items.filter(item => item.meal_type === 'dinner')
const itemsByDay = Array.from({ length: 7 }, (_, i) =>
mealPlan.items.filter(item => item.day_of_week === i + 1)
dinnerItems.filter(item => item.day_of_week === i + 1)
)
const statusVariant =
@@ -668,7 +631,7 @@ export default function Dashboard() {
Week of {formatIsoDate(mealPlan.week_start_date)}
</h1>
<p className="text-sm text-surface-500">
{mealPlan.items.length} meals planned · {mealPlan.items.filter(i => i.approval_status === 'approved').length} approved
{dinnerItems.length} dinners planned · {dinnerItems.filter(i => i.approval_status === 'approved').length} approved
</p>
</div>
</div>
@@ -680,12 +643,10 @@ export default function Dashboard() {
onNext={() => navigateWeek(shiftIsoDate(weekStart, 7))}
onJumpHome={() => navigateWeek(upcomingMonday())}
/>
<div className="relative">
{mealPlan.items.length === 0 && (
<button
onClick={() => setPlanMenuOpen(o => !o)}
onClick={handlePlanWeek}
disabled={planningWeek}
aria-haspopup="menu"
aria-expanded={planMenuOpen}
className="inline-flex items-center gap-1.5 px-3 py-2 text-sm font-medium rounded-lg bg-primary-600 text-white hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-primary-400 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{planningWeek ? (
@@ -693,33 +654,9 @@ export default function Dashboard() {
) : (
<Sparkles className="w-4 h-4" />
)}
{planningWeek ? 'Planning…' : 'Plan the week'}
{!planningWeek && <ChevronDown className="w-3 h-3" />}
{planningWeek ? 'Planning…' : 'Plan dinners'}
</button>
{planMenuOpen && (
<div
role="menu"
className="absolute right-0 top-full mt-1 w-56 bg-white border border-surface-200 rounded-lg shadow-lg z-20 py-1 animate-fade-in"
>
<button
role="menuitem"
onClick={() => handlePlanWeek(['dinner'])}
className="w-full text-left px-3 py-2 text-sm text-surface-700 hover:bg-surface-50 focus:outline-none focus:bg-surface-50"
>
Dinners only
<span className="block text-xs text-surface-500">Fill every empty dinner slot this week</span>
</button>
<button
role="menuitem"
onClick={() => handlePlanWeek(['breakfast', 'lunch', 'dinner'])}
className="w-full text-left px-3 py-2 text-sm text-surface-700 hover:bg-surface-50 focus:outline-none focus:bg-surface-50"
>
All meals
<span className="block text-xs text-surface-500">Fill every empty slot (breakfast, lunch, dinner) this week</span>
</button>
</div>
)}
</div>
)}
<Badge
variant={statusVariant}
aria-label={`Plan status: ${mealPlan.status.replace(/_/g, ' ')}`}
@@ -834,8 +771,8 @@ export default function Dashboard() {
<h2 className="text-lg font-semibold text-surface-900">Generate Meal Plan</h2>
</div>
<p className="text-sm text-surface-500 mb-4">
Pick a generation strategy. Both create a fresh plan for the current week and fill any
empty slots from the recipe library.
Pick a generation strategy. Both create three lower-calorie dinners using the planner's
eligibility and no-repeat rules.
</p>
<div className="space-y-2 mb-4">
<label className="flex items-start gap-2 cursor-pointer">
@@ -850,7 +787,7 @@ export default function Dashboard() {
/>
<div>
<div className="text-sm font-medium text-surface-900">Use the recipe library</div>
<div className="text-xs text-surface-500">Faster; no external API. Default.</div>
<div className="text-xs text-surface-500">Planner-selected dinners. Faster; no external API.</div>
</div>
</label>
<label className="flex items-start gap-2 cursor-pointer">
@@ -866,7 +803,7 @@ export default function Dashboard() {
<div>
<div className="text-sm font-medium text-surface-900">Ask the LLM</div>
<div className="text-xs text-surface-500">
Free-text prompt; kimi-k2.6:cloud picks meals, library fills the rest.
Free-text preferences, constrained by the same planner rules.
</div>
</div>
</label>
+23 -17
View File
@@ -1,8 +1,8 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useParams, Link } from 'react-router-dom'
import { Clock, Users, ChefHat, ArrowLeft, Printer, Star, AlertTriangle, MessageSquare } from 'lucide-react'
import { mealPlannerApi } from '../api'
import { Clock, Users, ChefHat, ArrowLeft, Printer, Star, AlertTriangle, MessageSquare, Flame } from 'lucide-react'
import { mealPlannerApi, resolveAssetUrl } from '../api'
import type { MealPlanItem, Feedback, SuggestedSides } from '../types'
import { Button } from '../components/ui/Button'
import { Badge } from '../components/ui/Badge'
@@ -184,7 +184,7 @@ export default function MealDetail() {
<div className="relative rounded-2xl overflow-hidden bg-surface-900">
{recipe.image_url ? (
<img
src={recipe.image_url}
src={resolveAssetUrl(recipe.image_url)}
alt={recipe.name}
className="w-full h-48 sm:h-64 object-cover opacity-90"
/>
@@ -238,6 +238,12 @@ export default function MealDetail() {
<Users className="w-4 h-4 text-surface-500" />
<span>{recipe.servings} servings</span>
</div>
{recipe.calories_per_serving != null && (
<div className="flex items-center gap-1.5 text-sm text-surface-600 bg-surface-100 px-3 py-1.5 rounded-lg">
<Flame className="w-4 h-4 text-surface-500" />
<span>{recipe.calories_per_serving} kcal/serving</span>
</div>
)}
{recipe.cuisine_tags?.length > 0 && (
<div className="flex gap-1.5">
{recipe.cuisine_tags.map(tag => (
@@ -254,20 +260,6 @@ export default function MealDetail() {
)}
</div>
{sideText && (
<Card>
<CardHeader>
<h2 className="text-lg font-semibold text-surface-900">Complete the meal</h2>
</CardHeader>
<CardBody>
<p className="text-sm text-surface-700">{sideText}</p>
{suggestedSides?.note && (
<p className="mt-2 text-xs text-surface-500">{suggestedSides.note}</p>
)}
</CardBody>
</Card>
)}
{/* Ingredients */}
<Card>
<CardHeader>
@@ -293,6 +285,20 @@ export default function MealDetail() {
</CardBody>
</Card>
{sideText && (
<Card>
<CardHeader>
<h2 className="text-lg font-semibold text-surface-900">Complete the meal</h2>
</CardHeader>
<CardBody>
<p className="text-sm text-surface-700">{sideText}</p>
{suggestedSides?.note && (
<p className="mt-2 text-xs text-surface-500">{suggestedSides.note}</p>
)}
</CardBody>
</Card>
)}
{/* Instructions */}
<Card>
<CardHeader>
+2 -2
View File
@@ -4,7 +4,7 @@ import {
Clock, Users, ChefHat, ArrowLeft, Flame, BookOpen,
ShoppingBasket, ExternalLink, CookingPot
} from 'lucide-react'
import { mealPlannerApi } from '../api'
import { mealPlannerApi, resolveAssetUrl } from '../api'
import type { Recipe } from '../types'
import { Button } from '../components/ui/Button'
import { Badge } from '../components/ui/Badge'
@@ -103,7 +103,7 @@ export default function RecipeDetail() {
<div className="md:col-span-2">
{recipe.image_url ? (
<div className="aspect-[16/9] rounded-xl overflow-hidden bg-surface-100">
<img src={recipe.image_url} alt={recipe.name} className="w-full h-full object-cover" />
<img src={resolveAssetUrl(recipe.image_url)} alt={recipe.name} className="w-full h-full object-cover" />
</div>
) : (
<div className="aspect-[16/9] rounded-xl bg-surface-100 flex items-center justify-center">
+4 -135
View File
@@ -1,13 +1,11 @@
import { useState, useCallback, useRef } from 'react'
import { useQuery, useQueryClient, useMutation } from '@tanstack/react-query'
import { useQuery } from '@tanstack/react-query'
import { Link } from 'react-router-dom'
import {
Search, SlidersHorizontal, CookingPot, Clock, Users, Sparkles,
Globe, Loader2, Check
} from 'lucide-react'
import { mealPlannerApi } from '../api'
import type { Recipe, RecipeSearchHit } from '../types'
import { showToast, showApiError } from '../lib/toast'
import { mealPlannerApi, resolveAssetUrl } from '../api'
import type { Recipe } from '../types'
import { Button } from '../components/ui/Button'
import { Input } from '../components/ui/Input'
import { Badge } from '../components/ui/Badge'
@@ -46,22 +44,11 @@ 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.
const [applied, setApplied] = useState({
@@ -107,30 +94,6 @@ 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 />
}
@@ -176,17 +139,6 @@ 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>
@@ -202,89 +154,6 @@ 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">
@@ -377,7 +246,7 @@ function RecipeCard({ recipe }: { recipe: Recipe }) {
{recipe.image_url ? (
<div className="aspect-[4/3] overflow-hidden bg-surface-100">
<img
src={recipe.image_url}
src={resolveAssetUrl(recipe.image_url)}
alt={recipe.name}
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
/>
+2 -2
View File
@@ -3,7 +3,7 @@ import { Link } from 'react-router-dom'
import {
Sparkles, CookingPot, Clock, Users, ArrowLeft
} from 'lucide-react'
import { mealPlannerApi } from '../api'
import { mealPlannerApi, resolveAssetUrl } from '../api'
import type { Recipe } from '../types'
import { Badge } from '../components/ui/Badge'
import { Card, CardBody } from '../components/ui/Card'
@@ -82,7 +82,7 @@ function RecipeCard({ recipe }: { recipe: Recipe }) {
{recipe.image_url ? (
<div className="aspect-[4/3] overflow-hidden bg-surface-100">
<img
src={recipe.image_url}
src={resolveAssetUrl(recipe.image_url)}
alt={recipe.name}
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
/>
+1 -19
View File
@@ -92,24 +92,6 @@ export interface RecipeIngredient {
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 {
id: string
family_profile_id: string
@@ -132,9 +114,9 @@ export interface MealPlanItem {
approval_status: 'pending' | 'approved' | 'denied' | 'swapped'
denial_reason?: string
denial_details?: string
estimated_cost?: number
score?: number | null
components?: Record<string, unknown> | null
estimated_cost?: number
used_pantry_items: string[]
recipe?: Recipe
created_at?: string