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
|
is_wildcard: bool = False
|
||||||
nutrition: NutritionPanel | None = None
|
nutrition: NutritionPanel | None = None
|
||||||
source_url: str | 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):
|
class GroceryLink(BaseModel):
|
||||||
|
|
@ -84,6 +86,8 @@ class RecipeRequest(BaseModel):
|
||||||
excluded_ids: list[int] = Field(default_factory=list)
|
excluded_ids: list[int] = Field(default_factory=list)
|
||||||
shopping_mode: bool = False
|
shopping_mode: bool = False
|
||||||
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
|
||||||
|
max_time_min: int | None = None # filter by estimated cooking time ceiling
|
||||||
unit_system: str = "metric" # "metric" | "imperial"
|
unit_system: str = "metric" # "metric" | "imperial"
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -562,6 +562,19 @@ def _hard_day_sort_tier(
|
||||||
return 2
|
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(
|
def _classify_method_complexity(
|
||||||
directions: list[str],
|
directions: list[str],
|
||||||
available_equipment: list[str] | None = None,
|
available_equipment: list[str] | None = None,
|
||||||
|
|
@ -712,16 +725,21 @@ class RecipeEngine:
|
||||||
if match_ratio < _L1_MIN_MATCH_RATIO:
|
if match_ratio < _L1_MIN_MATCH_RATIO:
|
||||||
continue
|
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
|
# Filter and tier-rank by hard_day_mode
|
||||||
if req.hard_day_mode:
|
if req.hard_day_mode:
|
||||||
directions: list[str] = row.get("directions") or []
|
if row_complexity == "involved":
|
||||||
if isinstance(directions, str):
|
|
||||||
try:
|
|
||||||
directions = json.loads(directions)
|
|
||||||
except Exception:
|
|
||||||
directions = [directions]
|
|
||||||
complexity = _classify_method_complexity(directions, available_equipment)
|
|
||||||
if complexity == "involved":
|
|
||||||
continue
|
continue
|
||||||
hard_day_tier_map[row["id"]] = _hard_day_sort_tier(
|
hard_day_tier_map[row["id"]] = _hard_day_sort_tier(
|
||||||
title=row.get("title", ""),
|
title=row.get("title", ""),
|
||||||
|
|
@ -729,6 +747,14 @@ class RecipeEngine:
|
||||||
directions=directions,
|
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
|
# 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:
|
||||||
|
|
@ -778,6 +804,8 @@ class RecipeEngine:
|
||||||
level=req.level,
|
level=req.level,
|
||||||
nutrition=nutrition if has_nutrition else None,
|
nutrition=nutrition if has_nutrition else None,
|
||||||
source_url=_build_source_url(row),
|
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.
|
# Sort corpus results — assembly templates are now served from a dedicated tab.
|
||||||
|
|
|
||||||
|
|
@ -370,6 +370,14 @@
|
||||||
:aria-pressed="filterMissing === 2"
|
:aria-pressed="filterMissing === 2"
|
||||||
@click="filterMissing = filterMissing === 2 ? null : 2"
|
@click="filterMissing = filterMissing === 2 ? null : 2"
|
||||||
>≤2 missing</button>
|
>≤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
|
<button
|
||||||
v-if="hasActiveFilters"
|
v-if="hasActiveFilters"
|
||||||
class="filter-chip filter-chip-clear"
|
class="filter-chip filter-chip-clear"
|
||||||
|
|
@ -377,6 +385,33 @@
|
||||||
@click="clearFilters"
|
@click="clearFilters"
|
||||||
><span aria-hidden="true">✕</span> Clear</button>
|
><span aria-hidden="true">✕</span> Clear</button>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- No suggestions -->
|
<!-- No suggestions -->
|
||||||
|
|
@ -403,6 +438,8 @@
|
||||||
<h3 class="text-lg font-bold recipe-title">{{ recipe.title }}</h3>
|
<h3 class="text-lg font-bold recipe-title">{{ recipe.title }}</h3>
|
||||||
<div class="flex flex-wrap gap-xs" style="align-items:center">
|
<div class="flex flex-wrap gap-xs" style="align-items:center">
|
||||||
<span class="status-badge status-success">{{ recipe.match_count }} matched</span>
|
<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 class="status-badge status-info">Level {{ recipe.level }}</span>
|
||||||
<span v-if="recipe.is_wildcard" class="status-badge status-info">Wildcard</span>
|
<span v-if="recipe.is_wildcard" class="status-badge status-info">Wildcard</span>
|
||||||
<button
|
<button
|
||||||
|
|
@ -739,6 +776,8 @@ const selectedRecipe = ref<RecipeSuggestion | null>(null)
|
||||||
const filterText = ref('')
|
const filterText = ref('')
|
||||||
const filterLevel = ref<number | null>(null)
|
const filterLevel = ref<number | null>(null)
|
||||||
const filterMissing = 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(() => {
|
const availableLevels = computed(() => {
|
||||||
if (!recipesStore.result) return []
|
if (!recipesStore.result) return []
|
||||||
|
|
@ -762,17 +801,35 @@ const filteredSuggestions = computed(() => {
|
||||||
if (filterMissing.value !== null) {
|
if (filterMissing.value !== null) {
|
||||||
items = items.filter((r) => r.missing_ingredients.length <= filterMissing.value!)
|
items = items.filter((r) => r.missing_ingredients.length <= filterMissing.value!)
|
||||||
}
|
}
|
||||||
|
if (filterComplexity.value !== null) {
|
||||||
|
items = items.filter((r) => r.complexity === filterComplexity.value)
|
||||||
|
}
|
||||||
return items
|
return items
|
||||||
})
|
})
|
||||||
|
|
||||||
const hasActiveFilters = computed(
|
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() {
|
function clearFilters() {
|
||||||
filterText.value = ''
|
filterText.value = ''
|
||||||
filterLevel.value = null
|
filterLevel.value = null
|
||||||
filterMissing.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[]>(() => {
|
const selectedGroceryLinks = computed<GroceryLink[]>(() => {
|
||||||
|
|
@ -1472,6 +1529,11 @@ details[open] .collapsible-summary::before {
|
||||||
padding: var(--spacing-xs) var(--spacing-md);
|
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 {
|
.results-section {
|
||||||
margin-top: var(--spacing-md);
|
margin-top: var(--spacing-md);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -495,6 +495,8 @@ export interface RecipeSuggestion {
|
||||||
is_wildcard: boolean
|
is_wildcard: boolean
|
||||||
nutrition: NutritionPanel | null
|
nutrition: NutritionPanel | null
|
||||||
source_url: string | null
|
source_url: string | null
|
||||||
|
complexity: 'easy' | 'moderate' | 'involved' | null
|
||||||
|
estimated_time_min: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NutritionFilters {
|
export interface NutritionFilters {
|
||||||
|
|
@ -534,6 +536,8 @@ export interface RecipeRequest {
|
||||||
excluded_ids: number[]
|
excluded_ids: number[]
|
||||||
shopping_mode: boolean
|
shopping_mode: boolean
|
||||||
pantry_match_only: boolean
|
pantry_match_only: boolean
|
||||||
|
complexity_filter: string | null
|
||||||
|
max_time_min: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Staple {
|
export interface Staple {
|
||||||
|
|
|
||||||
|
|
@ -133,6 +133,8 @@ export const useRecipesStore = defineStore('recipes', () => {
|
||||||
const wildcardConfirmed = ref(false)
|
const wildcardConfirmed = ref(false)
|
||||||
const shoppingMode = ref(false)
|
const shoppingMode = ref(false)
|
||||||
const pantryMatchOnly = ref(false)
|
const pantryMatchOnly = ref(false)
|
||||||
|
const complexityFilter = ref<string | null>(null)
|
||||||
|
const maxTimeMin = 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,
|
||||||
|
|
@ -178,6 +180,8 @@ export const useRecipesStore = defineStore('recipes', () => {
|
||||||
excluded_ids: [...excluded],
|
excluded_ids: [...excluded],
|
||||||
shopping_mode: shoppingMode.value,
|
shopping_mode: shoppingMode.value,
|
||||||
pantry_match_only: pantryMatchOnly.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,
|
wildcardConfirmed,
|
||||||
shoppingMode,
|
shoppingMode,
|
||||||
pantryMatchOnly,
|
pantryMatchOnly,
|
||||||
|
complexityFilter,
|
||||||
|
maxTimeMin,
|
||||||
nutritionFilters,
|
nutritionFilters,
|
||||||
dismissedIds,
|
dismissedIds,
|
||||||
dismissedCount,
|
dismissedCount,
|
||||||
|
|
|
||||||
|
|
@ -649,6 +649,31 @@
|
||||||
border: 1px solid var(--color-info-border);
|
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
|
ANIMATION UTILITIES
|
||||||
============================================ */
|
============================================ */
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue