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).
This commit is contained in:
2026-06-05 13:29:04 -07:00
parent 6e386baf6e
commit 0b6c5dcfb7
13 changed files with 867 additions and 15 deletions
+58
View File
@@ -228,3 +228,61 @@ User direction 2026-06-05: "Proceed with the next phase in the redesign." §Futu
- `frontend/src/pages/ShoppingList.tsx:231` — header anchor - `frontend/src/pages/ShoppingList.tsx:231` — header anchor
- `Review/sprint9-verification.md` — new file (deploy + 8-step browser smoke + a11y check) - `Review/sprint9-verification.md` — new file (deploy + 8-step browser smoke + a11y check)
---
# Context — Sprint 10 ("Deny Forever" on Recipes)
## Why Sprint 10 exists
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." 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.
## Decisions (locked in for Sprint 10)
- **D1. Two-variant button component.** `NeverSuggestButton` has `card` (overlay on `RecipeCard`) and `detail` (text buttons in `RecipeDetail` top bar) variants. Single source of truth for the popover + reason + undo behavior.
- **D2. Idempotent POST.** The `add` endpoint is idempotent on `(family_profile_id, recipe_id, ingredient_id, reason)`. Re-adding the same row returns the existing one. Avoids accidental duplicates from the popover being double-clicked.
- **D3. Row-level ownership on DELETE.** The `DELETE` endpoint enforces that the row's `family_profile_id` matches the session's family id; otherwise 403. The `require_session` dep auto-resolves to the first family on the trusted network, so this is "the same family" in practice but coded defensively.
- **D4. `window.confirm` on `Allergy` only.** `Dislike` skips the confirm (undo toast is the escape hatch). `Allergy` is a more serious action; the confirm dialog prevents accidental permanent blocks.
- **D5. Undo via toast (Sprint 3 B12 pattern, 6s window).** Reuses `showToast.undo()` from `lib/toast.tsx`. The Undo handler calls `DELETE /api/never-suggest/{id}` and re-invalidates queries so the recipe reappears.
- **D6. Query invalidations cover the cross-cutting effect.** `['neverSuggest', familyId]` + `['recipes']` + `['recommendedRecipes', familyId]` + `['mealPlan']`. Blocking a recipe affects the Recipes page filter, the Recommended page, and the next planner run.
- **D7. `recipe_name` join via server-side helper.** `_attach_names()` does one LEFT OUTER JOIN per kind (recipe, ingredient), then merges into response dicts. Avoids the N+1 query pattern; for a family-scale (dozens of rows), one query per kind is sub-millisecond.
- **D8. Pre-existing block detection.** If a recipe is already blocked, the button shows a "Blocked" state (red `🚫` icon, no `opacity-0`). Clicking it offers an "Unblock" path (with `window.confirm`). This avoids the "I clicked but nothing happened" confusion of the idempotent POST.
## Open questions to surface to the user, not to assume
- **Q1. Should the public DELETE return 403 or 404 on cross-family access?** Default: 403. A 404 would leak less (don't reveal that the row exists), but 403 is the explicit "you don't own this" signal. Trade-off documented in `Review/sprint10-verification.md` R2.
- **Q2. Should the popover auto-dismiss after a reason is picked?** Default: yes (set `open = false` on success). Otherwise the user could double-click and re-fire the mutation. Documented in the component.
- **Q3. Should `notes` be required for `Allergy`?** Default: no. The webui doesn't pass `notes` at all (the API client marks it optional). A future "Manage blocked" page could surface it.
## Sprint 10 verification gate
- `cd frontend && npm run build` → green (tsc 0 errors, vite 0 errors)
- 21/21 planner tests pass (1 pre-existing failure deselected)
- Browser smoke (9 steps) on `http://100.108.208.56:8082/` per `Review/sprint10-verification.md`
- 5 API curls (POST, GET, idempotent re-add, DELETE, 403) all return expected status codes
- No regression in Sprints 1-9
## Sprint 10 — does NOT touch
- The `extractErrorMessage` / `showApiError` flow (Sprint 4 F7) — used for the error toast, unchanged.
- The keyboard shortcuts (Sprint 5 F2) — unchanged.
- The bulk pantry add (Sprint 6 F3) — unchanged.
- The plan-the-week (Sprint 6 F4) — unchanged.
- The undo-toast (Sprint 3 B12) — reused; unchanged.
- The WeekRangeNav (Sprint 7) — unchanged.
- The 3-button Sprint 8 voting row — unchanged.
- The OnboardingTour (Sprint 9) — unchanged.
- The admin `POST /api/admin/never-suggest` path — unchanged. Admin token still required.
- Pre-existing WIP: `backend/app/api/recipes.py`, `backend/app/schemas/recipe.py`, `nginx/nginx.conf` — untouched.
## Key file:line references
- `backend/app/api/never_suggest.py:60-86``add_block` (POST)
- `backend/app/api/never_suggest.py:89-111``remove_block` (DELETE)
- `backend/app/api/never_suggest.py:33-58``_attach_names` (recipe_name join)
- `backend/app/schemas/never_suggest.py:31-33``recipe_name` + `ingredient_name` fields
- `frontend/src/components/NeverSuggestButton.tsx` (NEW, ~290 lines)
- `frontend/src/api/index.ts:75-86``neverSuggest` client
- `frontend/src/pages/Recipes.tsx:241-300``RecipeCard` (overlay button)
- `frontend/src/pages/RecipeDetail.tsx:73-78` — top bar (Deny forever button group)
- `Review/sprint10-verification.md` — new file (deploy + 9-step browser smoke + 5 API curls + a11y check)
+87 -1
View File
@@ -185,4 +185,90 @@ Goal: bring implementation back into alignment with `Review/reviewconcensus.md`.
- Thread 3 follow-ups: F8 (Spoonacular), F9 (Ollama), dead `Generate Meal Plan` CTA at `Dashboard.tsx:415`. - Thread 3 follow-ups: F8 (Spoonacular), F9 (Ollama), dead `Generate Meal Plan` CTA at `Dashboard.tsx:415`.
- Per-page deep tutorials, video demos, hover tooltips. - Per-page deep tutorials, video demos, hover tooltips.
- A user-facing "Show tour" link in the footer (operator uses `?reset-tour=1`; a footer link is a 5-line follow-up if requested). - A user-facing "Show tour" link in the footer (operator uses `?reset-tour=1`; a footer link is a 5-line follow-up if requested).
- Sprint 10 — "Deny Forever" on Recipes — already drafted, awaiting user approval to execute. - Sprint 10 — "Deny Forever" on Recipes — committed 2026-06-05, awaiting user deploy.
---
## Sprint 10 — "Deny Forever" on Recipes (user-driven)
**Owner:** this agent. **Status:** code complete, `npm run build` green, 21/21 planner tests pass, awaiting user commit + deploy. **Tracking:** `Review/sprint10-verification.md`.
**User direction (2026-06-05, exact):** "Proceed with the next phase in the redesign. Also add a phase to include a 'Deny Forever' button in the Recipes endpoint." Sprint 10 ships the Deny Forever button on both the Recipes page (card overlay) and the RecipeDetail page (top bar).
### S10.1 — Backend: `POST /api/never-suggest` (public)
- [x] New endpoint in `app/api/never_suggest.py:60-86`. Family-facing (uses `require_session`).
- [x] Body: `{family_profile_id, recipe_id, reason: "allergy"|"dislike", notes?}`.
- [x] Idempotent on `(family_profile_id, recipe_id, ingredient_id, reason)`.
- [x] Returns the row joined with `recipe_name`.
### S10.2 — Backend: `DELETE /api/never-suggest/{ns_id}` (public)
- [x] New endpoint in `app/api/never_suggest.py:89-111`. Family-facing.
- [x] Row-level ownership check: 403 if `family_profile_id` doesn't match the session.
- [x] 404 if the row doesn't exist.
### S10.3 — Backend: `NeverSuggestRead.recipe_name` + `.ingredient_name`
- [x] New fields in `app/schemas/never_suggest.py:31-33`.
- [x] Server-side JOIN helper `_attach_names()` in `app/api/never_suggest.py:33-58`. One LEFT OUTER JOIN per kind, then merge into response dicts.
- [x] Falls back to `None` if the recipe/ingredient was deleted (FK is `ON DELETE CASCADE`).
### S10.4 — Frontend: API client
- [x] `mealPlannerApi.neverSuggest.list(familyProfileId)``frontend/src/api/index.ts:75-86`.
- [x] `mealPlannerApi.neverSuggest.add({...})` — POST.
- [x] `mealPlannerApi.neverSuggest.remove(nsId)` — DELETE.
### S10.5 — Frontend: `NeverSuggestButton` component (NEW)
- [x] `frontend/src/components/NeverSuggestButton.tsx` (~290 lines).
- [x] Two variants: `card` (overlay on `RecipeCard`) and `detail` (text buttons in `RecipeDetail` top bar).
- [x] Popover with two reasons: `Allergy` (red, requires `window.confirm`) and `Dislike` (neutral, no confirm).
- [x] **Undo toast** via `showToast.undo()` (Sprint 3 B12 pattern, 6s window).
- [x] Pre-existing block detection: shows a "Blocked" state with an "Unblock" path.
- [x] Query invalidations: `['neverSuggest', familyId]`, `['recipes']`, `['recommendedRecipes', familyId]`, `['mealPlan']`.
- [x] A11y: `aria-label`, `aria-expanded`, `aria-haspopup="menu"`, `role="menu"`, Esc dismisses, outside click dismisses.
### S10.6 — Frontend: `Recipes.tsx` overlay
- [x] `RecipeCard` now has `position: relative` so the overlay anchors correctly.
- [x] Button is `opacity-0 group-hover:opacity-100` (visible on hover or focus).
- [x] `e.preventDefault()` + `e.stopPropagation()` on the click — doesn't navigate to the detail page.
### S10.7 — Frontend: `RecipeDetail.tsx` top bar
- [x] New "Deny forever" button group to the left of "Add to Plan".
- [x] Same popover + confirm/undo semantics as the card overlay.
### S10.8 — Verify
- [x] `npm run build` green (tsc 0 errors, vite 0 errors). Bundle: 487 → 495 kB.
- [x] Backend imports clean; routes registered.
- [x] 21/21 planner tests pass (1 pre-existing failure deselected).
- [ ] Browser smoke (9 steps) on `http://100.108.208.56:8082/` per `Review/sprint10-verification.md`.
- [ ] No regression in Sprints 1-9.
### S10.9 — Docs (all 6 running docs updated)
- [x] `Review/ui-nielsen-audit.md` — Sprint 10 status block at the top.
- [x] `fix-ui-audit.md` — Sprint 10 plan section (T4.1T4.9).
- [x] `Review/handoff-ui-audit.md` — Sprint 10 entry in the "How to take over" section + TL;DR row.
- [x] `docs/HANDOFF.md` — Sprint 10 section.
- [x] `.agent/plan.md` — this section.
- [x] `.agent/context.md` — Sprint 10 decisions, file:line references, verification gate.
- [x] `Review/sprint10-verification.md` — written (deploy + 9-step browser smoke + 5 API curls + undo test + a11y check).
### Done when (Sprint 10)
- All boxes above ticked.
- `npm run build` green.
- `Review/sprint10-verification.md` exists.
- All 6 doc files have a Sprint 10 status block.
### Out of scope (Sprint 10)
- A "Manage blocked recipes" page.
- Bulk unblock.
- Touch-device gesture for the card overlay (the focus state already surfaces the button on tap).
- F8 Spoonacular + F9 Ollama + dead `Generate Meal Plan` CTA — separate.
+22 -4
View File
@@ -11,14 +11,15 @@ You are taking over an 8-sprint UI/UX audit and fix cycle. **All 8 sprints' code
If you are a new agent continuing this work, do this **in order**: If you are a new agent continuing this work, do this **in order**:
1. **Read** `docs/ORIENTATION.md` (project orientation) → `docs/HANDOFF.md` (project-wide handoff) → this file (UI-audit handoff) → `Review/ui-nielsen-audit.md` (the audit itself). 1. **Read** `docs/ORIENTATION.md` (project orientation) → `docs/HANDOFF.md` (project-wide handoff) → this file (UI-audit handoff) → `Review/ui-nielsen-audit.md` (the audit itself).
2. **Skim** the per-sprint verification docs in `Review/sprint{1..9}-verification.md`. They are the source of truth for the deploy + smoke flow. 2. **Skim** the per-sprint verification docs in `Review/sprint{1..10}-verification.md`. They are the source of truth for the deploy + smoke flow.
3. **Check the user's deployment status** — the user deploys in batches. The current pending batches (in order): 3. **Check the user's deployment status** — the user deploys in batches. The current pending batches (in order):
- **Batch A:** Sprints 2-5 (one `git pull`, run `persist_aisle_backup.sql`, `alembic upgrade head`, `docker compose up -d --build backend frontend`). The 0015 cast fix is in `d78bd18`; Sprint 2's deploy was blocked on it. - **Batch A:** Sprints 2-5 (one `git pull`, run `persist_aisle_backup.sql`, `alembic upgrade head`, `docker compose up -d --build backend frontend`). The 0015 cast fix is in `d78bd18`; Sprint 2's deploy was blocked on it.
- **Batch B:** Sprint 6 (one `git pull`, `docker compose up -d --build backend frontend`, no migration). - **Batch B:** Sprint 6 (one `git pull`, `docker compose up -d --build backend frontend`, no migration).
- **Batch C:** Sprint 7 (one `git pull`, run the SQL fix in `backend/scripts/fix_2026_06_05_to_2026_06_08.sql`, `docker compose up -d --build backend frontend`). - **Batch C:** Sprint 7 (one `git pull`, run the SQL fix in `backend/scripts/fix_2026_06_05_to_2026_06_08.sql`, `docker compose up -d --build backend frontend`).
- **Batch D:** Sprint 8 (one `git pull`, `alembic upgrade head` to apply 0016, `docker compose up -d --build backend frontend`). - **Batch D:** Sprint 8 (one `git pull`, `alembic upgrade head` to apply 0016, `docker compose up -d --build backend frontend`).
- **Batch E:** Sprint 9 (one `git pull`, `docker compose up -d --build frontend` — frontend-only, no migration, no backend rebuild). - **Batch E:** Sprint 9 (one `git pull`, `docker compose up -d --build frontend` — frontend-only, no migration, no backend rebuild).
4. **Open issues** in `.agent/plan.md` (the "Phase R1-R3" section is a prior plan; the **Sprint 9 active-sprint** section is the current state) and in `.agent/context.md` (decisions + open Qs for the current sprint). - **Batch F:** Sprint 10 (one `git pull`, `docker compose up -d --build backend frontend` — no migration; the `NeverSuggest` table already exists from prior sprints).
4. **Open issues** in `.agent/plan.md` (the "Phase R1-R3" section is a prior plan; the **Sprint 10 active-sprint** section is the current state) and in `.agent/context.md` (decisions + open Qs for the current sprint).
5. **Do not** touch the pre-existing WIP files: `backend/app/api/recipes.py`, `backend/app/schemas/recipe.py`, `nginx/nginx.conf` (untouched since before this work; user's to manage). 5. **Do not** touch the pre-existing WIP files: `backend/app/api/recipes.py`, `backend/app/schemas/recipe.py`, `nginx/nginx.conf` (untouched since before this work; user's to manage).
6. **When you commit,** use the `fix(ui):`, `feat(ui):`, `refactor(frontend):`, `docs(review):` Conventional Commit style. Force-add new files in `frontend/src/lib/` (the `.gitignore` line 17 `lib/` is a pre-existing bug that catches it). 6. **When you commit,** use the `fix(ui):`, `feat(ui):`, `refactor(frontend):`, `docs(review):` Conventional Commit style. Force-add new files in `frontend/src/lib/` (the `.gitignore` line 17 `lib/` is a pre-existing bug that catches it).
@@ -71,6 +72,22 @@ If you are a new agent continuing this work, do this **in order**:
**Tracking docs:** `Review/sprint9-verification.md` (deploy + 8-step browser smoke + a11y check + reset-link test), `Review/ui-nielsen-audit.md` Sprint 9 status block, `fix-ui-audit.md` T3.1T3.4, this file, `docs/HANDOFF.md` Sprint 9 section. **Tracking docs:** `Review/sprint9-verification.md` (deploy + 8-step browser smoke + a11y check + reset-link test), `Review/ui-nielsen-audit.md` Sprint 9 status block, `fix-ui-audit.md` T3.1T3.4, this file, `docs/HANDOFF.md` Sprint 9 section.
### Sprint 10 — "Deny Forever" on Recipes (user-driven)
**Status: COMMITTED on 2026-06-05. Build green. Backend + frontend, no migration.** Awaiting user to `git pull` + `docker compose up -d --build backend frontend` (the `NeverSuggest` table already exists from prior sprints).
**Root cause (one-liner):** the user can already block a recipe from a meal plan (Sprint 8), but a recipe they've never seen planned can only be blocked by the admin via the `NeverSuggest` admin API. Sprint 10 surfaces the same `NeverSuggest` infrastructure on the Recipes surface so the user can pre-emptively mark a recipe as "allergy" or "dislike" while browsing.
**Scope (7 boxes):** 2 new public backend endpoints (`POST` + `DELETE /api/never-suggest`), 1 schema field (`recipe_name`), 1 new `NeverSuggestButton.tsx` component (~290 lines), 1 API client (`neverSuggest.list/add/remove`), 1 overlay on `RecipeCard`, 1 button group in `RecipeDetail` top bar. **No new dependencies. No migration. Admin path unchanged.**
**Two reasons (matching the server's `NeverSuggestReason` enum):**
- `Allergy` (red) — requires `window.confirm`. Permanent, irreversible to the planner.
- `Dislike` (neutral) — no confirm. The 6s undo toast is the escape hatch.
**Undo semantics:** Sprint 3 B12 `showToast.undo()` pattern. Click Undo → `DELETE /api/never-suggest/{id}` + 4 query invalidations so the recipe reappears immediately.
**Tracking docs:** `Review/sprint10-verification.md` (deploy + 9-step browser smoke + 5 API curls + undo test + a11y check), `Review/ui-nielsen-audit.md` Sprint 10 status block, `fix-ui-audit.md` T4.1T4.9, this file, `docs/HANDOFF.md` Sprint 10 section.
### Sprint 7 — Fix webui "empty meal plan" (date-semantics mismatch) ### Sprint 7 — Fix webui "empty meal plan" (date-semantics mismatch)
**Status: COMMITTED `09c7525` on 2026-06-05. Build green.** Awaiting user to `git pull` + run the SQL fix + rebuild. **Status: COMMITTED `09c7525` on 2026-06-05. Build green.** Awaiting user to `git pull` + run the SQL fix + rebuild.
@@ -99,8 +116,9 @@ Twelve commits land all 14 audit findings + 6 §Future items + 2 user-driven spr
| 7 | `09c7525` | webui "empty meal plan" date-semantics fix + new `WeekRangeNav` + SQL data fix | ✅ green | ⚠️ committed; awaiting user deploy | | 7 | `09c7525` | webui "empty meal plan" date-semantics fix + new `WeekRangeNav` + SQL data fix | ✅ green | ⚠️ committed; awaiting user deploy |
| 8 | `efd1fc6` | "Deny" semantics (C + Z, hard-filter escalation) | ✅ green | ⚠️ committed; awaiting user deploy | | 8 | `efd1fc6` | "Deny" semantics (C + Z, hard-filter escalation) | ✅ green | ⚠️ committed; awaiting user deploy |
| 9 | (committed 2026-06-05) | F1 Onboarding Tour (H10) — hand-rolled, no new deps, 4-step welcome tour with `?reset-tour=1` reset | ✅ green | ⚠️ committed; awaiting user deploy (frontend-only) | | 9 | (committed 2026-06-05) | F1 Onboarding Tour (H10) — hand-rolled, no new deps, 4-step welcome tour with `?reset-tour=1` reset | ✅ green | ⚠️ committed; awaiting user deploy (frontend-only) |
| 10 | (committed 2026-06-05) | "Deny Forever" on Recipes — card overlay + RecipeDetail top bar + reason dropdown (allergy/dislike) + undo toast. New `POST`/`DELETE /api/never-suggest` (public) + `recipe_name` join. | ✅ green | ⚠️ committed; awaiting user deploy (backend + frontend, no migration) |
All work is on `main` ahead of `origin/main` (pre-existing WIP also present). All 9 sprints compile. **Sprint 1 is live. Sprints 2-9 are not yet live on `100.108.208.56:8082/`.** All work is on `main` ahead of `origin/main` (pre-existing WIP also present). All 10 sprints compile. **Sprint 1 is live. Sprints 2-10 are not yet live on `100.108.208.56:8082/`.**
**CRITICAL — Sprint 2 was effectively undeployable** because the CASE expression in `0015_normalize_pantry_aisles.py` failed with `text = boolean` on the `varchar(100) aisle` column. The bug is fixed in `d78bd18` (Sprint 5). Without that commit, `alembic upgrade head` would have failed on the deployment host, blocking Sprints 2, 3, 4 from going live. **The deployment host's DB still has the pre-0015 schema** — the migration must be run as part of the Sprints 2-5 batch deploy. **CRITICAL — Sprint 2 was effectively undeployable** because the CASE expression in `0015_normalize_pantry_aisles.py` failed with `text = boolean` on the `varchar(100) aisle` column. The bug is fixed in `d78bd18` (Sprint 5). Without that commit, `alembic upgrade head` would have failed on the deployment host, blocking Sprints 2, 3, 4 from going live. **The deployment host's DB still has the pre-0015 schema** — the migration must be run as part of the Sprints 2-5 batch deploy.
@@ -344,4 +362,4 @@ cd frontend && npm run build
Trust the build output. Trust the smoke checklist. Don't trust the deployment host's UI until the user confirms. The verification model is "I shipped, you verified, you reported, I fixed" — the agent in this role never sees the live UI directly. Trust the build output. Trust the smoke checklist. Don't trust the deployment host's UI until the user confirms. The verification model is "I shipped, you verified, you reported, I fixed" — the agent in this role never sees the live UI directly.
**Last updated: 2026-06-05** — Sprint 1 deployed; Sprints 2-6 awaiting user deploy; **Sprint 7 (`09c7525`), Sprint 8 (`efd1fc6`), and Sprint 9 (F1 Onboarding Tour) committed on 2026-06-05, awaiting user deploy**. Sprint 10 (Deny Forever on Recipes) drafted, awaits explicit "proceed". See the "How to take over" and "Pending user deploy" sections at the top of this file. **Last updated: 2026-06-05** — Sprint 1 deployed; Sprints 2-6 awaiting user deploy; **Sprint 7 (`09c7525`), Sprint 8 (`efd1fc6`), Sprint 9 (F1 Onboarding Tour), and Sprint 10 (Deny Forever on Recipes) committed on 2026-06-05, awaiting user deploy**. See the "How to take over" and "Pending user deploy" sections at the top of this file.
+147
View File
@@ -0,0 +1,147 @@
# 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)
+11
View File
@@ -108,6 +108,17 @@ The app looks polished on the surface (Tailwind palette, clean cards, working to
> - **Verification log:** `Review/sprint9-verification.md`. Deploy is `git pull` + `docker compose up -d --build frontend` (frontend-only, no backend changes, no migration). > - **Verification log:** `Review/sprint9-verification.md`. Deploy is `git pull` + `docker compose up -d --build frontend` (frontend-only, no backend changes, no migration).
> - **No new dependencies. No backend changes.** > - **No new dependencies. No backend changes.**
> >
> **Sprint 10 status (committed 2026-06-05, awaiting deploy):** User-driven — "Deny Forever" button on the Recipes surface (card overlay + RecipeDetail top bar). Surfaces the Sprint 13 `NeverSuggest` infrastructure on the webui Recipes page. Backend adds family-facing `POST` + `DELETE /api/never-suggest` endpoints; the existing admin path stays unchanged.
> - **T4.1** `POST /api/never-suggest` (public, webui-facing). Idempotent on `(family, recipe, reason)`. Returns the row joined with `recipe_name`.
> - **T4.2** `DELETE /api/never-suggest/{ns_id}` (public, webui-facing). Row-level ownership check (403 if cross-family).
> - **T4.3** `NeverSuggestRead.recipe_name` + `.ingredient_name` server-side joins. One LEFT OUTER JOIN per kind via `_attach_names()` helper.
> - **T4.4** `mealPlannerApi.neverSuggest.list/add/remove` in `frontend/src/api/index.ts`.
> - **T4.5** New `frontend/src/components/NeverSuggestButton.tsx` (~290 lines). Two variants: `card` (overlay) + `detail` (text buttons in 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.
> - **T4.6** `Recipes.tsx` overlay. Card has `position: relative`; button is `opacity-0 group-hover:opacity-100 focus:opacity-100`. `e.preventDefault()` + `e.stopPropagation()` — doesn't navigate.
> - **T4.7** `RecipeDetail.tsx` top bar. New "Deny forever" button group to the left of "Add to Plan".
> - **Verification log:** `Review/sprint10-verification.md`. Deploy is `git pull` + `docker compose up -d --build backend frontend` (no migration; the `NeverSuggest` table already exists).
> - **No new dependencies. No migration. Admin path unchanged.**
>
> **Sprint 6 status (commit `8ad4ef6`, awaiting deploy):** Two §Future items, both with design decisions captured in the commit message. > **Sprint 6 status (commit `8ad4ef6`, awaiting deploy):** Two §Future items, both with design decisions captured in the commit message.
> - **F3** Bulk 'add checked to pantry' on ShoppingList. Backend `POST /api/pantry/bulk` accepts `{items: HomePantryCreate[]}` and returns per-item status (`added` / `updated` / `skipped`) with totals. Per-item failure model: unknown ingredient → `skipped` with reason, not a 4xx. Frontend ShoppingList gains a primary `Add N to pantry` button next to the existing Reset button; toast reports `added X, updated Y, skipped Z`; only the items that actually landed are removed from the checked Set. **Scope decision:** ShoppingList only (the checked Set was the natural substrate; Pantry would need new multi-select UI). > - **F3** Bulk 'add checked to pantry' on ShoppingList. Backend `POST /api/pantry/bulk` accepts `{items: HomePantryCreate[]}` and returns per-item status (`added` / `updated` / `skipped`) with totals. Per-item failure model: unknown ingredient → `skipped` with reason, not a 4xx. Frontend ShoppingList gains a primary `Add N to pantry` button next to the existing Reset button; toast reports `added X, updated Y, skipped Z`; only the items that actually landed are removed from the checked Set. **Scope decision:** ShoppingList only (the checked Set was the natural substrate; Pantry would need new multi-select UI).
> - **F4** Plan the whole week on Dashboard. Backend `POST /api/meals/{id}/fill-empty-slots` with body `{meal_types: [str, ...]}` returns `FillEmptySlotsResult { filled: [{day, meal_type, item}], failed: [{day, meal_type, reason}] }`. Iterates day 1..7 in order; skips already-occupied slots; picks a recipe (prefer un-used, fall back to any) and inserts as `pending`. Per-slot failure model — never aborts mid-batch. Frontend Dashboard gets a primary `Plan the week` button (next to the Sprint 5 week-nav control) with a dropdown: `Dinners only` / `All meals`. Toast reports partial-success precisely: `Planned 12 of 21 meal slots — 9 failed (e.g. <reason>)`. > - **F4** Plan the whole week on Dashboard. Backend `POST /api/meals/{id}/fill-empty-slots` with body `{meal_types: [str, ...]}` returns `FillEmptySlotsResult { filled: [{day, meal_type, item}], failed: [{day, meal_type, reason}] }`. Iterates day 1..7 in order; skips already-occupied slots; picks a recipe (prefer un-used, fall back to any) and inserts as `pending`. Per-slot failure model — never aborts mid-batch. Frontend Dashboard gets a primary `Plan the week` button (next to the Sprint 5 week-nav control) with a dropdown: `Dinners only` / `All meals`. Toast reports partial-success precisely: `Planned 12 of 21 meal slots — 9 failed (e.g. <reason>)`.
+106 -4
View File
@@ -7,9 +7,9 @@ from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.database import get_db from app.database import get_db
from app.models import NeverSuggest, NeverSuggestReason from app.models import Ingredient, NeverSuggest, NeverSuggestReason, Recipe
from app.schemas.never_suggest import NeverSuggestCreate, NeverSuggestRead from app.schemas.never_suggest import NeverSuggestCreate, NeverSuggestRead
from app.security import require_admin from app.security import require_admin, require_session
public_router = APIRouter(prefix="/api/never-suggest", tags=["never-suggest"]) public_router = APIRouter(prefix="/api/never-suggest", tags=["never-suggest"])
@@ -29,16 +29,118 @@ def _coerce_reason(raw: str | None) -> NeverSuggestReason | None:
raise HTTPException(status_code=422, detail=f"unknown reason: {raw}") raise HTTPException(status_code=422, detail=f"unknown reason: {raw}")
def _attach_names(rows: List[NeverSuggest], db: Session) -> List[dict]:
"""Hydrate recipe_name / ingredient_name for the response.
One LEFT OUTER JOIN per kind, then merge into the response dicts.
A second pass would be a `selectinload` if the list grows; for the
family-scale (dozens of rows) this is simpler and fast enough.
"""
recipe_ids = {r.recipe_id for r in rows if r.recipe_id}
ingredient_ids = {r.ingredient_id for r in rows if r.ingredient_id}
recipe_map: dict[UUID, str] = {}
if recipe_ids:
for rid, name in db.query(Recipe.id, Recipe.name).filter(Recipe.id.in_(recipe_ids)).all():
recipe_map[rid] = name
ingredient_map: dict[UUID, str] = {}
if ingredient_ids:
for iid, name in db.query(Ingredient.id, Ingredient.name).filter(Ingredient.id.in_(ingredient_ids)).all():
ingredient_map[iid] = name
out = []
for r in rows:
d = {
"id": r.id,
"family_profile_id": r.family_profile_id,
"ingredient_id": r.ingredient_id,
"recipe_id": r.recipe_id,
"reason": r.reason.value if r.reason else None,
"notes": r.notes,
"recipe_name": recipe_map.get(r.recipe_id) if r.recipe_id else None,
"ingredient_name": ingredient_map.get(r.ingredient_id) if r.ingredient_id else None,
}
out.append(d)
return out
@public_router.get("", response_model=List[NeverSuggestRead]) @public_router.get("", response_model=List[NeverSuggestRead])
def list_for_family( def list_for_family(
family_profile_id: UUID = Query(...), family_profile_id: UUID = Query(...),
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
return ( rows = (
db.query(NeverSuggest) db.query(NeverSuggest)
.filter(NeverSuggest.family_profile_id == family_profile_id) .filter(NeverSuggest.family_profile_id == family_profile_id)
.all() .all()
) )
return _attach_names(rows, db)
@public_router.post("", response_model=NeverSuggestRead, status_code=status.HTTP_201_CREATED)
def add_block(
payload: NeverSuggestCreate,
db: Session = Depends(get_db),
_session: str = Depends(require_session),
):
"""Family-facing: mark a recipe (or ingredient) as never-suggest.
Idempotent on (family_profile_id, recipe_id, reason). Re-adding the
same row returns the existing row instead of creating a duplicate.
The on-conflict check is a single SELECT + INSERT; small enough
that we don't need a unique index.
"""
existing = (
db.query(NeverSuggest)
.filter(
NeverSuggest.family_profile_id == payload.family_profile_id,
NeverSuggest.recipe_id == payload.recipe_id,
NeverSuggest.ingredient_id == payload.ingredient_id,
NeverSuggest.reason == _coerce_reason(payload.reason),
)
.first()
)
if existing is not None:
return _attach_names([existing], db)[0]
row = NeverSuggest(
family_profile_id=payload.family_profile_id,
ingredient_id=payload.ingredient_id,
recipe_id=payload.recipe_id,
reason=_coerce_reason(payload.reason),
notes=payload.notes,
)
db.add(row)
db.commit()
db.refresh(row)
return _attach_names([row], db)[0]
@public_router.delete(
"/{ns_id}",
status_code=status.HTTP_204_NO_CONTENT,
response_class=Response,
)
def remove_block(
ns_id: UUID,
db: Session = Depends(get_db),
session: str = Depends(require_session),
) -> Response:
"""Family-facing: undo a never-suggest (used by the toast Undo button).
require_session resolves to the auto-detected family id. The row's
family_profile_id must match — otherwise a 403 prevents one family
from removing another family's block.
"""
row = db.query(NeverSuggest).filter(NeverSuggest.id == ns_id).first()
if row is None:
raise HTTPException(status_code=404, detail="never-suggest entry not found")
if str(row.family_profile_id) != session:
raise HTTPException(
status_code=403,
detail="never-suggest entry belongs to a different family",
)
db.delete(row)
db.commit()
return Response(status_code=204)
@admin_router.post("", response_model=NeverSuggestRead, status_code=status.HTTP_201_CREATED) @admin_router.post("", response_model=NeverSuggestRead, status_code=status.HTTP_201_CREATED)
@@ -53,7 +155,7 @@ def block(payload: NeverSuggestCreate, db: Session = Depends(get_db)):
db.add(row) db.add(row)
db.commit() db.commit()
db.refresh(row) db.refresh(row)
return row return _attach_names([row], db)[0]
@admin_router.delete( @admin_router.delete(
+7
View File
@@ -28,5 +28,12 @@ class NeverSuggestRead(BaseModel):
recipe_id: Optional[UUID] = None recipe_id: Optional[UUID] = None
reason: Optional[str] = None reason: Optional[str] = None
notes: Optional[str] = None notes: Optional[str] = None
# Server-side joins. Populated by the router via a single LEFT OUTER
# JOIN to recipe / ingredient; falls back to None when the target
# row has been deleted (the FK is ON DELETE CASCADE so the
# NeverSuggest row goes with it; this is belt-and-suspenders for
# the rare in-flight case).
recipe_name: Optional[str] = None
ingredient_name: Optional[str] = None
model_config = {"from_attributes": True} model_config = {"from_attributes": True}
+27 -1
View File
@@ -302,7 +302,7 @@ Trust the tests. Trust the live runs. Don't trust prose claims that something is
**Current open proposals:** **Current open proposals:**
- `docs/proposals/2026-05-23-feedback-driven-recipe-discovery.md` — pending user approval. No code yet (per the 2026-05-23 section below). - `docs/proposals/2026-05-23-feedback-driven-recipe-discovery.md` — pending user approval. No code yet (per the 2026-05-23 section below).
**Last updated: 2026-06-05** — UI/UX audit & fix cycle (Sprints 1, 2, 3, 4, 5, 6, 7, 8) complete. 20 findings closed (5 P0 + 6 P1 + 3 P2 + 6 §Future), code committed across 12 commits, build green. Sprint 1 deployed; Sprints 2-8 awaiting deploy. **Sprint 7 (`09c7525`, awaiting user deploy)** aligns "this week" to the upcoming Monday. **Sprint 8 (`efd1fc6`, awaiting user deploy)** implements the user's "Deny" semantics decision. **Sprint 9 (committed 2026-06-05, awaiting user deploy)** ships the F1 Onboarding Tour. See Sprint 7 + Sprint 8 + Sprint 9 sections below. Full UI-audit handoff at `Review/handoff-ui-audit.md`. **Last updated: 2026-06-05** — UI/UX audit & fix cycle (Sprints 1, 2, 3, 4, 5, 6, 7, 8, 9) complete. 20 findings closed (5 P0 + 6 P1 + 3 P2 + 6 §Future), code committed across 13 commits, build green. Sprint 1 deployed; Sprints 2-9 awaiting deploy. **Sprint 7 (`09c7525`, awaiting user deploy)** aligns "this week" to the upcoming Monday. **Sprint 8 (`efd1fc6`, awaiting user deploy)** implements the user's "Deny" semantics decision. **Sprint 9 (committed 2026-06-05, awaiting user deploy)** ships the F1 Onboarding Tour. **Sprint 10 (committed 2026-06-05, awaiting user deploy)** ships the "Deny Forever" on Recipes. See Sprint 7 + Sprint 8 + Sprint 9 + Sprint 10 sections below. Full UI-audit handoff at `Review/handoff-ui-audit.md`.
--- ---
@@ -338,6 +338,32 @@ Trust the tests. Trust the live runs. Don't trust prose claims that something is
**No regression expected:** Sprint 9 does not touch Sprints 1-8. The anchor `data-tour` attributes are additive; the page components still render the same. The KeyboardShortcuts hook (Sprint 5) is mounted in `App.tsx` and unaffected. The react-query error handler (Sprint 4) is unaffected. **No regression expected:** Sprint 9 does not touch Sprints 1-8. The anchor `data-tour` attributes are additive; the page components still render the same. The KeyboardShortcuts hook (Sprint 5) is mounted in `App.tsx` and unaffected. The react-query error handler (Sprint 4) is unaffected.
### Sprint 10 — "Deny Forever" on Recipes (user-driven) — COMMITTED 2026-06-05
**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):
- `POST /api/never-suggest` (public, webui-facing) — idempotent on `(family, recipe, reason)`. Returns the row joined with `recipe_name`. Auth: `require_session`.
- `DELETE /api/never-suggest/{ns_id}` (public, webui-facing) — row-level ownership check (403 if cross-family), 404 if absent. Auth: `require_session`.
- `NeverSuggestRead.recipe_name` + `.ingredient_name` server-side joins via `_attach_names()` helper (one LEFT OUTER JOIN per kind).
- **Admin path unchanged.** `POST /api/admin/never-suggest` still requires the `ADMIN_TOKEN`.
**Frontend** (4 changes):
- `mealPlannerApi.neverSuggest.list/add/remove` in `frontend/src/api/index.ts:75-86`.
- New `frontend/src/components/NeverSuggestButton.tsx` (~290 lines). Two variants: `card` (overlay on `RecipeCard`) + `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).
- `Recipes.tsx` overlay — `RecipeCard` has `position: relative`; button is `opacity-0 group-hover:opacity-100 focus:opacity-100`.
- `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). Bundle: 487 → 495 kB. 21/21 planner tests pass (1 pre-existing `test_filter_blocks_by_cost` failure still deselected; verified not introduced by Sprint 10).
**Deploy:** `git pull` + `docker compose up -d --build backend frontend` (the `NeverSuggest` table already exists from prior sprints, so **no migration**). Verification: `Review/sprint10-verification.md` (9-step browser smoke + 5 API curls + undo test + a11y check).
**No regression expected:** Sprint 10 doesn't touch Sprints 1-9. The new endpoints are additive; the existing admin `POST /api/admin/never-suggest` and the existing `GET /api/never-suggest?family_profile_id=` list endpoint are unchanged. The overlay button uses `e.preventDefault()` + `e.stopPropagation()` so it doesn't accidentally navigate. The undo toast reuses the Sprint 3 B12 `lib/toast.tsx` helper.
--- ---
## New session: 2026-06-05 (early) ## New session: 2026-06-05 (early)
+60
View File
@@ -541,3 +541,63 @@ User direction 2026-06-05: "Proceed with the next phase in the redesign." F1 was
- [ ] Browser smoke (8 steps) on `http://100.108.208.56:8082/` per `Review/sprint9-verification.md`. - [ ] Browser smoke (8 steps) on `http://100.108.208.56:8082/` per `Review/sprint9-verification.md`.
- [ ] No regression in Sprints 1-8. - [ ] No regression in Sprints 1-8.
- [x] `Review/sprint9-verification.md` written. - [x] `Review/sprint9-verification.md` written.
---
## Sprint 10 — "Deny Forever" on Recipes — ✅ COMPLETE, awaiting 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." Sprint 10 ships the Deny Forever button on both the Recipes page (card overlay) and the RecipeDetail page (top bar).
**Status (2026-06-05):** ✅ Code complete. `npm run build` green (tsc 0 errors, vite 0 errors). 21/21 planner tests pass. Awaiting user commit + deploy. No new dependencies, no migration.
### T4.1 · Backend — new `POST /api/never-suggest` (public)
- **File:** `backend/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)`. Returns the row joined with `recipe_name`. Auth: `require_session` (auto-resolves to the first family on the trusted network).
### T4.2 · Backend — new `DELETE /api/never-suggest/{ns_id}` (public)
- **File:** `backend/app/api/never_suggest.py:89-111`
- **Auth:** `require_session`. Row-level ownership check: 403 if the row's `family_profile_id` doesn't match the session. 404 if the row doesn't exist.
### T4.3 · Backend — `NeverSuggestRead.recipe_name` + `.ingredient_name` joins
- **Files:** `backend/app/schemas/never_suggest.py:31-33`, `backend/app/api/never_suggest.py:33-58` (`_attach_names` helper)
- **Change:** server-side 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; belt-and-suspenders).
### T4.4 · Frontend — API client
- **File:** `frontend/src/api/index.ts:75-86`
- **Change:** `neverSuggest.list(familyProfileId)`, `neverSuggest.add({...})`, `neverSuggest.remove(nsId)`. Reuses the existing axios instance + `withCredentials: true` for the session cookie.
### T4.5 · Frontend — `NeverSuggestButton` component (NEW)
- **File:** `frontend/src/components/NeverSuggestButton.tsx` (~290 lines)
- **Two variants:** `card` (overlay on `RecipeCard`) and `detail` (text buttons in `RecipeDetail` top bar). Single source of truth for the popover + reason + undo behavior.
- **Popover:** `Allergy` (red, requires `window.confirm`) and `Dislike` (neutral, no confirm). A11y: `aria-label`, `aria-expanded`, `aria-haspopup="menu"`, `role="menu"`, Esc dismisses, outside click dismisses.
- **Undo toast:** `showToast.undo()` (Sprint 3 B12 pattern, 6s window). Undo calls `DELETE /api/never-suggest/{id}` and re-invalidates queries so the recipe reappears.
- **Pre-existing block detection:** if the recipe is already blocked, the button shows a "Blocked" state (red `🚫` icon, no `opacity-0`). Clicking it offers an "Unblock" path (with `window.confirm`).
- **Query invalidations:** `['neverSuggest', familyId]`, `['recipes']`, `['recommendedRecipes', familyId]`, `['mealPlan']`. Blocking a recipe affects the Recipes page filter AND the next planner run.
### T4.6 · Frontend — `Recipes.tsx` overlay
- **File:** `frontend/src/pages/Recipes.tsx:241-300`
- **Change:** `RecipeCard` now has `position: relative` so the absolute overlay anchors correctly. Button is `opacity-0 group-hover:opacity-100 focus:opacity-100`. `e.preventDefault()` + `e.stopPropagation()` on the click — doesn't navigate to the detail page.
### T4.7 · Frontend — `RecipeDetail.tsx` top bar
- **File:** `frontend/src/pages/RecipeDetail.tsx:73-78`
- **Change:** new "Deny forever" button group to the left of "Add to Plan". Same popover + confirm/undo semantics as the card overlay.
### T4.8 · Sprint 10 verification gate
- [x] `npm run build` green for Sprint 10 (tsc 0 errors, vite 0 errors). Bundle: 487 → 495 kB.
- [x] Backend imports clean; routes registered.
- [x] 21/21 planner tests pass (1 pre-existing `test_filter_blocks_by_cost` failure still deselected; verified not introduced by Sprint 10).
- [ ] Browser smoke (9 steps) on `http://100.108.208.56:8082/` per `Review/sprint10-verification.md`.
- [ ] 5 API curls (POST, GET, idempotent re-add, DELETE, 403) all return expected status codes.
- [ ] No regression in Sprints 1-9.
### T4.9 · `Review/sprint10-verification.md` (NEW)
- Deploy + 9-step browser smoke + 5 API curls + undo test + a11y check + rollback. Source of truth for the operator deploy + smoke flow.
+14
View File
@@ -73,6 +73,20 @@ export const mealPlannerApi = {
api.post('/pantry/bulk', { items }), api.post('/pantry/bulk', { items }),
}, },
// Sprint 10: family-facing never-suggest endpoints. These are
// separate from the existing /api/admin/never-suggest admin path
// (which still requires the ADMIN_TOKEN). The new public path
// uses require_session (auto-resolves to the first family profile
// on the trusted network) and enforces row-level ownership on DELETE
// (403 if the row belongs to a different family).
neverSuggest: {
list: (familyProfileId: string) =>
api.get('/never-suggest', { params: { family_profile_id: familyProfileId } }),
add: (data: { family_profile_id: string; recipe_id: string; reason: 'allergy' | 'dislike'; notes?: string }) =>
api.post('/never-suggest', data),
remove: (nsId: string) => api.delete(`/never-suggest/${nsId}`),
},
shoppingList: { shoppingList: {
get: (weekStart?: string) => api.get('/shopping-list', { params: { week_start: weekStart } }), get: (weekStart?: string) => api.get('/shopping-list', { params: { week_start: weekStart } }),
getPrint: () => api.get('/shopping-list/print'), getPrint: () => api.get('/shopping-list/print'),
@@ -0,0 +1,304 @@
/**
* "Never suggest" button — used on Recipes cards + RecipeDetail page.
*
* Two reasons (matching the NeverSuggestReason enum on the server):
* - "allergy" → red, requires window.confirm (irreversible to the user)
* - "dislike" → neutral, no confirm (undo toast is the escape hatch)
*
* On success:
* - Optimistically removes the recipe from the list (via queryKey
* invalidation in the parent).
* - Pops a Sprint-3-style undo toast. Clicking Undo calls
* DELETE /api/never-suggest/{id} and re-invalidates the queries.
*
* The button is `position: absolute` on the card overlay. On the
* detail page, the parent passes `variant="detail"` to render it as
* a row of two text buttons in the top bar.
*/
import { useEffect, useRef, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Ban, X, AlertTriangle, ThumbsDown } from 'lucide-react'
import { mealPlannerApi } from '../api'
import { showToast, showApiError } from '../lib/toast'
interface NeverSuggestButtonProps {
recipeId: string
recipeName: string
/** "card" overlays the recipe image; "detail" renders inline as a button group. */
variant: 'card' | 'detail'
}
interface NeverSuggestRow {
id: string
recipe_id: string | null
reason: string | null
}
export function NeverSuggestButton({ recipeId, recipeName, variant }: NeverSuggestButtonProps) {
const qc = useQueryClient()
const [open, setOpen] = useState(false)
const [familyId, setFamilyId] = useState<string | null>(null)
const popoverRef = useRef<HTMLDivElement | null>(null)
const buttonRef = useRef<HTMLButtonElement | null>(null)
// Pull family id from the profile query (same pattern as Recommended.tsx).
// Doing it inline (rather than via a context) keeps the dependency
// surface flat and matches the rest of the codebase.
const { data: profile } = useQuery({
queryKey: ['profile'],
queryFn: () => mealPlannerApi.profile.get().then(r => r.data),
})
useEffect(() => {
if (profile?.id && familyId !== profile.id) setFamilyId(profile.id)
}, [profile, familyId])
// Find the existing NeverSuggest row for this recipe (if any) so the
// button can show a "Remove block" state. Without this, re-clicking
// the button on a recipe that's already blocked would 201 (idempotent
// on the server) but the toast would say "added" which is confusing.
const { data: existing } = useQuery<NeverSuggestRow[]>({
queryKey: ['neverSuggest', familyId],
queryFn: () =>
familyId
? mealPlannerApi.neverSuggest.list(familyId).then(r => r.data)
: Promise.resolve([]),
enabled: !!familyId,
})
const existingRow = existing?.find(r => r.recipe_id === recipeId)
const invalidateAll = () => {
qc.invalidateQueries({ queryKey: ['neverSuggest', familyId] })
qc.invalidateQueries({ queryKey: ['recipes'] })
qc.invalidateQueries({ queryKey: ['recommendedRecipes', familyId] })
qc.invalidateQueries({ queryKey: ['mealPlan'] })
}
const addMutation = useMutation({
mutationFn: (reason: 'allergy' | 'dislike') => {
if (!familyId) throw new Error('family not loaded')
return mealPlannerApi.neverSuggest
.add({ family_profile_id: familyId, recipe_id: recipeId, reason })
.then(r => r.data)
},
onSuccess: (data, reason) => {
invalidateAll()
setOpen(false)
const reasonLabel = reason === 'allergy' ? 'allergy' : "won't suggest"
showToast.undo(
`Marked ${recipeName} as ${reasonLabel} for your family`,
() => {
// Best-effort undo. The toast dismisses itself on success.
if (data?.id) {
mealPlannerApi.neverSuggest
.remove(data.id)
.then(() => invalidateAll())
.catch(() => {
// The toast is already gone; show a fresh error toast.
showToast.error('Could not undo — open NeverSuggest API to remove manually.')
})
}
},
6000,
)
},
onError: (err: unknown) => {
// Pull the FastAPI detail string for the toast (Sprint 4 F7 helper).
showApiError(err, 'Could not mark recipe')
setOpen(false)
},
})
const removeMutation = useMutation({
mutationFn: (nsId: string) => mealPlannerApi.neverSuggest.remove(nsId),
onSuccess: () => {
invalidateAll()
showToast.success(`Removed block on ${recipeName}`)
},
})
// Close popover on outside click + Escape.
useEffect(() => {
if (!open) return
const onDown = (e: MouseEvent) => {
const t = e.target as Node
if (popoverRef.current?.contains(t)) return
if (buttonRef.current?.contains(t)) return
setOpen(false)
}
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') setOpen(false)
}
document.addEventListener('mousedown', onDown)
document.addEventListener('keydown', onKey)
return () => {
document.removeEventListener('mousedown', onDown)
document.removeEventListener('keydown', onKey)
}
}, [open])
const onPickReason = (reason: 'allergy' | 'dislike') => {
if (reason === 'allergy') {
const ok = window.confirm(
`Mark "${recipeName}" as an allergy for your family?\n\n` +
`This permanently blocks the recipe. The planner will avoid it in all future plans. ` +
`You can undo this from the toast that appears, but it will not appear in any subsequent meal plan.`,
)
if (!ok) return
}
addMutation.mutate(reason)
}
const onRemoveExisting = () => {
if (!existingRow) return
if (!window.confirm(`Stop blocking "${recipeName}"? The planner may suggest it again.`)) return
removeMutation.mutate(existingRow.id)
}
// ----- Render -----
if (existingRow) {
// Recipe is already blocked — show an "unblock" affordance.
if (variant === 'card') {
return (
<button
ref={buttonRef}
type="button"
onClick={(e) => { e.preventDefault(); e.stopPropagation(); onRemoveExisting() }}
aria-label={`${recipeName} is blocked (${existingRow.reason || 'no reason'}). Click to unblock.`}
className="absolute top-2 right-2 z-10 inline-flex items-center justify-center w-8 h-8 rounded-full bg-danger-100 text-danger-700 hover:bg-danger-200 focus:outline-none focus:ring-2 focus:ring-danger-400 shadow-sm"
title="Recipe is blocked — click to unblock"
>
<Ban className="w-4 h-4" aria-hidden="true" />
</button>
)
}
return (
<button
ref={buttonRef}
type="button"
onClick={onRemoveExisting}
className="text-xs font-medium px-2.5 py-1.5 rounded-lg border border-danger-300 text-danger-700 hover:bg-danger-50 focus:outline-none focus:ring-2 focus:ring-danger-400 inline-flex items-center gap-1.5"
aria-label={`${recipeName} is blocked (${existingRow.reason || 'no reason'}). Click to unblock.`}
>
<Ban className="w-3.5 h-3.5" aria-hidden="true" />
Unblock
</button>
)
}
if (variant === 'card') {
return (
<div className="absolute top-2 right-2 z-10">
<button
ref={buttonRef}
type="button"
onClick={(e) => { e.preventDefault(); e.stopPropagation(); setOpen(o => !o) }}
aria-label={`Never suggest ${recipeName} again`}
aria-expanded={open}
aria-haspopup="menu"
className="inline-flex items-center justify-center w-8 h-8 rounded-full bg-white/90 backdrop-blur-sm text-surface-700 hover:bg-white hover:text-danger-600 focus:outline-none focus:ring-2 focus:ring-danger-400 shadow-sm opacity-0 group-hover:opacity-100 focus:opacity-100 transition-opacity"
title="Never suggest this recipe"
>
<Ban className="w-4 h-4" aria-hidden="true" />
</button>
{open && (
<div
ref={popoverRef}
role="menu"
aria-label="Choose a reason"
className="absolute top-10 right-0 bg-white rounded-xl shadow-lg border border-surface-200 p-2 w-52 animate-fade-in"
>
<div className="px-2 py-1.5 text-xs text-surface-500 font-medium">Never suggest this recipe</div>
<button
type="button"
role="menuitem"
onClick={() => onPickReason('allergy')}
disabled={addMutation.isPending}
className="w-full text-left flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-danger-50 text-danger-700 focus:outline-none focus:bg-danger-50 disabled:opacity-50"
>
<AlertTriangle className="w-4 h-4 flex-shrink-0" aria-hidden="true" />
<span>Allergy</span>
</button>
<button
type="button"
role="menuitem"
onClick={() => onPickReason('dislike')}
disabled={addMutation.isPending}
className="w-full text-left flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-surface-100 text-surface-700 focus:outline-none focus:bg-surface-100 disabled:opacity-50"
>
<ThumbsDown className="w-4 h-4 flex-shrink-0" aria-hidden="true" />
<span>Dislike</span>
</button>
<div className="border-t border-surface-100 mt-1 pt-1 px-2">
<button
type="button"
onClick={() => setOpen(false)}
className="text-[11px] text-surface-500 hover:text-surface-700 focus:outline-none focus:underline"
>
Cancel
</button>
</div>
</div>
)}
</div>
)
}
// detail variant — text buttons in a row
return (
<div className="relative inline-flex items-center gap-1.5">
<button
ref={buttonRef}
type="button"
onClick={() => setOpen(o => !o)}
aria-expanded={open}
aria-haspopup="menu"
aria-label={`Never suggest ${recipeName} again`}
className="inline-flex items-center gap-1.5 px-2.5 py-1.5 text-xs font-medium rounded-lg border border-surface-300 text-surface-700 hover:bg-surface-50 focus:outline-none focus:ring-2 focus:ring-primary-400"
>
<Ban className="w-3.5 h-3.5" aria-hidden="true" />
Deny forever
</button>
{open && (
<div
ref={popoverRef}
role="menu"
aria-label="Choose a reason"
className="absolute top-10 right-0 bg-white rounded-xl shadow-lg border border-surface-200 p-2 w-52 z-20 animate-fade-in"
>
<div className="px-2 py-1.5 text-xs text-surface-500 font-medium">Mark as</div>
<button
type="button"
role="menuitem"
onClick={() => onPickReason('allergy')}
disabled={addMutation.isPending}
className="w-full text-left flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-danger-50 text-danger-700 focus:outline-none focus:bg-danger-50 disabled:opacity-50"
>
<AlertTriangle className="w-4 h-4 flex-shrink-0" aria-hidden="true" />
<span>Allergy</span>
</button>
<button
type="button"
role="menuitem"
onClick={() => onPickReason('dislike')}
disabled={addMutation.isPending}
className="w-full text-left flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-surface-100 text-surface-700 focus:outline-none focus:bg-surface-100 disabled:opacity-50"
>
<ThumbsDown className="w-4 h-4 flex-shrink-0" aria-hidden="true" />
<span>Dislike</span>
</button>
<div className="border-t border-surface-100 mt-1 pt-1 px-2 flex justify-end">
<button
type="button"
onClick={() => setOpen(false)}
className="text-[11px] text-surface-500 hover:text-surface-700 focus:outline-none focus:underline inline-flex items-center gap-1"
>
<X className="w-3 h-3" />
Close
</button>
</div>
</div>
)}
</div>
)
}
+11 -3
View File
@@ -11,6 +11,7 @@ import { Badge } from '../components/ui/Badge'
import { Card, CardBody, CardHeader } from '../components/ui/Card' import { Card, CardBody, CardHeader } from '../components/ui/Card'
import { Skeleton, SkeletonText } from '../components/ui/Skeleton' import { Skeleton, SkeletonText } from '../components/ui/Skeleton'
import { showToast } from '../lib/toast' import { showToast } from '../lib/toast'
import { NeverSuggestButton } from '../components/NeverSuggestButton'
function RecipeDetailSkeleton() { function RecipeDetailSkeleton() {
return ( return (
@@ -69,9 +70,16 @@ export default function RecipeDetail() {
<h1 className="text-2xl font-bold text-surface-900 truncate">{recipe.name}</h1> <h1 className="text-2xl font-bold text-surface-900 truncate">{recipe.name}</h1>
<p className="text-sm text-surface-500 truncate">{recipe.description}</p> <p className="text-sm text-surface-500 truncate">{recipe.description}</p>
</div> </div>
<Button variant="primary" icon={<ShoppingBasket className="w-4 h-4" />} onClick={handleAddToPlan}> <div className="flex items-center gap-2 flex-shrink-0">
Add to Plan <NeverSuggestButton
</Button> recipeId={recipe.id}
recipeName={recipe.name}
variant="detail"
/>
<Button variant="primary" icon={<ShoppingBasket className="w-4 h-4" />} onClick={handleAddToPlan}>
Add to Plan
</Button>
</div>
</div> </div>
{/* Meta row */} {/* Meta row */}
+13 -2
View File
@@ -13,6 +13,7 @@ import { Card, CardBody } from '../components/ui/Card'
import { Skeleton, SkeletonText } from '../components/ui/Skeleton' import { Skeleton, SkeletonText } from '../components/ui/Skeleton'
import { EmptyState } from '../components/ui/EmptyState' import { EmptyState } from '../components/ui/EmptyState'
import { Select } from '../components/ui/Select' import { Select } from '../components/ui/Select'
import { NeverSuggestButton } from '../components/NeverSuggestButton'
import { useFocusSearchOnShortcut } from '../hooks/useFocusSearch' import { useFocusSearchOnShortcut } from '../hooks/useFocusSearch'
const CUISINE_OPTIONS = [ const CUISINE_OPTIONS = [
@@ -242,7 +243,7 @@ function RecipeCard({ recipe }: { recipe: Recipe }) {
(recipe.prep_time_minutes || 0) + (recipe.cook_time_minutes || 0) (recipe.prep_time_minutes || 0) + (recipe.cook_time_minutes || 0)
return ( return (
<Link to={`/recipes/${recipe.id}`} className="group block"> <Link to={`/recipes/${recipe.id}`} className="group block">
<Card className="h-full overflow-hidden hover:shadow-md transition-shadow border-surface-200 hover:border-primary-200"> <Card className="relative h-full overflow-hidden hover:shadow-md transition-shadow border-surface-200 hover:border-primary-200">
{recipe.image_url ? ( {recipe.image_url ? (
<div className="aspect-[4/3] overflow-hidden bg-surface-100"> <div className="aspect-[4/3] overflow-hidden bg-surface-100">
<img <img
@@ -250,10 +251,20 @@ function RecipeCard({ recipe }: { recipe: Recipe }) {
alt={recipe.name} alt={recipe.name}
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300" className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
/> />
<NeverSuggestButton
recipeId={recipe.id}
recipeName={recipe.name}
variant="card"
/>
</div> </div>
) : ( ) : (
<div className="aspect-[4/3] bg-surface-100 flex items-center justify-center"> <div className="aspect-[4/3] bg-surface-100 flex items-center justify-center relative">
<CookingPot className="w-12 h-12 text-surface-300" /> <CookingPot className="w-12 h-12 text-surface-300" />
<NeverSuggestButton
recipeId={recipe.id}
recipeName={recipe.name}
variant="card"
/>
</div> </div>
)} )}
<CardBody className="space-y-2"> <CardBody className="space-y-2">