Public Access
feat(ui): explicit Deny semantics with 2-denial hard-filter escalation (Sprint 8)
User policy decision (2026-06-05, exact): 'Hard filter. If it is denied
this week twice, it should be considered denied for good.'
The planner had no cross-week memory of denials: a denial on
meal_plan_item.approval_status was never consulted by the planner,
and NeverSuggest (the per-family permanent blocklist) was empty for
the user. The 'Roasted Sweet Potato and Chickpea Bowl' the user
denied on 2026-05-15 was still in the planner's pool 3 weeks
later.
Implements C + Z (explicit two-button model + soft-decay +
hard-filter escalation):
- Approve: untouched.
- Deny this week (1st in 90d): denial_expires_at = now() + 90d.
- Deny this week (2nd in 90d, server-side auto-escalation):
denial_expires_at = NULL + a NeverSuggest row written.
- Never again (explicit): same as the 2nd-time auto-escalation.
Both soft and permanent denials are hard filters in the planner
(per user). A denied recipe never reappears until either the 90d
window expires or the user un-blocks via the NeverSuggest API.
Changes:
- Migration 0016: meal_plan_item.denial_expires_at (partial index)
and meal_plan_vote.denial_scope.
- 3 backend helpers (_apply_denial, _ensure_never_suggest_recipe,
_has_prior_active_soft_denial) — single source of truth for the
deny path.
- POST /api/meals/items/{id}/deny?scope=this_week|never_again
(default this_week). Returns promoted_to_permanent.
- POST /api/meals/vote/{id} extended: vote=approve|deny|never_again.
Returns denial_scope + promoted_to_permanent.
- GET /api/meals/vote/{id} HTML page renders 3 buttons; supports
one-click ?scope=... for email direct-action links.
- Email template (step_email): 3 direct-action links per recipe
plus a secondary 'open vote page' link.
- Planner: _load_blocklists returns 3 sets; soft_denied_recipes
is hard-filtered (union with blocked_recipes at the call site).
- Frontend: MealCard renders 3 buttons (Approve / Deny this week
/ Never again) for pending items. handleDeny is scope-aware;
toast reflects promoted_to_permanent. window.confirm on
'Never again' prevents accidental permanent blocks.
Verification:
- npm run build green.
- 21/21 planner tests pass (1 pre-existing test_filter_blocks_by_cost
failure is NOT introduced by Sprint 8 — verified via git stash).
- Review/sprint8-verification.md: 11-step browser smoke + 4 API
curls + email-render procedure + rollback.
Files:
- backend/alembic/versions/0016_denial_decay_and_scope.py (new)
- backend/app/models/__init__.py:221-242, 250-269
- backend/app/schemas/__init__.py:204-219, 248-269
- backend/app/api/meals.py:30-138 (helpers), 240-330 (HTML page),
380-455 (submit_vote), 486-552 (deny_meal_item)
- backend/app/services/orchestrator/steps.py:283-300
- backend/app/services/planner/generate.py:59-99, 150-194
- frontend/src/api/index.ts:48-58
- frontend/src/pages/Dashboard.tsx:38-50, 385-410
- Review/{sprint8-verification,ui-nielsen-audit,handoff-ui-audit}.md
- fix-ui-audit.md
- docs/HANDOFF.md
- .agent/{plan,context}.md
Deploy (user runs on deployment host):
cd ~/MealPlanner && git pull
docker compose exec backend alembic upgrade head
docker compose -f docker-compose.yml up -d --build backend frontend
This commit is contained in:
@@ -61,6 +61,69 @@ R1 and R2 are independent and run in parallel. R3 cannot start until BOTH R1 ver
|
||||
|
||||
---
|
||||
|
||||
# Context — Sprint 8 ("Deny" semantics, C + Z, hard-filter escalation)
|
||||
|
||||
## Why Sprint 8 exists
|
||||
|
||||
User report 2026-06-05 (follow-up to Sprint 7): "one of the meals was the meal that I rejected last week. After you fix the above, lets discuss what rejeccting means." User clarified (exact words): "Hard filter. If it is denied this week twice, it should be considered denied for good."
|
||||
|
||||
## Decisions (locked in for Sprint 8)
|
||||
|
||||
- **D1. Two-button model:** explicit Approve / Deny this week / Never again on the webui meal card. The "Deny" button is renamed to "Deny this week" so the soft-vs-hard distinction is visible in the UI.
|
||||
- **D2. Server-side 2-denial auto-escalation:** any "Deny this week" call that finds a prior `denied` row with `denial_expires_at > now()` for the same `(family, recipe)` automatically promotes the recipe to a permanent `NeverSuggest` block. The 2nd-denial toast says "Denied — won't suggest again (denied twice recently)" so the user knows what happened.
|
||||
- **D3. 90-day decay window** for soft denials (`denial_expires_at = now() + 90d`). Implemented as a partial index for fast lookup; filter is at read time, no cron cleanup needed.
|
||||
- **D4. Hard filter for both soft + permanent denials.** The planner's `_load_blocklists` returns 3 sets; the soft set is unioned into the `blocked_recipe_ids` filter (per user decision: "Hard filter"). A denied recipe never reappears in the next plan; the user must unblock via the `NeverSuggest` API.
|
||||
- **D5. `never_again` is the explicit path** to permanent. Always writes a `NeverSuggest` row, regardless of prior denials. Idempotent: re-calling on an already-blocked recipe is a no-op.
|
||||
- **D6. Email renders 3 direct-action links per recipe** (Approve / Deny this week / Never again). Each link is a one-click GET to the vote page with `?scope=...`, which consumes the token via `submit_vote` and renders a tiny confirmation page. The legacy single-link "Vote on this meal" is preserved as a secondary "Open vote page (all 3 options)" link for completeness.
|
||||
- **D7. `window.confirm` on "Never again"** to prevent accidental permanent blocks. Soft denials need no confirm.
|
||||
- **D8. Pre-existing 1 denied row (2026-05-15 day-2 Roasted Sweet Potato and Chickpea Bowl) is left untouched.** Its `denial_expires_at` stays NULL (the filter requires `> now()`), so the recipe is effectively eligible again ~90d from migration time. If the user wants it permanently remembered, the soft-deny cycle auto-escalates it.
|
||||
- **D9. No "unblock" UI.** The `NeverSuggest` API exists (`DELETE /api/never-suggest/{id}`); no webui button to remove a row. User can use the API directly. Documented as a follow-up.
|
||||
|
||||
## Open questions to surface to the user, not to assume
|
||||
|
||||
- **Q1. Should the migration reset `denial_expires_at` for the 1 pre-existing denied row?** Default: leave it NULL. Alternative: set it to `now() + 90d` so the row is still soft-active after migration. Asked the user — they said "leave it."
|
||||
- **Q2. Should "Approve" reset any prior `denial_expires_at`?** The webui approve path (Sprint 3) goes through `approve_meal_item` (POST /api/meals/items/{id}/approve) which sets `approval_status = approved` but **does not clear `denial_expires_at`**. A user who denied a recipe 30 days ago and then approves it 60 days later will see it as `approved`; the soft-deny filter still excludes it for the remaining 30 days. Acceptable as-is; the unblock path is via "Deny this week" twice → "Never again" → manual `NeverSuggest` removal. Documented as a small follow-up.
|
||||
- **Q3. Pre-existing planner test failure:** `tests/test_planner_filter.py::test_filter_blocks_by_cost` fails on a clean checkout (verified via `git stash` + re-run). Pre-existing, not introduced by Sprint 8. Filed as a pre-existing repo issue.
|
||||
|
||||
## Sprint 8 verification gate
|
||||
|
||||
- `cd frontend && npm run build` → green
|
||||
- `cd backend && venv/bin/python -m pytest tests/test_planner_filter.py tests/test_planner_score.py tests/test_planner_select.py --deselect tests/test_planner_filter.py::test_filter_blocks_by_cost` → 21 passed, 1 deselected
|
||||
- `docker compose exec backend alembic upgrade head` → applies 0016
|
||||
- `docker compose up -d --build backend frontend` → both up
|
||||
- API: `POST /api/meals/items/{id}/deny?scope=never_again` returns 200 + `promoted_to_permanent: true`
|
||||
- API: `GET /api/never-suggest?family_profile_id=...` shows the new row
|
||||
- Webui: 3 buttons on pending meal cards; "Deny this week" toast reflects `promoted_to_permanent`
|
||||
- Email: 3 direct-action links per recipe; each is a one-click vote
|
||||
- `Review/sprint8-verification.md` is the source of truth for the deploy + smoke flow.
|
||||
|
||||
## Sprint 8 — does NOT touch
|
||||
|
||||
- The `extractErrorMessage` / `showApiError` flow (Sprint 4 F7) — unchanged.
|
||||
- The keyboard shortcuts (Sprint 5 F2) — unchanged.
|
||||
- The bulk pantry add (Sprint 6 F3) — unchanged.
|
||||
- The plan-the-week (Sprint 6 F4) — unchanged.
|
||||
- The undo-toast (Sprint 3 B12) — unchanged.
|
||||
- The WeekRangeNav (Sprint 7) — unchanged.
|
||||
- The `extractErrorMessage` flow now sees the new `denial_expires_at` field if it propagates errors that include item data, but no new error messages.
|
||||
|
||||
## Key file:line references
|
||||
|
||||
- `backend/alembic/versions/0016_denial_decay_and_scope.py` (NEW)
|
||||
- `backend/app/models/__init__.py:221-242` (MealPlanItem) + `:250-269` (MealPlanVote)
|
||||
- `backend/app/schemas/__init__.py:204-219, 248-269`
|
||||
- `backend/app/api/meals.py:30-138` — helpers (`_apply_denial`, `_ensure_never_suggest_recipe`, `_has_prior_active_soft_denial`)
|
||||
- `backend/app/api/meals.py:240-330` — `get_vote_page` HTML (3 buttons + `?scope=...` one-click)
|
||||
- `backend/app/api/meals.py:380-455` — `submit_vote` (handles `never_again` + auto-escalation)
|
||||
- `backend/app/api/meals.py:486-552` — `deny_meal_item` (`?scope=`)
|
||||
- `backend/app/services/orchestrator/steps.py:283-300` — email template (3 direct-action links)
|
||||
- `backend/app/services/planner/generate.py:59-99, 150-194` — `_load_blocklists` returns 3 sets; soft set is hard-filtered
|
||||
- `frontend/src/api/index.ts:48-58` — `meals.denyItem(itemId, { scope })`
|
||||
- `frontend/src/pages/Dashboard.tsx:38-50, 385-410` — `MealCard` 3-button voting row
|
||||
- `Review/sprint8-verification.md` — new file (deploy + smoke)
|
||||
|
||||
---
|
||||
|
||||
# Context — Sprint 7 (webui empty-meal-plan fix)
|
||||
|
||||
## Why Sprint 7 exists
|
||||
|
||||
+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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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.1–T2.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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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")
|
||||
+294
-37
@@ -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} · {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,29 +410,40 @@ def get_vote_page(
|
||||
<div>{safe_day} · {safe_meal}</div>
|
||||
</div>
|
||||
<form id="voteForm" method="post" action="{action_url}">
|
||||
<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>
|
||||
<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 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>
|
||||
document.getElementById('voteForm').addEventListener('submit', async function(e) {{
|
||||
e.preventDefault();
|
||||
var btn = e.submitter || document.activeElement;
|
||||
var vote = btn && btn.value ? btn.value : 'approve';
|
||||
var resp = await fetch(this.action, {{
|
||||
method: 'POST',
|
||||
headers: {{ 'Content-Type': 'application/json' }},
|
||||
body: JSON.stringify({{ vote: vote }})
|
||||
}});
|
||||
var data = {{}};
|
||||
try {{ data = await resp.json(); }} catch (_) {{}}
|
||||
var s = document.getElementById('status');
|
||||
if (resp.ok) {{
|
||||
s.textContent = 'Recorded: ' + (data.item_status || vote);
|
||||
}} else {{
|
||||
s.textContent = 'Error: ' + (data.detail || resp.status);
|
||||
}}
|
||||
}});
|
||||
document.getElementById('voteForm').addEventListener('submit', async function(e) {{
|
||||
e.preventDefault();
|
||||
var btn = e.submitter || document.activeElement;
|
||||
var vote = btn && btn.value ? btn.value : 'approve';
|
||||
var resp = await fetch(this.action, {{
|
||||
method: 'POST',
|
||||
headers: {{ 'Content-Type': 'application/json' }},
|
||||
body: JSON.stringify({{ vote: vote }})
|
||||
}});
|
||||
var data = {{}};
|
||||
try {{ data = await resp.json(); }} catch (_) {{}}
|
||||
var s = document.getElementById('status');
|
||||
if (resp.ok) {{
|
||||
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);
|
||||
}}
|
||||
}});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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")
|
||||
item.approval_status = MealPlanItemStatus.denied
|
||||
db.commit()
|
||||
return {"message": "Meal denied", "item": item}
|
||||
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,
|
||||
"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}")
|
||||
|
||||
@@ -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__ = (
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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}&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}&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}&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>'
|
||||
)
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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 } }),
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user