Public Access
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:
@@ -61,6 +61,69 @@ R1 and R2 are independent and run in parallel. R3 cannot start until BOTH R1 ver
|
||||
|
||||
---
|
||||
|
||||
# Context — Sprint 8 ("Deny" semantics, C + Z, hard-filter escalation)
|
||||
|
||||
## Why Sprint 8 exists
|
||||
|
||||
User report 2026-06-05 (follow-up to Sprint 7): "one of the meals was the meal that I rejected last week. After you fix the above, lets discuss what rejeccting means." User clarified (exact words): "Hard filter. If it is denied this week twice, it should be considered denied for good."
|
||||
|
||||
## Decisions (locked in for Sprint 8)
|
||||
|
||||
- **D1. Two-button model:** explicit Approve / Deny this week / Never again on the webui meal card. The "Deny" button is renamed to "Deny this week" so the soft-vs-hard distinction is visible in the UI.
|
||||
- **D2. Server-side 2-denial auto-escalation:** any "Deny this week" call that finds a prior `denied` row with `denial_expires_at > now()` for the same `(family, recipe)` automatically promotes the recipe to a permanent `NeverSuggest` block. The 2nd-denial toast says "Denied — won't suggest again (denied twice recently)" so the user knows what happened.
|
||||
- **D3. 90-day decay window** for soft denials (`denial_expires_at = now() + 90d`). Implemented as a partial index for fast lookup; filter is at read time, no cron cleanup needed.
|
||||
- **D4. Hard filter for both soft + permanent denials.** The planner's `_load_blocklists` returns 3 sets; the soft set is unioned into the `blocked_recipe_ids` filter (per user decision: "Hard filter"). A denied recipe never reappears in the next plan; the user must unblock via the `NeverSuggest` API.
|
||||
- **D5. `never_again` is the explicit path** to permanent. Always writes a `NeverSuggest` row, regardless of prior denials. Idempotent: re-calling on an already-blocked recipe is a no-op.
|
||||
- **D6. Email renders 3 direct-action links per recipe** (Approve / Deny this week / Never again). Each link is a one-click GET to the vote page with `?scope=...`, which consumes the token via `submit_vote` and renders a tiny confirmation page. The legacy single-link "Vote on this meal" is preserved as a secondary "Open vote page (all 3 options)" link for completeness.
|
||||
- **D7. `window.confirm` on "Never again"** to prevent accidental permanent blocks. Soft denials need no confirm.
|
||||
- **D8. Pre-existing 1 denied row (2026-05-15 day-2 Roasted Sweet Potato and Chickpea Bowl) is left untouched.** Its `denial_expires_at` stays NULL (the filter requires `> now()`), so the recipe is effectively eligible again ~90d from migration time. If the user wants it permanently remembered, the soft-deny cycle auto-escalates it.
|
||||
- **D9. No "unblock" UI.** The `NeverSuggest` API exists (`DELETE /api/never-suggest/{id}`); no webui button to remove a row. User can use the API directly. Documented as a follow-up.
|
||||
|
||||
## Open questions to surface to the user, not to assume
|
||||
|
||||
- **Q1. Should the migration reset `denial_expires_at` for the 1 pre-existing denied row?** Default: leave it NULL. Alternative: set it to `now() + 90d` so the row is still soft-active after migration. Asked the user — they said "leave it."
|
||||
- **Q2. Should "Approve" reset any prior `denial_expires_at`?** The webui approve path (Sprint 3) goes through `approve_meal_item` (POST /api/meals/items/{id}/approve) which sets `approval_status = approved` but **does not clear `denial_expires_at`**. A user who denied a recipe 30 days ago and then approves it 60 days later will see it as `approved`; the soft-deny filter still excludes it for the remaining 30 days. Acceptable as-is; the unblock path is via "Deny this week" twice → "Never again" → manual `NeverSuggest` removal. Documented as a small follow-up.
|
||||
- **Q3. Pre-existing planner test failure:** `tests/test_planner_filter.py::test_filter_blocks_by_cost` fails on a clean checkout (verified via `git stash` + re-run). Pre-existing, not introduced by Sprint 8. Filed as a pre-existing repo issue.
|
||||
|
||||
## Sprint 8 verification gate
|
||||
|
||||
- `cd frontend && npm run build` → green
|
||||
- `cd backend && venv/bin/python -m pytest tests/test_planner_filter.py tests/test_planner_score.py tests/test_planner_select.py --deselect tests/test_planner_filter.py::test_filter_blocks_by_cost` → 21 passed, 1 deselected
|
||||
- `docker compose exec backend alembic upgrade head` → applies 0016
|
||||
- `docker compose up -d --build backend frontend` → both up
|
||||
- API: `POST /api/meals/items/{id}/deny?scope=never_again` returns 200 + `promoted_to_permanent: true`
|
||||
- API: `GET /api/never-suggest?family_profile_id=...` shows the new row
|
||||
- Webui: 3 buttons on pending meal cards; "Deny this week" toast reflects `promoted_to_permanent`
|
||||
- Email: 3 direct-action links per recipe; each is a one-click vote
|
||||
- `Review/sprint8-verification.md` is the source of truth for the deploy + smoke flow.
|
||||
|
||||
## Sprint 8 — does NOT touch
|
||||
|
||||
- The `extractErrorMessage` / `showApiError` flow (Sprint 4 F7) — unchanged.
|
||||
- The keyboard shortcuts (Sprint 5 F2) — unchanged.
|
||||
- The bulk pantry add (Sprint 6 F3) — unchanged.
|
||||
- The plan-the-week (Sprint 6 F4) — unchanged.
|
||||
- The undo-toast (Sprint 3 B12) — unchanged.
|
||||
- The WeekRangeNav (Sprint 7) — unchanged.
|
||||
- The `extractErrorMessage` flow now sees the new `denial_expires_at` field if it propagates errors that include item data, but no new error messages.
|
||||
|
||||
## Key file:line references
|
||||
|
||||
- `backend/alembic/versions/0016_denial_decay_and_scope.py` (NEW)
|
||||
- `backend/app/models/__init__.py:221-242` (MealPlanItem) + `:250-269` (MealPlanVote)
|
||||
- `backend/app/schemas/__init__.py:204-219, 248-269`
|
||||
- `backend/app/api/meals.py:30-138` — helpers (`_apply_denial`, `_ensure_never_suggest_recipe`, `_has_prior_active_soft_denial`)
|
||||
- `backend/app/api/meals.py:240-330` — `get_vote_page` HTML (3 buttons + `?scope=...` one-click)
|
||||
- `backend/app/api/meals.py:380-455` — `submit_vote` (handles `never_again` + auto-escalation)
|
||||
- `backend/app/api/meals.py:486-552` — `deny_meal_item` (`?scope=`)
|
||||
- `backend/app/services/orchestrator/steps.py:283-300` — email template (3 direct-action links)
|
||||
- `backend/app/services/planner/generate.py:59-99, 150-194` — `_load_blocklists` returns 3 sets; soft set is hard-filtered
|
||||
- `frontend/src/api/index.ts:48-58` — `meals.denyItem(itemId, { scope })`
|
||||
- `frontend/src/pages/Dashboard.tsx:38-50, 385-410` — `MealCard` 3-button voting row
|
||||
- `Review/sprint8-verification.md` — new file (deploy + smoke)
|
||||
|
||||
---
|
||||
|
||||
# Context — Sprint 7 (webui empty-meal-plan fix)
|
||||
|
||||
## Why Sprint 7 exists
|
||||
|
||||
Reference in New Issue
Block a user