diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d71722..8b6e5ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,16 @@ 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.84.0 — 2026-07-24 20:00 + +### Added +- Pantry items can be dragged between categories, like the shopping list. All 9 categories now always show (even empty ones) as drop targets, instead of only categories that already have items. +- Pantry has an "Auto-categorize" action, same heuristic as the shopping list's. + +### Fixed +- Pantry item edit dialog's category dropdown showed the raw stored value (e.g. "__other__", "produce") instead of its translated label. +- Merging duplicate pantry items now works even when quantities differ, are missing, or use different units — previously two rows only merged if their units matched exactly, so "garlic" (no quantity) and "ail" (3, no unit) wouldn't merge at all. + ## 0.83.0 — 2026-07-24 19:00 ### Added diff --git a/FEATURE_AUDIT.md b/FEATURE_AUDIT.md index da1bf1e..e1bb53b 100644 --- a/FEATURE_AUDIT.md +++ b/FEATURE_AUDIT.md @@ -69,9 +69,9 @@ Status legend: **Exists** (fully working) · **Partial** (works but with a real | Aisle categorization | Exists | Heuristic auto-assign + bulk re-categorize, plus inline custom-category creation and full drag-and-drop reordering (richer UI than previously documented) | `apps/web/lib/grocery-categories.ts` | | Grocery delivery integration | **Partial / stub** | Generic export payload works (integrator-shaped — no visible in-app UI consumer besides the Instacart adapter); Instacart adapter is an explicit stub (requires a partnership agreement Epicure doesn't have — returns 501 if unconfigured, throws if "configured") | `apps/web/lib/grocery-providers/instacart.ts` | | Other delivery/price integrations (DoorDash, Kroger, Walmart, live pricing) | **Missing** | Confirmed absent by repo-wide search | — | -| Pantry manual CRUD | Exists (extended 2026-07-24) | Full edit dialog added (previously add+delete only, despite the API already supporting `PUT`) — name/quantity/unit/expiry plus two new fields, **notes** (free text) and **category** (same `GROCERY_CATEGORIES` slugs shopping lists use). List now groups into collapsible-by-category sections when more than one category is present, mirroring the shopping list's grouping UX (without the drag-reorder — pantry has no manual ordering need). 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) | Exists | The `ingredients` table (canonical name + `aliases[]`) existed but was never populated or queried anywhere. Seeded with ~10 bilingual EN/FR staples (salt, sugar, pepper, flour, butter, milk, egg, onion, garlic, olive oil — `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. Resolution happens at compare-time from free text (`resolveIngredientKey`), not from a stored FK on both sides — recipe ingredients still don't carry `ingredientId`. | `apps/web/lib/ingredient-match.ts`, `packages/db/src/seed.ts` | -| Merge duplicate pantry items (new 2026-07-24) | 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 + normalized unit, sums quantities only when every row in a group has a parseable one (otherwise keeps the first known amount 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` | +| 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) | Exists | The `ingredients` table (canonical name + `aliases[]`) existed but was never populated or queried anywhere. Seeded with ~10 bilingual EN/FR staples (salt, sugar, pepper, flour, butter, milk, egg, onion, garlic, olive oil — `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. Resolution happens at compare-time from free text (`resolveIngredientKey`), not from a stored FK on both sides — recipe ingredients still don't carry `ingredientId`. **No UI to add/edit aliases** — the seed list in `packages/db/src/seed.ts` is the only place today; growing it means editing that file and re-running `pnpm db:seed` (idempotent, `onConflictDoNothing` on name). | `apps/web/lib/ingredient-match.ts`, `packages/db/src/seed.ts` | +| 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` | | Quantity display precision (closed 2026-07-24) | Exists | Pantry and shopping-list quantities are stored as `decimal(10,4)` and were displayed as the raw string (e.g. "0.3333 kg", "2.0000 kg") everywhere: in-app lists, print views, and Markdown exports. All 6 spots now go through `formatQuantity` (fraction-aware rounding, already used for recipe ingredient scaling) instead of the raw column value. | `apps/web/lib/fractions.ts`, `apps/web/components/meal-plan/{pantry-manager,shopping-list-view}.tsx`, `apps/web/app/print/{pantry,shopping-list/[id]}/page.tsx`, `apps/web/lib/markdown/{pantry,shopping-list}.ts` | diff --git a/apps/web/app/api/v1/pantry/merge-duplicates/route.ts b/apps/web/app/api/v1/pantry/merge-duplicates/route.ts index 490585c..3eb9fc4 100644 --- a/apps/web/app/api/v1/pantry/merge-duplicates/route.ts +++ b/apps/web/app/api/v1/pantry/merge-duplicates/route.ts @@ -11,15 +11,19 @@ function normalizeUnit(unit: string | null): string { * One-shot cleanup for pantry items that turn out to be the same ingredient * under different names ("sel", "sel fin", "sel de table") — a case the * alias index (lib/ingredient-match.ts) only prevents going forward, not - * for rows added before it existed. Groups by resolved ingredient key + - * normalized unit; for any group with more than one row, merges into the + * for rows added before it existed. Groups by resolved ingredient key alone + * (unit is NOT part of the grouping key — two rows of the same ingredient + * still merge even if one has no quantity/unit at all, or the two use + * different units); for any group with more than one row, merges into the * oldest row and deletes the rest. * * Quantities are only summed when every row in the group has a parseable - * quantity — mixing a known and an unknown amount would silently invent a - * number, so the first known quantity is kept instead. Notes are - * concatenated (nothing is dropped); expiresAt keeps the soonest date - * (the conservative choice — better to under-promise freshness than over). + * quantity AND they all share the same unit — mixing units without + * conversion, or mixing a known and an unknown amount, would silently + * invent a number, so the first known (quantity, unit) pair is kept + * instead. Notes are concatenated (nothing is dropped); expiresAt keeps the + * soonest date (the conservative choice — better to under-promise + * freshness than over). */ export async function POST(req: NextRequest) { const { session, response } = await requireSessionOrApiKey(req); @@ -33,7 +37,7 @@ export async function POST(req: NextRequest) { const groups = new Map(); for (const item of items) { - const key = `${resolveIngredientKey(item.rawName, aliasIndex)}::${normalizeUnit(item.unit)}`; + const key = resolveIngredientKey(item.rawName, aliasIndex); const group = groups.get(key) ?? []; group.push(item); groups.set(key, group); @@ -49,10 +53,16 @@ export async function POST(req: NextRequest) { const [survivor, ...rest] = group; const quantities = group.map((i) => (i.quantity ? parseFloat(i.quantity) : null)); - const allParseable = quantities.every((q) => q !== null && !isNaN(q)); + const units = group.map((i) => normalizeUnit(i.unit)); + const sameUnit = units.every((u) => u === units[0]); + const allParseable = sameUnit && quantities.every((q) => q !== null && !isNaN(q)); + const firstKnownIndex = quantities.findIndex((q) => q !== null && !isNaN(q)); const mergedQuantity = allParseable ? String(quantities.reduce((sum, q) => sum! + q!, 0)) - : quantities.find((q) => q !== null && !isNaN(q))?.toString() ?? survivor!.quantity; + : firstKnownIndex !== -1 ? quantities[firstKnownIndex]!.toString() : survivor!.quantity; + const mergedUnit = allParseable + ? survivor!.unit + : firstKnownIndex !== -1 ? group[firstKnownIndex]!.unit : survivor!.unit; const mergedNotes = [...new Set(group.map((i) => i.notes?.trim()).filter((n): n is string => !!n))].join("; ") || null; const mergedAisle = group.find((i) => i.aisle)?.aisle ?? null; @@ -62,6 +72,7 @@ export async function POST(req: NextRequest) { await db.update(pantryItems).set({ quantity: mergedQuantity, + unit: mergedUnit, notes: mergedNotes, aisle: mergedAisle, ingredientId: mergedIngredientId, diff --git a/apps/web/components/meal-plan/pantry-manager.tsx b/apps/web/components/meal-plan/pantry-manager.tsx index d8a585a..aac0360 100644 --- a/apps/web/components/meal-plan/pantry-manager.tsx +++ b/apps/web/components/meal-plan/pantry-manager.tsx @@ -3,12 +3,13 @@ import { useState } from "react"; import { useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; -import { Plus, Trash2, AlertTriangle, Package, Pencil, ChevronDown, Merge } from "lucide-react"; +import { Plus, Trash2, AlertTriangle, Package, Pencil, ChevronDown, Merge, Sparkles, Loader2, GripVertical } from "lucide-react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { cn } from "@/lib/utils"; import { formatQuantity, hasQuantity } from "@/lib/fractions"; +import { guessAisle, GROCERY_CATEGORIES, type GroceryCategory } from "@/lib/grocery-categories"; import { PantryScanDialog } from "@/components/pantry/pantry-scan-dialog"; import { PantryItemDialog, type PantryItem } from "@/components/pantry/pantry-item-dialog"; import { EmptyState } from "@/components/shared/empty-state"; @@ -22,14 +23,46 @@ import { AlertDialogHeader, AlertDialogTitle, } from "@/components/ui/alert-dialog"; +import { + DndContext, + type DragEndEvent, + PointerSensor, + useSensor, + useSensors, + useDraggable, + useDroppable, +} from "@dnd-kit/core"; const OTHER_KEY = "__other__"; +const ALL_CATEGORY_KEYS: string[] = [...GROCERY_CATEGORIES, OTHER_KEY]; function daysUntilExpiry(dateStr: string): number { const diff = new Date(dateStr).getTime() - Date.now(); return Math.ceil(diff / (1000 * 60 * 60 * 24)); } +function DroppableCategory({ groupKey, children }: { groupKey: string; children: React.ReactNode }) { + const { setNodeRef, isOver } = useDroppable({ id: groupKey }); + return ( +
+ {children} +
+ ); +} + +function DraggableItemRow({ item, children }: { item: PantryItem; children: React.ReactNode }) { + const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({ id: item.id }); + const style = transform ? { transform: `translate3d(${transform.x}px, ${transform.y}px, 0)`, zIndex: 10 } : undefined; + return ( +
+ + {children} +
+ ); +} + export function PantryManager({ initialItems }: { initialItems: PantryItem[] }) { const t = useTranslations("pantry"); const tShopping = useTranslations("shoppingLists"); @@ -43,8 +76,17 @@ export function PantryManager({ initialItems }: { initialItems: PantryItem[] }) const [adding, setAdding] = useState(false); const [confirmId, setConfirmId] = useState(null); const [editingItem, setEditingItem] = useState(null); - const [collapsed, setCollapsed] = useState>(new Set()); const [merging, setMerging] = useState(false); + const [autoCategorizing, setAutoCategorizing] = useState(false); + const [collapsed, setCollapsed] = useState>(() => { + const initialByKey = new Map(); + for (const item of initialItems) { + const key = item.aisle ?? OTHER_KEY; + initialByKey.set(key, (initialByKey.get(key) ?? 0) + 1); + } + return new Set(ALL_CATEGORY_KEYS.filter((key) => !initialByKey.get(key))); + }); + const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 4 } })); async function mergeDuplicates() { setMerging(true); @@ -60,6 +102,39 @@ export function PantryManager({ initialItems }: { initialItems: PantryItem[] }) } } + async function autoCategorize() { + const targets = items.filter((i) => !i.aisle); + const updates = targets + .map((i) => ({ id: i.id, aisle: guessAisle(i.rawName) })) + .filter((u): u is { id: string; aisle: GroceryCategory } => u.aisle !== null); + + if (updates.length === 0) { + toast.error(tShopping("autoCategorizeNoneFound")); + return; + } + + setAutoCategorizing(true); + try { + await Promise.all(updates.map((u) => + fetch(`/api/v1/pantry/${u.id}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ aisle: u.aisle }), + }) + )); + const byId = new Map(updates.map((u) => [u.id, u.aisle])); + setItems((prev) => prev.map((i) => (byId.has(i.id) ? { ...i, aisle: byId.get(i.id)! } : i))); + setCollapsed((prev) => { + const next = new Set(prev); + for (const u of updates) next.delete(u.aisle); + return next; + }); + toast.success(tShopping("autoCategorizeSuccess", { count: updates.length })); + } finally { + setAutoCategorizing(false); + } + } + async function add() { if (!name.trim()) return; setAdding(true); @@ -80,6 +155,7 @@ export function PantryManager({ initialItems }: { initialItems: PantryItem[] }) ...prev, { id, rawName: name.trim(), quantity: quantity || null, unit: unit || null, notes: null, aisle: null, expiresAt: expiresAt ? new Date(expiresAt).toISOString() : null }, ]); + setCollapsed((prev) => { const next = new Set(prev); next.delete(OTHER_KEY); return next; }); setName(""); setQuantity(""); setUnit(""); setExpiresAt(""); } finally { setAdding(false); @@ -92,6 +168,29 @@ export function PantryManager({ initialItems }: { initialItems: PantryItem[] }) else toast.error(t("removeFailed")); } + async function moveToCategory(id: string, newKey: string) { + const newAisle = newKey === OTHER_KEY ? null : newKey; + setItems((prev) => prev.map((i) => (i.id === id ? { ...i, aisle: newAisle } : i))); + const res = await fetch(`/api/v1/pantry/${id}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ aisle: newAisle }), + }); + if (!res.ok) toast.error(t("editFailed")); + } + + function handleDragEnd(event: DragEndEvent) { + const { active, over } = event; + if (!over) return; + const item = items.find((i) => i.id === active.id); + if (!item) return; + const currentKey = item.aisle ?? OTHER_KEY; + const destKey = String(over.id); + if (currentKey === destKey) return; + setCollapsed((prev) => { const next = new Set(prev); next.delete(destKey); return next; }); + void moveToCategory(item.id, destKey); + } + function toggleCollapsed(key: string) { setCollapsed((prev) => { const next = new Set(prev); @@ -110,11 +209,11 @@ export function PantryManager({ initialItems }: { initialItems: PantryItem[] }) }; const grouped = new Map(); + for (const key of ALL_CATEGORY_KEYS) grouped.set(key, []); for (const item of items) { const key = item.aisle ?? OTHER_KEY; - const group = grouped.get(key) ?? []; - group.push(item); - grouped.set(key, group); + if (!grouped.has(key)) grouped.set(key, []); + grouped.get(key)!.push(item); } for (const group of grouped.values()) group.sort(sortWithinGroup); @@ -123,7 +222,7 @@ export function PantryManager({ initialItems }: { initialItems: PantryItem[] }) } const sortedGroupKeys = [...grouped.keys()].sort((a, b) => categoryLabel(a).localeCompare(categoryLabel(b))); - const showGroupHeaders = grouped.size > 1; + const uncategorizedCount = items.filter((i) => !i.aisle).length; return (
@@ -137,6 +236,11 @@ export function PantryManager({ initialItems }: { initialItems: PantryItem[] }) {t("add")} router.refresh()} /> + {uncategorizedCount > 0 && ( + + )} @@ -146,13 +250,13 @@ export function PantryManager({ initialItems }: { initialItems: PantryItem[] }) {items.length === 0 ? ( ) : ( -
- {sortedGroupKeys.map((key) => { - const groupItems = grouped.get(key)!; - const isCollapsed = collapsed.has(key); - return ( -
- {showGroupHeaders && ( + +
+ {sortedGroupKeys.map((key) => { + const groupItems = grouped.get(key)!; + const isCollapsed = collapsed.has(key); + return ( +
- )} - {!isCollapsed && ( -
- {groupItems.map((item) => { - const days = item.expiresAt ? daysUntilExpiry(item.expiresAt) : null; - const expiring = days !== null && days <= 3; - const expired = days !== null && days < 0; - return ( -
-
-
- {item.rawName} - {hasQuantity(item.quantity) && ( - {formatQuantity(parseFloat(item.quantity!))}{item.unit ? ` ${item.unit}` : ""} - )} - {expired && {t("expired")}} - {expiring && !expired && {t("expiresInDays", { days })}} -
- {item.expiresAt && !expired && !expiring && ( -

{t("expiresOn", { date: new Date(item.expiresAt).toLocaleDateString() })}

- )} - {item.notes &&

{item.notes}

} -
- - + {!isCollapsed && ( + + {groupItems.length === 0 ? ( +

{t("categoryEmptyDropHint")}

+ ) : ( +
+ {groupItems.map((item) => { + const days = item.expiresAt ? daysUntilExpiry(item.expiresAt) : null; + const expiring = days !== null && days <= 3; + const expired = days !== null && days < 0; + return ( + +
+
+ {item.rawName} + {hasQuantity(item.quantity) && ( + {formatQuantity(parseFloat(item.quantity!))}{item.unit ? ` ${item.unit}` : ""} + )} + {expired && {t("expired")}} + {expiring && !expired && {t("expiresInDays", { days })}} +
+ {item.expiresAt && !expired && !expiring && ( +

{t("expiresOn", { date: new Date(item.expiresAt).toLocaleDateString() })}

+ )} + {item.notes &&

{item.notes}

} +
+ + +
+ ); + })}
- ); - })} -
- )} -
- ); - })} -
+ )} + + )} +
+ ); + })} +
+ )} !open && setConfirmId(null)}> diff --git a/apps/web/components/pantry/pantry-item-dialog.tsx b/apps/web/components/pantry/pantry-item-dialog.tsx index b974040..2dd6201 100644 --- a/apps/web/components/pantry/pantry-item-dialog.tsx +++ b/apps/web/components/pantry/pantry-item-dialog.tsx @@ -35,6 +35,10 @@ export type PantryItem = { const OTHER_VALUE = "__other__"; +function categoryLabel(value: string, tShopping: (key: string) => string): string { + return value === OTHER_VALUE ? tShopping("aisleOther") : tShopping(`categories.${value}`); +} + export function PantryItemDialog({ item, open, @@ -115,7 +119,7 @@ export function PantryItemDialog({