feat(ui): Sprint 10 — Deny Forever on Recipes (card overlay + detail button + undo toast)

User-driven follow-up to Sprint 8: surface the Sprint 1-3 NeverSuggest
infrastructure on the Recipes surface so a family can pre-emptively
mark a recipe as never-suggest before it appears in a plan.

Backend (3 changes):
- POST /api/never-suggest (public, webui-facing). Idempotent on
  (family, recipe, reason). Returns the row joined with recipe_name.
- DELETE /api/never-suggest/{ns_id} (public, webui-facing). Row-level
  ownership check (403 if cross-family), 404 if absent.
- NeverSuggestRead.recipe_name + .ingredient_name server-side joins
  via _attach_names() helper (one LEFT OUTER JOIN per kind).
- Admin path (POST/DELETE /api/admin/never-suggest) unchanged.

Frontend (4 changes):
- New NeverSuggestButton component (~290 lines). Two variants: card
  (overlay on RecipeCard) and detail (text buttons in RecipeDetail
  top bar). Popover with Allergy (red, window.confirm) + Dislike
  (neutral, no confirm). Undo toast via showToast.undo() (Sprint 3
  B12 pattern, 6s window). Pre-existing block detection shows a
  Blocked state with an Unblock path.
- mealPlannerApi.neverSuggest.list/add/remove in api/index.ts.
- Recipes.tsx overlay: RecipeCard has position: relative; button is
  opacity-0 group-hover:opacity-100 focus:opacity-100. e.preventDefault
  + e.stopPropagation prevents accidental navigation.
- RecipeDetail.tsx top bar: new Deny forever button group to the left
  of Add to Plan.

Build: npm run build green (tsc 0 errors, vite 0 errors) on
docker-willester. Bundle 487 -> 495 kB. No new dependencies. No
migration (NeverSuggest table exists from prior sprints).

