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:
parent
b4f031e87d
commit
3933136666
7 changed files with 146 additions and 12 deletions
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue