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:
@@ -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