diff --git a/app/models/schemas/recipe.py b/app/models/schemas/recipe.py index 139f383..50fc293 100644 --- a/app/models/schemas/recipe.py +++ b/app/models/schemas/recipe.py @@ -41,6 +41,8 @@ class RecipeSuggestion(BaseModel): is_wildcard: bool = False nutrition: NutritionPanel | None = None source_url: str | None = None + complexity: str | None = None # 'easy' | 'moderate' | 'involved' + estimated_time_min: int | None = None # derived from step count + method signals class GroceryLink(BaseModel): @@ -84,6 +86,8 @@ class RecipeRequest(BaseModel): excluded_ids: list[int] = Field(default_factory=list) shopping_mode: bool = False pantry_match_only: bool = False # when True, only return recipes with zero missing ingredients + complexity_filter: str | None = None # 'easy' | 'moderate' | 'involved' — None = any + max_time_min: int | None = None # filter by estimated cooking time ceiling unit_system: str = "metric" # "metric" | "imperial" diff --git a/app/services/recipe/recipe_engine.py b/app/services/recipe/recipe_engine.py index 46a42d3..0c9fcda 100644 --- a/app/services/recipe/recipe_engine.py +++ b/app/services/recipe/recipe_engine.py @@ -562,6 +562,19 @@ def _hard_day_sort_tier( return 2 +def _estimate_time_min(directions: list[str], complexity: str) -> int: + """Rough cooking time estimate from step count and method complexity. + + Not precise — intended for filtering and display hints only. + """ + steps = len(directions) + if complexity == "easy": + return max(5, 10 + steps * 3) + if complexity == "involved": + return max(20, 30 + steps * 6) + return max(10, 20 + steps * 4) # moderate + + def _classify_method_complexity( directions: list[str], available_equipment: list[str] | None = None, @@ -712,16 +725,21 @@ class RecipeEngine: if match_ratio < _L1_MIN_MATCH_RATIO: continue + # Parse directions — needed for complexity, hard_day_mode, and time estimate. + directions: list[str] = row.get("directions") or [] + if isinstance(directions, str): + try: + directions = json.loads(directions) + except Exception: + directions = [directions] + + # Compute complexity for every suggestion (used for badge + filter). + row_complexity = _classify_method_complexity(directions, available_equipment) + row_time_min = _estimate_time_min(directions, row_complexity) + # Filter and tier-rank by hard_day_mode if req.hard_day_mode: - directions: list[str] = row.get("directions") or [] - if isinstance(directions, str): - try: - directions = json.loads(directions) - except Exception: - directions = [directions] - complexity = _classify_method_complexity(directions, available_equipment) - if complexity == "involved": + if row_complexity == "involved": continue hard_day_tier_map[row["id"]] = _hard_day_sort_tier( title=row.get("title", ""), @@ -729,6 +747,14 @@ class RecipeEngine: directions=directions, ) + # Complexity filter (#58) + if req.complexity_filter and row_complexity != req.complexity_filter: + continue + + # Max time filter (#58) + if req.max_time_min is not None and row_time_min > req.max_time_min: + continue + # Level 2: also add dietary constraint swaps from substitution_pairs if req.level == 2 and req.constraints: for ing in ingredient_names: @@ -778,6 +804,8 @@ class RecipeEngine: level=req.level, nutrition=nutrition if has_nutrition else None, source_url=_build_source_url(row), + complexity=row_complexity, + estimated_time_min=row_time_min, )) # Sort corpus results — assembly templates are now served from a dedicated tab. diff --git a/frontend/src/components/RecipesView.vue b/frontend/src/components/RecipesView.vue index 0fa715b..6aca286 100644 --- a/frontend/src/components/RecipesView.vue +++ b/frontend/src/components/RecipesView.vue @@ -370,6 +370,14 @@ :aria-pressed="filterMissing === 2" @click="filterMissing = filterMissing === 2 ? null : 2" >≤2 missing + + + + +
+ + +
+ + + +
+
+

{{ spotlightRecipe.title }}

+
+ {{ spotlightRecipe.complexity }} + ~{{ spotlightRecipe.estimated_time_min }}m + +
+
+

{{ spotlightRecipe.match_count }} ingredients matched from your pantry

+ +
@@ -403,6 +438,8 @@

{{ recipe.title }}

{{ recipe.match_count }} matched + {{ recipe.complexity }} + ~{{ recipe.estimated_time_min }}m Level {{ recipe.level }} Wildcard