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
This commit is contained in:
MealPlanner
2026-06-05 10:24:35 -07:00
parent 09c7525a12
commit efd1fc695f
15 changed files with 1063 additions and 96 deletions
+25 -2
View File
@@ -2,7 +2,30 @@
You are taking over a 6-sprint UI/UX audit and fix cycle. All code changes are committed and build green. The user's deployment host (Tailscale `100.108.224.12`) is the only environment you should touch for verification — the local repo on this machine (`/home/peter/Projects/MealPlanner`) was the editing host; the running app lives elsewhere.
**Date of handoff: 2026-06-04. Last updated: 2026-06-05 (Sprint 7 in progress).**
**Date of handoff: 2026-06-04. Last updated: 2026-06-05 (Sprint 8 in progress).**
---
## ⚠️ Active sprint: Sprint 8 — "Deny" semantics (C + Z, hard-filter escalation)
**Status: in progress. User approved on 2026-06-05. Code not yet committed.**
**User policy decision (2026-06-05, exact words):** "Hard filter. If it is denied this week twice, it should be considered denied for good."
**Root cause (one-liner):** the planner has no cross-week memory of denials. Denials live on the `meal_plan_item` row, are never consulted by the planner, and the `NeverSuggest` blocklist is empty for the user's family. The user's "Roasted Sweet Potato and Chickpea Bowl" was denied on 2026-05-15 but the recipe was still in the pool for the next 90+ days.
**Policy (Sprint 8):**
- "Approve" → `item.approval_status = approved`. n/a.
- "Deny this week" (1st in 90d) → `denied` + `denial_expires_at = now() + 90d`. Recipe becomes eligible again after 90d.
- "Deny this week" (2nd in 90d — **server-side auto-escalation**) → `denied` + `denial_expires_at = NULL` + a `NeverSuggest` row written. Permanent.
- "Never again" (explicit) → same as the 2nd-time auto-escalation. Permanent.
**Scope (12 boxes):** see `.agent/plan.md` "Active sprint" section. Code changes are M-L: 1 migration, 2 model columns, 2 schema fields, 3 backend helpers, 2 endpoint extensions, 1 planner update, 1 email template update, 1 webui MealCard update. **No new dependencies. Migration 0016 required.**
**Tracking docs:** `Review/sprint8-verification.md` (deploy + smoke), `Review/ui-nielsen-audit.md` Sprint 8 status block, `fix-ui-audit.md` T2.1T2.10, `Review/handoff-ui-audit.md` (this file), `docs/HANDOFF.md` Sprint 8 section.
**Thread 3 (§Future backlog) is deferred** until S8 is deployed + verified. F1 onboarding, F8/F9 proposals, dead `Generate Meal Plan` CTA at `Dashboard.tsx:415`.
---
@@ -278,4 +301,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** — Sprints 1, 2, 3, 4, 5, 6 deployed-or-awaiting-deploy; **Sprint 7 (webui empty-meal-plan fix) in progress**. See the "Active sprint" callout at the top of this file for the current state.
**Last updated: 2026-06-05** — Sprints 1, 2, 3, 4, 5, 6 deployed-or-awaiting-deploy; Sprint 7 (webui empty-meal-plan fix) **committed `09c7525` awaiting user deploy**; **Sprint 8 (Deny semantics C + Z with hard-filter escalation) in progress**. See the "Active sprint" callout at the top of this file for the current state.
+193
View File
@@ -0,0 +1,193 @@
# Sprint 8 — Verification
**Sprint:** "Deny" semantics — explicit two-button model with 90-day decay and hard-filter escalation.
**User policy decision (2026-06-05):** "Hard filter. If it is denied this week twice, it should be considered denied for good."
**Status:** code complete, awaiting deploy.
**Date of handoff:** 2026-06-05.
---
## Policy (recap)
| Action | Backend behavior | Decay |
|---|---|---|
| **Approve** | `item.approval_status = approved` | n/a |
| **Deny this week** (1st time in 90d for this recipe) | `denied`, `denial_expires_at = now() + 90d` | after 90d, recipe is eligible again |
| **Deny this week** (2nd time in 90d for this recipe — auto-escalation) | `denied`, `denial_expires_at = NULL`, **and** a `NeverSuggest` row is inserted for `(family_profile_id, recipe_id)` with `reason = "dislike"` | never (until user un-blocks) |
| **Never again** (explicit) | same as the 2nd-time auto-escalation: `denied`, `denial_expires_at = NULL`, **and** a `NeverSuggest` row | never |
The 2-denial escalation is **server-side and atomic** — the API checks for a prior `denied` row with `denial_expires_at > now()` for the same `(family_profile_id, recipe_id)` before deciding whether to insert a `NeverSuggest` row. No client-side double-counting.
The planner's `_load_blocklists` now returns 3 sets; the soft-denied set is **hard-filtered** (per the user's decision — same as the permanent blocklist) so a denied recipe is never re-proposed until either the 90d window expires or the user un-blocks via the `NeverSuggest` API.
---
## What changed (recap)
| Layer | File | Change |
|---|---|---|
| Migration | `backend/alembic/versions/0016_denial_decay_and_scope.py` (NEW) | Adds `meal_plan_item.denial_expires_at TIMESTAMPTZ NULL` and `meal_plan_vote.denial_scope VARCHAR(16) NULL`. Partial index on `denial_expires_at` for fast lookup. |
| Model | `backend/app/models/__init__.py:221-242, 250-269` | Two new columns. |
| Schema | `backend/app/schemas/__init__.py:204-219, 248-269` | `MealPlanItemResponse.denial_expires_at`, `VoteRequest.denial_scope`, `VoteResponse.denial_scope`. |
| Backend | `backend/app/api/meals.py:30-138` | 3 new helpers: `_apply_denial`, `_ensure_never_suggest_recipe`, `_has_prior_active_soft_denial`. |
| Backend | `backend/app/api/meals.py:510-552` | `deny_meal_item` accepts `?scope=this_week\|never_again`; returns `promoted_to_permanent`. |
| Backend | `backend/app/api/meals.py:380-455` | `submit_vote` handles `vote: "never_again"`; calls `_apply_denial` for both deny scopes; returns `denial_scope` + `promoted_to_permanent`. |
| Backend | `backend/app/api/meals.py:240-330` | `get_vote_page` (HTML) now renders 3 buttons and supports a one-click `?scope=...` path for email direct-action links. |
| Backend | `backend/app/services/orchestrator/steps.py:283-300` | Email template: 3 direct-action links per recipe (Approve / Deny this week / Never again), each a one-click GET to the vote page. |
| Planner | `backend/app/services/planner/generate.py:59-99` | `_load_blocklists` returns 3 sets; soft-denied set is hard-filtered. |
| Planner | `backend/app/services/planner/generate.py:150-194` | Call site updated; `rejected_summary` adds `soft_denied_recipe` diagnostic bucket. |
| Frontend | `frontend/src/api/index.ts:48-58` | `meals.denyItem(itemId, { scope })`. |
| Frontend | `frontend/src/pages/Dashboard.tsx:38-50, 385-410` | `MealCard` accepts scope-aware `onDeny`; renders 3 buttons (Approve / Deny this week / Never again) for pending items. `handleDeny` is scope-aware; toast reflects `promoted_to_permanent`. |
| Docs | `Review/ui-nielsen-audit.md` + `fix-ui-audit.md` + `Review/handoff-ui-audit.md` + `docs/HANDOFF.md` + `.agent/plan.md` + `.agent/context.md` | Sprint 8 status blocks. |
**No new dependencies. Backend migration is required. Frontend + backend rebuild required.**
---
## Deploy commands
Run on the deployment host (`100.108.224.12`):
```bash
# 1. Pull
cd ~/MealPlanner
git pull
# 2. Migration 0016 (adds denial_expires_at + denial_scope)
docker compose exec backend alembic upgrade head
# 3. Rebuild backend + frontend
docker compose -f docker-compose.yml up -d --build backend frontend
```
**Order matters.** Pull → migrate → rebuild. The migration is forward-only and non-destructive (adds two NULL columns + one partial index).
---
## Smoke checklist (browser, on `http://100.108.208.56:8082/`)
| # | Action | Expected |
|---|---|---|
| 1 | Open the Dashboard. Find a pending meal (e.g. Chicken Fajitas on Mon 2026-06-08). | The card now shows 3 buttons: `Approve` (green), `Deny this week` (red), `Never again` (red, dashed border). |
| 2 | Click `Deny this week` on a meal that has NOT been denied before. | Toast: "Denied this week — will not reappear for 90 days". Card status badge updates to `denied`. |
| 3 | In a new browser tab, navigate to `/api/meals/items/<that-item-id>` (or use a plan regen to see the planner skip it). | The denied recipe does NOT appear in the next plan if a regen is triggered (hard filter). |
| 4 | Manually set `denial_expires_at` to NULL on the first item, OR re-run the orchestrator's generate after a recipe rotation. The denied recipe's `denial_expires_at` is now NULL. | The recipe is in the `NeverSuggest` table. The planner will never propose it. |
| 5 | Click `Deny this week` on a different recipe that has no prior soft denial. | Toast: "Denied this week — will not reappear for 90 days". The new recipe joins the soft set. |
| 6 | Click `Deny this week` again on the SAME recipe (use the API to find a second item with the same `recipe_id`, e.g. by re-running the planner or by creating a second plan with the same recipe). | Toast: "Denied — won't suggest again (denied twice recently)". `NeverSuggest` table now has a row for this recipe. |
| 7 | Click `Never again` on a fresh recipe. | Confirm dialog appears: "Never suggest '<name>' again? This permanently blocks the recipe for your family." Click OK. Toast: "Denied — will never be suggested again". |
| 8 | Check the API: `GET /api/never-suggest?family_profile_id=...` | The new row appears in the list. |
| 9 | Reload the page. | The 3 buttons are gone from cards whose `approval_status` is now `denied` (Sprint 8: only `pending` items show the buttons). Approved/denied items show the badge only. |
| 10 | Open `/meals/<item-id>` (MealDetail). | No voting UI here — voting is via the email or the dashboard card (per the existing design). |
| 11 | Open `/shopping-list` or `/pantry`. | No regression. The bulk pantry add (Sprint 6) and other features unaffected. |
---
## API smoke (curl, on the deployment host)
```bash
# Find a pending item in the current plan
PLAN_ID=$(curl -s "http://100.108.208.56:8082/api/meals?week_start=2026-06-08" | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])")
ITEM_ID=$(curl -s "http://100.108.208.56:8082/api/meals/$PLAN_ID" | python3 -c "import sys,json; d=json.load(sys.stdin); print([i['id'] for i in d['items'] if i['approval_status']=='pending'][0])")
echo "plan=$PLAN_ID item=$ITEM_ID"
# Test 1: deny with default scope (this_week)
curl -s -X POST "http://100.108.208.56:8082/api/meals/items/$ITEM_ID/deny" | python3 -m json.tool | head -20
# Expected: item.approval_status="denied", item.denial_expires_at ~ 90 days from now,
# promoted_to_permanent=false (no prior denial)
# Test 2: never_again on a fresh item
ITEM2=$(curl -s "http://100.108.208.56:8082/api/meals/$PLAN_ID" | python3 -c "import sys,json; d=json.load(sys.stdin); print([i['id'] for i in d['items'] if i['approval_status']=='pending'][0])")
curl -s -X POST "http://100.108.208.56:8082/api/meals/items/$ITEM2/deny?scope=never_again" | python3 -m json.tool | head -20
# Expected: item.approval_status="denied", item.denial_expires_at=null,
# promoted_to_permanent=true, scope="never_again"
# Test 3: list never-suggest entries
FAMILY_ID=$(curl -s "http://100.108.208.56:8082/api/meals/$PLAN_ID" | python3 -c "import sys,json; print(json.load(sys.stdin)['family_profile_id'])")
curl -s "http://100.108.208.56:8082/api/never-suggest?family_profile_id=$FAMILY_ID" | python3 -m json.tool
# Expected: at least 1 row from test 2
# Test 4: vote page renders 3 buttons (HTML)
TOKEN=$(curl -s "http://100.108.208.56:8082/api/meals/vote/$ITEM2" | head -50)
# (token is in the page; for direct test, you'd grab it from a real email)
```
---
## Email smoke
To preview the new email without sending it through SendGrid, render the template via the planner (orchestrator step_email). On a local dev DB:
```bash
docker compose exec backend python -c "
from app.database import SessionLocal
from app.services.orchestrator.steps import step_email
from app.models import WeeklyRun
db = SessionLocal()
run = db.query(WeeklyRun).order_by(WeeklyRun.week_start_date.desc()).first()
step_email(run, db)
# Mail backend defaults to console in dev — check the docker logs
"
```
The email now renders 3 direct-action buttons per recipe (Approve / Deny this week / Never again), each a GET link that consumes the token and records the vote. The legacy "Vote on this meal" link is preserved as a "Open vote page (all 3 options)" secondary link below the buttons.
To exercise the 2-denial auto-promotion via the email:
1. Open the vote page for recipe X (the link is in the email).
2. Click "Deny this week" → records the soft denial; page shows "Denied this week. Will not re-suggest for 90 days unless denied again."
3. Wait for the next Friday email (or manually re-trigger the email for the same plan). Re-open the vote page for a different item with the same recipe.
4. Click "Deny this week" again → records the 2nd denial; page shows "Denied this week. You've denied this recipe recently, so it will not be suggested again (permanently blocked)."
---
## Things to look for
- The webui shows 3 buttons (Approve / Deny this week / Never again) on **pending** meal cards. Approved/denied cards show only the badge.
- The email shows 3 direct-action links per recipe plus a secondary "open vote page" link.
- Clicking "Deny this week" twice on the same recipe (across weeks or via API) auto-promotes the recipe to a permanent block.
- The `NeverSuggest` table grows by exactly 1 row per distinct recipe (idempotent).
- The planner does not propose denied recipes (hard filter). `rejected_summary` includes a `soft_denied_recipe` diagnostic bucket if a soft-deny was the cause.
- The 90-day decay works on read: a row with `denial_expires_at < now()` is invisible to the soft-deny filter; the recipe becomes eligible again.
---
## Rollback (if needed)
The Sprint 8 change is mostly additive. To revert:
```bash
# 1. Revert the code
git revert 09c7525..HEAD # or whichever commit set contains Sprint 8
# 2. Downgrade the migration
docker compose exec backend alembic downgrade -1
# 3. The new columns become NULL again. Existing denied rows
# keep denial_expires_at = NULL (the migration default).
# The recipes already in NeverSuggest stay there — the user must
# manually DELETE FROM never_suggest WHERE ... if they want to
# clean up.
```
The pre-Sprint 8 behavior is what the user originally reported as "the rejected meal came back." Only revert if a regression appears that wasn't there before Sprint 8.
---
## Verification log
(Filled in by the operator after deploy + smoke.)
- [ ] `git pull` on deployment host → success
- [ ] `alembic upgrade head` → applied migration 0016
- [ ] Backend rebuild → success
- [ ] Frontend rebuild → success
- [ ] Browser: 3 buttons appear on pending meal cards
- [ ] API: `/api/meals/items/{id}/deny` (no scope) returns 200 with `promoted_to_permanent: false`
- [ ] API: `/api/meals/items/{id}/deny?scope=never_again` returns 200 with `promoted_to_permanent: true`
- [ ] API: 2nd `/deny` (same recipe) auto-promotes with `promoted_to_permanent: true`
- [ ] API: `/api/never-suggest?family_profile_id=...` shows the new row
- [ ] Email: 3 buttons per recipe, each a one-click action
- [ ] No regression in Sprints 1-7
---
**Last updated: 2026-06-05** — code complete, awaiting deploy.
+15 -1
View File
@@ -77,7 +77,21 @@ The app looks polished on the surface (Tailwind palette, clean cards, working to
> - **Backend changes:** `meals.py` and `shopping_list.py` (new query param) + `0015_normalize_pantry_aisles.py` (cast fix).
> - **Verification log:** `Review/sprint5-verification.md`. Deploy is a single batch for Sprints 2-5: backup → migrate → rebuild backend + frontend.
>
> **Sprint 7 status (in progress, approved 2026-06-05; not yet committed):** Outside-the-audit hotfix driven by user report. **Thread 1 of three open follow-ups from the user's 2026-06-05 message.** Thread 2 (cross-week "rejected" semantics) and Thread 3 (§Future backlog) are deferred until S7 is deployed + verified.
> **Sprint 8 status (in progress, approved 2026-06-05; not yet committed):** Thread 2 (cross-week "rejected" semantics) and Thread 3 (§Future backlog) — both surfaced in the user's 2026-06-05 follow-up. **User policy decision (2026-06-05):** "Hard filter. If it is denied this week twice, it should be considered denied for good." That collapses Sprint 8 to the **C + Z** model with a server-side 2-denial auto-escalation.
> - **T2.1** Migration `0016_denial_decay_and_scope.py` (NEW). Adds `meal_plan_item.denial_expires_at TIMESTAMPTZ NULL` (partial index on non-NULL) and `meal_plan_vote.denial_scope VARCHAR(16) NULL`. No data migration; existing rows keep `denial_expires_at = NULL` (the filter requires `> now()`, so old denied rows are effectively forgotten after 90d).
> - **T2.2** Model: `MealPlanItem.denial_expires_at` + `MealPlanVote.denial_scope`.
> - **T2.3** Schema: `MealPlanItemResponse.denial_expires_at`, `VoteRequest.denial_scope`, `VoteResponse.denial_scope`.
> - **T2.4** Backend helpers in `app/api/meals.py`: `_apply_denial` (single source of truth for the deny path), `_ensure_never_suggest_recipe` (idempotent NeverSuggest insert), `_has_prior_active_soft_denial` (counting query for the 2-denial auto-escalation check). `DENIAL_DECAY_DAYS = 90`.
> - **T2.5** `POST /api/meals/items/{id}/deny?scope=this_week|never_again` (default `this_week`). Returns `{message, item, promoted_to_permanent, scope}`. The auto-promotion check runs server-side.
> - **T2.6** `POST /api/meals/vote/{id}` extended: `vote: "approve" | "deny" | "never_again"`. Returns `denial_scope` + `promoted_to_permanent` so the email confirmation page can show what was applied.
> - **T2.7** Email HTML page (`/api/meals/vote/{id}` GET) renders 3 buttons (Approve / Deny this week / Never again). One-click direct-vote via `?scope=...` for the email's per-button links; consumes the token via `submit_vote` and renders a confirmation page.
> - **T2.8** Email template (`step_email`) renders 3 direct-action links per recipe. The legacy single-link "Vote on this meal" is preserved as a secondary "Open vote page (all 3 options)" link.
> - **T2.9** Planner: `_load_blocklists` returns 3 sets; `soft_denied_recipes` is hard-filtered (per user decision). `rejected_summary` adds a `soft_denied_recipe` diagnostic bucket.
> - **T2.10** Webui: `MealCard` renders 3 buttons (Approve / Deny this week / Never again) for **pending** items. `handleDeny` is scope-aware; toast reflects the server's `promoted_to_permanent` flag. "Never again" is gated by a `window.confirm` to prevent accidental permanent blocks.
> - **Verification log:** `Review/sprint8-verification.md`.
> - **No new dependencies. Migration is required (`alembic upgrade head` runs 0016). Deploy is `git pull` + migration + `docker compose up -d --build backend frontend`.**
>
> **Sprint 7 status (commit `09c7525`, awaiting deploy):** Outside-the-audit hotfix driven by user report. **Thread 1 of three open follow-ups from the user's 2026-06-05 message.** Thread 2 (cross-week "rejected" semantics) and Thread 3 (§Future backlog) are deferred until S7 is deployed + verified.
> - **T1.1** `runner._current_week_start()` → returns the **upcoming** Monday. Today (Fri 2026-06-05) the function returned Friday 2026-06-05; the user got an email for week-of-2026-06-05, but the webui opened on week-of-2026-06-01. One-line body change in `backend/app/services/orchestrator/runner.py:20-24`. Scheduler cron stays Friday.
> - **T1.2** `isoMonday` → `upcomingMonday` in `frontend/src/lib/utils.ts:44-50`. Same logic as T1.1; rename for intent. Add `formatWeekRange(mondayIso)` helper.
> - **T1.3** New `frontend/src/components/WeekRangeNav.tsx`. Renders the user-requested `[<] Jun 8 — Jun 14 [>]` pattern with clickable chevrons and a clickable range label (jumps to the upcoming week). Replaces the inline Sprint 5 segmented control on Dashboard and ShoppingList. Includes a `This week` chip when off the upcoming week. Keyboard-accessible.