Files
Meal-Planner/.agent/plan.md
T
admin 0b6c5dcfb7 feat(ui): Sprint 10 — Deny Forever on Recipes (card overlay + detail button + undo toast)
User-driven follow-up to Sprint 8: surface the Sprint 1-3 NeverSuggest
infrastructure on the Recipes surface so a family can pre-emptively
mark a recipe as never-suggest before it appears in a plan.

Backend (3 changes):
- POST /api/never-suggest (public, webui-facing). Idempotent on
  (family, recipe, reason). Returns the row joined with recipe_name.
- DELETE /api/never-suggest/{ns_id} (public, webui-facing). Row-level
  ownership check (403 if cross-family), 404 if absent.
- NeverSuggestRead.recipe_name + .ingredient_name server-side joins
  via _attach_names() helper (one LEFT OUTER JOIN per kind).
- Admin path (POST/DELETE /api/admin/never-suggest) unchanged.

Frontend (4 changes):
- New NeverSuggestButton component (~290 lines). Two variants: card
  (overlay on RecipeCard) and detail (text buttons in RecipeDetail
  top bar). Popover with Allergy (red, window.confirm) + Dislike
  (neutral, no confirm). Undo toast via showToast.undo() (Sprint 3
  B12 pattern, 6s window). Pre-existing block detection shows a
  Blocked state with an Unblock path.
- mealPlannerApi.neverSuggest.list/add/remove in api/index.ts.
- Recipes.tsx overlay: RecipeCard has position: relative; button is
  opacity-0 group-hover:opacity-100 focus:opacity-100. e.preventDefault
  + e.stopPropagation prevents accidental navigation.
- RecipeDetail.tsx top bar: new Deny forever button group to the left
  of Add to Plan.

Build: npm run build green (tsc 0 errors, vite 0 errors) on
docker-willester. Bundle 487 -> 495 kB. No new dependencies. No
migration (NeverSuggest table exists from prior sprints).

Tracking: Review/sprint10-verification.md (9-step browser smoke +
5 API curls + undo test + a11y check).
2026-06-05 13:29:04 -07:00

