2026-03-27 14:50:34 +09:00
# 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
2026-04-26 18:44:59 +09:00
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')
2026-03-27 14:50:34 +09:00
preparation?: string; // e.g., 'diced', 'minced', 'melted'
isOptional: boolean;
nutritionContribution: NutritionInfo; // Per-ingredient computed nutrition
}
2026-04-26 18:44:59 +09:00
// 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';
2026-03-27 14:50:34 +09:00
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
2026-04-26 18:44:59 +09:00
* 2. Convert ingredient quantity/unit to product's servingUnit (already metric)
2026-03-27 14:50:34 +09:00
* 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[];
}
```
2026-04-26 18:44:59 +09:00
- 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` .
2026-03-27 14:50:34 +09:00
### 6.3 — Recipe CRUD with Auto-Calculation
- On `POST /recipes` and `PATCH /recipes/:id` :
2026-04-26 18:44:59 +09:00
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
2026-03-27 14:50:34 +09:00
- 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)`
2026-04-26 18:44:59 +09:00
- 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
2026-03-27 14:50:34 +09:00
- `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.