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