From 200a6ef87b3686f6c875e4eaedf59542f885fea9 Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Thu, 16 Apr 2026 09:27:34 -0700 Subject: [PATCH] feat(recipes): complexity badges, time hints, Surprise Me, Just Pick One MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #55 — Complexity rating on recipe cards: - Derived from direction text via _classify_method_complexity() - Badge displayed on every card: easy (green), moderate (amber), involved (red) - Filterable via complexity filter chips in the results bar #58 — Cooking time + difficulty as filter domains: - estimated_time_min derived from step count + complexity - Time hint (~Nm) shown on every card - complexity_filter and max_time_min fields in RecipeRequest - Both applied in the engine before suggestions are built #53 — Surprise Me: picks a random suggestion from the filtered pool, avoids repeating the last pick. Shown in a spotlight card. #57 — Just Pick One: surfaces the top-matched suggestion in the same spotlight card. One tap to commit to cooking it. Closes #55, #58, #53, #57 --- app/models/schemas/recipe.py | 4 ++ app/services/recipe/recipe_engine.py | 44 +++++++++++++---- frontend/src/components/RecipesView.vue | 64 ++++++++++++++++++++++++- frontend/src/services/api.ts | 4 ++ frontend/src/stores/recipes.ts | 6 +++ frontend/src/theme.css | 25 ++++++++++ 6 files changed, 138 insertions(+), 9 deletions(-) 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