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:
pyr0ball 2026-04-16 09:27:34 -07:00
parent c8fdc21c29
commit 200a6ef87b
6 changed files with 138 additions and 9 deletions

View file

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

View file

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

View file

@ -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);
}

View file

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

View file

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

View file

@ -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
============================================ */