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:
+73
-40
@@ -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 7 — Fix 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.1–T2.10).
|
||||
- [x] `fix-ui-audit.md` — Sprint 8 plan section (T2.1–T2.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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
Reference in New Issue
Block a user