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

10 KiB
Raw Blame History

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

// 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

{ 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:
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.