# 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; // stored in metric (g | ml) or as a discrete count
  unit: RecipeUnit; // metric/discrete only after normalization
  originalQuantity?: number; // optional, preserved from import (e.g. 1)
  originalUnit?: ImperialUnit | RecipeUnit; // optional, preserved from import (e.g. 'cup')
  preparation?: string; // e.g., 'diced', 'minced', 'melted'
  isOptional: boolean;
  nutritionContribution: NutritionInfo; // Per-ingredient computed nutrition
}

// Storage units for recipe ingredients — same set as `ServingUnit` in Phase 5.
export type RecipeUnit = 'g' | 'ml' | 'piece' | 'slice';

// Accepted at input/import only; converted to `RecipeUnit` before persistence.
export type ImperialUnit = 'oz' | 'lb' | 'cup' | 'tbsp' | 'tsp' | 'fl_oz';

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 (already metric)
   * 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[];
}
```

- Because products are stored in metric (`g | ml | piece | slice`), the calculator only needs to bridge metric ↔ metric and discrete ↔ metric (via `Product.servingSize`).
- Imperial input handling lives in `UnitConversionService` (see 6.2a) and runs **before** the calculator at create/update/import time.

### 6.2a — Unit Conversion Service

- `UnitConversionService.toMetric(quantity, unit, product)` returns `{ quantity, unit: RecipeUnit }`.
- Mass conversions (exact): `oz → g` (× 28.3495), `lb → g` (× 453.592).
- Volume conversions (exact, US customary): `tsp → ml` (× 4.92892), `tbsp → ml` (× 14.7868), `fl_oz → ml` (× 29.5735), `cup → ml` (× 236.588).
- Mass ↔ volume conversions require `product.densityGPerMl`. If absent, the service returns an error tagged `MISSING_DENSITY` and the route returns 422 with the offending ingredient so the user can either supply a density on the product or restate the quantity in the product's native unit.
- Discrete units (`piece`, `slice`) cannot be converted from imperial — reject at validation.
- This service is **input-side only**: ingredients persisted on a recipe are always already in `RecipeUnit`.

### 6.3 — Recipe CRUD with Auto-Calculation

- On `POST /recipes` and `PATCH /recipes/:id`:
  1. Validate ingredients exist in product library (including soft-deleted)
  2. Run each ingredient through `UnitConversionService.toMetric()` so persisted `unit` is always `RecipeUnit`; preserve the user's original input as `originalQuantity`/`originalUnit` for display
  3. Call `NutritionCalculatorService.calculateRecipeNutrition()`
  4. Call `NutritionCalculatorService.generateWarnings()`
  5. 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[] }` where `unit` may be imperial
  - Service runs each ingredient through `UnitConversionService.toMetric()` and matches names to existing products (fuzzy match by name)
  - Returns the structured recipe for user review — unmatched ingredients and `MISSING_DENSITY` failures are flagged for manual resolution; nothing is persisted yet
- `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.
