# Phase 6 — Recipe Management **Goal**: Enter, import, and manage recipes. Auto-calculate nutrition from product library ingredients. Highlight nutritional warnings. This phase ties into the product library (Phase 5) and will be consumed by meal planning (Phase 8). **Depends on**: Phase 0, Phase 5 --- ## Deliverables 1. `Recipe` MongoDB schema and full CRUD API 2. Automatic nutrition calculation from ingredient list 3. Nutritional warning generation 4. Recipe scaling (adjust servings) 5. Recipe import via LLM (plain text → structured) 6. Recipe editor web UI with live nutrition sidebar --- ## Data Model ### Recipe Schema ```typescript // packages/shared/src/types/recipe.ts export interface Recipe { id: string; householdId: string; name: string; description?: string; servings: number; prepTime?: number; // minutes cookTime?: number; // minutes totalTime?: number; // minutes (auto-calculated or manual) ingredients: RecipeIngredient[]; steps: RecipeStep[]; tags: string[]; // e.g., 'vegetarian', 'quick', 'meal-prep' cuisine?: string; // e.g., 'Italian', 'Japanese' imageUrl?: string; source?: RecipeSource; totalNutrition: NutritionInfo; // Denormalized, computed on save perServingNutrition: NutritionInfo; // Denormalized, computed on save warnings: NutritionWarning[]; // Computed on save isFavorite: boolean; createdBy: string; createdAt: Date; updatedAt: Date; } export interface RecipeIngredient { productId: string; // Reference to Product productName: string; // Denormalized for display quantity: number; unit: ServingUnit; preparation?: string; // e.g., 'diced', 'minced', 'melted' isOptional: boolean; nutritionContribution: NutritionInfo; // Per-ingredient computed nutrition } export interface RecipeStep { order: number; instruction: string; duration?: number; // minutes tip?: string; } export interface RecipeSource { type: 'manual' | 'url' | 'llm_import' | 'text_import'; url?: string; importedAt?: Date; } export enum NutritionWarning { HIGH_CALORIES = 'high_calories', // > 800 kcal/serving HIGH_SODIUM = 'high_sodium', // > 1500mg/serving HIGH_SUGAR = 'high_sugar', // > 25g/serving HIGH_SATURATED_FAT = 'high_saturated_fat', // > 13g/serving LOW_PROTEIN = 'low_protein', // < 10g/serving LOW_FIBER = 'low_fiber', // < 3g/serving HIGH_CHOLESTEROL = 'high_cholesterol', // > 200mg/serving } ``` ### MongoDB Indexes ```javascript { householdId: 1, name: 'text', tags: 'text', cuisine: 'text' } { householdId: 1, 'ingredients.productId': 1 } // Find recipes using a product { householdId: 1, tags: 1 } { householdId: 1, isFavorite: 1 } ``` --- ## API Endpoints ### RecipesModule | Method | Path | Description | Auth | | ------ | -------------------------------- | --------------------------------------- | ------ | | GET | `/recipes` | List/search recipes (paginated) | member | | GET | `/recipes/:id` | Get single recipe | member | | POST | `/recipes` | Create recipe | member | | PATCH | `/recipes/:id` | Update recipe | member | | DELETE | `/recipes/:id` | Soft-delete recipe | admin | | POST | `/recipes/:id/scale` | Get scaled version (preview, not saved) | member | | POST | `/recipes/import-text` | Import from plain text via LLM | member | | POST | `/recipes/import-url` | Import from URL via LLM | member | | GET | `/recipes/by-product/:productId` | Find recipes using a specific product | member | ### Query Parameters for GET `/recipes` ``` ?q=pasta # Full-text search &tags=vegetarian,quick # Filter by tags &cuisine=Italian # Filter by cuisine &maxCalories=600 # Filter by per-serving calories &isFavorite=true # Favorites only &cursor=abc123 &limit=20 ``` --- ## Tasks ### 6.1 — Shared Types & Validation - Add recipe types to `packages/shared/src/types/recipe.ts` - Zod schemas: - `CreateRecipeSchema` — ingredients must reference valid productIds - `UpdateRecipeSchema` — partial - `ScaleRecipeSchema` — `{ targetServings: number }` - `ImportRecipeTextSchema` — `{ text: string }` - `ImportRecipeUrlSchema` — `{ url: string }` ### 6.2 — Nutrition Calculation Service - `NutritionCalculatorService`: ```typescript class NutritionCalculatorService { /** * For each ingredient: * 1. Lookup the product by productId * 2. Convert ingredient quantity/unit to product's servingUnit * 3. Calculate nutrition proportionally: (ingredient_qty / serving_size) * nutrition_per_serving * 4. Sum across all ingredients → totalNutrition * 5. Divide by servings → perServingNutrition */ calculateRecipeNutrition( ingredients: RecipeIngredient[], servings: number, ): RecipeNutritionResult; /** * Check per-serving nutrition against warning thresholds */ generateWarnings(perServingNutrition: NutritionInfo): NutritionWarning[]; } ``` - Unit conversion helper: handle common conversions (g ↔ oz, ml ↔ cups, etc.) - Not all conversions are possible (density-dependent) — log warning, use best approximation - This is explicitly **informative, not clinical-grade accurate** ### 6.3 — Recipe CRUD with Auto-Calculation - On `POST /recipes` and `PATCH /recipes/:id`: 1. Validate ingredients exist in product library 2. Call `NutritionCalculatorService.calculateRecipeNutrition()` 3. Call `NutritionCalculatorService.generateWarnings()` 4. Store computed `totalNutrition`, `perServingNutrition`, `warnings` on document - On product nutrition update (Phase 5 edit), trigger background recalculation: - Find all recipes where `ingredients[].productId == updatedProductId` - Recalculate each recipe's nutrition ### 6.4 — Recipe Scaling - `POST /recipes/:id/scale` with `{ targetServings: number }`: - Returns a scaled **preview** (not persisted) with adjusted ingredient quantities and recalculated nutrition - `scaledQuantity = originalQuantity * (targetServings / originalServings)` ### 6.5 — Recipe Import (LLM) - `POST /recipes/import-text`: - Accepts `{ text: string }` (pasted recipe) - Calls `ILlmProvider.parseRecipe(text)` - LLM returns structured: `{ name, servings, ingredients[]: { name, quantity, unit }, steps[] }` - Service attempts to match ingredient names to existing products (fuzzy match by name) - Returns structured recipe for user review — unmatched ingredients flagged for manual product creation - `POST /recipes/import-url`: - Calls `ILlmProvider.parseRecipeFromUrl(url)` - Same flow as text import - With `NoOpLlmProvider`: returns `{ available: false }` ### 6.6 — Web UI: Recipe Management - `/recipes` page: - Search bar, tag and cuisine filters - Recipe card grid: image, name, time, calories/serving, warning badges - Favorites tab - `/recipes/new` and `/recipes/:id/edit`: - Recipe metadata form (name, description, servings, times, cuisine, tags) - Ingredient editor: - Autocomplete from product library - Quantity + unit inputs - "Add ingredient" button, drag-to-reorder - Per-ingredient nutrition shown inline - Steps editor: ordered text areas, optional duration per step - **Live nutrition sidebar**: updates as ingredients are added/changed - Shows total and per-serving macros - Warning badges with explanations - "Scale" button: adjust servings in sidebar to see scaled amounts - `/recipes/:id` detail page: - Full recipe view with ingredients, steps, nutrition panel - "Import from text" and "Import from URL" buttons in recipe list page --- ## Acceptance Criteria - [ ] Can create a recipe with ingredients linked to products - [ ] Nutrition is automatically calculated and stored on the recipe - [ ] Warnings are generated for recipes exceeding thresholds - [ ] Recipe scaling returns correctly adjusted quantities - [ ] Editing a product's nutrition triggers recipe recalculation - [ ] Text/URL import endpoint delegates to LLM provider interface - [ ] Web UI shows live nutrition as ingredients are added - [ ] Full-text search finds recipes by name, tags, cuisine --- ## Estimated Effort Medium. Nutrition calculation logic and unit conversion require careful implementation. UI is moderately complex with the live sidebar.