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.
|
||||
|
||||
Reference in New Issue
Block a user