275 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Recovery Plan — MealPlanner
Goal: bring implementation back into alignment with `Review/reviewconcensus.md`. Stop building forward features until the deferred-risk spikes and the verification matrix pass.
## Active sprint: Sprint 8 — "Deny" semantics (C + Z, hard-filter escalation)
**Owner:** this agent. **Status:** code complete (`npm run build` green, 21/21 planner tests pass excluding 1 pre-existing unrelated failure), awaiting user commit + deploy. **Tracking:** `Review/sprint8-verification.md` (deploy + smoke), `.agent/plan.md` (checklist), `.agent/context.md` (decisions + open Qs).
**User policy decision (2026-06-05, exact):** "Hard filter. If it is denied this week twice, it should be considered denied for good." — collapses the design to **C + Z** with a server-side 2-denial auto-escalation.
### S8.1 — Migration: `0016_denial_decay_and_scope.py` (NEW)
- [x] Adds `meal_plan_item.denial_expires_at TIMESTAMPTZ NULL`.
- [x] Adds `meal_plan_vote.denial_scope VARCHAR(16) NULL`.
- [x] Partial index on `meal_plan_item.denial_expires_at` (postgresql_where IS NOT NULL) for the planner's soft-deny lookup.
- [x] Downgrade reverses all three.
### S8.2 — Model: `app/models/__init__.py`
- [x] `MealPlanItem.denial_expires_at` column added.
- [x] `MealPlanVote.denial_scope` column added.
### S8.3 — Schema: `app/schemas/__init__.py`
- [x] `MealPlanItemResponse.denial_expires_at: Optional[datetime]`.
- [x] `VoteRequest.denial_scope: Optional[str]` with `pattern=^(this_week|never_again)$`.
- [x] `VoteResponse.denial_scope: Optional[str]`.
### S8.4 — Backend helpers: `app/api/meals.py`
- [x] `_apply_denial(db, item, scope)` — single source of truth for the deny path. Returns `{item, promoted_to_permanent, scope}`. Commits.
- [x] `_ensure_never_suggest_recipe(db, family_id, recipe_id, reason)` — idempotent NeverSuggest insert. Returns `True` if new, `False` if existing.
- [x] `_has_prior_active_soft_denial(db, family_id, recipe_id, current_item_id=None)` — count query for the 2-denial check.
- [x] `DENIAL_DECAY_DAYS = 90` constant.
### S8.5 — Backend endpoints: `app/api/meals.py`
- [x] `POST /api/meals/items/{id}/deny?scope=this_week|never_again` (default `this_week`).
- Returns `{message, item, promoted_to_permanent, scope}`.
- `swap_meal_item` also clears `denial_expires_at` (defensive: a new recipe_id is a fresh start).
- [x] `POST /api/meals/vote/{id}` extended: `vote: "approve" | "deny" | "never_again"`.
- Returns `{status, item_status, denial_scope, promoted_to_permanent}`.
- The 2-denial auto-escalation runs server-side for both `deny` and `never_again`.
- [x] `GET /api/meals/vote/{id}` HTML page renders 3 buttons. Supports one-click `?scope=...` for the email's per-button links.
### S8.6 — Email template: `app/services/orchestrator/steps.py`
- [x] 3 direct-action links per recipe (Approve / Deny this week / Never again).
- [x] Legacy "Vote on this meal" preserved as a secondary "Open vote page (all 3 options)" link.
### S8.7 — Planner: `app/services/planner/generate.py`
- [x] `_load_blocklists` returns 3 sets: `(blocked_ingredients, blocked_recipes, soft_denied_recipes)`.
- [x] `soft_denied_recipes` is the **hard filter** (per user decision: same as `blocked_recipes`).
- [x] `rejected_summary` adds a `soft_denied_recipe` diagnostic bucket.
### S8.8 — Frontend: `Dashboard.tsx` + `api/index.ts`
- [x] `api/index.ts:48-58``meals.denyItem(itemId, { scope })`.
- [x] `Dashboard.tsx:38-50, 385-410``MealCard` accepts scope-aware `onDeny`; renders 3 buttons (Approve / Deny this week / Never again) for pending items.
- [x] `handleDeny` is scope-aware; toast reflects the server's `promoted_to_permanent` flag.
- [x] "Never again" is gated by `window.confirm` to prevent accidental permanent blocks.
- [x] Buttons only show on `pending` items (approved/denied items show the badge only).
### S8.9 — Verify
- [x] `npm run build` green for Sprint 8 (tsc 0 errors, vite 0 errors).
- [x] Backend smoke: 21/21 planner tests pass (1 pre-existing `test_filter_blocks_by_cost` failure is **not** introduced by S8 — verified via `git stash` + re-run on a clean tree).
- [x] Static checks: all 6 new modules import cleanly, helper logic verified via Python AST + import-test against `backend/venv`.
- [x] `Review/sprint8-verification.md` written with deploy + 11-step browser smoke + 4 API curls + email-render procedure + rollback.
- [ ] Deploy verified on `100.108.224.12` — see verification log.
- [ ] No regression in Sprints 1-7.
### S8.10 — Docs (all 6 running docs updated)
- [x] `Review/ui-nielsen-audit.md` — Sprint 8 status block at the top (T2.1T2.10).
- [x] `fix-ui-audit.md` — Sprint 8 plan section (T2.1T2.10).
- [x] `Review/handoff-ui-audit.md` — "Active sprint" callout + bottom "Last updated" line.
- [x] `docs/HANDOFF.md` — Sprint 7 + Sprint 8 sections before the 2026-06-03 session.
- [x] `.agent/plan.md` — this section.
- [x] `.agent/context.md` — Sprint 8 decisions, file:line references, verification gate.
### Done when (Sprint 8)
- All 12 boxes above ticked.
- `npm run build` green.
- `Review/sprint8-verification.md` exists.
- All 6 doc files have a Sprint 8 status block.
- User commits + runs the deploy + runs the SQL + reports the smoke checklist.
### Out of scope (Sprint 8)
- Thread 3: §Future backlog (F1 onboarding, F8/F9 proposals, dead `Generate Meal Plan` CTA at `Dashboard.tsx:415`).
- "Unblock" UI on the webui. The `NeverSuggest` API exists; no UI to remove a row. User can use the API directly.
- Decay-sweep cron. The 90-day filter is at read time; expired rows just become invisible. No cleanup needed.
- Pre-existing denied row (2026-05-15 day-2 Roasted Sweet Potato and Chickpea Bowl) — left untouched. `denial_expires_at` stays NULL; the recipe is effectively forgotten after 90d from now (today is 2026-06-05, so it'll be eligible again ~2026-09-03). If the user wants it remembered permanently, they can re-trigger the soft-deny cycle by clicking "Deny this week" on the next plan that includes it.
---
## Phase R1 — Stabilize (parallel-safe)
- [ ] R1-A: Verification harness. Add `backend/tests/` with pytest config, a `conftest.py` with a transactional DB fixture, and smoke tests covering: app import, `/health`, `/health/db`, every router's GET list endpoint, Alembic `upgrade head` round-trip on a throwaway DB. Add `.github/workflows/ci.yml` running lint + pytest + frontend `npm run build`.
- [ ] R1-B: Auth dependencies on existing routers. Implement an `app.security` module with: (1) `require_admin` dep — bearer token compared to `settings.ADMIN_TOKEN`, applied to ALL `/api/admin/*` routes; (2) `require_session` dep — signed-cookie session (itsdangerous, key = `SECRET_KEY`) for profile/pantry/recipes/meals/shopping-list mutations; reads stay open inside the trusted network. Per-voter approval token flow stays as-is. Update `.env.example` with `ADMIN_TOKEN`. Document the model in `docs/SECURITY.md`.
- [ ] R1-C: Make `/api/admin/scrape` async. Convert the endpoint to enqueue a background job (FastAPI `BackgroundTasks` for now; APScheduler later). Endpoint returns 202 + `scrape_log_id`; status polled via `/api/admin/logs/{id}`. ScraperService must open its own DB session inside the task (the request-scoped `db` is gone by then).
## Phase R2 — De-risk deferred work (parallel-safe, must run BEFORE further feature work per review §2.4)
- [ ] R2-A: Live-scrape spike. Run `LuckyCaliforniaScraper` against `https://luckysupermarkets.com` once, capture the raw HTML/PNG to `backend/tests/fixtures/lucky_ca/`, write a unit test that parses the captured fixture (no live network in CI). Document selector decisions in `.agent/context.md`. If the page can't be parsed, file the schema impact before going further.
- [ ] R2-B: Email + approval round-trip spike. Implement minimal SendGrid sender (`app/services/email.py`), an `app/services/approval.py` that issues per-voter signed tokens (TTL, single-use), the GET confirmation page + POST submit handler (the routes already exist as stubs in `meals.py`), and a CLI script `scripts/send_test_approval.py` that creates a fake meal plan, emails one voter, and verifies the click→POST→DB write path end to end against a sandboxed inbox or `MAIL_BACKEND=console`. Goal: prove the schema (family_member, approval_token tables) survives one full round trip BEFORE building Phase 4/5/9.
## Phase R3 — Resume feature work (sequential, only after R1+R2 green)
- [ ] R3-A: Phase 4 Recipe Engine — search, tagging, never-suggest filter.
- [ ] R3-B: Phase 9 Meal Planner generation algorithm.
- [ ] R3-C: Phase 6 SendGrid templated emails (proposal, reminder, confirmation).
- [ ] R3-D: Phase 8 Feedback UI.
- [ ] R3-E: APScheduler with `--workers 1` for weekly scrape + plan generation + email send.
- [ ] R3-F: Phase 10 image strategy.
## Halt conditions
- R2 spikes fail → stop, propose schema/spec change, await approval.
- Verification matrix in `Review/reviewconcensus.md §6` not green → no R3 work begins.
---
## Sprint 9 — F1 Onboarding Tour (H10)
**Owner:** this agent. **Status:** code complete, `npm run build` green, awaiting user commit + deploy. **Tracking:** `Review/sprint9-verification.md`.
**User policy decision (2026-06-05, exact):** "Proceed with the next phase in the redesign." Selected Sprint 9 = F1 (the only §Future item with a clear UI scope). F8 (Spoonacular) and F9 (Ollama) are full backend proposals; the dead `Generate Meal Plan` CTA is a separate follow-up.
### S9.1 — New `OnboardingTour.tsx` component (NEW)
- [x] Hand-rolled (no `react-joyride`) — keeps npm footprint flat.
- [x] 4 steps: Dashboard / Pantry / Recipes / Shopping List.
- [x] Anchors to `[data-tour="<id>"]` attributes on existing elements.
- [x] Tooltip card pinned to anchor (top/bottom/center fallback for off-route steps).
- [x] Anchor highlight = primary-400 ring + soft scrim; tooltip is a real `<div role="dialog" aria-modal="true">`.
- [x] Step progress = 4 progress bars.
- [x] Keyboard: `1``4` jump, `←/→` step, `Esc` dismiss, `Tab` order is `Skip → Back → Next`.
- [x] `useOnboarding()` hook + `?reset-tour=1` re-trigger; localStorage key `mealplanner:onboarding-complete`.
- [x] Focus captured on open (primary action), restored on close.
- [x] All reads/writes to localStorage wrapped in try/catch (private mode safe).
### S9.2 — Anchor points (5 lines of code total)
- [x] `pages/Dashboard.tsx:602``<Card data-tour="dashboard">` on the Weekly Overview grid.
- [x] `pages/Pantry.tsx:185``<div data-tour="pantry">` on the page header (always present).
- [x] `pages/Pantry.tsx:208` — second anchor on the add-form `<Card>` (when the form is open).
- [x] `pages/Recipes.tsx:124``<Button data-tour="recipes">` on the Filters button.
- [x] `pages/ShoppingList.tsx:231``<div data-tour="shopping-list">` on the page header.
### S9.3 — `App.tsx` mount
- [x] `useOnboarding()` at App root, `isComplete` passed to `<OnboardingTour>`.
- [x] `onComplete` mapped to `onboarding.reset()` (flips the flag so re-renders don't re-show).
- [x] Mounted as sibling of `<ShortcutHelpBanner />` inside `<BrowserRouter>` (so `useLocation` / `useNavigate` work).
### S9.4 — Verify
- [x] `npm run build` green (tsc 0 errors, vite 0 errors).
- [ ] Browser smoke (8 steps) on `http://100.108.208.56:8082/` per `Review/sprint9-verification.md`.
- [ ] No regression in Sprints 18 (keyboard shortcuts, error toast, 3-button vote row, WeekRangeNav, bulk pantry add).
### S9.5 — Docs (all 6 running docs updated)
- [x] `Review/ui-nielsen-audit.md` — Sprint 9 status block at the top.
- [x] `fix-ui-audit.md` — Sprint 9 plan section (T3.1T3.4).
- [x] `Review/handoff-ui-audit.md` — Sprint 9 entry in the "How to take over" section + TL;DR row.
- [x] `docs/HANDOFF.md` — Sprint 9 section.
- [x] `.agent/plan.md` — this section.
- [x] `.agent/context.md` — Sprint 9 decisions + file:line references.
- [x] `Review/sprint9-verification.md` — written (8-step browser smoke + a11y check + reset-link test).
### Done when (Sprint 9)
- All boxes above ticked.
- `npm run build` green.
- `Review/sprint9-verification.md` exists.
- All 6 doc files have a Sprint 9 status block.
### Out of scope (Sprint 9)
- 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 — 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.