Tracking: Review/sprint10-verification.md (9-step browser smoke +
5 API curls + undo test + a11y check).
This commit is contained in:
2026-06-05 13:29:04 -07:00
parent 6e386baf6e
commit 0b6c5dcfb7
13 changed files with 867 additions and 15 deletions
+14
View File
@@ -73,6 +73,20 @@ export const mealPlannerApi = {
api.post('/pantry/bulk', { items }),
},
// Sprint 10: family-facing never-suggest endpoints. These are
// separate from the existing /api/admin/never-suggest admin path
// (which still requires the ADMIN_TOKEN). The new public path
// uses require_session (auto-resolves to the first family profile
// on the trusted network) and enforces row-level ownership on DELETE
// (403 if the row belongs to a different family).
neverSuggest: {
list: (familyProfileId: string) =>
api.get('/never-suggest', { params: { family_profile_id: familyProfileId } }),
add: (data: { family_profile_id: string; recipe_id: string; reason: 'allergy' | 'dislike'; notes?: string }) =>
api.post('/never-suggest', data),
remove: (nsId: string) => api.delete(`/never-suggest/${nsId}`),
},
shoppingList: {
get: (weekStart?: string) => api.get('/shopping-list', { params: { week_start: weekStart } }),
getPrint: () => api.get('/shopping-list/print'),
@@ -0,0 +1,304 @@
/**
* "Never suggest" button — used on Recipes cards + RecipeDetail page.
*
* Two reasons (matching the NeverSuggestReason enum on the server):
* - "allergy" → red, requires window.confirm (irreversible to the user)
* - "dislike" → neutral, no confirm (undo toast is the escape hatch)
*
* On success:
* - Optimistically removes the recipe from the list (via queryKey
* invalidation in the parent).
* - Pops a Sprint-3-style undo toast. Clicking Undo calls
* DELETE /api/never-suggest/{id} and re-invalidates the queries.
*
* The button is `position: absolute` on the card overlay. On the
* detail page, the parent passes `variant="detail"` to render it as
* a row of two text buttons in the top bar.
*/
import { useEffect, useRef, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Ban, X, AlertTriangle, ThumbsDown } from 'lucide-react'
import { mealPlannerApi } from '../api'
import { showToast, showApiError } from '../lib/toast'
interface NeverSuggestButtonProps {
recipeId: string
recipeName: string
/** "card" overlays the recipe image; "detail" renders inline as a button group. */
variant: 'card' | 'detail'
}
interface NeverSuggestRow {
id: string
recipe_id: string | null
reason: string | null
}
export function NeverSuggestButton({ recipeId, recipeName, variant }: NeverSuggestButtonProps) {
const qc = useQueryClient()
const [open, setOpen] = useState(false)
const [familyId, setFamilyId] = useState<string | null>(null)
const popoverRef = useRef<HTMLDivElement | null>(null)
const buttonRef = useRef<HTMLButtonElement | null>(null)
// Pull family id from the profile query (same pattern as Recommended.tsx).
// Doing it inline (rather than via a context) keeps the dependency
// surface flat and matches the rest of the codebase.
const { data: profile } = useQuery({
queryKey: ['profile'],
queryFn: () => mealPlannerApi.profile.get().then(r => r.data),
})
useEffect(() => {
if (profile?.id && familyId !== profile.id) setFamilyId(profile.id)
}, [profile, familyId])
// Find the existing NeverSuggest row for this recipe (if any) so the
// button can show a "Remove block" state. Without this, re-clicking
// the button on a recipe that's already blocked would 201 (idempotent
// on the server) but the toast would say "added" which is confusing.
const { data: existing } = useQuery<NeverSuggestRow[]>({
queryKey: ['neverSuggest', familyId],
queryFn: () =>
familyId
? mealPlannerApi.neverSuggest.list(familyId).then(r => r.data)
: Promise.resolve([]),
enabled: !!familyId,
})
const existingRow = existing?.find(r => r.recipe_id === recipeId)
const invalidateAll = () => {
qc.invalidateQueries({ queryKey: ['neverSuggest', familyId] })
qc.invalidateQueries({ queryKey: ['recipes'] })
qc.invalidateQueries({ queryKey: ['recommendedRecipes', familyId] })
qc.invalidateQueries({ queryKey: ['mealPlan'] })
}
const addMutation = useMutation({
mutationFn: (reason: 'allergy' | 'dislike') => {
if (!familyId) throw new Error('family not loaded')
return mealPlannerApi.neverSuggest
.add({ family_profile_id: familyId, recipe_id: recipeId, reason })
.then(r => r.data)
},
onSuccess: (data, reason) => {
invalidateAll()
setOpen(false)
const reasonLabel = reason === 'allergy' ? 'allergy' : "won't suggest"
showToast.undo(
`Marked ${recipeName} as ${reasonLabel} for your family`,
() => {
// Best-effort undo. The toast dismisses itself on success.
if (data?.id) {
mealPlannerApi.neverSuggest
.remove(data.id)
.then(() => invalidateAll())
.catch(() => {
// The toast is already gone; show a fresh error toast.
showToast.error('Could not undo — open NeverSuggest API to remove manually.')
})
}
},
6000,
)
},
onError: (err: unknown) => {
// Pull the FastAPI detail string for the toast (Sprint 4 F7 helper).
showApiError(err, 'Could not mark recipe')
setOpen(false)
},
})
const removeMutation = useMutation({
mutationFn: (nsId: string) => mealPlannerApi.neverSuggest.remove(nsId),
onSuccess: () => {
invalidateAll()
showToast.success(`Removed block on ${recipeName}`)
},
})
// Close popover on outside click + Escape.
useEffect(() => {
if (!open) return
const onDown = (e: MouseEvent) => {
const t = e.target as Node
if (popoverRef.current?.contains(t)) return
if (buttonRef.current?.contains(t)) return
setOpen(false)
}
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') setOpen(false)
}
document.addEventListener('mousedown', onDown)
document.addEventListener('keydown', onKey)
return () => {
document.removeEventListener('mousedown', onDown)
document.removeEventListener('keydown', onKey)
}
}, [open])
const onPickReason = (reason: 'allergy' | 'dislike') => {
if (reason === 'allergy') {
const ok = window.confirm(
`Mark "${recipeName}" as an allergy for your family?\n\n` +
`This permanently blocks the recipe. The planner will avoid it in all future plans. ` +
`You can undo this from the toast that appears, but it will not appear in any subsequent meal plan.`,
)
if (!ok) return
}
addMutation.mutate(reason)
}
const onRemoveExisting = () => {
if (!existingRow) return
if (!window.confirm(`Stop blocking "${recipeName}"? The planner may suggest it again.`)) return
removeMutation.mutate(existingRow.id)
}
// ----- Render -----
if (existingRow) {
// Recipe is already blocked — show an "unblock" affordance.
if (variant === 'card') {
return (
<button
ref={buttonRef}
type="button"
onClick={(e) => { e.preventDefault(); e.stopPropagation(); onRemoveExisting() }}
aria-label={`${recipeName} is blocked (${existingRow.reason || 'no reason'}). Click to unblock.`}
className="absolute top-2 right-2 z-10 inline-flex items-center justify-center w-8 h-8 rounded-full bg-danger-100 text-danger-700 hover:bg-danger-200 focus:outline-none focus:ring-2 focus:ring-danger-400 shadow-sm"
title="Recipe is blocked — click to unblock"
>
<Ban className="w-4 h-4" aria-hidden="true" />
</button>
)
}
return (
<button
ref={buttonRef}
type="button"
onClick={onRemoveExisting}
className="text-xs font-medium px-2.5 py-1.5 rounded-lg border border-danger-300 text-danger-700 hover:bg-danger-50 focus:outline-none focus:ring-2 focus:ring-danger-400 inline-flex items-center gap-1.5"
aria-label={`${recipeName} is blocked (${existingRow.reason || 'no reason'}). Click to unblock.`}
>
<Ban className="w-3.5 h-3.5" aria-hidden="true" />
Unblock
</button>
)
}
if (variant === 'card') {
return (
<div className="absolute top-2 right-2 z-10">
<button
ref={buttonRef}
type="button"
onClick={(e) => { e.preventDefault(); e.stopPropagation(); setOpen(o => !o) }}
aria-label={`Never suggest ${recipeName} again`}
aria-expanded={open}
aria-haspopup="menu"
className="inline-flex items-center justify-center w-8 h-8 rounded-full bg-white/90 backdrop-blur-sm text-surface-700 hover:bg-white hover:text-danger-600 focus:outline-none focus:ring-2 focus:ring-danger-400 shadow-sm opacity-0 group-hover:opacity-100 focus:opacity-100 transition-opacity"
title="Never suggest this recipe"
>
<Ban className="w-4 h-4" aria-hidden="true" />
</button>
{open && (
<div
ref={popoverRef}
role="menu"
aria-label="Choose a reason"
className="absolute top-10 right-0 bg-white rounded-xl shadow-lg border border-surface-200 p-2 w-52 animate-fade-in"
>
<div className="px-2 py-1.5 text-xs text-surface-500 font-medium">Never suggest this recipe</div>
<button
type="button"
role="menuitem"
onClick={() => onPickReason('allergy')}
disabled={addMutation.isPending}
className="w-full text-left flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-danger-50 text-danger-700 focus:outline-none focus:bg-danger-50 disabled:opacity-50"
>
<AlertTriangle className="w-4 h-4 flex-shrink-0" aria-hidden="true" />
<span>Allergy</span>
</button>
<button
type="button"
role="menuitem"
onClick={() => onPickReason('dislike')}
disabled={addMutation.isPending}
className="w-full text-left flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-surface-100 text-surface-700 focus:outline-none focus:bg-surface-100 disabled:opacity-50"
>
<ThumbsDown className="w-4 h-4 flex-shrink-0" aria-hidden="true" />
<span>Dislike</span>
</button>
<div className="border-t border-surface-100 mt-1 pt-1 px-2">
<button
type="button"
onClick={() => setOpen(false)}
className="text-[11px] text-surface-500 hover:text-surface-700 focus:outline-none focus:underline"
>
Cancel
</button>
</div>
</div>
)}
</div>
)
}
// detail variant — text buttons in a row
return (
<div className="relative inline-flex items-center gap-1.5">
<button
ref={buttonRef}
type="button"
onClick={() => setOpen(o => !o)}
aria-expanded={open}
aria-haspopup="menu"
aria-label={`Never suggest ${recipeName} again`}
className="inline-flex items-center gap-1.5 px-2.5 py-1.5 text-xs font-medium rounded-lg border border-surface-300 text-surface-700 hover:bg-surface-50 focus:outline-none focus:ring-2 focus:ring-primary-400"
>
<Ban className="w-3.5 h-3.5" aria-hidden="true" />
Deny forever
</button>
{open && (
<div
ref={popoverRef}
role="menu"
aria-label="Choose a reason"
className="absolute top-10 right-0 bg-white rounded-xl shadow-lg border border-surface-200 p-2 w-52 z-20 animate-fade-in"
>
<div className="px-2 py-1.5 text-xs text-surface-500 font-medium">Mark as</div>
<button
type="button"
role="menuitem"
onClick={() => onPickReason('allergy')}
disabled={addMutation.isPending}
className="w-full text-left flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-danger-50 text-danger-700 focus:outline-none focus:bg-danger-50 disabled:opacity-50"
>
<AlertTriangle className="w-4 h-4 flex-shrink-0" aria-hidden="true" />
<span>Allergy</span>
</button>
<button
type="button"
role="menuitem"
onClick={() => onPickReason('dislike')}
disabled={addMutation.isPending}
className="w-full text-left flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-surface-100 text-surface-700 focus:outline-none focus:bg-surface-100 disabled:opacity-50"
>
<ThumbsDown className="w-4 h-4 flex-shrink-0" aria-hidden="true" />
<span>Dislike</span>
</button>
<div className="border-t border-surface-100 mt-1 pt-1 px-2 flex justify-end">
<button
type="button"
onClick={() => setOpen(false)}
className="text-[11px] text-surface-500 hover:text-surface-700 focus:outline-none focus:underline inline-flex items-center gap-1"
>
<X className="w-3 h-3" />
Close
</button>
</div>
</div>
)}
</div>
)
}
+11 -3
View File
@@ -11,6 +11,7 @@ import { Badge } from '../components/ui/Badge'
import { Card, CardBody, CardHeader } from '../components/ui/Card'
import { Skeleton, SkeletonText } from '../components/ui/Skeleton'
import { showToast } from '../lib/toast'
import { NeverSuggestButton } from '../components/NeverSuggestButton'
function RecipeDetailSkeleton() {
return (
@@ -69,9 +70,16 @@ export default function RecipeDetail() {
<h1 className="text-2xl font-bold text-surface-900 truncate">{recipe.name}</h1>
<p className="text-sm text-surface-500 truncate">{recipe.description}</p>
</div>
<Button variant="primary" icon={<ShoppingBasket className="w-4 h-4" />} onClick={handleAddToPlan}>
Add to Plan
</Button>
<div className="flex items-center gap-2 flex-shrink-0">
<NeverSuggestButton
recipeId={recipe.id}
recipeName={recipe.name}
variant="detail"
/>
<Button variant="primary" icon={<ShoppingBasket className="w-4 h-4" />} onClick={handleAddToPlan}>
Add to Plan
</Button>
</div>
</div>
{/* Meta row */}
+13 -2
View File
@@ -13,6 +13,7 @@ import { Card, CardBody } from '../components/ui/Card'
import { Skeleton, SkeletonText } from '../components/ui/Skeleton'
import { EmptyState } from '../components/ui/EmptyState'
import { Select } from '../components/ui/Select'
import { NeverSuggestButton } from '../components/NeverSuggestButton'
import { useFocusSearchOnShortcut } from '../hooks/useFocusSearch'
const CUISINE_OPTIONS = [
@@ -242,7 +243,7 @@ function RecipeCard({ recipe }: { recipe: Recipe }) {
(recipe.prep_time_minutes || 0) + (recipe.cook_time_minutes || 0)
return (
<Link to={`/recipes/${recipe.id}`} className="group block">
<Card className="h-full overflow-hidden hover:shadow-md transition-shadow border-surface-200 hover:border-primary-200">
<Card className="relative h-full overflow-hidden hover:shadow-md transition-shadow border-surface-200 hover:border-primary-200">
{recipe.image_url ? (
<div className="aspect-[4/3] overflow-hidden bg-surface-100">
<img
@@ -250,10 +251,20 @@ function RecipeCard({ recipe }: { recipe: Recipe }) {
alt={recipe.name}
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
/>
<NeverSuggestButton
recipeId={recipe.id}
recipeName={recipe.name}
variant="card"
/>
</div>
) : (
<div className="aspect-[4/3] bg-surface-100 flex items-center justify-center">
<div className="aspect-[4/3] bg-surface-100 flex items-center justify-center relative">
<CookingPot className="w-12 h-12 text-surface-300" />
<NeverSuggestButton
recipeId={recipe.id}
recipeName={recipe.name}
variant="card"
/>
</div>
)}
<CardBody className="space-y-2">