fix: save, shopping list, and route ordering for Build Your Own

- Persist built recipes to recipes table on /build so they get real DB IDs
  and can be bookmarked via saved_recipes (FK was pointing at negative IDs)
- Populate missing_ingredients in build_from_selection() from role_overrides
  vs pantry diff -- backend now owns shopping list computation
- Remove client-side cartItems tracking; shopping list derived from
  builtRecipe.missing_ingredients instead
- Fix saved_recipes 422: mount saved_recipes router before recipes router in
  routes.py so /recipes/saved isn't captured by /recipes/{recipe_id}
- Bump SaveRecipeModal z-index to 500 (above detail-overlay at 400)
- Replace "Add to pantry" primary action with "Grocery list" clipboard copy;
  "Add to pantry" demoted to compact secondary button
This commit is contained in:
pyr0ball 2026-04-14 14:48:30 -07:00
parent b4f031e87d
commit 3933136666
7 changed files with 146 additions and 12 deletions

View file

@ -212,11 +212,27 @@ async def build_recipe(
for item in items
if item.get("product_name")
}
return build_from_selection(
suggestion = build_from_selection(
template_slug=req.template_id,
role_overrides=req.role_overrides,
pantry_set=pantry_set,
)
if suggestion is None:
return None
# Persist to recipes table so the result can be saved/bookmarked.
# external_id encodes template + selections for stable dedup.
import hashlib as _hl, json as _js
sel_hash = _hl.md5(
_js.dumps(req.role_overrides, sort_keys=True).encode()
).hexdigest()[:8]
external_id = f"assembly:{req.template_id}:{sel_hash}"
real_id = store.upsert_built_recipe(
external_id=external_id,
title=suggestion.title,
ingredients=suggestion.matched_ingredients,
directions=suggestion.directions,
)
return suggestion.model_copy(update={"id": real_id})
finally:
store.close()

View file

@ -9,11 +9,11 @@ api_router.include_router(receipts.router, prefix="/receipts", tags=
api_router.include_router(ocr.router, prefix="/receipts", tags=["ocr"])
api_router.include_router(export.router, tags=["export"])
api_router.include_router(inventory.router, prefix="/inventory", tags=["inventory"])
api_router.include_router(saved_recipes.router, prefix="/recipes/saved", tags=["saved-recipes"])
api_router.include_router(recipes.router, prefix="/recipes", tags=["recipes"])
api_router.include_router(settings.router, prefix="/settings", tags=["settings"])
api_router.include_router(staples.router, prefix="/staples", tags=["staples"])
api_router.include_router(feedback.router, prefix="/feedback", tags=["feedback"])
api_router.include_router(household.router, prefix="/household", tags=["household"])
api_router.include_router(saved_recipes.router, prefix="/recipes/saved", tags=["saved-recipes"])
api_router.include_router(imitate.router, prefix="/imitate", tags=["imitate"])
api_router.include_router(community_router)

View file

@ -686,6 +686,44 @@ class Store:
def get_recipe(self, recipe_id: int) -> dict | None:
return self._fetch_one("SELECT * FROM recipes WHERE id = ?", (recipe_id,))
def upsert_built_recipe(
self,
external_id: str,
title: str,
ingredients: list[str],
directions: list[str],
) -> int:
"""Persist an assembly-built recipe and return its DB id.
Uses external_id as a stable dedup key so the same build slug doesn't
accumulate duplicate rows across multiple user sessions.
"""
import json as _json
self.conn.execute(
"""
INSERT OR IGNORE INTO recipes
(external_id, title, ingredients, ingredient_names, directions, source)
VALUES (?, ?, ?, ?, ?, 'assembly')
""",
(
external_id,
title,
_json.dumps(ingredients),
_json.dumps(ingredients),
_json.dumps(directions),
),
)
# Update title in case the build was re-run with tweaked selections
self.conn.execute(
"UPDATE recipes SET title = ? WHERE external_id = ?",
(title, external_id),
)
self.conn.commit()
row = self._fetch_one(
"SELECT id FROM recipes WHERE external_id = ?", (external_id,)
)
return row["id"] # type: ignore[index]
def get_element_profiles(self, names: list[str]) -> dict[str, list[str]]:
"""Return {ingredient_name: [element_tag, ...]} for the given names.

View file

@ -995,6 +995,12 @@ def build_from_selection(
effective_pantry = pantry_set | set(role_overrides.values())
title = _personalized_title(tmpl, effective_pantry, seed + tmpl.id)
# Items in role_overrides that aren't in the user's pantry = shopping list
missing = [
item for item in role_overrides.values()
if item and item not in pantry_set
]
return RecipeSuggestion(
id=tmpl.id,
title=title,
@ -1002,7 +1008,7 @@ def build_from_selection(
element_coverage={},
swap_candidates=[],
matched_ingredients=all_matched,
missing_ingredients=[],
missing_ingredients=missing,
directions=tmpl.directions,
notes=tmpl.notes,
level=1,

View file

@ -186,6 +186,13 @@
@close="phase = 'select'"
@cooked="phase = 'select'"
/>
<!-- Shopping list: items the user chose that aren't in their pantry -->
<div v-if="(builtRecipe.missing_ingredients ?? []).length > 0" class="cart-list card mb-sm">
<h3 class="text-sm font-semibold mb-xs">🛒 You'll need to pick up</h3>
<ul class="cart-items">
<li v-for="item in builtRecipe.missing_ingredients" :key="item" class="cart-item text-sm">{{ item }}</li>
</ul>
</div>
<div class="byo-actions mt-sm">
<button class="btn btn-secondary" @click="resetToTemplate">Try a different build</button>
<button class="btn btn-secondary" @click="phase = 'wizard'">Adjust ingredients</button>
@ -231,6 +238,8 @@ const builtRecipe = ref<RecipeSuggestion | null>(null)
const buildLoading = ref(false)
const buildError = ref<string | null>(null)
// Shopping list is derived from builtRecipe.missing_ingredients (computed by backend)
const missingModes = [
{ label: 'Available only', value: 'hidden' },
{ label: 'Show missing', value: 'greyed' },
@ -281,6 +290,7 @@ function toggleIngredient(name: string) {
const current = new Set(roleOverrides.value[role] ?? [])
current.has(name) ? current.delete(name) : current.add(name)
roleOverrides.value = { ...roleOverrides.value, [role]: [...current] }
}
function useCustomIngredient() {
@ -288,9 +298,27 @@ function useCustomIngredient() {
if (!name) return
const role = currentRole.value?.display
if (!role) return
// Add to role overrides so it's included in the build request
const current = new Set(roleOverrides.value[role] ?? [])
current.add(name)
roleOverrides.value = { ...roleOverrides.value, [role]: [...current] }
// Inject into the local candidates list so it renders as a selected card.
// Mark in_pantry: true so it stays visible regardless of missing-ingredient mode.
if (candidates.value) {
const knownNames = new Set([
...(candidates.value.compatible ?? []).map((i) => i.name.toLowerCase()),
...(candidates.value.other ?? []).map((i) => i.name.toLowerCase()),
])
if (!knownNames.has(name.toLowerCase())) {
candidates.value = {
...candidates.value,
compatible: [{ name, in_pantry: true, tags: [] }, ...(candidates.value.compatible ?? [])],
}
}
}
filterText.value = ''
}
@ -536,4 +564,23 @@ onMounted(async () => {
padding: 0;
text-decoration: underline;
}
.cart-list {
padding: var(--spacing-sm) var(--spacing-md);
}
.cart-items {
list-style: none;
display: flex;
flex-wrap: wrap;
gap: var(--spacing-xs);
margin-top: var(--spacing-xs);
}
.cart-item {
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
padding: 2px var(--spacing-sm);
}
</style>

View file

@ -161,14 +161,21 @@
<div class="add-pantry-col">
<p v-if="addError" role="alert" aria-live="assertive" class="add-error text-xs">{{ addError }}</p>
<p v-if="addedToPantry" role="status" aria-live="polite" class="add-success text-xs"> Added to pantry!</p>
<button
class="btn btn-accent flex-1"
:disabled="addingToPantry"
@click="addToPantry"
>
<span v-if="addingToPantry">Adding</span>
<span v-else>Add {{ checkedCount }} to pantry</span>
</button>
<div class="grocery-actions">
<button
class="btn btn-primary flex-1"
@click="copyGroceryList"
>{{ groceryCopied ? '✓ Copied!' : `📋 Grocery list (${checkedCount})` }}</button>
<button
class="btn btn-secondary"
:disabled="addingToPantry"
@click="addToPantry"
:title="`Add ${checkedCount} item${checkedCount !== 1 ? 's' : ''} to your pantry`"
>
<span v-if="addingToPantry">Adding</span>
<span v-else>+ Pantry</span>
</button>
</div>
</div>
</template>
<button v-else class="btn btn-primary flex-1" @click="handleCook">
@ -246,6 +253,7 @@ const checkedIngredients = ref<Set<string>>(new Set())
const addingToPantry = ref(false)
const addedToPantry = ref(false)
const addError = ref<string | null>(null)
const groceryCopied = ref(false)
const checkedCount = computed(() => checkedIngredients.value.size)
@ -300,6 +308,19 @@ async function shareList() {
}
}
async function copyGroceryList() {
const items = [...checkedIngredients.value]
if (!items.length) return
const text = `Shopping list for ${props.recipe.title}:\n${items.map((i) => `${i}`).join('\n')}`
if (navigator.share) {
await navigator.share({ title: `Shopping list: ${props.recipe.title}`, text })
} else {
await navigator.clipboard.writeText(text)
groceryCopied.value = true
setTimeout(() => { groceryCopied.value = false }, 2000)
}
}
function groceryLinkFor(ingredient: string): GroceryLink | undefined {
const needle = ingredient.toLowerCase()
return props.groceryLinks.find((l) => l.ingredient.toLowerCase() === needle)
@ -577,6 +598,12 @@ function handleCook() {
gap: 2px;
}
.grocery-actions {
display: flex;
gap: var(--spacing-xs);
align-items: stretch;
}
.add-error {
color: var(--color-error, #dc2626);
}

View file

@ -209,7 +209,7 @@ async function submit() {
display: flex;
align-items: center;
justify-content: center;
z-index: 200;
z-index: 500;
padding: var(--spacing-md);
}