From 74989950928b86c2295a616d94773c71a87a6601 Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Mon, 27 Apr 2026 16:03:27 -0700 Subject: [PATCH] feat(filters): split time filter into hands-on and total time (kiwi#52) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds max_active_min request field and backend filter. Active time uses parse_time_effort().active_min (passive waits excluded). Recipes with no parsed active time signal are not excluded (avoid hiding unlabelled results). Total and active limits are AND'd when both set. UI: two pill rows — "Hands-on time" (15/30/45/1hr) and "Total time" (30m/1hr/90m/2hr/3hr/4+hr). Replaces single row capped at 90 min. --- app/models/schemas/recipe.py | 3 +- app/services/recipe/recipe_engine.py | 8 +++ frontend/src/components/RecipesView.vue | 90 ++++++++++++++++++++----- frontend/src/services/api.ts | 1 + frontend/src/stores/recipes.ts | 3 + 5 files changed, 87 insertions(+), 18 deletions(-) diff --git a/app/models/schemas/recipe.py b/app/models/schemas/recipe.py index c0b434d..0fc8d0a 100644 --- a/app/models/schemas/recipe.py +++ b/app/models/schemas/recipe.py @@ -137,7 +137,8 @@ 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 + max_total_min: int | None = None # filter by parsed total time (active + passive) + max_active_min: int | None = None # filter by hands-on active time only unit_system: str = "metric" # "metric" | "imperial" diff --git a/app/services/recipe/recipe_engine.py b/app/services/recipe/recipe_engine.py index 817479b..6d8b901 100644 --- a/app/services/recipe/recipe_engine.py +++ b/app/services/recipe/recipe_engine.py @@ -918,6 +918,14 @@ class RecipeEngine: elif row_time_min > req.max_total_min: continue + # Active (hands-on) time filter — independent of total time. + # Lets users request "≤30 min hands-on, any total" to include slow braises. + # Skips recipes where active_min == 0 (no time signals parsed) to avoid + # hiding valid results when the parser couldn't extract timing. + if req.max_active_min is not None and row_time_effort.active_min > 0: + if row_time_effort.active_min > req.max_active_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 79547f6..04b3219 100644 --- a/frontend/src/components/RecipesView.vue +++ b/frontend/src/components/RecipesView.vue @@ -142,22 +142,40 @@
- -
- + +
+ Hands-on time +
+ +
+ + +
+ Total time +
+ +
+
+

- Filters by time found in recipe steps. - No time limit set. + Both limits apply when set. Hands-on excludes wait time (marinating, baking, etc.).

@@ -958,12 +976,21 @@ const activeNutritionFilterCount = computed(() => const activeLevel = computed(() => levels.find(l => l.value === recipesStore.level)) // Time budget buckets for the time-first entry selector (kiwi#52) -const timeBuckets = [ +// Active = hands-on cooking; total = active + passive waits (marinating, baking, etc.) +const activeTimeBuckets = [ { label: '15 min', value: 15 }, { label: '30 min', value: 30 }, { label: '45 min', value: 45 }, - { label: '1 hour', value: 60 }, + { label: '1 hr', value: 60 }, +] + +const totalTimeBuckets = [ + { label: '30 min', value: 30 }, + { label: '1 hr', value: 60 }, { label: '90 min', value: 90 }, + { label: '2 hr', value: 120 }, + { label: '3 hr', value: 180 }, + { label: '4+ hr', value: 240 }, ] const cuisineStyles = [ @@ -1529,10 +1556,28 @@ details[open] .collapsible-summary::before { /* Time bucket selector (kiwi#52) */ .time-bucket-group { margin-top: var(--spacing-sm, 0.5rem); + display: flex; + flex-direction: column; + gap: var(--spacing-xs); +} + +.time-row { + display: flex; + align-items: center; + gap: var(--spacing-sm); + flex-wrap: wrap; +} + +.time-row-label { + font-size: var(--font-size-sm); + color: var(--color-text-secondary); + white-space: nowrap; + min-width: 6rem; + font-weight: 500; } .time-bucket-btn { - min-width: 4.5rem; + min-width: 4rem; border-radius: var(--radius-full, 9999px); font-weight: 500; } @@ -1543,6 +1588,17 @@ details[open] .collapsible-summary::before { border-color: var(--color-primary, #1a6b4a); } +@media (max-width: 480px) { + .time-row { + flex-direction: column; + align-items: flex-start; + } + + .time-row-label { + min-width: unset; + } +} + /* Preset grid — auto-fill 2+ columns */ .preset-grid { display: grid; diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 7dfe789..5e6c981 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -627,6 +627,7 @@ export interface RecipeRequest { complexity_filter: string | null max_time_min: number | null max_total_min: number | null + max_active_min: number | null } export interface Staple { diff --git a/frontend/src/stores/recipes.ts b/frontend/src/stores/recipes.ts index f85df6b..de6027a 100644 --- a/frontend/src/stores/recipes.ts +++ b/frontend/src/stores/recipes.ts @@ -152,6 +152,7 @@ export const useRecipesStore = defineStore('recipes', () => { const complexityFilter = ref(null) const maxTimeMin = ref(null) const maxTotalMin = ref(null) + const maxActiveMin = ref(null) const nutritionFilters = ref({ max_calories: null, max_sugar_g: null, @@ -207,6 +208,7 @@ export const useRecipesStore = defineStore('recipes', () => { complexity_filter: complexityFilter.value, max_time_min: maxTimeMin.value, max_total_min: maxTotalMin.value, + max_active_min: maxActiveMin.value, } } @@ -396,6 +398,7 @@ export const useRecipesStore = defineStore('recipes', () => { complexityFilter, maxTimeMin, maxTotalMin, + maxActiveMin, nutritionFilters, dismissedIds, dismissedCount,