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
|
for item in items
|
||||||
if item.get("product_name")
|
if item.get("product_name")
|
||||||
}
|
}
|
||||||
return build_from_selection(
|
suggestion = build_from_selection(
|
||||||
template_slug=req.template_id,
|
template_slug=req.template_id,
|
||||||
role_overrides=req.role_overrides,
|
role_overrides=req.role_overrides,
|
||||||
pantry_set=pantry_set,
|
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:
|
finally:
|
||||||
store.close()
|
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(ocr.router, prefix="/receipts", tags=["ocr"])
|
||||||
api_router.include_router(export.router, tags=["export"])
|
api_router.include_router(export.router, tags=["export"])
|
||||||
api_router.include_router(inventory.router, prefix="/inventory", tags=["inventory"])
|
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(recipes.router, prefix="/recipes", tags=["recipes"])
|
||||||
api_router.include_router(settings.router, prefix="/settings", tags=["settings"])
|
api_router.include_router(settings.router, prefix="/settings", tags=["settings"])
|
||||||
api_router.include_router(staples.router, prefix="/staples", tags=["staples"])
|
api_router.include_router(staples.router, prefix="/staples", tags=["staples"])
|
||||||
api_router.include_router(feedback.router, prefix="/feedback", tags=["feedback"])
|
api_router.include_router(feedback.router, prefix="/feedback", tags=["feedback"])
|
||||||
api_router.include_router(household.router, prefix="/household", tags=["household"])
|
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(imitate.router, prefix="/imitate", tags=["imitate"])
|
||||||
api_router.include_router(community_router)
|
api_router.include_router(community_router)
|
||||||
|
|
@ -686,6 +686,44 @@ class Store:
|
||||||
def get_recipe(self, recipe_id: int) -> dict | None:
|
def get_recipe(self, recipe_id: int) -> dict | None:
|
||||||
return self._fetch_one("SELECT * FROM recipes WHERE id = ?", (recipe_id,))
|
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]]:
|
def get_element_profiles(self, names: list[str]) -> dict[str, list[str]]:
|
||||||
"""Return {ingredient_name: [element_tag, ...]} for the given names.
|
"""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())
|
effective_pantry = pantry_set | set(role_overrides.values())
|
||||||
title = _personalized_title(tmpl, effective_pantry, seed + tmpl.id)
|
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(
|
return RecipeSuggestion(
|
||||||
id=tmpl.id,
|
id=tmpl.id,
|
||||||
title=title,
|
title=title,
|
||||||
|
|
@ -1002,7 +1008,7 @@ def build_from_selection(
|
||||||
element_coverage={},
|
element_coverage={},
|
||||||
swap_candidates=[],
|
swap_candidates=[],
|
||||||
matched_ingredients=all_matched,
|
matched_ingredients=all_matched,
|
||||||
missing_ingredients=[],
|
missing_ingredients=missing,
|
||||||
directions=tmpl.directions,
|
directions=tmpl.directions,
|
||||||
notes=tmpl.notes,
|
notes=tmpl.notes,
|
||||||
level=1,
|
level=1,
|
||||||
|
|
|
||||||
|
|
@ -186,6 +186,13 @@
|
||||||
@close="phase = 'select'"
|
@close="phase = 'select'"
|
||||||
@cooked="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">
|
<div class="byo-actions mt-sm">
|
||||||
<button class="btn btn-secondary" @click="resetToTemplate">Try a different build</button>
|
<button class="btn btn-secondary" @click="resetToTemplate">Try a different build</button>
|
||||||
<button class="btn btn-secondary" @click="phase = 'wizard'">Adjust ingredients</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 buildLoading = ref(false)
|
||||||
const buildError = ref<string | null>(null)
|
const buildError = ref<string | null>(null)
|
||||||
|
|
||||||
|
// Shopping list is derived from builtRecipe.missing_ingredients (computed by backend)
|
||||||
|
|
||||||
const missingModes = [
|
const missingModes = [
|
||||||
{ label: 'Available only', value: 'hidden' },
|
{ label: 'Available only', value: 'hidden' },
|
||||||
{ label: 'Show missing', value: 'greyed' },
|
{ label: 'Show missing', value: 'greyed' },
|
||||||
|
|
@ -281,6 +290,7 @@ function toggleIngredient(name: string) {
|
||||||
const current = new Set(roleOverrides.value[role] ?? [])
|
const current = new Set(roleOverrides.value[role] ?? [])
|
||||||
current.has(name) ? current.delete(name) : current.add(name)
|
current.has(name) ? current.delete(name) : current.add(name)
|
||||||
roleOverrides.value = { ...roleOverrides.value, [role]: [...current] }
|
roleOverrides.value = { ...roleOverrides.value, [role]: [...current] }
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function useCustomIngredient() {
|
function useCustomIngredient() {
|
||||||
|
|
@ -288,9 +298,27 @@ function useCustomIngredient() {
|
||||||
if (!name) return
|
if (!name) return
|
||||||
const role = currentRole.value?.display
|
const role = currentRole.value?.display
|
||||||
if (!role) return
|
if (!role) return
|
||||||
|
|
||||||
|
// Add to role overrides so it's included in the build request
|
||||||
const current = new Set(roleOverrides.value[role] ?? [])
|
const current = new Set(roleOverrides.value[role] ?? [])
|
||||||
current.add(name)
|
current.add(name)
|
||||||
roleOverrides.value = { ...roleOverrides.value, [role]: [...current] }
|
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 = ''
|
filterText.value = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -536,4 +564,23 @@ onMounted(async () => {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
text-decoration: underline;
|
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>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -161,15 +161,22 @@
|
||||||
<div class="add-pantry-col">
|
<div class="add-pantry-col">
|
||||||
<p v-if="addError" role="alert" aria-live="assertive" class="add-error text-xs">{{ addError }}</p>
|
<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>
|
<p v-if="addedToPantry" role="status" aria-live="polite" class="add-success text-xs">✓ Added to pantry!</p>
|
||||||
|
<div class="grocery-actions">
|
||||||
<button
|
<button
|
||||||
class="btn btn-accent flex-1"
|
class="btn btn-primary flex-1"
|
||||||
|
@click="copyGroceryList"
|
||||||
|
>{{ groceryCopied ? '✓ Copied!' : `📋 Grocery list (${checkedCount})` }}</button>
|
||||||
|
<button
|
||||||
|
class="btn btn-secondary"
|
||||||
:disabled="addingToPantry"
|
:disabled="addingToPantry"
|
||||||
@click="addToPantry"
|
@click="addToPantry"
|
||||||
|
:title="`Add ${checkedCount} item${checkedCount !== 1 ? 's' : ''} to your pantry`"
|
||||||
>
|
>
|
||||||
<span v-if="addingToPantry">Adding…</span>
|
<span v-if="addingToPantry">Adding…</span>
|
||||||
<span v-else>Add {{ checkedCount }} to pantry</span>
|
<span v-else>+ Pantry</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<button v-else class="btn btn-primary flex-1" @click="handleCook">
|
<button v-else class="btn btn-primary flex-1" @click="handleCook">
|
||||||
✓ I cooked this
|
✓ I cooked this
|
||||||
|
|
@ -246,6 +253,7 @@ const checkedIngredients = ref<Set<string>>(new Set())
|
||||||
const addingToPantry = ref(false)
|
const addingToPantry = ref(false)
|
||||||
const addedToPantry = ref(false)
|
const addedToPantry = ref(false)
|
||||||
const addError = ref<string | null>(null)
|
const addError = ref<string | null>(null)
|
||||||
|
const groceryCopied = ref(false)
|
||||||
|
|
||||||
const checkedCount = computed(() => checkedIngredients.value.size)
|
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 {
|
function groceryLinkFor(ingredient: string): GroceryLink | undefined {
|
||||||
const needle = ingredient.toLowerCase()
|
const needle = ingredient.toLowerCase()
|
||||||
return props.groceryLinks.find((l) => l.ingredient.toLowerCase() === needle)
|
return props.groceryLinks.find((l) => l.ingredient.toLowerCase() === needle)
|
||||||
|
|
@ -577,6 +598,12 @@ function handleCook() {
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.grocery-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--spacing-xs);
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
.add-error {
|
.add-error {
|
||||||
color: var(--color-error, #dc2626);
|
color: var(--color-error, #dc2626);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -209,7 +209,7 @@ async function submit() {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
z-index: 200;
|
z-index: 500;
|
||||||
padding: var(--spacing-md);
|
padding: var(--spacing-md);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue