MeshiTrack/docs/phases/phase-6-recipes.md

254 lines
10 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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.