Public Access
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).
275 lines
17 KiB
Markdown
275 lines
17 KiB
Markdown
# 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.1–T2.10).
|
||
- [x] `fix-ui-audit.md` — Sprint 8 plan section (T2.1–T2.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 1–8 (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.1–T3.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.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.
|