Files
Meal-Planner/Review/sprint10-verification.md
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

148 lines
10 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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` joins** — `app/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 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-suggest`
- `neverSuggest.remove(nsId)``DELETE /api/never-suggest/{nsId}`
2. **New component**`frontend/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 <name> 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 <name> 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 <name>?" 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):**
```bash
# 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)