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
|
pantry_match_only: bool = False # when True, only return recipes with zero missing ingredients
|
||||||
complexity_filter: str | None = None # 'easy' | 'moderate' | 'involved' — None = any
|
complexity_filter: str | None = None # 'easy' | 'moderate' | 'involved' — None = any
|
||||||
max_time_min: int | None = None # filter by estimated cooking time ceiling
|
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"
|
unit_system: str = "metric" # "metric" | "imperial"
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -918,6 +918,14 @@ class RecipeEngine:
|
||||||
elif row_time_min > req.max_total_min:
|
elif row_time_min > req.max_total_min:
|
||||||
continue
|
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
|
# Level 2: also add dietary constraint swaps from substitution_pairs
|
||||||
if req.level == 2 and req.constraints:
|
if req.level == 2 and req.constraints:
|
||||||
for ing in ingredient_names:
|
for ing in ingredient_names:
|
||||||
|
|
|
||||||
|
|
@ -142,22 +142,40 @@
|
||||||
<!-- Time Budget selector (kiwi#52) -->
|
<!-- Time Budget selector (kiwi#52) -->
|
||||||
<!-- Shows when time_first_layout != 'normal' (auto or time_first) -->
|
<!-- Shows when time_first_layout != 'normal' (auto or time_first) -->
|
||||||
<div v-if="settingsStore.timeFirstLayout !== 'normal'" class="form-group time-bucket-group">
|
<div v-if="settingsStore.timeFirstLayout !== 'normal'" class="form-group time-bucket-group">
|
||||||
<label class="form-label">How much time do you have?</label>
|
<!-- Hands-on / active time row -->
|
||||||
<div class="flex flex-wrap gap-sm">
|
<div class="time-row">
|
||||||
|
<span class="time-row-label">Hands-on time</span>
|
||||||
|
<div class="flex flex-wrap gap-xs">
|
||||||
<button
|
<button
|
||||||
v-for="bucket in timeBuckets"
|
v-for="bucket in activeTimeBuckets"
|
||||||
:key="bucket.label"
|
: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',
|
:class="['btn', 'btn-sm', 'time-bucket-btn',
|
||||||
recipesStore.maxTotalMin === bucket.value ? 'time-bucket-active' : 'btn-secondary']"
|
recipesStore.maxTotalMin === bucket.value ? 'time-bucket-active' : 'btn-secondary']"
|
||||||
@click="recipesStore.maxTotalMin = recipesStore.maxTotalMin === bucket.value ? null : bucket.value"
|
@click="recipesStore.maxTotalMin = recipesStore.maxTotalMin === bucket.value ? null : bucket.value"
|
||||||
:aria-pressed="recipesStore.maxTotalMin === bucket.value"
|
:aria-pressed="recipesStore.maxTotalMin === bucket.value"
|
||||||
>
|
:title="'Max ' + bucket.label + ' start to finish'"
|
||||||
{{ bucket.label }}
|
>{{ bucket.label }}</button>
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<p class="form-hint">
|
<p class="form-hint">
|
||||||
Filters by time found in recipe steps.
|
Both limits apply when set. Hands-on excludes wait time (marinating, baking, etc.).
|
||||||
<span v-if="!recipesStore.maxTotalMin">No time limit set.</span>
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -958,12 +976,21 @@ const activeNutritionFilterCount = computed(() =>
|
||||||
const activeLevel = computed(() => levels.find(l => l.value === recipesStore.level))
|
const activeLevel = computed(() => levels.find(l => l.value === recipesStore.level))
|
||||||
|
|
||||||
// Time budget buckets for the time-first entry selector (kiwi#52)
|
// 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: '15 min', value: 15 },
|
||||||
{ label: '30 min', value: 30 },
|
{ label: '30 min', value: 30 },
|
||||||
{ label: '45 min', value: 45 },
|
{ 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: '90 min', value: 90 },
|
||||||
|
{ label: '2 hr', value: 120 },
|
||||||
|
{ label: '3 hr', value: 180 },
|
||||||
|
{ label: '4+ hr', value: 240 },
|
||||||
]
|
]
|
||||||
|
|
||||||
const cuisineStyles = [
|
const cuisineStyles = [
|
||||||
|
|
@ -1529,10 +1556,28 @@ details[open] .collapsible-summary::before {
|
||||||
/* Time bucket selector (kiwi#52) */
|
/* Time bucket selector (kiwi#52) */
|
||||||
.time-bucket-group {
|
.time-bucket-group {
|
||||||
margin-top: var(--spacing-sm, 0.5rem);
|
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 {
|
.time-bucket-btn {
|
||||||
min-width: 4.5rem;
|
min-width: 4rem;
|
||||||
border-radius: var(--radius-full, 9999px);
|
border-radius: var(--radius-full, 9999px);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
@ -1543,6 +1588,17 @@ details[open] .collapsible-summary::before {
|
||||||
border-color: var(--color-primary, #1a6b4a);
|
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 — auto-fill 2+ columns */
|
||||||
.preset-grid {
|
.preset-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|
|
||||||
|
|
@ -627,6 +627,7 @@ export interface RecipeRequest {
|
||||||
complexity_filter: string | null
|
complexity_filter: string | null
|
||||||
max_time_min: number | null
|
max_time_min: number | null
|
||||||
max_total_min: number | null
|
max_total_min: number | null
|
||||||
|
max_active_min: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Staple {
|
export interface Staple {
|
||||||
|
|
|
||||||
|
|
@ -152,6 +152,7 @@ export const useRecipesStore = defineStore('recipes', () => {
|
||||||
const complexityFilter = ref<string | null>(null)
|
const complexityFilter = ref<string | null>(null)
|
||||||
const maxTimeMin = ref<number | null>(null)
|
const maxTimeMin = ref<number | null>(null)
|
||||||
const maxTotalMin = ref<number | null>(null)
|
const maxTotalMin = ref<number | null>(null)
|
||||||
|
const maxActiveMin = ref<number | null>(null)
|
||||||
const nutritionFilters = ref<NutritionFilters>({
|
const nutritionFilters = ref<NutritionFilters>({
|
||||||
max_calories: null,
|
max_calories: null,
|
||||||
max_sugar_g: null,
|
max_sugar_g: null,
|
||||||
|
|
@ -207,6 +208,7 @@ export const useRecipesStore = defineStore('recipes', () => {
|
||||||
complexity_filter: complexityFilter.value,
|
complexity_filter: complexityFilter.value,
|
||||||
max_time_min: maxTimeMin.value,
|
max_time_min: maxTimeMin.value,
|
||||||
max_total_min: maxTotalMin.value,
|
max_total_min: maxTotalMin.value,
|
||||||
|
max_active_min: maxActiveMin.value,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -396,6 +398,7 @@ export const useRecipesStore = defineStore('recipes', () => {
|
||||||
complexityFilter,
|
complexityFilter,
|
||||||
maxTimeMin,
|
maxTimeMin,
|
||||||
maxTotalMin,
|
maxTotalMin,
|
||||||
|
maxActiveMin,
|
||||||
nutritionFilters,
|
nutritionFilters,
|
||||||
dismissedIds,
|
dismissedIds,
|
||||||
dismissedCount,
|
dismissedCount,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue