feat(recipes): pantry match floor filter — 'can make now' toggle

Adds pantry_match_only flag to RecipeRequest. When enabled, any recipe
with one or more missing ingredients (after swaps) is excluded from
results. Swapped ingredients count as covered.

Frontend: toggle checkbox in recipe settings panel, disabled when
shopping mode is active (the two modes are mutually exclusive). Hides
the max-missing input when pantry-match-only is on (irrelevant there).

Closes #63
This commit is contained in:
pyr0ball 2026-04-16 09:12:24 -07:00
parent 0de6182f48
commit 2ad71f2636
5 changed files with 23 additions and 2 deletions

View file

@ -83,6 +83,7 @@ class RecipeRequest(BaseModel):
nutrition_filters: NutritionFilters = Field(default_factory=NutritionFilters)
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
unit_system: str = "metric" # "metric" | "imperial"

View file

@ -699,6 +699,11 @@ class RecipeEngine:
if not req.shopping_mode and effective_max_missing is not None and len(missing) > effective_max_missing:
continue
# "Can make now" toggle: drop any recipe that still has missing ingredients
# after swaps are applied. Swapped items count as covered.
if req.pantry_match_only and missing:
continue
# L1 match ratio gate: drop results where less than 60% of the recipe's
# ingredients are in the pantry. Prevents low-signal results like a
# 10-ingredient recipe matching on only one common item.

View file

@ -169,6 +169,17 @@
<span id="allergy-hint" class="form-hint">No recipes containing these ingredients will appear.</span>
</div>
<!-- Can Make Now toggle -->
<div class="form-group">
<label class="flex-start gap-sm shopping-toggle">
<input type="checkbox" v-model="recipesStore.pantryMatchOnly" :disabled="recipesStore.shoppingMode" />
<span class="form-label" style="margin-bottom: 0;">Can make now (no missing ingredients)</span>
</label>
<p v-if="recipesStore.pantryMatchOnly && !recipesStore.shoppingMode" class="text-sm text-secondary mt-xs">
Only recipes where every ingredient is in your pantry no substitutions, no shopping.
</p>
</div>
<!-- Shopping Mode (temporary home moves to Shopping tab in #71) -->
<div class="form-group">
<label class="flex-start gap-sm shopping-toggle">
@ -180,8 +191,8 @@
</p>
</div>
<!-- Max Missing hidden in shopping mode -->
<div v-if="!recipesStore.shoppingMode" class="form-group">
<!-- Max Missing hidden in shopping mode or pantry-match-only mode -->
<div v-if="!recipesStore.shoppingMode && !recipesStore.pantryMatchOnly" class="form-group">
<label class="form-label" for="max-missing">Max Missing Ingredients <span class="text-muted text-xs">(optional)</span></label>
<input
id="max-missing"

View file

@ -533,6 +533,7 @@ export interface RecipeRequest {
nutrition_filters: NutritionFilters
excluded_ids: number[]
shopping_mode: boolean
pantry_match_only: boolean
}
export interface Staple {

View file

@ -132,6 +132,7 @@ export const useRecipesStore = defineStore('recipes', () => {
const category = ref<string | null>(null)
const wildcardConfirmed = ref(false)
const shoppingMode = ref(false)
const pantryMatchOnly = ref(false)
const nutritionFilters = ref<NutritionFilters>({
max_calories: null,
max_sugar_g: null,
@ -176,6 +177,7 @@ export const useRecipesStore = defineStore('recipes', () => {
nutrition_filters: nutritionFilters.value,
excluded_ids: [...excluded],
shopping_mode: shoppingMode.value,
pantry_match_only: pantryMatchOnly.value,
}
}
@ -306,6 +308,7 @@ export const useRecipesStore = defineStore('recipes', () => {
category,
wildcardConfirmed,
shoppingMode,
pantryMatchOnly,
nutritionFilters,
dismissedIds,
dismissedCount,