Phase 5 cleanup

This commit is contained in:
Aerilyn Weber 2026-04-26 18:44:59 +09:00
parent 5536acd67d
commit 76a516a417
136 changed files with 6322 additions and 1985 deletions

View file

@ -50,13 +50,21 @@ export interface Recipe {
export interface RecipeIngredient {
productId: string; // Reference to Product
productName: string; // Denormalized for display
quantity: number;
unit: ServingUnit;
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;
@ -143,7 +151,7 @@ class NutritionCalculatorService {
/**
* For each ingredient:
* 1. Lookup the product by productId
* 2. Convert ingredient quantity/unit to product's servingUnit
* 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
@ -160,17 +168,26 @@ class NutritionCalculatorService {
}
```
- 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**
- 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
2. Call `NutritionCalculatorService.calculateRecipeNutrition()`
3. Call `NutritionCalculatorService.generateWarnings()`
4. Store computed `totalNutrition`, `perServingNutrition`, `warnings` on document
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
@ -186,9 +203,9 @@ class NutritionCalculatorService {
- `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
- 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