Public Access
feat(meals): enforce three lower-calorie dinners with media
This commit is contained in:
+16
-11
@@ -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),
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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`)
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user