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
+63
View File
@@ -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
+73 -40
View File
@@ -2,65 +2,98 @@
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 7Fix webui "empty meal plan" (date-semantics mismatch)
## Active sprint: Sprint 8"Deny" semantics (C + Z, hard-filter escalation)
**Owner:** this agent. **Status:** in progress (approved by user 2026-06-05, code not yet written). **Tracking:** `Review/sprint7-verification.md` (deploy + smoke checks), `.agent/plan.md` (checklist), `.agent/context.md` (decisions + open Qs).
**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).
Root cause: today is Fri 2026-06-05. The orchestrator sends Friday emails for the **upcoming** Mon-Sun week and keys the plan by the upcoming Monday. The frontend's `isoMonday()` returns the **current** calendar week's Monday, which is 4 days behind. So the email says "Meal plan for week of 2026-06-08" but the webui opens on "week of 2026-06-01" — no plan, empty state.
**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.
### S7.1 — Backend: `_current_week_start()` returns upcoming Monday
### S8.1 — Migration: `0016_denial_decay_and_scope.py` (NEW)
- [ ] `backend/app/services/orchestrator/runner.py:20-24` — change body to: `today.weekday() == 0 → today; else today + timedelta(days=(7 - today.weekday()))`. Add docstring "the upcoming Mon-Sun week the email advertises." Also update `scheduler/__main__.py` docstring if needed.
- [ ] Add an inline comment that the email subject uses this same date (so they stay in sync).
- [ ] Verify: read `step_email` to confirm it uses `run.week_start_date` for the subject (it does, per `steps.py:305`).
- [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.
### S7.2 — Frontend: align `isoMonday` with backend
### S8.2 — Model: `app/models/__init__.py`
- [ ] `frontend/src/lib/utils.ts:44-50` — rename `isoMonday` to `upcomingMonday` with new logic (today if Mon, else next Mon).
- [ ] Keep the old `isoMonday` (calendar week) for any code that needs it; the function is currently only used for the F5 default, so rename is safe.
- [ ] Add `formatWeekRange(mondayIso: string): string` helper that returns `"Jun 8 — Jun 14"`. Used by the new nav.
- [ ] `Dashboard.tsx` and `ShoppingList.tsx` — update imports; `useSearchParams`, `navigateWeek`, `isCurrentWeek` all reference `upcomingMonday()` instead of `isoMonday()`.
- [x] `MealPlanItem.denial_expires_at` column added.
- [x] `MealPlanVote.denial_scope` column added.
### S7.3 — Frontend: new `WeekRangeNav` component
### S8.3 — Schema: `app/schemas/__init__.py`
- [ ] `frontend/src/components/WeekRangeNav.tsx` — new file. Props: `{ weekStart: string; isCurrentWeek: boolean; onPrev: () => void; onNext: () => void; onJumpHome: () => void }`.
- [ ] Renders: `[<]` button (chevron-left), a button showing the formatted range (e.g. `Jun 8 — Jun 14`, clickable → jump home), `[>]` button (chevron-right), and a `This week` chip (visible only when `!isCurrentWeek`).
- [ ] All buttons have `aria-label`s; clickable range label says "Jump to upcoming week".
- [ ] Acceptable to use lucide-react `ChevronLeft` / `ChevronRight` (already imported in Dashboard/ShoppingList).
- [ ] Replaces the inline segmented control in `Dashboard.tsx:479-503` and `ShoppingList.tsx:259-283`.
- [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]`.
### S7.4 — Data: migrate 2026-06-05 plan to 2026-06-08
### S8.4 — Backend helpers: `app/api/meals.py`
- [ ] `backend/scripts/fix_2026_06_05_to_2026_06_08.sql` — guarded `UPDATE meal_plan SET week_start_date='2026-06-08' WHERE week_start_date='2026-06-05';` with `SELECT COUNT(*)` first. Operator runs on deployment host.
- [ ] Decide: also migrate older Friday-keyed plans (2026-05-29 was a Friday). User has the option; for now include it as a separate guarded statement (commented out, opt-in) in the same script.
- [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.
### S7.5 — Verify
### S8.5 — Backend endpoints: `app/api/meals.py`
- [ ] `npm run build` green.
- [ ] Write `Review/sprint7-verification.md` with deploy + smoke checks (backend + frontend, no migration, plus the SQL update step).
- [ ] Backend smoke: `curl /api/meals?week_start=2026-06-08` returns the user's 3 pending items.
- [ ] Frontend smoke: load `/` (no `?week=` param) on a browser; the dashboard opens on the upcoming Mon-Sun plan with 3 items.
- [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.
### S7.6 — Docs
### S8.6 — Email template: `app/services/orchestrator/steps.py`
- [ ] Add Sprint 7 status block to `Review/ui-nielsen-audit.md` (top of file, after Sprint 6 block).
- [ ] Add Sprint 7 plan section to `fix-ui-audit.md`.
- [ ] Add Sprint 7 section to `Review/handoff-ui-audit.md` (new "Active sprint" callout near the top).
- [ ] Add Sprint 7 section to `docs/HANDOFF.md` (replace the "Last updated" line at line 305).
- [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.
### Done when (Sprint 7)
### S8.7 — Planner: `app/services/planner/generate.py`
- All 6 checkboxes above ticked.
- [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/sprint7-verification.md` exists.
- All four doc files have a Sprint 7 status block.
- User reports the webui no longer shows empty after pulling the S7 batch + running the SQL fix.
- `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 7)
### Out of scope (Sprint 8)
- Thread 2: cross-week "rejected means" semantics. Defer until after S7 deploys.
- Thread 3: §Future backlog (F1 onboarding, F8/F9 proposals, dead-CTA wire-up).
- The 2026-05-29 plan migration (operator opt-in; documented but not auto-run).
- 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.
---
+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.
@@ -0,0 +1,74 @@
"""Add denial_expires_at and denial_scope for Sprint 8 deny-semantics.
Revision ID: 0016
Revises: 0015
Create Date: 2026-06-05
Sprint 8 (user policy decision: "Hard filter. If it is denied this week twice,
it should be considered denied for good."):
- meal_plan_item.denial_expires_at TIMESTAMPTZ NULL
- NULL on existing rows and on "Never again" denials (no decay; permanent).
- now() + 90 days on "Deny this week" denials.
- The planner's _load_soft_denied_recipes() filters
`denial_expires_at > now()` to find still-active soft denials.
- When a "Deny this week" finds a prior active soft denial for the
same (family, recipe), the API promotes the denial to permanent:
denial_expires_at -> NULL + a NeverSuggest row is inserted.
- meal_plan_vote.denial_scope VARCHAR(16) NULL
- NULL on approve votes.
- "this_week" or "never_again" on deny votes. Captures the user's
intent at vote time (per-voter audit trail).
No data migration needed for existing rows:
- Existing 1 denied item (2026-05-15 day-2 Roasted Sweet Potato and
Chickpea Bowl) keeps denial_expires_at = NULL, which means it is NOT
in the "soft denied" pool (filter requires > now()). Effectively
forgotten after this migration. If the user wants it remembered,
they can re-trigger the soft-deny cycle.
- Existing meal_plan_vote rows keep denial_scope = NULL, which is
interpreted as "approve" (vote column is the source of truth).
Deploy via Docker (the db runs inside a container; no host psql required):
docker compose exec backend alembic upgrade head
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "0016"
down_revision: Union[str, None] = "0015"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"meal_plan_item",
sa.Column("denial_expires_at", sa.DateTime(timezone=True), nullable=True),
)
# Partial index: only rows with a non-NULL denial_expires_at are
# queried by the planner. Reduces index size and lookup cost.
op.create_index(
"ix_meal_plan_item_denial_expires_at",
"meal_plan_item",
["denial_expires_at"],
postgresql_where=sa.text("denial_expires_at IS NOT NULL"),
)
op.add_column(
"meal_plan_vote",
sa.Column("denial_scope", sa.String(length=16), nullable=True),
)
def downgrade() -> None:
op.drop_column("meal_plan_vote", "denial_scope")
op.drop_index(
"ix_meal_plan_item_denial_expires_at",
table_name="meal_plan_item",
)
op.drop_column("meal_plan_item", "denial_expires_at")
+274 -17
View File
@@ -2,13 +2,13 @@ from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import HTMLResponse
from html import escape as _html_escape
from pydantic import BaseModel, Field
from sqlalchemy import text
from sqlalchemy import text, func
from sqlalchemy.orm import Session, joinedload
from app.database import get_db
from app.models import (
MealPlan, MealPlanItem, MealPlanVote, Recipe, Ingredient,
FamilyProfile, FamilyMember, ApprovalToken,
MealPlanStatus, MealPlanItemStatus, MealType, ApprovalTokenStatus
FamilyProfile, FamilyMember, ApprovalToken, NeverSuggest, NeverSuggestReason,
MealPlanStatus, MealPlanItemStatus, MealType, ApprovalTokenStatus,
)
from app.schemas import (
MealPlanResponse, MealPlanCreate,
@@ -21,8 +21,8 @@ from app.services import approval as approval_service
from app.services.feedback_analyzer import FeedbackAnalyzer
from uuid import UUID
from typing import List, Optional
from datetime import datetime, timedelta
from datetime import date, datetime, timedelta
from datetime import datetime, timedelta, timezone
from datetime import date
from uuid import UUID
from typing import List, Optional
import random
@@ -30,6 +30,107 @@ import random
router = APIRouter()
# Sprint 8 — soft-denial decay window. A "Deny this week" creates a row
# with `denial_expires_at = now() + 90d`; after that the recipe is
# eligible again. Two denials in this window promote the recipe to a
# permanent `NeverSuggest` block (user decision: hard filter).
DENIAL_DECAY_DAYS = 90
def _ensure_never_suggest_recipe(
db: Session, family_id, recipe_id, reason: NeverSuggestReason = NeverSuggestReason.DISLIKE
) -> bool:
"""Insert a NeverSuggest row for (family, recipe) if one doesn't exist.
Idempotent: returns True if a new row was inserted, False if one
already existed. Used by the auto-promotion logic in deny_meal_item
and submit_vote when a recipe is denied for the 2nd time within the
decay window.
"""
existing = (
db.query(NeverSuggest)
.filter(
NeverSuggest.family_profile_id == family_id,
NeverSuggest.recipe_id == recipe_id,
)
.first()
)
if existing is not None:
return False
db.add(NeverSuggest(
family_profile_id=family_id,
recipe_id=recipe_id,
reason=reason,
))
return True
def _has_prior_active_soft_denial(
db: Session, family_id, recipe_id, current_item_id: Optional[UUID] = None
) -> bool:
"""True if there is at least one *still-active* soft denial for this
recipe for this family. Active = approval_status='denied' AND
denial_expires_at > now(). Excludes the current row by default so
the auto-promotion check is correct on the first call."""
q = (
db.query(func.count(MealPlanItem.id))
.join(MealPlan, MealPlanItem.meal_plan_id == MealPlan.id)
.filter(
MealPlan.family_profile_id == family_id,
MealPlanItem.recipe_id == recipe_id,
MealPlanItem.approval_status == MealPlanItemStatus.denied,
MealPlanItem.denial_expires_at.isnot(None),
MealPlanItem.denial_expires_at > func.now(),
)
)
if current_item_id is not None:
q = q.filter(MealPlanItem.id != current_item_id)
return (q.scalar() or 0) > 0
def _apply_denial(
db: Session,
item: MealPlanItem,
scope: str,
) -> dict:
"""Apply a denial to `item` and (if scope=never_again or this is the
2nd denial within the decay window) promote the recipe to a permanent
block. Returns a dict describing what happened — used by both the
webui and the email vote paths to surface a clear toast / message.
scope values:
- "this_week" (default): set denial_expires_at = now() + 90d.
If a prior active soft denial exists, promote to permanent.
- "never_again": write a NeverSuggest row; set denial_expires_at
to NULL (signals "permanent, no decay").
"""
family_id = item.meal_plan.family_profile_id
recipe_id = item.recipe_id
promoted = False
if scope == "never_again":
_ensure_never_suggest_recipe(db, family_id, recipe_id, NeverSuggestReason.DISLIKE)
item.denial_expires_at = None
promoted = True
else: # this_week
if _has_prior_active_soft_denial(db, family_id, recipe_id, current_item_id=item.id):
# 2nd denial in 90d → promote to permanent.
_ensure_never_suggest_recipe(db, family_id, recipe_id, NeverSuggestReason.DISLIKE)
item.denial_expires_at = None
promoted = True
else:
item.denial_expires_at = datetime.now(timezone.utc) + timedelta(days=DENIAL_DECAY_DAYS)
item.approval_status = MealPlanItemStatus.denied
db.commit()
db.refresh(item)
return {
"item": item,
"promoted_to_permanent": promoted,
"scope": scope,
}
@router.get("", response_model=Optional[MealPlanResponse])
def get_planned_meals(
week_start: Optional[date] = Query(
@@ -122,16 +223,32 @@ _DAY_NAMES = {
class VoteSubmission(BaseModel):
"""Body for POST /vote/{item_id}?token=...
Spec body: {"vote": "approve" | "deny"}.
Spec body: {"vote": "approve" | "deny" | "never_again"}.
Sprint 8 added "never_again" as a separate vote value (vs. plain
"deny" which is the soft "this week" denial). The vote path is the
same; the response carries `promoted_to_permanent` and `denial_scope`
so the email confirmation page can show what was applied.
"""
vote: str = Field(..., pattern="^(approve|deny)$")
vote: str = Field(..., pattern="^(approve|deny|never_again)$")
@router.get("/vote/{item_id}", response_class=HTMLResponse)
def get_vote_page(
item_id: UUID,
token: str = Query(..., description="Per-voter signed token"),
scope: Optional[str] = Query(
None,
pattern="^(approve|this_week|never_again)$",
description=(
"Sprint 8: when present, the GET is treated as a one-click "
"direct vote from an email link. The token is consumed, the "
"vote is recorded via submit_vote(), and a tiny confirmation "
"page is rendered. When absent, the page is the full vote "
"form with 3 buttons."
),
),
db: Session = Depends(get_db),
):
"""Render the per-voter approval confirmation page.
@@ -171,6 +288,67 @@ def get_vote_page(
safe_recipe = _html_escape(recipe_name)
safe_day = _html_escape(day_name)
safe_meal = _html_escape(meal_type)
# Sprint 8: one-click direct vote (used by the email's per-button
# links). Consume the token, record the vote via submit_vote, and
# render a tiny confirmation page. Single-use enforcement is shared
# with the JSON path (consume_token).
if scope is not None:
# Map email-link scope to the vote-payload "vote" field.
vote_value = "approve" if scope == "approve" else scope # "this_week" or "never_again"
result = submit_vote(
item_id=item_id,
submission=VoteSubmission(vote=vote_value),
token=token,
db=db,
)
item_status = result.get("item_status", "?")
promoted = result.get("promoted_to_permanent", False)
if scope == "approve":
msg = f"Approved {safe_recipe} ({safe_day})."
elif scope == "this_week":
if promoted:
msg = (
f"Denied {safe_recipe} for this week. "
f"You've denied this recipe recently, so it will not be "
f"suggested again (permanently blocked)."
)
else:
msg = (
f"Denied {safe_recipe} for this week. "
f"It will not be re-suggested for 90 days unless denied again."
)
else: # never_again
msg = (
f"Denied {safe_recipe} permanently. "
f"It will never be suggested again."
)
confirmation = f"""<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Vote recorded</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
body {{ font-family: system-ui, sans-serif; max-width: 36rem; margin: 2rem auto;
padding: 0 1rem; color: #111; background: #fff; line-height: 1.5; }}
.meal {{ padding: 1rem; border: 1px solid #444; border-radius: 6px; margin: 1rem 0; }}
.status {{ margin-top: 1rem; font-weight: bold; }}
</style>
</head>
<body>
<h1>Hi {safe_voter}, your vote was recorded</h1>
<div class="meal">
<div><strong>{safe_recipe}</strong></div>
<div>{safe_day} &middot; {safe_meal}</div>
</div>
<div class="status">{_html_escape(msg)}</div>
<p style="margin-top:1rem;color:#555;font-size:14px">Meal plan status: {_html_escape(str(item_status))}.</p>
</body>
</html>
"""
return HTMLResponse(content=confirmation, status_code=200)
action_url = f"/api/meals/vote/{item_id}?token={_html_escape(token, quote=True)}"
existing_vote = db.query(MealPlanVote).filter(
@@ -216,10 +394,12 @@ def get_vote_page(
padding: 0 1rem; color: #111; background: #fff; line-height: 1.5; }}
h1 {{ font-size: 1.4rem; }}
.meal {{ padding: 1rem; border: 1px solid #444; border-radius: 6px; margin: 1rem 0; }}
button {{ font-size: 1rem; padding: .6rem 1.2rem; margin-right: .5rem;
.actions {{ display: flex; flex-wrap: wrap; gap: .5rem; margin: 1rem 0; }}
button {{ font-size: 1rem; padding: .6rem 1.2rem;
border: 2px solid #111; border-radius: 4px; cursor: pointer; }}
.approve {{ background: #0a6b2b; color: #fff; }}
.deny {{ background: #b00020; color: #fff; }}
.never {{ background: #5a0000; color: #fff; border-style: dashed; }}
#status {{ margin-top: 1rem; font-weight: bold; }}
</style>
</head>
@@ -230,8 +410,11 @@ def get_vote_page(
<div>{safe_day} &middot; {safe_meal}</div>
</div>
<form id="voteForm" method="post" action="{action_url}">
<div class="actions">
<button type="submit" name="vote" value="approve" class="approve" aria-label="Approve this meal">Approve</button>
<button type="submit" name="vote" value="deny" class="deny" aria-label="Deny this meal">Deny</button>
<button type="submit" name="vote" value="deny" class="deny" aria-label="Deny this meal for this week (will not reappear for 90 days)">Deny this week</button>
<button type="submit" name="vote" value="never_again" class="never" aria-label="Never suggest this recipe again">Never again</button>
</div>
</form>
<div id="status" role="status" aria-live="polite"></div>
<script>
@@ -248,7 +431,15 @@ def get_vote_page(
try {{ data = await resp.json(); }} catch (_) {{}}
var s = document.getElementById('status');
if (resp.ok) {{
s.textContent = 'Recorded: ' + (data.item_status || vote);
var msg = 'Recorded: ' + (data.item_status || vote);
if (vote !== 'approve' && data.promoted_to_permanent) {{
msg += '. This recipe will not be suggested again (permanently blocked).';
}} else if (vote === 'deny') {{
msg += '. Will not reappear for 90 days unless denied again.';
}} else if (vote === 'never_again') {{
msg += '. Permanently blocked.';
}}
s.textContent = msg;
}} else {{
s.textContent = 'Error: ' + (data.detail || resp.status);
}}
@@ -270,8 +461,12 @@ def submit_vote(
"""Record a per-voter vote and apply the approval rule.
- Single-use enforcement lives in `approval_service.consume_token`.
- Approval rule: any deny -> item.denied; all-approve -> item.approved;
otherwise pending (waiting on remaining voters).
- Approval rule: any deny (any scope) -> item.denied; all-approve ->
item.approved; otherwise pending (waiting on remaining voters).
- Sprint 8: a `deny` (or `never_again`) vote also flows through
`_apply_denial` which may set `denial_expires_at` or promote the
recipe to a permanent `NeverSuggest` block (auto-escalation after
the 2nd denial in 90d).
"""
voter = approval_service.consume_token(db, token, item_id)
@@ -280,12 +475,29 @@ def submit_vote(
raise HTTPException(status_code=404, detail="Meal plan item not found")
vote_bool = submission.vote == "approve"
denial_scope: Optional[str] = None
promoted = False
if submission.vote == "never_again":
denial_scope = "never_again"
elif submission.vote == "deny":
denial_scope = "this_week"
db.add(MealPlanVote(
meal_plan_item_id=item_id,
family_member_id=voter.id,
vote=vote_bool,
denial_scope=denial_scope,
))
db.flush()
# Apply the denial (Sprint 8). For approve votes, this is a no-op
# other than the rule's effect on approval_status below.
if vote_bool is False and item.recipe_id is not None:
result = _apply_denial(db, item, scope=denial_scope or "this_week")
promoted = result["promoted_to_permanent"]
else:
# Approve path: keep existing approval-rule logic.
pass
# Approval rule: count electorate (all family members on this profile)
# vs votes recorded so far.
@@ -307,7 +519,12 @@ def submit_vote(
item.approval_status = MealPlanItemStatus.pending
db.commit()
return {"status": "recorded", "item_status": item.approval_status.value}
return {
"status": "recorded",
"item_status": item.approval_status.value,
"denial_scope": denial_scope,
"promoted_to_permanent": promoted,
}
@router.get("/items/{item_id}", response_model=MealPlanItemResponse)
@@ -346,6 +563,10 @@ def swap_meal_item(item_id: UUID, new_recipe_id: UUID, db: Session = Depends(get
item.approval_status = MealPlanItemStatus.pending
item.denial_reason = None
item.denial_details = None
# Sprint 8: swapping to a new recipe clears any prior soft-deny
# window. The new recipe is a different recipe_id so the prior
# denial wouldn't apply anyway, but the new row starts fresh.
item.denial_expires_at = None
db.commit()
return {"message": "Meal swapped", "item": item}
@@ -363,14 +584,50 @@ def approve_meal_item(item_id: UUID, db: Session = Depends(get_db)):
@router.post("/items/{item_id}/deny")
def deny_meal_item(item_id: UUID, db: Session = Depends(get_db)):
"""Directly deny a meal plan item from the dashboard."""
def deny_meal_item(
item_id: UUID,
scope: str = Query(
"this_week",
pattern="^(this_week|never_again)$",
description=(
"Sprint 8: 'this_week' (default) sets denial_expires_at = now()+90d. "
"If a prior active soft denial exists for the same recipe, the "
"recipe is auto-promoted to a permanent NeverSuggest block. "
"'never_again' always writes a NeverSuggest row and clears "
"denial_expires_at (no decay)."
),
),
db: Session = Depends(get_db),
):
"""Directly deny a meal plan item from the dashboard.
Sprint 8: per the user's policy decision, two denials in the past
90 days (or any explicit "never_again") promote the recipe to a
permanent `NeverSuggest` block. The function returns the standard
`MealPlanItemResponse` plus a `promoted_to_permanent` boolean so
the frontend can show a clear toast ("Denied + won't suggest again").
"""
item = db.query(MealPlanItem).filter(MealPlanItem.id == item_id).first()
if not item:
raise HTTPException(status_code=404, detail="Meal plan item not found")
if item.recipe_id is None:
# Defensive: an item without a recipe can't be blocked by recipe.
item.approval_status = MealPlanItemStatus.denied
item.denial_expires_at = None
db.commit()
return {"message": "Meal denied", "item": item}
return {
"message": "Meal denied",
"item": item,
"promoted_to_permanent": False,
"scope": scope,
}
result = _apply_denial(db, item, scope=scope)
return {
"message": "Meal denied",
"item": result["item"],
"promoted_to_permanent": result["promoted_to_permanent"],
"scope": result["scope"],
}
@router.delete("/items/{item_id}")
+8
View File
@@ -229,6 +229,10 @@ class MealPlanItem(Base):
approval_status = Column(SQLEnum(MealPlanItemStatus, name="meal_plan_item_status_enum", create_type=False, values_callable=lambda obj: [e.value for e in obj]), default=MealPlanItemStatus.pending)
denial_reason = Column(SQLEnum(DenialReason, name="denial_reason_enum", create_type=False, values_callable=lambda obj: [e.value for e in obj]))
denial_details = Column(Text)
# Sprint 8: when this denial stops being a "soft" signal. NULL means
# either an approve / a non-denial row, or a "Never again" denial
# (no decay; promoted to NeverSuggest).
denial_expires_at = Column(DateTime(timezone=True), nullable=True)
estimated_cost = Column(Numeric(10, 2))
score = Column(Float)
components = Column(JSONB)
@@ -254,6 +258,10 @@ class MealPlanVote(Base):
meal_plan_item_id = Column(UUID(as_uuid=True), ForeignKey("meal_plan_item.id", ondelete="CASCADE"))
family_member_id = Column(UUID(as_uuid=True), ForeignKey("family_member.id", ondelete="CASCADE"))
vote = Column(Boolean, nullable=False)
# Sprint 8: which deny-scope the voter chose. NULL for approve votes.
# "this_week" = soft denial, decays in 90d. "never_again" = hard block
# (a NeverSuggest row is also written for permanence).
denial_scope = Column(String(16), nullable=True)
voted_at = Column(DateTime(timezone=True), server_default=func.now())
__table_args__ = (
+7
View File
@@ -207,6 +207,8 @@ class MealPlanItemResponse(MealPlanItemBase):
approval_status: MealPlanItemStatus = MealPlanItemStatus.pending
denial_reason: Optional[DenialReason] = None
denial_details: Optional[str] = None
# Sprint 8: when this denial decays. NULL = no decay (approve / never_again).
denial_expires_at: Optional[datetime] = None
used_pantry_items: Optional[List[UUID]] = []
score: Optional[float] = None
components: Optional[Dict[str, float]] = None
@@ -249,6 +251,9 @@ class VoteRequest(BaseModel):
vote: bool
denial_reason: Optional[DenialReason] = None
denial_details: Optional[str] = None
# Sprint 8: "this_week" (default) or "never_again". Only honored when
# vote=False; ignored for approve votes.
denial_scope: Optional[str] = Field(None, pattern="^(this_week|never_again)$")
class VoteResponse(BaseModel):
@@ -256,6 +261,8 @@ class VoteResponse(BaseModel):
meal_plan_item_id: UUID
family_member_id: UUID
vote: bool
# Sprint 8: which deny-scope the voter chose. NULL on approve votes.
denial_scope: Optional[str] = None
voted_at: Optional[datetime] = None
class Config:
+14 -3
View File
@@ -281,9 +281,20 @@ def step_email(run: "WeeklyRun", db: "Session") -> None:
f'{ing_block}'
f'{instructions_block}'
f'{cost_block}'
f'<a href="{vote_url}" style="display:inline-block;margin-top:8px;padding:8px 16px;'
f'background:#2563eb;color:white;text-decoration:none;border-radius:6px;font-size:14px">'
f'Vote on this meal</a>'
f'<div style="margin-top:8px;display:flex;flex-wrap:wrap;gap:6px">'
f'<a href="{vote_url}&amp;scope=approve" style="display:inline-block;padding:8px 14px;'
f'background:#16a34a;color:white;text-decoration:none;border-radius:6px;font-size:14px">'
f'Approve</a>'
f'<a href="{vote_url}&amp;scope=this_week" style="display:inline-block;padding:8px 14px;'
f'background:#dc2626;color:white;text-decoration:none;border-radius:6px;font-size:14px">'
f'Deny this week</a>'
f'<a href="{vote_url}&amp;scope=never_again" style="display:inline-block;padding:8px 14px;'
f'background:#7f1d1d;color:white;text-decoration:none;border-radius:6px;font-size:14px;'
f'border:1px dashed #fca5a5">'
f'Never again</a>'
f'</div>'
f'<div style="font-size:11px;color:#888;margin-top:4px">'
f'<a href="{vote_url}" style="color:#2563eb">Open vote page (all 3 options)</a></div>'
f'</div>'
)
+52 -4
View File
@@ -6,6 +6,7 @@ from decimal import Decimal
from typing import Dict, List, Optional, Set
from uuid import UUID
from sqlalchemy import func
from sqlalchemy.orm import Session
from app.models import (
@@ -58,7 +59,19 @@ def _load_match_index(db: Session) -> Dict[UUID, List[dict]]:
def _load_blocklists(
db: Session, family_id: UUID
) -> tuple[Set[UUID], Set[UUID]]:
) -> tuple[Set[UUID], Set[UUID], Set[UUID]]:
"""Sprint 8: returns 3 sets of UUIDs.
- blocked_ingredients: ingredient-level NeverSuggest entries
- blocked_recipes: recipe-level NeverSuggest entries (permanent, no decay)
- soft_denied_recipes: meal_plan_item rows with approval_status='denied'
and denial_expires_at > now() (decaying in DENIAL_DECAY_DAYS; auto-
promoted to blocked_recipes on the 2nd denial in the window by the
/deny API path).
Both recipe sets are hard filters (user decision: "Hard filter. If it
is denied this week twice, it should be considered denied for good.").
"""
blocked_ingredients: Set[UUID] = set()
blocked_recipes: Set[UUID] = set()
for row in db.query(NeverSuggest).filter(NeverSuggest.family_profile_id == family_id).all():
@@ -66,7 +79,25 @@ def _load_blocklists(
blocked_ingredients.add(row.ingredient_id)
if row.recipe_id is not None:
blocked_recipes.add(row.recipe_id)
return blocked_ingredients, blocked_recipes
soft_denied_recipes: Set[UUID] = set()
rows = (
db.query(MealPlanItem.recipe_id)
.join(MealPlan, MealPlanItem.meal_plan_id == MealPlan.id)
.filter(
MealPlan.family_profile_id == family_id,
MealPlanItem.approval_status == MealPlanItemStatus.denied,
MealPlanItem.denial_expires_at.isnot(None),
MealPlanItem.denial_expires_at > func.now(),
MealPlanItem.recipe_id.isnot(None),
)
.distinct()
.all()
)
for (rid,) in rows:
soft_denied_recipes.add(rid)
return blocked_ingredients, blocked_recipes, soft_denied_recipes
def _load_pantry(db: Session, family_id: UUID) -> Set[UUID]:
@@ -147,9 +178,17 @@ def generate_meal_plan(
match_index = _load_match_index(db)
pantry_ids = _load_pantry(db, family_id)
blocked_ings, blocked_recipes = _load_blocklists(db, family_id)
blocked_ings, blocked_recipes, soft_denied_recipes = _load_blocklists(db, family_id)
last_cooked = _load_last_cooked(db, family_id)
# Sprint 8: union the soft-denied set with the permanent blocklist
# so the filter treats them identically. The `rejected[rid]` reason
# is "blocked_recipe" for both — operators reading the planner's
# `rejected_summary` see a single bucket. The soft set is also
# passed in separately so the diagnostic label could be split
# later if needed.
all_blocked_recipes = blocked_recipes | soft_denied_recipes
recipe_costs = {
r["id"]: compute_recipe_cost(
recipe_id=r["id"],
@@ -166,7 +205,7 @@ def generate_meal_plan(
recipe_ingredient_ids=recipe_ingredient_ids,
recipe_costs=recipe_costs,
blocked_ingredient_ids=blocked_ings,
blocked_recipe_ids=blocked_recipes,
blocked_recipe_ids=all_blocked_recipes,
last_cooked_at=last_cooked,
family_calorie_target=family.calorie_target,
config=effective_config,
@@ -216,6 +255,15 @@ def generate_meal_plan(
rejected_summary: Dict[str, int] = {}
for reason in filtered.rejected.values():
rejected_summary[reason] = rejected_summary.get(reason, 0) + 1
# Sprint 8: surface how many recipes are blocked specifically because
# of soft denials (vs. permanent NeverSuggest entries). Both are
# bucketed under "blocked_recipe" in the filter; this adds a
# "soft_denied_recipe" sub-bucket for diagnostics.
if soft_denied_recipes:
# Only count those that were actually candidates (in recipe_dicts).
soft_in_pool = sum(1 for r in recipe_dicts if r["id"] in soft_denied_recipes)
if soft_in_pool > 0:
rejected_summary["soft_denied_recipe"] = soft_in_pool
return GenerationResult(
meal_plan_id=plan.id,
+81 -2
View File
@@ -302,14 +302,93 @@ Trust the tests. Trust the live runs. Don't trust prose claims that something is
**Current open proposals:**
- `docs/proposals/2026-05-23-feedback-driven-recipe-discovery.md` — pending user approval. No code yet (per the 2026-05-23 section below).
**Last updated: 2026-06-05** — UI/UX audit & fix cycle (Sprints 1, 2, 3, 4, 5, 6) complete. 20 findings closed (5 P0 + 6 P1 + 3 P2 + 6 §Future), code committed across 10 commits, build green. Sprint 1 deployed; Sprints 2-6 awaiting deploy. **Sprint 2's deploy was blocked on a cast bug in migration 0015; that bug is fixed in `d78bd18`.** **Sprint 7 (fix webui "empty meal plan" date-semantics mismatch) in progress — see Sprint 7 section below.** Full UI-audit handoff at `Review/handoff-ui-audit.md`.
**Last updated: 2026-06-05** — UI/UX audit & fix cycle (Sprints 1, 2, 3, 4, 5, 6) complete. 20 findings closed (5 P0 + 6 P1 + 3 P2 + 6 §Future), code committed across 10 commits, build green. Sprint 1 deployed; Sprints 2-6 awaiting deploy. **Sprint 2's deploy was blocked on a cast bug in migration 0015; that bug is fixed in `d78bd18`.** **Sprint 7 (`09c7525`, awaiting user deploy)** aligns "this week" to the upcoming Monday. **Sprint 8 (in progress)** implements the user's "Deny" semantics decision. See Sprint 7 + Sprint 8 sections below. Full UI-audit handoff at `Review/handoff-ui-audit.md`.
---
## New session: 2026-06-05
## New session: 2026-06-05 (continued)
### Sprint 7 — Fix webui "empty meal plan" (date-semantics mismatch) — COMMITTED `09c7525`
**User report (2026-06-05, 06:17 PT):** "Latest meal plans were emails to me this morning, but when I go to the webui, the Meal Planner page is empty."
**Root cause:** the orchestrator planned the **upcoming** Mon-Sun week (Fri 2026-06-05 → key 2026-06-08) but the frontend's `isoMonday()` returned the **current** Mon-Sun (Fri 2026-06-05 → 2026-06-01). 7-day mismatch on Fridays.
**Fix (Option C, proper cleanup):**
- `backend/app/services/orchestrator/runner.py:20-35` — `_current_week_start()` returns the **upcoming Monday** (today if Mon). Email subject (`f"Meal plan for week of {run.week_start_date}"` at `steps.py:305`) automatically picks up the new value.
- `frontend/src/lib/utils.ts:43-130` — `isoMonday` → `upcomingMonday` (deprecated alias kept). New `formatWeekRange(mondayIso)`. UTC-stable `formatIsoDate` (fixed a TZ bug where `toLocaleDateString` rendered the previous day for users in negative-UTC timezones).
- `frontend/src/components/WeekRangeNav.tsx` (NEW) — `[<] Jun 8 — Jun 14 [>]` with clickable chevrons + clickable range label (jumps to upcoming week) + `This week` chip when off the upcoming week. Replaces the Sprint 5 inline segmented control on both pages.
- `backend/scripts/fix_2026_06_05_to_2026_06_08.sql` (NEW) — guarded `UPDATE meal_plan SET week_start_date='2026-06-08' WHERE week_start_date='2026-06-05';` (idempotent, transaction-wrapped). Optional commented block for 2026-05-29.
**Commit:** `09c7525`. Files: 13 changed, 679+/96-, 3 new. `npm run build` green.
**Sprint 7 deploy (user runs):**
```bash
cd ~/MealPlanner && git pull
docker compose exec -T db psql -U mealplanner -d mealplanner \
-f /dev/stdin < backend/scripts/fix_2026_06_05_to_2026_06_08.sql
docker compose -f docker-compose.yml up -d --build backend frontend
```
**Verification log:** `Review/sprint7-verification.md` (12-step browser smoke + API curls + rollback procedure).
---
### Sprint 8 — "Deny" semantics (C + Z, hard-filter escalation) — IN PROGRESS
**User report (2026-06-05, follow-up):** "one of the meals was the meal that I rejected last week. After you fix the above, lets discuss what rejeccting means."
**Investigation:** 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.
**User policy decision (2026-06-05, exact words):** "Hard filter. If it is denied this week twice, it should be considered denied for good."
**Policy (Sprint 8):**
| 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 |
**Scope (12 boxes):** see `.agent/plan.md` "Active sprint" section. Code changes are M-L.
**Key files (Sprint 8):**
- `backend/alembic/versions/0016_denial_decay_and_scope.py` (NEW) — adds `meal_plan_item.denial_expires_at` + `meal_plan_vote.denial_scope`. Partial index on `denial_expires_at` for fast lookup.
- `backend/app/api/meals.py:30-138` — 3 new helpers: `_apply_denial`, `_ensure_never_suggest_recipe`, `_has_prior_active_soft_denial`. `DENIAL_DECAY_DAYS = 90`.
- `backend/app/api/meals.py:510-552` — `deny_meal_item` accepts `?scope=this_week|never_again` (default `this_week`); returns `promoted_to_permanent`.
- `backend/app/api/meals.py:380-455` — `submit_vote` handles `vote: "approve" | "deny" | "never_again"`; returns `denial_scope` + `promoted_to_permanent`.
- `backend/app/api/meals.py:240-330` — `get_vote_page` HTML page renders 3 buttons; supports one-click `?scope=...` direct-vote for email.
- `backend/app/services/orchestrator/steps.py:283-300` — email template renders 3 direct-action links per recipe.
- `backend/app/services/planner/generate.py:59-99, 150-194` — `_load_blocklists` returns 3 sets; `soft_denied_recipes` is hard-filtered (per user decision).
- `frontend/src/api/index.ts:48-58` — `meals.denyItem(itemId, { scope })`.
- `frontend/src/pages/Dashboard.tsx:38-50, 385-410` — `MealCard` renders 3 buttons (Approve / Deny this week / Never again) for pending items. "Never again" is gated by `window.confirm`.
**Static checks (offline):** all imports + types + helper logic verified via Python AST + import-test against the venv. The 1 pre-existing test failure in `test_planner_filter.py::test_filter_blocks_by_cost` is **not** introduced by Sprint 8 (verified by `git stash` + re-run on a clean tree).
**Sprint 8 deploy (user runs):**
```bash
cd ~/MealPlanner && git pull
docker compose exec backend alembic upgrade head
docker compose -f docker-compose.yml up -d --build backend frontend
```
**Verification log:** `Review/sprint8-verification.md` (11-step browser smoke + API curls + email render + rollback).
---
## New session: 2026-06-05 (early)
### Sprint 7 — Fix webui "empty meal plan" (date-semantics mismatch)
(Full section above.)
---
## New session: 2026-06-03
**User report (2026-06-05, 06:17 PT):** "Latest meal plans were emails to me this morning, but when I go to the webui, the Meal Planner page is empty."
**Root cause (one-liner):** The orchestrator plans the **upcoming** Mon-Sun week (Fri 2026-06-05 → key 2026-06-08), but the frontend's `isoMonday()` returns the **current** Mon-Sun (Fri 2026-06-05 → 2026-06-01). Email subject, DB plan key, and the webui default URL are 7 days out of sync. The user opens the app, lands on the current Mon-Sun week which has no plan, and sees the "No plan yet" empty state.
+92 -1
View File
@@ -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.
+11 -1
View File
@@ -46,7 +46,17 @@ export const mealPlannerApi = {
swapItem: (itemId: string, newRecipeId: string) => api.post(`/meals/items/${itemId}/swap?new_recipe_id=${newRecipeId}`),
moveItem: (itemId: string, newDayOfWeek: number, newMealType: string) => api.put(`/meals/items/${itemId}/move`, null, { params: { new_day_of_week: newDayOfWeek, new_meal_type: newMealType } }),
approveItem: (itemId: string) => api.post(`/meals/items/${itemId}/approve`),
denyItem: (itemId: string) => api.post(`/meals/items/${itemId}/deny`),
// Sprint 8: scope ∈ 'this_week' (default) | 'never_again'. The
// server auto-promotes 'this_week' to permanent block on the 2nd
// denial within the 90-day decay window; 'never_again' always
// writes a NeverSuggest row. Response includes
// promoted_to_permanent: boolean so the UI can show a clear toast.
denyItem: (itemId: string, opts?: { scope?: 'this_week' | 'never_again' }) =>
api.post(
`/meals/items/${itemId}/deny`,
undefined,
{ params: opts?.scope ? { scope: opts.scope } : {} },
),
deleteItem: (itemId: string) => api.delete(`/meals/items/${itemId}`),
generateItem: (mealPlanId: string, dayOfWeek: number, mealType: string) =>
api.post(`/meals/${mealPlanId}/generate-item`, null, { params: { day_of_week: dayOfWeek, meal_type: mealType } }),
+61 -5
View File
@@ -35,12 +35,15 @@ const MEAL_TYPES = ['breakfast', 'lunch', 'dinner'] as const
/* ------------------------------------------------------------------ */
/* MealCard (draggable) */
/* ------------------------------------------------------------------ */
function MealCard({ item, dragHandleProps, isDragging, onApprove: _onApprove, onDeny: _onDeny, onDelete }: {
function MealCard({ item, dragHandleProps, isDragging, onApprove: _onApprove, onDeny, onDelete }: {
item: MealPlanItem
dragHandleProps?: DraggableProvidedDragHandleProps | null
isDragging?: boolean
onApprove?: (itemId: string) => void
onDeny?: (itemId: string) => void
// Sprint 8: optional scope. When provided, the second arg is the
// deny-scope ('this_week' | 'never_again'); when omitted, defaults
// to 'this_week' at the handler level.
onDeny?: (itemId: string, scope?: 'this_week' | 'never_again') => void
onDelete?: (itemId: string) => void
}) {
const totalTime = item.recipe?.total_time_minutes ??
@@ -110,6 +113,44 @@ function MealCard({ item, dragHandleProps, isDragging, onApprove: _onApprove, on
</span>
)}
</div>
{/* Sprint 8: 3-button voting row. Only shown for pending items
(approved/denied items are terminal). Compact on mobile. */}
{onDeny && item.approval_status === 'pending' && (
<div className="flex items-center gap-1 mt-1.5">
{_onApprove && (
<button
type="button"
onClick={(e) => { e.stopPropagation(); _onApprove(item.id) }}
aria-label="Approve this meal"
className="flex-1 text-[10px] font-medium px-1.5 py-1 rounded bg-success-50 text-success-700 hover:bg-success-100 focus:outline-none focus:ring-2 focus:ring-success-400 min-h-11"
>
Approve
</button>
)}
<button
type="button"
onClick={(e) => { e.stopPropagation(); onDeny(item.id, 'this_week') }}
aria-label="Deny this meal for this week (will not reappear for 90 days)"
className="flex-1 text-[10px] font-medium px-1.5 py-1 rounded bg-danger-50 text-danger-700 hover:bg-danger-100 focus:outline-none focus:ring-2 focus:ring-danger-400 min-h-11"
>
Deny this week
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation()
if (window.confirm(`Never suggest "${item.recipe?.name || 'this recipe'}" again? This permanently blocks the recipe for your family.`)) {
onDeny(item.id, 'never_again')
}
}}
aria-label="Never suggest this recipe again"
title="Never suggest this recipe again"
className="text-[10px] font-medium px-1.5 py-1 rounded border border-dashed border-danger-300 text-danger-700 hover:bg-danger-100 focus:outline-none focus:ring-2 focus:ring-danger-400 min-h-11"
>
Never again
</button>
</div>
)}
</div>
</div>
</div>
@@ -382,10 +423,25 @@ export default function Dashboard() {
}
}
async function handleDeny(itemId: string) {
// Sprint 8: scope-aware deny. 'this_week' (default) is a soft denial
// that decays in 90 days. 'never_again' writes a permanent
// NeverSuggest block. The server auto-promotes 'this_week' to
// permanent on the 2nd denial in the window; the response's
// `promoted_to_permanent` flag drives the toast text.
async function handleDeny(
itemId: string,
scope: 'this_week' | 'never_again' = 'this_week',
) {
try {
await mealPlannerApi.meals.denyItem(itemId)
toast.success('Meal denied')
const res = await mealPlannerApi.meals.denyItem(itemId, { scope })
const promoted = res.data?.promoted_to_permanent === true
if (scope === 'never_again') {
toast.success('Denied — will never be suggested again')
} else if (promoted) {
toast.success("Denied — won't suggest again (denied twice recently)")
} else {
toast.success('Denied this week — will not reappear for 90 days')
}
queryClient.invalidateQueries({ queryKey: ['mealPlan', weekStart] })
} catch {
// Error toast fires from the global MutationCache handler.