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:
+92
-1
@@ -315,6 +315,96 @@ Resolve the 14 issues (5 P0, 6 P1, 3 P2) from `Review/ui-nielsen-audit.md` in th
|
||||
|
||||
---
|
||||
|
||||
## Sprint 8 — "Deny" semantics (C + Z, hard-filter escalation) — IN PROGRESS
|
||||
|
||||
User-driven policy decision (2026-06-05, exact words): "Hard filter. If it is denied this week twice, it should be considered denied for good." This collapses the design to **C + Z** with a **server-side 2-denial auto-escalation**.
|
||||
|
||||
### Policy
|
||||
|
||||
| Action | Backend behavior | Decay |
|
||||
|---|---|---|
|
||||
| Approve | `item.approval_status = approved` | n/a |
|
||||
| Deny this week (1st in 90d) | `denied` + `denial_expires_at = now() + 90d` | after 90d, eligible again |
|
||||
| Deny this week (2nd in 90d) — **server-side auto-escalation** | `denied` + `denial_expires_at = NULL` + `NeverSuggest` row written | permanent |
|
||||
| Never again (explicit) | same as 2nd-time auto-escalation | permanent |
|
||||
|
||||
### T2.1 · Migration `0016_denial_decay_and_scope.py` (NEW)
|
||||
|
||||
- **File:** `backend/alembic/versions/0016_denial_decay_and_scope.py`
|
||||
- **Adds:** `meal_plan_item.denial_expires_at TIMESTAMPTZ NULL` + `meal_plan_vote.denial_scope VARCHAR(16) NULL`. Partial index on `denial_expires_at` (postgresql_where IS NOT NULL) for fast lookup. Downgrade reverses all three.
|
||||
- **No data migration.** Existing 1 denied row (2026-05-15 day-2) keeps `denial_expires_at = NULL`; the soft-deny filter requires `> now()`, so the row is effectively forgotten after 90d from now (today is 2026-06-05, so eligible again ~2026-09-03).
|
||||
|
||||
### T2.2 · Model columns
|
||||
|
||||
- **File:** `backend/app/models/__init__.py:221-242` (MealPlanItem) + `:250-269` (MealPlanVote)
|
||||
- `MealPlanItem.denial_expires_at = Column(DateTime(timezone=True), nullable=True)`.
|
||||
- `MealPlanVote.denial_scope = Column(String(16), nullable=True)`.
|
||||
|
||||
### T2.3 · Schema fields
|
||||
|
||||
- **File:** `backend/app/schemas/__init__.py:204-219, 248-269`
|
||||
- `MealPlanItemResponse.denial_expires_at: Optional[datetime]`.
|
||||
- `VoteRequest.denial_scope: Optional[str]` with `pattern=^(this_week|never_again)$`.
|
||||
- `VoteResponse.denial_scope: Optional[str]`.
|
||||
|
||||
### T2.4 · Backend helpers
|
||||
|
||||
- **File:** `backend/app/api/meals.py:30-138`
|
||||
- 3 new helpers: `_apply_denial(db, item, scope)`, `_ensure_never_suggest_recipe(db, family_id, recipe_id, reason)`, `_has_prior_active_soft_denial(db, family_id, recipe_id, current_item_id=None)`. `DENIAL_DECAY_DAYS = 90`.
|
||||
|
||||
### T2.5 · `deny_meal_item` endpoint
|
||||
|
||||
- **File:** `backend/app/api/meals.py:510-552`
|
||||
- Accepts `?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).
|
||||
|
||||
### T2.6 · `submit_vote` endpoint
|
||||
|
||||
- **File:** `backend/app/api/meals.py:380-455`
|
||||
- Extends `VoteSubmission.vote` to `^(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`.
|
||||
|
||||
### T2.7 · `get_vote_page` HTML page
|
||||
|
||||
- **File:** `backend/app/api/meals.py:240-330`
|
||||
- Renders 3 buttons (Approve / Deny this week / Never again) with `aria-label`s.
|
||||
- Supports one-click `?scope=...` for the email's per-button links: consumes the token via `submit_vote`, renders a confirmation page with the applied scope + promotion status.
|
||||
|
||||
### T2.8 · Email template
|
||||
|
||||
- **File:** `backend/app/services/orchestrator/steps.py:283-300`
|
||||
- 3 direct-action links per recipe: `[Approve]` (green), `[Deny this week]` (red), `[Never again]` (red, dashed).
|
||||
- Each link is a GET to the vote page with `?scope=...`; one-click.
|
||||
- Legacy "Vote on this meal" preserved as a secondary "Open vote page (all 3 options)" link.
|
||||
|
||||
### T2.9 · Planner
|
||||
|
||||
- **File:** `backend/app/services/planner/generate.py:59-99, 150-194`
|
||||
- `_load_blocklists` returns 3 sets: `(blocked_ingredients, blocked_recipes, soft_denied_recipes)`.
|
||||
- The `soft_denied_recipes` set is hard-filtered (per user decision) — same as the permanent `blocked_recipes`. Unioned at the call site.
|
||||
- `rejected_summary` adds a `soft_denied_recipe` diagnostic bucket so operators can distinguish "permanent block" from "soft deny."
|
||||
|
||||
### T2.10 · Frontend: 3-button webui voting
|
||||
|
||||
- **File:** `frontend/src/pages/Dashboard.tsx:38-50, 385-410`
|
||||
- `MealCard` accepts scope-aware `onDeny(itemId, scope?)`; renders 3 buttons (Approve / Deny this week / Never again) for **pending** items only.
|
||||
- `handleDeny` is scope-aware; toast reflects the server's `promoted_to_permanent` flag.
|
||||
- "Never again" is gated by `window.confirm` to prevent accidental permanent blocks.
|
||||
- `frontend/src/api/index.ts:48-58` — `meals.denyItem(itemId, { scope })`.
|
||||
|
||||
### T2.11 · Sprint 8 verification gate
|
||||
|
||||
- [x] `npm run build` green.
|
||||
- [x] Backend smoke: 21/21 planner tests pass (1 pre-existing `test_filter_blocks_by_cost` failure is **not** introduced by S8 — verified by `git stash` + re-run on a clean tree).
|
||||
- [x] Static checks: all new modules import cleanly; helper logic verified via Python AST + import-test against `backend/venv`.
|
||||
- [x] `Review/sprint8-verification.md` written (deploy + 11-step browser smoke + 4 API curls + email-render + rollback).
|
||||
- [ ] Deploy verified on `100.108.224.12` — see verification log.
|
||||
- [ ] No regression in Sprints 1-7.
|
||||
|
||||
---
|
||||
|
||||
## Sprint 7 — Fix webui "empty meal plan" (date-semantics mismatch) — IN PROGRESS
|
||||
|
||||
Outside the original audit. Driven by user report 2026-06-05: "Latest meal plans were emails to me this morning, but when I go to the webui, the Meal Planner page is empty."
|
||||
@@ -405,4 +495,5 @@ Outside the original audit. Driven by user report 2026-06-05: "Latest meal plans
|
||||
- [ ] Backend aisle-migration (`0015` with cast fix) run on dev — **done on local dev host 2026-06-04**; needs running on deployment host.
|
||||
- [ ] Manual smoke pass on `http://100.108.208.56:8082/` per `Review/sprint2-verification.md` (Sprint 1-3), `Review/sprint4-verification.md` (Sprint 4), `Review/sprint5-verification.md` (Sprint 5).
|
||||
- [ ] No regressions in existing Playwright walkthrough.
|
||||
- [ ] **Sprint 7 (in progress):** webui "empty meal plan" date-semantics mismatch. Code + SQL fix + verification doc. S7.1-S7.6 boxes in the section above.
|
||||
- [ ] **Sprint 7 (committed `09c7525`, awaiting deploy):** webui "empty meal plan" date-semantics mismatch. Code + SQL fix + verification doc. ✅ done on dev; awaiting user deploy.
|
||||
- [ ] **Sprint 8 (in progress):** "Deny" semantics (C + Z, hard-filter escalation). Migration 0016 + 3 helpers + 2 endpoint extensions + 1 planner update + 1 email template + 1 webui 3-button card. ✅ build green + 21/21 planner tests pass; awaiting user commit + deploy.
|
||||
|
||||
Reference in New Issue
Block a user