feat(filters): split time filter into hands-on and total time (kiwi#52)
Some checks are pending
CI / Backend (Python) (push) Waiting to run
CI / Frontend (Vue) (push) Waiting to run
Mirror / mirror (push) Waiting to run

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.
This commit is contained in:
pyr0ball 2026-04-27 16:03:27 -07:00
parent 640fcefa9e
commit 7498995092
5 changed files with 87 additions and 18 deletions

View file

@ -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"

View file

@ -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:

View file

@ -142,22 +142,40 @@
<!-- Time Budget selector (kiwi#52) -->
<!-- Shows when time_first_layout != 'normal' (auto or time_first) -->
<div v-if="settingsStore.timeFirstLayout !== 'normal'" class="form-group time-bucket-group">
<label class="form-label">How much time do you have?</label>
<div class="flex flex-wrap gap-sm">
<!-- Hands-on / active time row -->
<div class="time-row">
<span class="time-row-label">Hands-on time</span>
<div class="flex flex-wrap gap-xs">
<button
v-for="bucket in timeBuckets"
:key="bucket.label"
v-for="bucket in activeTimeBuckets"
:key="'active-' + bucket.label"
:class="['btn', 'btn-sm', 'time-bucket-btn',
recipesStore.maxActiveMin === bucket.value ? 'time-bucket-active' : 'btn-secondary']"
@click="recipesStore.maxActiveMin = recipesStore.maxActiveMin === bucket.value ? null : bucket.value"
:aria-pressed="recipesStore.maxActiveMin === bucket.value"
:title="'Max ' + bucket.label + ' of active cooking'"
>{{ bucket.label }}</button>
</div>
</div>
<!-- Total time (including passive waits) row -->
<div class="time-row">
<span class="time-row-label">Total time</span>
<div class="flex flex-wrap gap-xs">
<button
v-for="bucket in totalTimeBuckets"
:key="'total-' + bucket.label"
:class="['btn', 'btn-sm', 'time-bucket-btn',
recipesStore.maxTotalMin === bucket.value ? 'time-bucket-active' : 'btn-secondary']"
@click="recipesStore.maxTotalMin = recipesStore.maxTotalMin === bucket.value ? null : bucket.value"
:aria-pressed="recipesStore.maxTotalMin === bucket.value"
>
{{ bucket.label }}
</button>
:title="'Max ' + bucket.label + ' start to finish'"
>{{ bucket.label }}</button>
</div>
</div>
<p class="form-hint">
Filters by time found in recipe steps.
<span v-if="!recipesStore.maxTotalMin">No time limit set.</span>
Both limits apply when set. Hands-on excludes wait time (marinating, baking, etc.).
</p>
</div>
@ -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;

View file

@ -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 {

View file

@ -152,6 +152,7 @@ export const useRecipesStore = defineStore('recipes', () => {
const complexityFilter = ref<string | null>(null)
const maxTimeMin = ref<number | null>(null)
const maxTotalMin = ref<number | null>(null)
const maxActiveMin = ref<number | null>(null)
const nutritionFilters = ref<NutritionFilters>({
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,