Files
Meal-Planner/Review/sprint10-verification.md
T
admin 0b6c5dcfb7 feat(ui): Sprint 10 — Deny Forever on Recipes (card overlay + detail button + undo toast)
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).
2026-06-05 13:29:04 -07:00

10 KiB
Raw Blame History

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 13 NeverSuggest infrastructure on the Recipes surface.


What ships

Backend (3 changes)

  1. 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).
  2. 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_id doesn't match the session.
    • 404 if the row doesn't exist.
  3. NeverSuggestRead.recipe_name + .ingredient_name joinsapp/schemas/never_suggest.py:31-33.

    • Populated server-side via a helper _attach_names() in app/api/never_suggest.py:33-58 (one LEFT OUTER JOIN per kind, then merge into response dicts).
    • Falls back to None if the recipe / ingredient was deleted (FK is ON DELETE CASCADE so the row goes with it; this is belt-and-suspenders for the rare in-flight case).
  4. Admin path unchanged. POST /api/admin/never-suggest and DELETE /api/admin/never-suggest/{id} still require the ADMIN_TOKEN. The new public paths use the same family-network trust model as the rest of the webui (per app/security.py:64-79).

Frontend (4 changes)

  1. API clientfrontend/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-suggest
    • neverSuggest.remove(nsId)DELETE /api/never-suggest/{nsId}
  2. New componentfrontend/src/components/NeverSuggestButton.tsx (~290 lines).

    • Two variants: card (overlay button on RecipeCard) and detail (text buttons in RecipeDetail top bar).
    • Popover with two reasons: Allergy (red, requires window.confirm) and Dislike (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.
  3. Recipes.tsx — overlay button on each RecipeCard. Hidden by default (opacity-0 group-hover:opacity-100); visible on focus or hover. The Card now has position: relative so the absolute overlay anchors correctly.

  4. 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.py imports cleanly.
  • app/main.py registers 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_cost failure still deselected; verified not introduced by Sprint 10).

Browser smoke on http://100.108.208.56:8082/:

  1. First-visit onboarding tour (Sprint 9). Open an incognito window. Tour auto-shows on the Dashboard. Press Esc to dismiss.
  2. Recipes page overlay. Navigate to /recipes. Hover any recipe card. A small 🚫 icon appears in the top-right of the image. Click it → popover with Allergy and Dislike buttons.
  3. 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.
  4. 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.
  5. Allergy confirm gate. Repeat step 2. Pick "Allergy" → a window.confirm dialog asks "Mark as an allergy for your family?". Cancel → nothing happens. OK → same as Dislike but with a reason: 'allergy' row written.
  6. 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.
  7. 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".
  8. 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.
  9. A11y. Tab through the page: focus reaches the overlay button. Press Enter → popover opens. Arrow keys move between Allergy and Dislike. Press Esc → popover closes. The recipe card's Link is still clickable (the e.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 WeekRangeNav still 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=1 re-triggers.
  • The existing POST /api/admin/never-suggest admin path is unchanged; admin token still required.

Out of scope

  • A "Manage blocked recipes" page (the toast + GET /api/never-suggest is the surfacing for now).
  • Bulk unblock.
  • Sprint 8's "Approve" path — already handles denial_expires_at correctly (per Sprint 8 D2 + Q2).
  • The dead Generate Meal Plan CTA — separate §Future item.
  • F8 Spoonacular + F9 Ollama — full backend proposals, separate.

Risks & mitigations

  • R1: Overlay button on RecipeCard may be hidden on touch devices. The opacity-0 group-hover:opacity-100 pattern 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 DELETE enforces row-level ownership. A malicious caller could in theory craft a ns_id belonging 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_name JOIN 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 on recipe.id (PK).
  • R4: neverSuggest.add with notes: '' vs notes: undefined. The schema treats both as None server-side. The webui doesn't pass notes at 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, or nginx/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)