diff --git a/app/api/endpoints/settings.py b/app/api/endpoints/settings.py index 7c0a548..2a99bb8 100644 --- a/app/api/endpoints/settings.py +++ b/app/api/endpoints/settings.py @@ -10,7 +10,7 @@ from app.db.store import Store router = APIRouter() -_ALLOWED_KEYS = frozenset({"cooking_equipment", "unit_system", "shopping_locale", "sensory_preferences"}) +_ALLOWED_KEYS = frozenset({"cooking_equipment", "unit_system", "shopping_locale", "sensory_preferences", "time_first_layout"}) class SettingBody(BaseModel): diff --git a/app/models/schemas/recipe.py b/app/models/schemas/recipe.py index 0d2b61d..8eb267d 100644 --- a/app/models/schemas/recipe.py +++ b/app/models/schemas/recipe.py @@ -105,6 +105,7 @@ class RecipeRequest(BaseModel): 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 + max_total_min: int | None = None # filter by parsed total time from recipe directions unit_system: str = "metric" # "metric" | "imperial" diff --git a/app/services/recipe/recipe_engine.py b/app/services/recipe/recipe_engine.py index 62661de..91d16e8 100644 --- a/app/services/recipe/recipe_engine.py +++ b/app/services/recipe/recipe_engine.py @@ -25,6 +25,7 @@ from app.services.recipe.element_classifier import ElementClassifier from app.services.recipe.grocery_links import GroceryLinkBuilder from app.services.recipe.substitution_engine import SubstitutionEngine from app.services.recipe.sensory import SensoryExclude, build_sensory_exclude, passes_sensory_filter +from app.services.recipe.time_effort import parse_time_effort _LEFTOVER_DAILY_MAX_FREE = 5 @@ -613,6 +614,21 @@ def _estimate_time_min(directions: list[str], complexity: str) -> int: return max(10, 20 + steps * 4) # moderate +def _within_time(directions: list[str], max_total_min: int) -> bool: + """Return True if parsed total time (active + passive) is within max_total_min. + + Graceful degradation: + - Empty directions -> True (no data, don't hide) + - total_min == 0 (no time signals found) -> True (unparseable, don't hide) + """ + if not directions: + return True + profile = parse_time_effort(directions) + if profile.total_min == 0: + return True + return profile.total_min <= max_total_min + + def _classify_method_complexity( directions: list[str], available_equipment: list[str] | None = None, @@ -807,6 +823,10 @@ class RecipeEngine: if req.max_time_min is not None and row_time_min > req.max_time_min: continue + # Total time filter (kiwi#52) — uses parsed time from directions + if req.max_total_min is not None and not _within_time(directions, req.max_total_min): + continue + # Level 2: also add dietary constraint swaps from substitution_pairs if req.level == 2 and req.constraints: for ing in ingredient_names: diff --git a/frontend/src/components/RecipesView.vue b/frontend/src/components/RecipesView.vue index f4a87c9..95d0ea5 100644 --- a/frontend/src/components/RecipesView.vue +++ b/frontend/src/components/RecipesView.vue @@ -102,6 +102,28 @@ Tap "Find recipes" again to apply.

+ + +
+ +
+ +
+

+ Filters by time found in recipe steps. + No time limit set. +

+
+
@@ -696,6 +718,7 @@