feat(recipes): complexity badges, time hints, Surprise Me, Just Pick One
#55 — Complexity rating on recipe cards: - Derived from direction text via _classify_method_complexity() - Badge displayed on every card: easy (green), moderate (amber), involved (red) - Filterable via complexity filter chips in the results bar #58 — Cooking time + difficulty as filter domains: - estimated_time_min derived from step count + complexity - Time hint (~Nm) shown on every card - complexity_filter and max_time_min fields in RecipeRequest - Both applied in the engine before suggestions are built #53 — Surprise Me: picks a random suggestion from the filtered pool, avoids repeating the last pick. Shown in a spotlight card. #57 — Just Pick One: surfaces the top-matched suggestion in the same spotlight card. One tap to commit to cooking it. Closes #55, #58, #53, #57
This commit is contained in:
parent
c8fdc21c29
commit
200a6ef87b
6 changed files with 138 additions and 9 deletions
|
|
@ -41,6 +41,8 @@ class RecipeSuggestion(BaseModel):
|
|||
is_wildcard: bool = False
|
||||
nutrition: NutritionPanel | None = None
|
||||
source_url: str | None = None
|
||||
complexity: str | None = None # 'easy' | 'moderate' | 'involved'
|
||||
estimated_time_min: int | None = None # derived from step count + method signals
|
||||
|
||||
|
||||
class GroceryLink(BaseModel):
|
||||
|
|
@ -84,6 +86,8 @@ class RecipeRequest(BaseModel):
|
|||
excluded_ids: list[int] = Field(default_factory=list)
|
||||
shopping_mode: bool = False
|
||||
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
|
||||
unit_system: str = "metric" # "metric" | "imperial"
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -562,6 +562,19 @@ def _hard_day_sort_tier(
|
|||
return 2
|
||||
|
||||
|
||||
def _estimate_time_min(directions: list[str], complexity: str) -> int:
|
||||
"""Rough cooking time estimate from step count and method complexity.
|
||||
|
||||
Not precise — intended for filtering and display hints only.
|
||||
"""
|
||||
steps = len(directions)
|
||||
if complexity == "easy":
|
||||
return max(5, 10 + steps * 3)
|
||||
if complexity == "involved":
|
||||
return max(20, 30 + steps * 6)
|
||||
return max(10, 20 + steps * 4) # moderate
|
||||
|
||||
|
||||
def _classify_method_complexity(
|
||||
directions: list[str],
|
||||
available_equipment: list[str] | None = None,
|
||||
|
|
@ -712,16 +725,21 @@ class RecipeEngine:
|
|||
if match_ratio < _L1_MIN_MATCH_RATIO:
|
||||
continue
|
||||
|
||||
# Parse directions — needed for complexity, hard_day_mode, and time estimate.
|
||||
directions: list[str] = row.get("directions") or []
|
||||
if isinstance(directions, str):
|
||||
try:
|
||||
directions = json.loads(directions)
|
||||
except Exception:
|
||||
directions = [directions]
|
||||
|
||||
# Compute complexity for every suggestion (used for badge + filter).
|
||||
row_complexity = _classify_method_complexity(directions, available_equipment)
|
||||
row_time_min = _estimate_time_min(directions, row_complexity)
|
||||
|
||||
# Filter and tier-rank by hard_day_mode
|
||||
if req.hard_day_mode:
|
||||
directions: list[str] = row.get("directions") or []
|
||||
if isinstance(directions, str):
|
||||
try:
|
||||
directions = json.loads(directions)
|
||||
except Exception:
|
||||
directions = [directions]
|
||||
complexity = _classify_method_complexity(directions, available_equipment)
|
||||
if complexity == "involved":
|
||||
if row_complexity == "involved":
|
||||
continue
|
||||
hard_day_tier_map[row["id"]] = _hard_day_sort_tier(
|
||||
title=row.get("title", ""),
|
||||
|
|
@ -729,6 +747,14 @@ class RecipeEngine:
|
|||
directions=directions,
|
||||
)
|
||||
|
||||
# Complexity filter (#58)
|
||||
if req.complexity_filter and row_complexity != req.complexity_filter:
|
||||
continue
|
||||
|
||||
# Max time filter (#58)
|
||||
if req.max_time_min is not None and row_time_min > req.max_time_min:
|
||||
continue
|
||||
|
||||
# Level 2: also add dietary constraint swaps from substitution_pairs
|
||||
if req.level == 2 and req.constraints:
|
||||
for ing in ingredient_names:
|
||||
|
|
@ -778,6 +804,8 @@ class RecipeEngine:
|
|||
level=req.level,
|
||||
nutrition=nutrition if has_nutrition else None,
|
||||
source_url=_build_source_url(row),
|
||||
complexity=row_complexity,
|
||||
estimated_time_min=row_time_min,
|
||||
))
|
||||
|
||||
# Sort corpus results — assembly templates are now served from a dedicated tab.
|
||||
|
|
|
|||
|
|
@ -370,6 +370,14 @@
|
|||
:aria-pressed="filterMissing === 2"
|
||||
@click="filterMissing = filterMissing === 2 ? null : 2"
|
||||
>≤2 missing</button>
|
||||
<!-- Complexity filter chips (#55 / #58) -->
|
||||
<button
|
||||
v-for="cx in ['easy', 'moderate', 'involved']"
|
||||
:key="cx"
|
||||
:class="['filter-chip', { active: filterComplexity === cx }]"
|
||||
:aria-pressed="filterComplexity === cx"
|
||||
@click="filterComplexity = filterComplexity === cx ? null : cx"
|
||||
>{{ cx }}</button>
|
||||
<button
|
||||
v-if="hasActiveFilters"
|
||||
class="filter-chip filter-chip-clear"
|
||||
|
|
@ -377,6 +385,33 @@
|
|||
@click="clearFilters"
|
||||
><span aria-hidden="true">✕</span> Clear</button>
|
||||
</div>
|
||||
|
||||
<!-- Zero-decision picks (#53 Surprise Me / #57 Just Pick One) -->
|
||||
<div v-if="filteredSuggestions.length > 0" class="flex gap-sm flex-wrap" style="margin-top: var(--spacing-sm)">
|
||||
<button class="btn btn-secondary btn-sm" @click="pickSurprise" :disabled="filteredSuggestions.length === 0">
|
||||
🎲 Surprise me
|
||||
</button>
|
||||
<button class="btn btn-secondary btn-sm" @click="pickBest" :disabled="filteredSuggestions.length === 0">
|
||||
⚡ Just pick one
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Spotlight (Surprise Me / Just Pick One result) -->
|
||||
<div v-if="spotlightRecipe" class="card spotlight-card slide-up mb-md">
|
||||
<div class="flex-between mb-sm">
|
||||
<h3 class="text-lg font-bold">{{ spotlightRecipe.title }}</h3>
|
||||
<div class="flex gap-xs" style="align-items:center">
|
||||
<span v-if="spotlightRecipe.complexity" :class="['status-badge', `complexity-${spotlightRecipe.complexity}`]">{{ spotlightRecipe.complexity }}</span>
|
||||
<span v-if="spotlightRecipe.estimated_time_min" class="status-badge status-neutral">~{{ spotlightRecipe.estimated_time_min }}m</span>
|
||||
<button class="btn-icon" @click="spotlightRecipe = null" aria-label="Dismiss">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-sm text-secondary mb-xs">{{ spotlightRecipe.match_count }} ingredients matched from your pantry</p>
|
||||
<button class="btn btn-primary btn-sm" @click="selectedRecipe = spotlightRecipe; spotlightRecipe = null">
|
||||
Cook this
|
||||
</button>
|
||||
<button class="btn btn-ghost btn-sm ml-sm" @click="pickSurprise">Try another</button>
|
||||
</div>
|
||||
|
||||
<!-- No suggestions -->
|
||||
|
|
@ -403,6 +438,8 @@
|
|||
<h3 class="text-lg font-bold recipe-title">{{ recipe.title }}</h3>
|
||||
<div class="flex flex-wrap gap-xs" style="align-items:center">
|
||||
<span class="status-badge status-success">{{ recipe.match_count }} matched</span>
|
||||
<span v-if="recipe.complexity" :class="['status-badge', `complexity-${recipe.complexity}`]">{{ recipe.complexity }}</span>
|
||||
<span v-if="recipe.estimated_time_min" class="status-badge status-neutral">~{{ recipe.estimated_time_min }}m</span>
|
||||
<span class="status-badge status-info">Level {{ recipe.level }}</span>
|
||||
<span v-if="recipe.is_wildcard" class="status-badge status-info">Wildcard</span>
|
||||
<button
|
||||
|
|
@ -739,6 +776,8 @@ const selectedRecipe = ref<RecipeSuggestion | null>(null)
|
|||
const filterText = ref('')
|
||||
const filterLevel = ref<number | null>(null)
|
||||
const filterMissing = ref<number | null>(null)
|
||||
const filterComplexity = ref<string | null>(null)
|
||||
const spotlightRecipe = ref<RecipeSuggestion | null>(null)
|
||||
|
||||
const availableLevels = computed(() => {
|
||||
if (!recipesStore.result) return []
|
||||
|
|
@ -762,17 +801,35 @@ const filteredSuggestions = computed(() => {
|
|||
if (filterMissing.value !== null) {
|
||||
items = items.filter((r) => r.missing_ingredients.length <= filterMissing.value!)
|
||||
}
|
||||
if (filterComplexity.value !== null) {
|
||||
items = items.filter((r) => r.complexity === filterComplexity.value)
|
||||
}
|
||||
return items
|
||||
})
|
||||
|
||||
const hasActiveFilters = computed(
|
||||
() => filterText.value.trim() !== '' || filterLevel.value !== null || filterMissing.value !== null
|
||||
() => filterText.value.trim() !== '' || filterLevel.value !== null || filterMissing.value !== null || filterComplexity.value !== null
|
||||
)
|
||||
|
||||
function clearFilters() {
|
||||
filterText.value = ''
|
||||
filterLevel.value = null
|
||||
filterMissing.value = null
|
||||
filterComplexity.value = null
|
||||
}
|
||||
|
||||
function pickSurprise() {
|
||||
const pool = filteredSuggestions.value
|
||||
if (!pool.length) return
|
||||
const exclude = spotlightRecipe.value?.id
|
||||
const candidates = pool.length > 1 ? pool.filter((r) => r.id !== exclude) : pool
|
||||
spotlightRecipe.value = candidates[Math.floor(Math.random() * candidates.length)]
|
||||
}
|
||||
|
||||
function pickBest() {
|
||||
const pool = filteredSuggestions.value
|
||||
if (!pool.length) return
|
||||
spotlightRecipe.value = pool[0]
|
||||
}
|
||||
|
||||
const selectedGroceryLinks = computed<GroceryLink[]>(() => {
|
||||
|
|
@ -1472,6 +1529,11 @@ details[open] .collapsible-summary::before {
|
|||
padding: var(--spacing-xs) var(--spacing-md);
|
||||
}
|
||||
|
||||
.spotlight-card {
|
||||
border: 2px solid var(--color-primary);
|
||||
background: linear-gradient(135deg, var(--color-bg-elevated) 0%, rgba(232, 168, 32, 0.06) 100%);
|
||||
}
|
||||
|
||||
.results-section {
|
||||
margin-top: var(--spacing-md);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -495,6 +495,8 @@ export interface RecipeSuggestion {
|
|||
is_wildcard: boolean
|
||||
nutrition: NutritionPanel | null
|
||||
source_url: string | null
|
||||
complexity: 'easy' | 'moderate' | 'involved' | null
|
||||
estimated_time_min: number | null
|
||||
}
|
||||
|
||||
export interface NutritionFilters {
|
||||
|
|
@ -534,6 +536,8 @@ export interface RecipeRequest {
|
|||
excluded_ids: number[]
|
||||
shopping_mode: boolean
|
||||
pantry_match_only: boolean
|
||||
complexity_filter: string | null
|
||||
max_time_min: number | null
|
||||
}
|
||||
|
||||
export interface Staple {
|
||||
|
|
|
|||
|
|
@ -133,6 +133,8 @@ export const useRecipesStore = defineStore('recipes', () => {
|
|||
const wildcardConfirmed = ref(false)
|
||||
const shoppingMode = ref(false)
|
||||
const pantryMatchOnly = ref(false)
|
||||
const complexityFilter = ref<string | null>(null)
|
||||
const maxTimeMin = ref<number | null>(null)
|
||||
const nutritionFilters = ref<NutritionFilters>({
|
||||
max_calories: null,
|
||||
max_sugar_g: null,
|
||||
|
|
@ -178,6 +180,8 @@ export const useRecipesStore = defineStore('recipes', () => {
|
|||
excluded_ids: [...excluded],
|
||||
shopping_mode: shoppingMode.value,
|
||||
pantry_match_only: pantryMatchOnly.value,
|
||||
complexity_filter: complexityFilter.value,
|
||||
max_time_min: maxTimeMin.value,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -309,6 +313,8 @@ export const useRecipesStore = defineStore('recipes', () => {
|
|||
wildcardConfirmed,
|
||||
shoppingMode,
|
||||
pantryMatchOnly,
|
||||
complexityFilter,
|
||||
maxTimeMin,
|
||||
nutritionFilters,
|
||||
dismissedIds,
|
||||
dismissedCount,
|
||||
|
|
|
|||
|
|
@ -649,6 +649,31 @@
|
|||
border: 1px solid var(--color-info-border);
|
||||
}
|
||||
|
||||
.status-neutral {
|
||||
background: rgba(255, 248, 235, 0.06);
|
||||
color: var(--color-text-secondary);
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
/* Recipe complexity badges */
|
||||
.complexity-easy {
|
||||
background: var(--color-success-bg);
|
||||
color: var(--color-success-light);
|
||||
border: 1px solid var(--color-success-border);
|
||||
}
|
||||
|
||||
.complexity-moderate {
|
||||
background: var(--color-warning-bg);
|
||||
color: var(--color-warning-light);
|
||||
border: 1px solid var(--color-warning-border);
|
||||
}
|
||||
|
||||
.complexity-involved {
|
||||
background: var(--color-error-bg);
|
||||
color: var(--color-error-light);
|
||||
border: 1px solid var(--color-error-border);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
ANIMATION UTILITIES
|
||||
============================================ */
|
||||
|
|
|
|||
Loading…
Reference in a new issue