feat(filters): split time filter into hands-on and total time (kiwi#52)
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:
parent
640fcefa9e
commit
7498995092
5 changed files with 87 additions and 18 deletions
|
|
@ -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"
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
<button
|
||||
v-for="bucket in timeBuckets"
|
||||
:key="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>
|
||||
<!-- 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 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"
|
||||
: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;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue