Public Access
feat(meals): suggest complementary sides
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -428,4 +428,4 @@ class LLMPlanResponse(BaseModel):
|
|||||||
picked_count: int
|
picked_count: int
|
||||||
filled_count: int
|
filled_count: int
|
||||||
failed_count: int
|
failed_count: int
|
||||||
reasoning: Optional[str] = None
|
reasoning: Optional[str] = None
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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}&scope=approve" style="display:inline-block;padding:8px 14px;'
|
f'<a href="{vote_url}&scope=approve" style="display:inline-block;padding:8px 14px;'
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user