Files
Meal-Planner/.agent/plan.md
T
MealPlanner efd1fc695f feat(ui): explicit Deny semantics with 2-denial hard-filter escalation (Sprint 8)
User policy decision (2026-06-05, exact): 'Hard filter. If it is denied
this week twice, it should be considered denied for good.'

The planner had no cross-week memory of denials: a denial on
meal_plan_item.approval_status was never consulted by the planner,
and NeverSuggest (the per-family permanent blocklist) was empty for
the user. The 'Roasted Sweet Potato and Chickpea Bowl' the user
denied on 2026-05-15 was still in the planner's pool 3 weeks
later.

Implements C + Z (explicit two-button model + soft-decay +
hard-filter escalation):
- Approve: untouched.
- Deny this week (1st in 90d): denial_expires_at = now() + 90d.
- Deny this week (2nd in 90d, server-side auto-escalation):
  denial_expires_at = NULL + a NeverSuggest row written.
- Never again (explicit): same as the 2nd-time auto-escalation.

Both soft and permanent denials are hard filters in the planner
(per user). A denied recipe never reappears until either the 90d
window expires or the user un-blocks via the NeverSuggest API.

Changes:
- Migration 0016: meal_plan_item.denial_expires_at (partial index)
  and meal_plan_vote.denial_scope.
- 3 backend helpers (_apply_denial, _ensure_never_suggest_recipe,
  _has_prior_active_soft_denial) — single source of truth for the
  deny path.
- POST /api/meals/items/{id}/deny?scope=this_week|never_again
  (default this_week). Returns promoted_to_permanent.
- POST /api/meals/vote/{id} extended: vote=approve|deny|never_again.
  Returns denial_scope + promoted_to_permanent.
- GET /api/meals/vote/{id} HTML page renders 3 buttons; supports
  one-click ?scope=... for email direct-action links.
- Email template (step_email): 3 direct-action links per recipe
  plus a secondary 'open vote page' link.
- Planner: _load_blocklists returns 3 sets; soft_denied_recipes
  is hard-filtered (union with blocked_recipes at the call site).
- Frontend: MealCard renders 3 buttons (Approve / Deny this week
  / Never again) for pending items. handleDeny is scope-aware;
  toast reflects promoted_to_permanent. window.confirm on
  'Never again' prevents accidental permanent blocks.

Verification:
- npm run build green.
- 21/21 planner tests pass (1 pre-existing test_filter_blocks_by_cost
  failure is NOT introduced by Sprint 8 — verified via git stash).
- Review/sprint8-verification.md: 11-step browser smoke + 4 API
  curls + email-render procedure + rollback.

Files:
- backend/alembic/versions/0016_denial_decay_and_scope.py (new)
- backend/app/models/__init__.py:221-242, 250-269
- backend/app/schemas/__init__.py:204-219, 248-269
- backend/app/api/meals.py:30-138 (helpers), 240-330 (HTML page),
  380-455 (submit_vote), 486-552 (deny_meal_item)
- backend/app/services/orchestrator/steps.py:283-300
- backend/app/services/planner/generate.py:59-99, 150-194
- frontend/src/api/index.ts:48-58
- frontend/src/pages/Dashboard.tsx:38-50, 385-410
- Review/{sprint8-verification,ui-nielsen-audit,handoff-ui-audit}.md
- fix-ui-audit.md
- docs/HANDOFF.md
- .agent/{plan,context}.md

Deploy (user runs on deployment host):
  cd ~/MealPlanner && git pull
  docker compose exec backend alembic upgrade head
  docker compose -f docker-compose.yml up -d --build backend frontend
2026-06-05 10:24:35 -07:00

124 lines
8.9 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.