diff --git a/CHANGELOG.md b/CHANGELOG.md index ce68413..2e8d0d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ All notable changes to Epicure are documented here. This file is mirrored in-app at `/changelog` (and in the admin dashboard) via `apps/web/lib/changelog.ts` — update both together. +## 0.87.0 — 2026-07-24 21:50 + +### Added +- Admin → Ingredients: search box (name + alias), category filter with live counts, aliases entered as removable tag chips, and category picked from a dropdown instead of free text — needed once the list grew past a handful of entries. + ## 0.86.0 — 2026-07-24 21:30 ### Added diff --git a/FEATURE_AUDIT.md b/FEATURE_AUDIT.md index 5a075f4..6150738 100644 --- a/FEATURE_AUDIT.md +++ b/FEATURE_AUDIT.md @@ -72,7 +72,7 @@ Status legend: **Exists** (fully working) · **Partial** (works but with a real | Other delivery/price integrations (DoorDash, Kroger, Walmart, live pricing) | **Missing** | Confirmed absent by repo-wide search | — | | Pantry manual CRUD | Exists (extended 2026-07-24, twice) | Full edit dialog added (previously add+delete only, despite the API already supporting `PUT`) — name/quantity/unit/expiry plus **notes** (free text) and **category** (same `GROCERY_CATEGORIES` slugs shopping lists use; edit dialog's category dropdown initially displayed the raw slug/`__other__` instead of the translated label — fixed by passing a value→label render function to `SelectValue`, same pattern already used in `shopping-list-view.tsx`'s sort dropdown). List always renders all 9 category sections (8 `GROCERY_CATEGORIES` + Other) as collapsible groups, even empty ones — not just categories that currently have items — and items can be dragged between sections (dnd-kit `useDraggable`/`useDroppable`, cross-category drop only; no within-category manual reorder since pantry items have no `sortOrder` column to persist one). An "Auto-categorize" action (same `guessAisle` heuristic as the shopping list) fills in categories for uncategorized items in one click. Includes a bulk case-insensitive name+unit merge endpoint (used by the scan-confirm flow). | `apps/web/app/api/v1/pantry/**`, `apps/web/components/meal-plan/pantry-manager.tsx`, `apps/web/components/pantry/pantry-item-dialog.tsx` | | Ingredient alias matching (new 2026-07-24, expanded same day) | Exists | The `ingredients` table (canonical name + `aliases[]`) existed but was never populated or queried anywhere. Seeded with ~137 bilingual EN/FR entries across all 8 grocery categories (produce, dairy/eggs, meat/seafood, bakery, frozen, pantry, spices/condiments, beverages — `packages/db/src/seed.ts`) and wired into every place that compares ingredient names by raw text: pantry add/edit (sets `ingredientId` when a name/alias matches), the can-cook / "use it up soon" scorer, auto-deduct-on-cook, and shopping-list pantry-awareness on generation. Comparison is case- **and accent-insensitive** (`café`/`Café`/`cafe` all equal — NFD-normalize + strip combining marks) in both the alias resolver and the older plain-name fallback matcher (`pantry-shopping-match.ts`'s `normalizeName`, used for the reliable-ingredientId-or-name-fallback path). Resolution happens at compare-time from free text (`resolveIngredientKey`), not from a stored FK on both sides — recipe ingredients still don't carry `ingredientId`. Growing the list: Admin → Ingredients (UI) or editing `packages/db/src/seed.ts` + `pnpm db:seed` (idempotent). | `apps/web/lib/ingredient-match.ts`, `packages/db/src/seed.ts` | -| Admin: Ingredients CRUD (new 2026-07-24) | Exists | Admin-only page to manage canonical ingredients + aliases without a code change/redeploy — previously the seed file was the only place to add them. Create/edit/delete; name is unique (409 on collision); deleting an ingredient just unlinks any pantry item/recipe ingredient that pointed at it (`ingredientId` is `onDelete: "set null"` on both), nothing else is deleted. | `apps/web/app/admin/ingredients/page.tsx`, `apps/web/app/api/v1/admin/ingredients/**`, `apps/web/components/admin/ingredients-manager.tsx` | +| Admin: Ingredients CRUD (new 2026-07-24, UI polished same day) | Exists | Admin-only page to manage canonical ingredients + aliases without a code change/redeploy — previously the seed file was the only place to add them. With ~137 seeded entries a flat unfiltered list stopped being usable, so it also got: a search box (name + alias, case/accent-insensitive), a category filter dropdown with live per-category counts, aliases shown as chips instead of a truncated comma string, and the category field is a `GROCERY_CATEGORIES` `Select` (was free-text, so a typo'd category silently created an unmatched-anywhere bucket) with aliases entered via a tag input (type + Enter, mirrors `edit-collection-dialog.tsx`'s tag UX) instead of one error-prone comma-separated field. Create/edit/delete; name is unique (409 on collision); deleting an ingredient just unlinks any pantry item/recipe ingredient that pointed at it (`ingredientId` is `onDelete: "set null"` on both), nothing else is deleted. | `apps/web/app/admin/ingredients/page.tsx`, `apps/web/app/api/v1/admin/ingredients/**`, `apps/web/components/admin/ingredients-manager.tsx` | | Merge duplicate pantry items (new 2026-07-24, relaxed same day) | Exists | One-shot cleanup for pre-existing pantry rows that are the same ingredient under different names (added before alias matching existed) — groups by resolved ingredient key **alone** (unit is deliberately not part of the grouping key, so two rows merge even with mismatched, missing, or differently-unit'd quantities); sums quantities only when every row in a group has a parseable quantity **and** the same unit, otherwise keeps the first known (quantity, unit) pair rather than guessing. Concatenates notes, keeps the soonest expiry. Manual trigger (a button in the pantry toolbar), not automatic — manual single-item add still always inserts a new row rather than silently merging, since two batches of the same ingredient can have different expiry dates worth tracking separately. | `apps/web/app/api/v1/pantry/merge-duplicates/route.ts` | | Auto-deduct pantry on cook | Exists (closed 2026-07-24) | Both UI callers that previously hardcoded `deductFromPantry: false` (`batch-cook-dishes.tsx`, `meal-planner.tsx`) now pass `true`. The new general "Mark cooked" feature (see below) additionally exposes it as a per-cook checkbox, default checked, rather than a silent always-on. Matching now goes through the ingredient-alias resolver, not a raw name string compare. | `apps/web/app/api/v1/recipes/[id]/cooked/route.ts`, `apps/web/components/recipe/batch-cook-dishes.tsx`, `apps/web/components/meal-plan/meal-planner.tsx` | | Cook log edit/delete (new 2026-07-24) | Exists | Plain (non-batch) cook-log entries can now be listed, edited (date/servings/notes), and removed — previously log-once, no way to fix a mistake or remove a duplicate entry. The recipe page's "Cooked N times" text is a hover tooltip (up to 8 dates, "+N more" beyond) that also opens a full manage sheet on click. Editing/deleting never touches pantry quantities — a deduction from when the entry was created isn't reversed or reapplied. | `apps/web/app/api/v1/recipes/[id]/cooked/[logId]/route.ts`, `apps/web/components/recipe/{mark-cooked-section,edit-cook-log-dialog}.tsx` | diff --git a/apps/web/components/admin/ingredients-manager.tsx b/apps/web/components/admin/ingredients-manager.tsx index 181cd7e..d105829 100644 --- a/apps/web/components/admin/ingredients-manager.tsx +++ b/apps/web/components/admin/ingredients-manager.tsx @@ -1,11 +1,19 @@ "use client"; -import { useState } from "react"; +import { useMemo, useRef, useState, type KeyboardEvent } from "react"; import { toast } from "sonner"; -import { Plus, Pencil, Trash2, Tag } from "lucide-react"; +import { Plus, Pencil, Trash2, Tag, X, Search } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; +import { Badge } from "@/components/ui/badge"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; import { Dialog, DialogContent, @@ -24,6 +32,20 @@ import { AlertDialogTitle, } from "@/components/ui/alert-dialog"; import { EmptyState } from "@/components/shared/empty-state"; +import { GROCERY_CATEGORIES, type GroceryCategory } from "@/lib/grocery-categories"; + +const CATEGORY_LABELS: Record = { + produce: "Produce", + dairyEggs: "Dairy & Eggs", + meatSeafood: "Meat & Seafood", + bakery: "Bakery", + frozen: "Frozen", + pantry: "Pantry", + spicesCondiments: "Spices & Condiments", + beverages: "Beverages", +}; +const UNCATEGORIZED = "__uncategorized__"; +const ALL_CATEGORIES = "__all__"; type Ingredient = { id: string; @@ -32,8 +54,66 @@ type Ingredient = { category: string | null; }; -function parseAliases(input: string): string[] { - return [...new Set(input.split(",").map((a) => a.trim()).filter(Boolean))]; +// Same case/accent-insensitive intent as lib/ingredient-match.ts's +// normalize() — duplicated rather than imported, since that module pulls +// in the DB client (server-only) and can't be bundled for the browser. +function normalize(s: string): string { + return s.trim().toLowerCase().normalize("NFD").replace(/[̀-ͯ]/g, ""); +} + +function categoryLabel(category: string | null): string { + if (!category) return "Uncategorized"; + return CATEGORY_LABELS[category as GroceryCategory] ?? category; +} + +function AliasTagInput({ aliases, onChange }: { aliases: string[]; onChange: (aliases: string[]) => void }) { + const [input, setInput] = useState(""); + const inputRef = useRef(null); + + function addAlias(raw: string) { + const alias = raw.trim(); + if (!alias || aliases.some((a) => normalize(a) === normalize(alias)) || aliases.length >= 50) return; + onChange([...aliases, alias]); + setInput(""); + } + + function removeAlias(alias: string) { + onChange(aliases.filter((a) => a !== alias)); + } + + function handleKeyDown(e: KeyboardEvent) { + if (e.key === "Enter" || e.key === ",") { + e.preventDefault(); + addAlias(input); + } else if (e.key === "Backspace" && !input && aliases.length > 0) { + onChange(aliases.slice(0, -1)); + } + } + + return ( +
inputRef.current?.focus()} + > + {aliases.map((alias) => ( + + {alias} + + + ))} + setInput(e.target.value)} + onKeyDown={handleKeyDown} + onBlur={() => { if (input.trim()) addAlias(input); }} + placeholder={aliases.length === 0 ? "Type an alias, press Enter…" : ""} + className="flex-1 min-w-[120px] bg-transparent text-sm outline-none placeholder:text-muted-foreground" + /> +
+ ); } function IngredientDialog({ @@ -48,15 +128,14 @@ function IngredientDialog({ onSaved: (ingredient: Ingredient) => void; }) { const [name, setName] = useState(ingredient?.name ?? ""); - const [aliasesText, setAliasesText] = useState(ingredient?.aliases.join(", ") ?? ""); - const [category, setCategory] = useState(ingredient?.category ?? ""); + const [aliases, setAliases] = useState(ingredient?.aliases ?? []); + const [category, setCategory] = useState(ingredient?.category ?? UNCATEGORIZED); const [saving, setSaving] = useState(false); const isEdit = !!ingredient; async function handleSave() { const trimmedName = name.trim(); if (!trimmedName) return; - const aliases = parseAliases(aliasesText); setSaving(true); try { const res = await fetch( @@ -64,7 +143,7 @@ function IngredientDialog({ { method: isEdit ? "PUT" : "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ name: trimmedName, aliases, category: category.trim() || null }), + body: JSON.stringify({ name: trimmedName, aliases, category: category === UNCATEGORIZED ? null : category }), } ); if (!res.ok) { @@ -74,7 +153,7 @@ function IngredientDialog({ } toast.success(isEdit ? "Ingredient updated" : "Ingredient created"); const id = isEdit ? ingredient.id : ((await res.json()) as { id: string }).id; - onSaved({ id, name: trimmedName, aliases, category: category.trim() || null }); + onSaved({ id, name: trimmedName, aliases, category: category === UNCATEGORIZED ? null : category }); onOpenChange(false); } finally { setSaving(false); @@ -93,20 +172,25 @@ function IngredientDialog({ setName(e.target.value)} placeholder="e.g. salt" />
- - setAliasesText(e.target.value)} - placeholder="e.g. sel, sel fin, sel de table, table salt" - /> + +

- Matched case-insensitively against pantry item and recipe ingredient names. + Matched case- and accent-insensitively against pantry item and recipe ingredient names.

- - setCategory(e.target.value)} placeholder="e.g. spicesCondiments" /> + +
@@ -124,6 +208,8 @@ function IngredientDialog({ export function IngredientsManager({ initialIngredients }: { initialIngredients: Ingredient[] }) { const [ingredients, setIngredients] = useState(initialIngredients); + const [query, setQuery] = useState(""); + const [categoryFilter, setCategoryFilter] = useState(ALL_CATEGORIES); const [dialogOpen, setDialogOpen] = useState(false); const [editingIngredient, setEditingIngredient] = useState(null); const [confirmId, setConfirmId] = useState(null); @@ -148,33 +234,85 @@ export function IngredientsManager({ initialIngredients }: { initialIngredients: } } + const categoryCounts = useMemo(() => { + const counts = new Map(); + for (const i of ingredients) { + const key = i.category ?? UNCATEGORIZED; + counts.set(key, (counts.get(key) ?? 0) + 1); + } + return counts; + }, [ingredients]); + + const filtered = useMemo(() => { + const q = normalize(query); + return ingredients + .filter((i) => categoryFilter === ALL_CATEGORIES || (i.category ?? UNCATEGORIZED) === categoryFilter) + .filter((i) => { + if (!q) return true; + if (normalize(i.name).includes(q)) return true; + return i.aliases.some((a) => normalize(a).includes(q)); + }) + .sort((a, b) => a.name.localeCompare(b.name)); + }, [ingredients, query, categoryFilter]); + const pendingDelete = ingredients.find((i) => i.id === confirmId) ?? null; return (
-
-
+

+ {filtered.length === ingredients.length + ? `${ingredients.length} ingredient${ingredients.length === 1 ? "" : "s"}` + : `${filtered.length} of ${ingredients.length} ingredients`} +

+ {ingredients.length === 0 ? ( + ) : filtered.length === 0 ? ( +

No ingredients match your search.

) : (
- {ingredients.map((ingredient) => ( + {filtered.map((ingredient) => (
-
+
{ingredient.name} - {ingredient.category && ( - - {ingredient.category} - - )} + {categoryLabel(ingredient.category)}
{ingredient.aliases.length > 0 && ( -

{ingredient.aliases.join(", ")}

+
+ {ingredient.aliases.map((alias) => ( + {alias} + ))} +
)}