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
+20 -1
View File
@@ -21,7 +21,7 @@ import {
type DroppableStateSnapshot,
} from '@hello-pangea/dnd'
import { mealPlannerApi } from '../api'
import type { MealPlan, MealPlanItem } from '../types'
import type { MealPlan, MealPlanItem, SuggestedSides } from '../types'
import { Badge } from '../components/ui/Badge'
import { Card, CardBody, CardHeader } from '../components/ui/Card'
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 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) */
/* ------------------------------------------------------------------ */
@@ -55,6 +67,8 @@ function MealCard({ item, dragHandleProps, isDragging, onApprove: _onApprove, on
item.approval_status === 'denied' ? 'danger' :
item.approval_status === 'swapped' ? 'warning' :
'neutral'
const suggestedSides = getSuggestedSides(item)
const sideText = suggestedSides ? formatSuggestedSides(suggestedSides) : null
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'}`}>
@@ -100,6 +114,11 @@ function MealCard({ item, dragHandleProps, isDragging, onApprove: _onApprove, on
{totalTime > 0 && `${totalTime} min · `}
{item.recipe?.servings} servings
</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">
<Badge
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 { Clock, Users, ChefHat, ArrowLeft, Printer, Star, AlertTriangle, MessageSquare } from 'lucide-react'
import { mealPlannerApi } from '../api'
import type { MealPlanItem, Feedback } from '../types'
import type { MealPlanItem, Feedback, SuggestedSides } from '../types'
import { Button } from '../components/ui/Button'
import { Badge } from '../components/ui/Badge'
import { Card, CardBody, CardHeader } from '../components/ui/Card'
@@ -22,6 +22,18 @@ const DENIAL_REASONS = [
{ 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 }) {
return (
<div className="flex gap-1">
@@ -158,6 +170,8 @@ export default function MealDetail() {
const totalTime = recipe.total_time_minutes ??
(recipe.prep_time_minutes || 0) + (recipe.cook_time_minutes || 0)
const suggestedSides = getSuggestedSides(item)
const sideText = suggestedSides ? formatSuggestedSides(suggestedSides) : null
return (
<div className="space-y-6 max-w-5xl mx-auto">
@@ -240,6 +254,20 @@ 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>
+17
View File
@@ -62,6 +62,21 @@ export interface Recipe {
created_at?: string
updated_at?: string
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 {
@@ -117,6 +132,8 @@ export interface MealPlanItem {
approval_status: 'pending' | 'approved' | 'denied' | 'swapped'
denial_reason?: string
denial_details?: string
score?: number | null
components?: Record<string, unknown> | null
estimated_cost?: number
used_pantry_items: string[]
recipe?: Recipe