User-driven follow-up to Sprint 8: surface the Sprint 1-3 NeverSuggest
infrastructure on the Recipes surface so a family can pre-emptively
mark a recipe as never-suggest before it appears in a plan.
Backend (3 changes):
- POST /api/never-suggest (public, webui-facing). Idempotent on
(family, recipe, reason). Returns the row joined with recipe_name.
- DELETE /api/never-suggest/{ns_id} (public, webui-facing). Row-level
ownership check (403 if cross-family), 404 if absent.
- NeverSuggestRead.recipe_name + .ingredient_name server-side joins
via _attach_names() helper (one LEFT OUTER JOIN per kind).
- Admin path (POST/DELETE /api/admin/never-suggest) unchanged.
Frontend (4 changes):
- New NeverSuggestButton component (~290 lines). Two variants: card
(overlay on RecipeCard) and detail (text buttons in RecipeDetail
top bar). Popover with Allergy (red, window.confirm) + Dislike
(neutral, no confirm). Undo toast via showToast.undo() (Sprint 3
B12 pattern, 6s window). Pre-existing block detection shows a
Blocked state with an Unblock path.
- mealPlannerApi.neverSuggest.list/add/remove in api/index.ts.
- Recipes.tsx overlay: RecipeCard has position: relative; button is
opacity-0 group-hover:opacity-100 focus:opacity-100. e.preventDefault
+ e.stopPropagation prevents accidental navigation.
- RecipeDetail.tsx top bar: new Deny forever button group to the left
of Add to Plan.
Build: npm run build green (tsc 0 errors, vite 0 errors) on
docker-willester. Bundle 487 -> 495 kB. No new dependencies. No
migration (NeverSuggest table exists from prior sprints).
Tracking: Review/sprint10-verification.md (9-step browser smoke +
5 API curls + undo test + a11y check).
10 KiB
Sprint 10 — "Deny Forever" on Recipes (user-driven)
Status (2026-06-05): ✅ Code complete. npm run build green. 21/21 planner tests pass. Awaiting user commit + deploy.
User direction (2026-06-05): "Proceed with the next phase in the redesign. Also add a phase to include a 'Deny Forever' button in the Recipes endpoint."
Why: Sprint 8's "Deny" semantics let the user block a recipe from a meal plan, but the user may want to block a recipe before it ever appears in a plan — for example after browsing /recipes and finding a recipe the family dislikes. Sprint 10 surfaces the Sprint 1–3 NeverSuggest infrastructure on the Recipes surface.
What ships
Backend (3 changes)
-
POST /api/never-suggest(public, webui-facing) —app/api/never_suggest.py:60-86.- Body:
{family_profile_id, recipe_id, reason: "allergy"|"dislike", notes?}. - Idempotent on
(family_profile_id, recipe_id, ingredient_id, reason). Re-adding the same row returns the existing one. - Auth:
require_session(auto-resolves to the first family profile on the trusted network). - Returns the new row joined with
recipe_name(LEFT OUTER JOIN).
- Body:
-
DELETE /api/never-suggest/{ns_id}(public, webui-facing) —app/api/never_suggest.py:89-111.- Auth:
require_session. - Row-level ownership check: returns 403 if the row's
family_profile_iddoesn't match the session. - 404 if the row doesn't exist.
- Auth:
-
NeverSuggestRead.recipe_name+.ingredient_namejoins —app/schemas/never_suggest.py:31-33.- Populated server-side via a helper
_attach_names()inapp/api/never_suggest.py:33-58(one LEFT OUTER JOIN per kind, then merge into response dicts). - Falls back to
Noneif the recipe / ingredient was deleted (FK isON DELETE CASCADEso the row goes with it; this is belt-and-suspenders for the rare in-flight case).
- Populated server-side via a helper
-
Admin path unchanged.
POST /api/admin/never-suggestandDELETE /api/admin/never-suggest/{id}still require theADMIN_TOKEN. The new public paths use the same family-network trust model as the rest of the webui (perapp/security.py:64-79).
Frontend (4 changes)
-
API client —
frontend/src/api/index.ts:75-86:neverSuggest.list(familyProfileId)→GET /api/never-suggest?family_profile_id=...neverSuggest.add({family_profile_id, recipe_id, reason, notes?})→POST /api/never-suggestneverSuggest.remove(nsId)→DELETE /api/never-suggest/{nsId}
-
New component —
frontend/src/components/NeverSuggestButton.tsx(~290 lines).- Two variants:
card(overlay button onRecipeCard) anddetail(text buttons inRecipeDetailtop bar). - Popover with two reasons:
Allergy(red, requireswindow.confirm) andDislike(neutral, no confirm). - Undo toast (Sprint 3 B12 pattern, 6s window): clicking Undo calls
DELETE /api/never-suggest/{id}and re-invalidates queries. - Pre-existing block detection: if the recipe is already blocked, the button shows a "Blocked" state and clicking it offers an "Unblock" path (with
window.confirm). - Query invalidations:
['neverSuggest', familyId],['recipes'],['recommendedRecipes', familyId],['mealPlan']. Blocking a recipe affects both the Recipes page filter AND the next planner run. - A11y:
aria-label,aria-expanded,aria-haspopup="menu",role="menu", focus is captured by the popover, Esc dismisses, outside click dismisses.
- Two variants:
-
Recipes.tsx— overlay button on eachRecipeCard. Hidden by default (opacity-0 group-hover:opacity-100); visible on focus or hover. The Card now hasposition: relativeso the absolute overlay anchors correctly. -
RecipeDetail.tsx— "Deny forever" button group in the top bar (next to "Add to Plan"). Renders inline as a row of two text buttons.
Why undo instead of permanent action
The user said: "Yes — toast with Undo (Recommended)". The undo toast is the escape hatch. Allergy still gates with window.confirm (a single misclick on a small overlay button could be disastrous), but Dislike skips the confirm and trusts the undo toast. The Undo button calls DELETE /api/never-suggest/{id} and re-invalidates the queries so the recipe reappears immediately.
Verify (deploy + smoke)
Build: cd frontend && npm run build → green (tsc 0 errors, vite 0 errors). Verified locally. Bundle: 487 → 495 kB (the new component + a 1-line query key in the existing client).
Backend smoke (local, no DB available — verified via route registration + AST check):
app/api/never_suggest.pyimports cleanly.app/main.pyregisters both routers (no change needed).- Routes:
GET /api/never-suggest,POST /api/never-suggest,DELETE /api/never-suggest/{ns_id}all registered. - 21/21 planner tests pass (1 pre-existing
test_filter_blocks_by_costfailure still deselected; verified not introduced by Sprint 10).
Browser smoke on http://100.108.208.56:8082/:
- First-visit onboarding tour (Sprint 9). Open an incognito window. Tour auto-shows on the Dashboard. Press
Escto dismiss. - Recipes page overlay. Navigate to
/recipes. Hover any recipe card. A small🚫icon appears in the top-right of the image. Click it → popover withAllergyandDislikebuttons. - Pick "Dislike". Recipe disappears from the list. A toast appears: "Marked as won't suggest for your family [Undo]". Wait 6s — the toast auto-dismisses. The recipe is permanently blocked from now on.
- Undo round-trip. Repeat step 2 with another recipe. Pick "Dislike". While the toast is still visible, click Undo. The recipe reappears in the list within 1s. Query:
curl /api/never-suggest?family_profile_id=<id>shows 0 rows for that recipe. - Allergy confirm gate. Repeat step 2. Pick "Allergy" → a
window.confirmdialog asks "Mark as an allergy for your family?". Cancel → nothing happens. OK → same as Dislike but with areason: 'allergy'row written. - RecipeDetail page. Navigate to
/recipes/<id>. The "Deny forever" button appears in the top bar (left of "Add to Plan"). Click → popover with the same two reasons. Same UX as the overlay. - Already-blocked state. Pick a recipe that's already blocked. The card overlay shows a red
🚫icon (no opacity-0). Click → "Stop blocking ?" confirm → recipe reappears in the list. On the RecipeDetail page, the button label changes to "Unblock". - Cross-query invalidation. Pick a recipe, then navigate to
/recipes/recommended(or wait for a fresh plan). The blocked recipe does NOT appear in the recommended list. The next planner run also avoids it. - A11y. Tab through the page: focus reaches the overlay button. Press Enter → popover opens. Arrow keys move between
AllergyandDislike. PressEsc→ popover closes. The recipe card'sLinkis still clickable (thee.stopPropagation()on the button prevents accidental navigation).
API curls (post-deploy):
# 1. Add a block
curl -X POST http://100.108.208.56:8082/api/never-suggest \
-H "Content-Type: application/json" \
-d '{"family_profile_id":"<id>","recipe_id":"<rid>","reason":"dislike"}' \
-w "|HTTP %{http_code}\n"
# Expect: 201 + JSON with id, recipe_name populated
# 2. List
curl "http://100.108.208.56:8082/api/never-suggest?family_profile_id=<id>"
# Expect: array with the new row + recipe_name populated
# 3. Idempotent re-add
curl -X POST http://100.108.208.56:8082/api/never-suggest \
-d '{"family_profile_id":"<id>","recipe_id":"<rid>","reason":"dislike"}' \
-H "Content-Type: application/json"
# Expect: 201 + same id as the first call
# 4. Delete
curl -X DELETE http://100.108.208.56:8082/api/never-suggest/<ns_id> -w "|HTTP %{http_code}\n"
# Expect: 204
# 5. Cross-family 403 (manually swap the family_id in the row)
# Expect: 403
Regression check:
- Sprint 8 "Deny this week" / "Never again" on meal cards still works.
- Sprint 7
WeekRangeNavstill renders on Dashboard and ShoppingList. - Sprint 5 keyboard shortcuts still work (
g d,g r,/,?). - Sprint 9 onboarding tour still shows on first visit;
?reset-tour=1re-triggers. - The existing
POST /api/admin/never-suggestadmin path is unchanged; admin token still required.
Out of scope
- A "Manage blocked recipes" page (the toast +
GET /api/never-suggestis the surfacing for now). - Bulk unblock.
- Sprint 8's "Approve" path — already handles
denial_expires_atcorrectly (per Sprint 8 D2 + Q2). - The dead
Generate Meal PlanCTA — separate §Future item. - F8 Spoonacular + F9 Ollama — full backend proposals, separate.
Risks & mitigations
- R1: Overlay button on
RecipeCardmay be hidden on touch devices. Theopacity-0 group-hover:opacity-100pattern only works with a mouse. Touch users see the button when they tap the card (the focus state triggers the same opacity). A future Sprint could add a swipe-up gesture to reveal all card actions; not in scope here. - R2: Cross-family 403. The new
DELETEenforces row-level ownership. A malicious caller could in theory craft ans_idbelonging to another family and get a 403. Returning a 404 instead of a 403 would leak less (don't reveal that the row exists). Trade-off documented; 403 is the explicit "this row exists but isn't yours" signal and matches the rest of the codebase. - R3:
recipe_nameJOIN is N+1-friendly but not eager-loaded. A family with 100 blocked recipes would issue 2 SQL queries (one for the rows, one for the recipe names). For 100 rows this is sub-millisecond; the planner test suite confirms the schema is well-indexed onrecipe.id(PK). - R4:
neverSuggest.addwithnotes: ''vsnotes: undefined. The schema treats both asNoneserver-side. The webui doesn't passnotesat all (it's optional in the API client), so this is a non-issue. - R5: Pre-existing WIP. Sprint 10 doesn't touch
backend/app/api/recipes.py,backend/app/schemas/recipe.py, ornginx/nginx.conf— those are the user's to manage.
Commit
One commit: feat(ui): Sprint 10 — Deny Forever on Recipes (card overlay + detail button + undo toast). Files:
backend/app/api/never_suggest.py(new POST + DELETE; recipe_name join helper)backend/app/schemas/never_suggest.py(recipe_name + ingredient_name fields)frontend/src/components/NeverSuggestButton.tsx(NEW, ~290 lines)frontend/src/api/index.ts(neverSuggest client)frontend/src/pages/Recipes.tsx(overlay on RecipeCard)frontend/src/pages/RecipeDetail.tsx(Deny forever in top bar)