Public Access
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:
@@ -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
|
||||
- `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
@@ -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`.
|
||||
- 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).
|
||||
- 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.1–T4.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.
|
||||
|
||||
@@ -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**:
|
||||
|
||||
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):
|
||||
- **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 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 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).
|
||||
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.1–T3.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.1–T4.9, this file, `docs/HANDOFF.md` Sprint 10 section.
|
||||
|
||||
### 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.
|
||||
@@ -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 |
|
||||
| 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) |
|
||||
| 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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
**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.
|
||||
|
||||
@@ -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 1–3 `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)
|
||||
@@ -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).
|
||||
> - **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 1–3 `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.
|
||||
> - **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>)`.
|
||||
|
||||
@@ -7,9 +7,9 @@ from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
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.security import require_admin
|
||||
from app.security import require_admin, require_session
|
||||
|
||||
|
||||
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}")
|
||||
|
||||
|
||||
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])
|
||||
def list_for_family(
|
||||
family_profile_id: UUID = Query(...),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
return (
|
||||
rows = (
|
||||
db.query(NeverSuggest)
|
||||
.filter(NeverSuggest.family_profile_id == family_profile_id)
|
||||
.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)
|
||||
@@ -53,7 +155,7 @@ def block(payload: NeverSuggestCreate, db: Session = Depends(get_db)):
|
||||
db.add(row)
|
||||
db.commit()
|
||||
db.refresh(row)
|
||||
return row
|
||||
return _attach_names([row], db)[0]
|
||||
|
||||
|
||||
@admin_router.delete(
|
||||
|
||||
@@ -28,5 +28,12 @@ class NeverSuggestRead(BaseModel):
|
||||
recipe_id: Optional[UUID] = None
|
||||
reason: 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}
|
||||
|
||||
+27
-1
@@ -302,7 +302,7 @@ Trust the tests. Trust the live runs. Don't trust prose claims that something is
|
||||
**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).
|
||||
|
||||
**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.
|
||||
|
||||
### 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 1–3 `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)
|
||||
|
||||
@@ -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`.
|
||||
- [ ] No regression in Sprints 1-8.
|
||||
- [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.
|
||||
|
||||
@@ -73,6 +73,20 @@ export const mealPlannerApi = {
|
||||
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: {
|
||||
get: (weekStart?: string) => api.get('/shopping-list', { params: { week_start: weekStart } }),
|
||||
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,6 +11,7 @@ import { Badge } from '../components/ui/Badge'
|
||||
import { Card, CardBody, CardHeader } from '../components/ui/Card'
|
||||
import { Skeleton, SkeletonText } from '../components/ui/Skeleton'
|
||||
import { showToast } from '../lib/toast'
|
||||
import { NeverSuggestButton } from '../components/NeverSuggestButton'
|
||||
|
||||
function RecipeDetailSkeleton() {
|
||||
return (
|
||||
@@ -69,9 +70,16 @@ export default function RecipeDetail() {
|
||||
<h1 className="text-2xl font-bold text-surface-900 truncate">{recipe.name}</h1>
|
||||
<p className="text-sm text-surface-500 truncate">{recipe.description}</p>
|
||||
</div>
|
||||
<Button variant="primary" icon={<ShoppingBasket className="w-4 h-4" />} onClick={handleAddToPlan}>
|
||||
Add to Plan
|
||||
</Button>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<NeverSuggestButton
|
||||
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>
|
||||
|
||||
{/* Meta row */}
|
||||
|
||||
@@ -13,6 +13,7 @@ import { Card, CardBody } from '../components/ui/Card'
|
||||
import { Skeleton, SkeletonText } from '../components/ui/Skeleton'
|
||||
import { EmptyState } from '../components/ui/EmptyState'
|
||||
import { Select } from '../components/ui/Select'
|
||||
import { NeverSuggestButton } from '../components/NeverSuggestButton'
|
||||
import { useFocusSearchOnShortcut } from '../hooks/useFocusSearch'
|
||||
|
||||
const CUISINE_OPTIONS = [
|
||||
@@ -242,7 +243,7 @@ function RecipeCard({ recipe }: { recipe: Recipe }) {
|
||||
(recipe.prep_time_minutes || 0) + (recipe.cook_time_minutes || 0)
|
||||
return (
|
||||
<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 ? (
|
||||
<div className="aspect-[4/3] overflow-hidden bg-surface-100">
|
||||
<img
|
||||
@@ -250,10 +251,20 @@ function RecipeCard({ recipe }: { recipe: Recipe }) {
|
||||
alt={recipe.name}
|
||||
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 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" />
|
||||
<NeverSuggestButton
|
||||
recipeId={recipe.id}
|
||||
recipeName={recipe.name}
|
||||
variant="card"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<CardBody className="space-y-2">
|
||||
|
||||
Reference in New Issue
Block a user