From c3e7dc1ea4d2199fabe8f64718c1d943e19eaaa1 Mon Sep 17 00:00:00 2001
From: pyr0ball
Date: Fri, 24 Apr 2026 10:15:58 -0700
Subject: [PATCH] feat: time-first recipe entry (kiwi#52)
- Add max_total_min to RecipeRequest schema and TypeScript interface
- Add _within_time() helper to recipe_engine using parse_time_effort()
with graceful degradation (empty directions or no signals -> pass)
- Wire max_total_min filter into suggest() loop after max_time_min
- Add time_first_layout to allowed settings keys
- Add timeFirstLayout ref to settings store (preserves sensoryPreferences)
- Add maxTotalMin ref to recipes store, wired into _buildRequest()
- Add time bucket selector UI (15/30/45/60/90 min) in RecipesView
Find tab, gated by timeFirstLayout != 'normal'
- Add time-first layout selector section in SettingsView
- Add 5 _within_time unit tests and 2 settings key tests
---
app/api/endpoints/settings.py | 2 +-
app/models/schemas/recipe.py | 1 +
app/services/recipe/recipe_engine.py | 20 +++++++
frontend/src/components/RecipesView.vue | 50 +++++++++++++++++
frontend/src/components/SettingsView.vue | 59 +++++++++++++++++++++
frontend/src/services/api.ts | 1 +
frontend/src/stores/recipes.ts | 3 ++
frontend/src/stores/settings.ts | 11 +++-
tests/api/test_settings.py | 28 ++++++++++
tests/services/recipe/test_recipe_engine.py | 36 +++++++++++++
10 files changed, 209 insertions(+), 2 deletions(-)
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.
+
+
+
+
dietaryOpen = (e.target as HTMLDetailsElement).open">
@@ -696,6 +718,7 @@