feat(meals): suggest complementary sides
CI / backend (pytest + alembic) (push) Has been cancelled
CI / frontend (build) (push) Has been cancelled

This commit is contained in:
2026-06-29 14:59:30 -07:00
parent 18d7300b57
commit 7f5757094e
12 changed files with 294 additions and 9 deletions
+13
View File
@@ -35,6 +35,7 @@ from app.config import settings
from app.database import get_db from app.database import get_db
from app.models import FamilyProfile, MealPlan, MealPlanItem, MealType, Recipe from app.models import FamilyProfile, MealPlan, MealPlanItem, MealType, Recipe
from app.security import require_session from app.security import require_session
from app.services.meal_pairings import components_with_suggested_sides
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -257,6 +258,10 @@ def synthesize_plan(
raw_picks = _parse_picks(raw or "") raw_picks = _parse_picks(raw or "")
valid_recipe_ids = {r["id"] for r in library} valid_recipe_ids = {r["id"] for r in library}
picks = _validate_picks(raw_picks, valid_recipe_ids) picks = _validate_picks(raw_picks, valid_recipe_ids)
recipe_by_id = {
str(r.id): r
for r in db.query(Recipe).filter(Recipe.id.in_([uuid.UUID(rid) for rid in valid_recipe_ids])).all()
}
logger.info( logger.info(
"LLM plan: prompt=%d chars, raw_picks=%d, valid_picks=%d", "LLM plan: prompt=%d chars, raw_picks=%d, valid_picks=%d",
len(payload.prompt), len(raw_picks), len(picks), len(payload.prompt), len(raw_picks), len(picks),
@@ -279,6 +284,10 @@ def synthesize_plan(
recipe_id=uuid.UUID(pick.recipe_id), recipe_id=uuid.UUID(pick.recipe_id),
day_of_week=pick.day_of_week, day_of_week=pick.day_of_week,
meal_type=MealType(pick.meal_type), meal_type=MealType(pick.meal_type),
components=components_with_suggested_sides(
recipe_by_id.get(pick.recipe_id),
pick.meal_type,
),
)) ))
db.flush() db.flush()
@@ -310,6 +319,10 @@ def synthesize_plan(
recipe_id=uuid.UUID(chosen["id"]), recipe_id=uuid.UUID(chosen["id"]),
day_of_week=day, day_of_week=day,
meal_type=MealType(mt), meal_type=MealType(mt),
components=components_with_suggested_sides(
recipe_by_id.get(chosen["id"]),
mt,
),
)) ))
used_recipe_ids.add(uuid.UUID(chosen["id"])) used_recipe_ids.add(uuid.UUID(chosen["id"]))
filled_count += 1 filled_count += 1
+6 -2
View File
@@ -30,6 +30,10 @@ admin_router = APIRouter(
public_router = APIRouter(prefix="/api/meal-plans", tags=["meal-plans"]) public_router = APIRouter(prefix="/api/meal-plans", tags=["meal-plans"])
def _components_payload(components: dict | None) -> dict:
return dict(components or {})
def _to_response(week_start, plan_id, items: List[MealPlanItem], result: GenerationResult) -> GenerationResponse: def _to_response(week_start, plan_id, items: List[MealPlanItem], result: GenerationResult) -> GenerationResponse:
item_payloads: List[GenerationItem] = [] item_payloads: List[GenerationItem] = []
score_by_recipe = {s.recipe_id: s for s in result.selected} score_by_recipe = {s.recipe_id: s for s in result.selected}
@@ -41,7 +45,7 @@ def _to_response(week_start, plan_id, items: List[MealPlanItem], result: Generat
day_of_week=it.day_of_week, day_of_week=it.day_of_week,
estimated_cost=it.estimated_cost or 0, estimated_cost=it.estimated_cost or 0,
score=scored.score if scored else 0.0, score=scored.score if scored else 0.0,
components={k: float(v) for k, v in (scored.components.items() if scored else [])}, components=_components_payload(it.components or (scored.components if scored else None)),
) )
) )
return GenerationResponse( return GenerationResponse(
@@ -135,7 +139,7 @@ def get_plan(plan_id: UUID, db: Session = Depends(get_db)) -> GenerationResponse
day_of_week=it.day_of_week, day_of_week=it.day_of_week,
estimated_cost=it.estimated_cost or 0, estimated_cost=it.estimated_cost or 0,
score=it.score or 0.0, score=it.score or 0.0,
components={k: float(v) for k, v in (it.components.items() if it.components else [])}, components=_components_payload(it.components),
) )
for it in items for it in items
], ],
+3
View File
@@ -19,6 +19,7 @@ from app.schemas import (
from app.security import require_session from app.security import require_session
from app.services import approval as approval_service from app.services import approval as approval_service
from app.services.feedback_analyzer import FeedbackAnalyzer from app.services.feedback_analyzer import FeedbackAnalyzer
from app.services.meal_pairings import components_with_suggested_sides
from uuid import UUID from uuid import UUID
from typing import List, Optional from typing import List, Optional
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
@@ -683,6 +684,7 @@ def generate_single_item(
day_of_week=day_of_week, day_of_week=day_of_week,
meal_type=MealType[meal_type.upper()], meal_type=MealType[meal_type.upper()],
approval_status=MealPlanItemStatus.pending, approval_status=MealPlanItemStatus.pending,
components=components_with_suggested_sides(recipe, meal_type),
) )
db.add(new_item) db.add(new_item)
db.commit() db.commit()
@@ -769,6 +771,7 @@ def fill_empty_slots(
day_of_week=day, day_of_week=day,
meal_type=MealType[mt.upper()], meal_type=MealType[mt.upper()],
approval_status=MealPlanItemStatus.pending, approval_status=MealPlanItemStatus.pending,
components=components_with_suggested_sides(recipe, mt),
) )
db.add(new_item) db.add(new_item)
try: try:
+1 -1
View File
@@ -211,7 +211,7 @@ class MealPlanItemResponse(MealPlanItemBase):
denial_expires_at: Optional[datetime] = None denial_expires_at: Optional[datetime] = None
used_pantry_items: Optional[List[UUID]] = [] used_pantry_items: Optional[List[UUID]] = []
score: Optional[float] = None score: Optional[float] = None
components: Optional[Dict[str, float]] = None components: Optional[Dict[str, Any]] = None
created_at: Optional[datetime] = None created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None updated_at: Optional[datetime] = None
recipe: Optional[RecipeResponse] = None recipe: Optional[RecipeResponse] = None
+2 -2
View File
@@ -2,7 +2,7 @@ from __future__ import annotations
from datetime import date from datetime import date
from decimal import Decimal from decimal import Decimal
from typing import Dict, List, Optional from typing import Any, Dict, List, Optional
from uuid import UUID from uuid import UUID
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
@@ -27,7 +27,7 @@ class GenerationItem(BaseModel):
day_of_week: int day_of_week: int
estimated_cost: Decimal estimated_cost: Decimal
score: float score: float
components: Dict[str, float] components: Dict[str, Any]
class GenerationDebug(BaseModel): class GenerationDebug(BaseModel):
+116
View File
@@ -0,0 +1,116 @@
from __future__ import annotations
from typing import Any, Mapping
from app.models import MealType
_COMPLETE_MEAL_TERMS = {
"bowl", "burger", "burrito", "casserole", "chili", "curry", "fried rice",
"lasagna", "noodle", "paella", "pasta", "pizza", "quesadilla", "ramen",
"rice bowl", "risotto", "salad", "sandwich", "soup", "spaghetti", "stew",
"stir fry", "stir-fry", "taco", "tacos", "wrap",
}
_CARB_TERMS = {
"bread", "bun", "couscous", "farro", "grain", "noodle", "orzo", "pasta",
"pita", "potato", "quinoa", "rice", "tortilla",
}
_VEG_TERMS = {
"asparagus", "beans", "broccoli", "brussels", "cabbage", "carrot",
"cauliflower", "corn", "greens", "kale", "pepper", "salad", "spinach",
"vegetable", "zucchini",
}
_PROTEIN_TERMS = {
"beef", "breast", "chicken", "chop", "cod", "cutlet", "fish", "pork",
"salmon", "shrimp", "steak", "tilapia", "tofu", "turkey",
}
_PAIRINGS_BY_CUISINE = {
"asian": ("sesame broccoli", "steamed jasmine rice"),
"chinese": ("garlic green beans", "steamed jasmine rice"),
"indian": ("roasted cauliflower", "basmati rice"),
"italian": ("garlicky green beans", "orzo or crusty bread"),
"mediterranean": ("cucumber tomato salad", "warm pita or couscous"),
"mexican": ("sauteed peppers and onions", "cilantro lime rice"),
"thai": ("cucumber salad", "steamed jasmine rice"),
}
_DEFAULT_PAIRING = ("roasted broccoli", "rice pilaf or roasted potatoes")
def components_with_suggested_sides(
recipe: Any,
meal_type: MealType | str,
base_components: Mapping[str, Any] | None = None,
) -> dict[str, Any]:
components = dict(base_components or {})
suggestion = suggest_sides_for_recipe(recipe, meal_type)
if suggestion:
components["suggested_sides"] = suggestion
return components
def suggest_sides_for_recipe(recipe: Any, meal_type: MealType | str) -> dict[str, Any] | None:
meal_value = meal_type.value if isinstance(meal_type, MealType) else str(meal_type).lower()
if meal_value == MealType.BREAKFAST.value:
return None
explicit_sides = [
side.get("name")
for side in (getattr(recipe, "side_dishes", None) or [])
if isinstance(side, dict) and side.get("name")
]
if explicit_sides:
return {
"needed": True,
"items": explicit_sides[:2],
"note": "Use the recipe's recommended side dish pairing.",
}
text = _recipe_text(recipe)
if not _looks_like_simple_protein(recipe, text):
return None
vegetable, carb = _pairing_for_cuisine(getattr(recipe, "cuisine_tags", None) or [])
return {
"needed": True,
"vegetable": vegetable,
"carb": carb,
"note": "Simple protein entree; add a vegetable and carb to make it a complete meal.",
}
def _recipe_text(recipe: Any) -> str:
parts = [
getattr(recipe, "name", "") or "",
getattr(recipe, "protein_type", "") or "",
" ".join(getattr(recipe, "cuisine_tags", None) or []),
]
for ingredient in getattr(recipe, "ingredients", None) or []:
if isinstance(ingredient, dict):
parts.append(str(ingredient.get("name") or ingredient.get("ingredient") or ""))
return " ".join(parts).lower()
def _looks_like_simple_protein(recipe: Any, text: str) -> bool:
if any(term in text for term in _COMPLETE_MEAL_TERMS):
return False
has_protein = bool(getattr(recipe, "protein_type", None)) or any(
term in text for term in _PROTEIN_TERMS
)
if not has_protein:
return False
has_carb = any(term in text for term in _CARB_TERMS)
has_veg = any(term in text for term in _VEG_TERMS)
return not (has_carb and has_veg)
def _pairing_for_cuisine(tags: list[str]) -> tuple[str, str]:
lowered = {tag.lower() for tag in tags}
for key, pairing in _PAIRINGS_BY_CUISINE.items():
if key in lowered:
return pairing
return _DEFAULT_PAIRING
@@ -36,6 +36,26 @@ if TYPE_CHECKING:
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _suggested_sides_html(components: dict | None) -> str:
sides = (components or {}).get("suggested_sides")
if not isinstance(sides, dict):
return ""
items = sides.get("items")
if isinstance(items, list) and items:
text = "Pair with " + " + ".join(str(item) for item in items[:2])
else:
pair = [sides.get("vegetable"), sides.get("carb")]
pair = [str(part) for part in pair if part]
if not pair:
return ""
text = "Add " + " + ".join(pair)
return (
"<p style='font-size:13px;background:#ecfdf5;color:#065f46;"
"padding:8px;border-radius:6px;margin:8px 0'>"
f"{html.escape(text)}</p>"
)
def step_scrape(run: "WeeklyRun", db: "Session") -> None: def step_scrape(run: "WeeklyRun", db: "Session") -> None:
if run.scraped_at is not None: if run.scraped_at is not None:
logger.info("step_scrape: already done for %s, skipping", run.week_start_date) logger.info("step_scrape: already done for %s, skipping", run.week_start_date)
@@ -273,6 +293,7 @@ def step_email(run: "WeeklyRun", db: "Session") -> None:
f"<p style='font-size:13px;color:#888'>Est. ~${est_cost_per_serving:.2f}/serving</p>" f"<p style='font-size:13px;color:#888'>Est. ~${est_cost_per_serving:.2f}/serving</p>"
if est_cost_total > 0 else "" if est_cost_total > 0 else ""
) )
sides_block = _suggested_sides_html(item.components)
item_html_parts.append( item_html_parts.append(
f'<div style="border:1px solid #e5e7eb;border-radius:8px;padding:16px;margin-bottom:12px">' f'<div style="border:1px solid #e5e7eb;border-radius:8px;padding:16px;margin-bottom:12px">'
@@ -280,6 +301,7 @@ def step_email(run: "WeeklyRun", db: "Session") -> None:
f'<h3 style="margin:8px 0 4px">{recipe_name}</h3>' f'<h3 style="margin:8px 0 4px">{recipe_name}</h3>'
f'{ing_block}' f'{ing_block}'
f'{instructions_block}' f'{instructions_block}'
f'{sides_block}'
f'{cost_block}' f'{cost_block}'
f'<div style="margin-top:8px;display:flex;flex-wrap:wrap;gap:6px">' f'<div style="margin-top:8px;display:flex;flex-wrap:wrap;gap:6px">'
f'<a href="{vote_url}&amp;scope=approve" style="display:inline-block;padding:8px 14px;' f'<a href="{vote_url}&amp;scope=approve" style="display:inline-block;padding:8px 14px;'
+8 -1
View File
@@ -29,6 +29,7 @@ from app.services.planner.filter import filter_recipes
from app.services.planner.score import score_recipes from app.services.planner.score import score_recipes
from app.services.planner.select import select_set from app.services.planner.select import select_set
from app.services.planner.types import GenerationResult from app.services.planner.types import GenerationResult
from app.services.meal_pairings import components_with_suggested_sides
def _load_match_index(db: Session) -> Dict[UUID, List[dict]]: def _load_match_index(db: Session) -> Dict[UUID, List[dict]]:
@@ -234,9 +235,11 @@ def generate_meal_plan(
db.flush() db.flush()
_dinner_days = [1, 3, 5] # Mon, Wed, Fri — spread across the week _dinner_days = [1, 3, 5] # Mon, Wed, Fri — spread across the week
recipe_by_id = {recipe.id: recipe for recipe in recipes}
for index, scored_recipe in enumerate(chosen): for index, scored_recipe in enumerate(chosen):
day = _dinner_days[index] if index < len(_dinner_days) else index + 1 day = _dinner_days[index] if index < len(_dinner_days) else index + 1
meal_type = MealType.DINNER meal_type = MealType.DINNER
recipe = recipe_by_id.get(scored_recipe.recipe_id)
item = MealPlanItem( item = MealPlanItem(
meal_plan_id=plan.id, meal_plan_id=plan.id,
recipe_id=scored_recipe.recipe_id, recipe_id=scored_recipe.recipe_id,
@@ -245,7 +248,11 @@ def generate_meal_plan(
approval_status=MealPlanItemStatus.pending, approval_status=MealPlanItemStatus.pending,
estimated_cost=scored_recipe.cost.total_cost, estimated_cost=scored_recipe.cost.total_cost,
score=scored_recipe.score, score=scored_recipe.score,
components=scored_recipe.components, components=components_with_suggested_sides(
recipe,
meal_type,
scored_recipe.components,
),
) )
db.add(item) db.add(item)
+56
View File
@@ -0,0 +1,56 @@
from types import SimpleNamespace
from app.models import MealType
from app.services.meal_pairings import components_with_suggested_sides, suggest_sides_for_recipe
def _recipe(**kwargs):
defaults = {
"name": "Grilled Chicken Breast",
"protein_type": "chicken",
"cuisine_tags": [],
"ingredients": [],
"side_dishes": [],
}
defaults.update(kwargs)
return SimpleNamespace(**defaults)
def test_simple_protein_gets_vegetable_and_carb_pairing():
sides = suggest_sides_for_recipe(_recipe(), MealType.DINNER)
assert sides is not None
assert sides["vegetable"] == "roasted broccoli"
assert sides["carb"] == "rice pilaf or roasted potatoes"
def test_complete_meal_does_not_get_extra_pairing():
recipe = _recipe(name="Chicken Pasta Bake", protein_type="chicken")
assert suggest_sides_for_recipe(recipe, MealType.DINNER) is None
def test_breakfast_does_not_get_side_pairing():
recipe = _recipe(name="Turkey Sausage", protein_type="turkey")
assert suggest_sides_for_recipe(recipe, MealType.BREAKFAST) is None
def test_recipe_side_dishes_are_used_when_present():
recipe = _recipe(side_dishes=[{"name": "green salad"}, {"name": "garlic bread"}])
sides = suggest_sides_for_recipe(recipe, "dinner")
assert sides is not None
assert sides["items"] == ["green salad", "garlic bread"]
def test_components_preserve_existing_scores():
components = components_with_suggested_sides(
_recipe(),
MealType.DINNER,
{"savings": 0.25},
)
assert components["savings"] == 0.25
assert components["suggested_sides"]["needed"] is True
+20 -1
View File
@@ -21,7 +21,7 @@ import {
type DroppableStateSnapshot, type DroppableStateSnapshot,
} from '@hello-pangea/dnd' } from '@hello-pangea/dnd'
import { mealPlannerApi } from '../api' import { mealPlannerApi } from '../api'
import type { MealPlan, MealPlanItem } from '../types' import type { MealPlan, MealPlanItem, SuggestedSides } from '../types'
import { Badge } from '../components/ui/Badge' import { Badge } from '../components/ui/Badge'
import { Card, CardBody, CardHeader } from '../components/ui/Card' import { Card, CardBody, CardHeader } from '../components/ui/Card'
import { SkeletonCard, Skeleton } from '../components/ui/Skeleton' import { SkeletonCard, Skeleton } from '../components/ui/Skeleton'
@@ -33,6 +33,18 @@ const DAY_NAMES = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
const FULL_DAY_NAMES = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'] const FULL_DAY_NAMES = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday']
const MEAL_TYPES = ['breakfast', 'lunch', 'dinner'] as const const MEAL_TYPES = ['breakfast', 'lunch', 'dinner'] as const
function getSuggestedSides(item: MealPlanItem): SuggestedSides | null {
const value = item.components?.suggested_sides
if (!value || typeof value !== 'object') return null
return value as SuggestedSides
}
function formatSuggestedSides(sides: SuggestedSides): string | null {
if (sides.items?.length) return `Pair with ${sides.items.join(' + ')}`
const pair = [sides.vegetable, sides.carb].filter(Boolean).join(' + ')
return pair ? `Add ${pair}` : null
}
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
/* MealCard (draggable) */ /* MealCard (draggable) */
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
@@ -55,6 +67,8 @@ function MealCard({ item, dragHandleProps, isDragging, onApprove: _onApprove, on
item.approval_status === 'denied' ? 'danger' : item.approval_status === 'denied' ? 'danger' :
item.approval_status === 'swapped' ? 'warning' : item.approval_status === 'swapped' ? 'warning' :
'neutral' 'neutral'
const suggestedSides = getSuggestedSides(item)
const sideText = suggestedSides ? formatSuggestedSides(suggestedSides) : null
return ( return (
<div className={`relative group block bg-surface-0 rounded-xl border overflow-hidden hover:shadow-md hover:border-primary-200 transition-all duration-200 ${isDragging ? 'shadow-lg border-primary-400 ring-2 ring-primary-200' : 'border-surface-200'}`}> <div className={`relative group block bg-surface-0 rounded-xl border overflow-hidden hover:shadow-md hover:border-primary-200 transition-all duration-200 ${isDragging ? 'shadow-lg border-primary-400 ring-2 ring-primary-200' : 'border-surface-200'}`}>
@@ -100,6 +114,11 @@ function MealCard({ item, dragHandleProps, isDragging, onApprove: _onApprove, on
{totalTime > 0 && `${totalTime} min · `} {totalTime > 0 && `${totalTime} min · `}
{item.recipe?.servings} servings {item.recipe?.servings} servings
</p> </p>
{sideText && (
<p className="mt-1 rounded-lg bg-primary-50 px-2 py-1 text-[11px] leading-snug text-primary-800">
{sideText}
</p>
)}
<div className="flex items-center gap-1.5 mt-1"> <div className="flex items-center gap-1.5 mt-1">
<Badge <Badge
variant={statusVariant} variant={statusVariant}
+29 -1
View File
@@ -3,7 +3,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useParams, Link } from 'react-router-dom' import { useParams, Link } from 'react-router-dom'
import { Clock, Users, ChefHat, ArrowLeft, Printer, Star, AlertTriangle, MessageSquare } from 'lucide-react' import { Clock, Users, ChefHat, ArrowLeft, Printer, Star, AlertTriangle, MessageSquare } from 'lucide-react'
import { mealPlannerApi } from '../api' import { mealPlannerApi } from '../api'
import type { MealPlanItem, Feedback } from '../types' import type { MealPlanItem, Feedback, SuggestedSides } from '../types'
import { Button } from '../components/ui/Button' import { Button } from '../components/ui/Button'
import { Badge } from '../components/ui/Badge' import { Badge } from '../components/ui/Badge'
import { Card, CardBody, CardHeader } from '../components/ui/Card' import { Card, CardBody, CardHeader } from '../components/ui/Card'
@@ -22,6 +22,18 @@ const DENIAL_REASONS = [
{ value: 'other', label: 'Other' }, { value: 'other', label: 'Other' },
] ]
function getSuggestedSides(item: MealPlanItem): SuggestedSides | null {
const value = item.components?.suggested_sides
if (!value || typeof value !== 'object') return null
return value as SuggestedSides
}
function formatSuggestedSides(sides: SuggestedSides): string | null {
if (sides.items?.length) return `Pair with ${sides.items.join(' + ')}.`
const pair = [sides.vegetable, sides.carb].filter(Boolean).join(' and ')
return pair ? `Add ${pair}.` : null
}
function StarRating({ value, onChange }: { value: number; onChange: (n: number) => void }) { function StarRating({ value, onChange }: { value: number; onChange: (n: number) => void }) {
return ( return (
<div className="flex gap-1"> <div className="flex gap-1">
@@ -158,6 +170,8 @@ export default function MealDetail() {
const totalTime = recipe.total_time_minutes ?? const totalTime = recipe.total_time_minutes ??
(recipe.prep_time_minutes || 0) + (recipe.cook_time_minutes || 0) (recipe.prep_time_minutes || 0) + (recipe.cook_time_minutes || 0)
const suggestedSides = getSuggestedSides(item)
const sideText = suggestedSides ? formatSuggestedSides(suggestedSides) : null
return ( return (
<div className="space-y-6 max-w-5xl mx-auto"> <div className="space-y-6 max-w-5xl mx-auto">
@@ -240,6 +254,20 @@ export default function MealDetail() {
)} )}
</div> </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 */} {/* Ingredients */}
<Card> <Card>
<CardHeader> <CardHeader>
+17
View File
@@ -62,6 +62,21 @@ export interface Recipe {
created_at?: string created_at?: string
updated_at?: string updated_at?: string
total_time_minutes?: number total_time_minutes?: number
side_dishes?: SideDish[]
}
export interface SuggestedSides {
needed?: boolean
vegetable?: string
carb?: string
items?: string[]
note?: string
}
export interface SideDish {
name: string
ingredients?: Array<{ name: string; qty: number; unit?: string }>
prep_notes?: string
} }
export interface RecipeIngredient { export interface RecipeIngredient {
@@ -117,6 +132,8 @@ export interface MealPlanItem {
approval_status: 'pending' | 'approved' | 'denied' | 'swapped' approval_status: 'pending' | 'approved' | 'denied' | 'swapped'
denial_reason?: string denial_reason?: string
denial_details?: string denial_details?: string
score?: number | null
components?: Record<string, unknown> | null
estimated_cost?: number estimated_cost?: number
used_pantry_items: string[] used_pantry_items: string[]
recipe?: Recipe recipe?: Recipe