Files
Meal-Planner/Review/sprint8-verification.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

12 KiB

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):

# 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 '' 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)

# 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:

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:

# 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.