feat: shopping list rename/delete, item reorder+categories+search, ingredient-quantity parsing fix

- Fixed a real i18n bug: the checked-count line called the wrong
  translation namespace and rendered the literal key on screen
- Shopping lists can now be renamed and deleted from both the list index
  and detail pages (API already supported delete; rename was net new)
- Root-caused "long list UI is off": meal-plan-generated lists never set
  an aisle, so every item fell into one undifferentiated "Other" bucket
  despite the grouping UI existing. Added a keyword-based aisle guesser
  wired into list generation (fallback only, never overrides an explicit
  aisle) plus a one-click "auto-categorize" for existing lists
- Items can now be reordered by drag-and-drop within a category (dnd-kit),
  recategorized via a dropdown, deleted, searched, and sorted (category /
  alphabetical / unchecked-first); searching flattens the grouped view
- Fixed a separate bug: AI-generated ingredients sometimes embedded the
  quantity/unit in the name itself (e.g. "2 cups flour" as one string).
  Added extractIngredientQuantity() as a Zod transform at both recipe
  create/update routes (the choke point every creation path funnels
  through) to split it back out, plus schema descriptions on the AI
  ingredient schemas as a prevention layer

New migration 0028 (shopping_list_items.sort_order), left unapplied like
the others. Verified with typecheck, lint, and a clean --no-cache docker
build.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Arnaud
2026-07-10 10:29:28 +02:00
parent d62e2a6383
commit 5afc7cd182
24 changed files with 5549 additions and 76 deletions
@@ -8,6 +8,7 @@ import { db, shoppingLists, eq } from "@epicure/db";
import { ShoppingListView } from "@/components/meal-plan/shopping-list-view";
import { ShareShoppingListButton } from "@/components/shopping-lists/share-shopping-list-button";
import { GroceryExportButton } from "@/components/shopping-lists/grocery-export-button";
import { ShoppingListActionsMenu } from "@/components/shopping-lists/shopping-list-actions-menu";
import { buttonVariants } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { getShoppingListAccess, canWriteShoppingList } from "@/lib/shopping-list-access";
@@ -30,7 +31,7 @@ export default async function ShoppingListPage({ params }: Params) {
const list = await db.query.shoppingLists.findFirst({
where: eq(shoppingLists.id, id),
with: { items: { orderBy: (t, { asc }) => [asc(t.aisle), asc(t.rawName)] } },
with: { items: { orderBy: (t, { asc }) => [asc(t.aisle), asc(t.sortOrder), asc(t.rawName)] } },
});
if (!list) notFound();
@@ -58,6 +59,9 @@ export default async function ShoppingListPage({ params }: Params) {
markdown={shoppingListToMarkdown({ name: list.name, items: list.items })}
filename={list.name}
/>
{access.role === "owner" && (
<ShoppingListActionsMenu listId={id} name={list.name} redirectAfterDeleteTo="/shopping-lists" />
)}
</div>
</div>
<ShoppingListView
@@ -70,6 +74,7 @@ export default async function ShoppingListPage({ params }: Params) {
unit: i.unit,
aisle: i.aisle,
checked: i.checked,
sortOrder: i.sortOrder,
}))}
/>
</div>
@@ -6,6 +6,7 @@ import { requireSessionOrApiKey } from "@/lib/api-auth";
import { deleteObject } from "@/lib/storage";
import { dispatchWebhook } from "@/lib/webhooks";
import { parseQuantity } from "@/lib/parse-quantity";
import { extractIngredientQuantity } from "@/lib/extract-ingredient-quantity";
const UpdateRecipeSchema = z.object({
title: z.string().min(1).max(200).optional(),
@@ -31,6 +32,9 @@ const UpdateRecipeSchema = z.object({
unit: z.string().max(50).optional(),
note: z.string().max(500).optional(),
order: z.number().int().default(0),
}).transform((ing) => {
const { rawName, quantity, unit } = extractIngredientQuantity(ing.rawName, ing.quantity, ing.unit);
return { ...ing, rawName, quantity, unit };
})).max(100).optional(),
steps: z.array(z.object({
instruction: z.string().min(1).max(2000),
+4
View File
@@ -6,6 +6,7 @@ import { requireSessionOrApiKey } from "@/lib/api-auth";
import { checkAndIncrementTierLimit, TierLimitError } from "@/lib/tiers";
import { dispatchWebhook } from "@/lib/webhooks";
import { parseQuantity } from "@/lib/parse-quantity";
import { extractIngredientQuantity } from "@/lib/extract-ingredient-quantity";
const CreateRecipeSchema = z.object({
title: z.string().min(1).max(200),
@@ -33,6 +34,9 @@ const CreateRecipeSchema = z.object({
unit: z.string().max(50).optional(),
note: z.string().max(500).optional(),
order: z.number().int().default(0),
}).transform((ing) => {
const { rawName, quantity, unit } = extractIngredientQuantity(ing.rawName, ing.quantity, ing.unit);
return { ...ing, rawName, quantity, unit };
})).max(100).default([]),
steps: z.array(z.object({
instruction: z.string().min(1).max(2000),
@@ -8,6 +8,11 @@ type Params = { params: Promise<{ id: string; itemId: string }> };
const UpdateItemSchema = z.object({
checked: z.boolean().optional(),
rawName: z.string().min(1).optional(),
quantity: z.string().nullable().optional(),
unit: z.string().nullable().optional(),
aisle: z.string().nullable().optional(),
sortOrder: z.number().int().optional(),
});
export async function PUT(req: NextRequest, { params }: Params) {
@@ -24,9 +29,35 @@ export async function PUT(req: NextRequest, { params }: Params) {
return NextResponse.json({ error: "Validation error", issues: parsed.error.issues }, { status: 400 });
}
await db.update(shoppingListItems)
.set({ checked: parsed.data.checked ?? false })
.where(and(eq(shoppingListItems.id, itemId), eq(shoppingListItems.listId, id)));
const data = parsed.data;
const patch: Partial<typeof shoppingListItems.$inferInsert> = {};
if (data.checked !== undefined) patch.checked = data.checked;
if (data.rawName !== undefined) patch.rawName = data.rawName;
if (data.quantity !== undefined) patch.quantity = data.quantity;
if (data.unit !== undefined) patch.unit = data.unit;
if (data.aisle !== undefined) patch.aisle = data.aisle;
if (data.sortOrder !== undefined) patch.sortOrder = data.sortOrder;
if (Object.keys(patch).length > 0) {
await db.update(shoppingListItems)
.set(patch)
.where(and(eq(shoppingListItems.id, itemId), eq(shoppingListItems.listId, id)));
}
return NextResponse.json({ updated: true });
}
export async function DELETE(_req: NextRequest, { params }: Params) {
const { session, response } = await requireSession();
if (response) return response;
const { id, itemId } = await params;
const access = await getShoppingListAccess(id, session!.user.id);
if (!access) return NextResponse.json({ error: "Not found" }, { status: 404 });
if (!canWriteShoppingList(access.role)) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
await db.delete(shoppingListItems)
.where(and(eq(shoppingListItems.id, itemId), eq(shoppingListItems.listId, id)));
return NextResponse.json({ deleted: true });
}
@@ -1,8 +1,9 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { db, shoppingListItems } from "@epicure/db";
import { db, shoppingListItems, eq, and } from "@epicure/db";
import { requireSession } from "@/lib/api-auth";
import { getShoppingListAccess, canWriteShoppingList } from "@/lib/shopping-list-access";
import { guessAisle } from "@/lib/grocery-categories";
const AddItemsSchema = z.object({
items: z.array(z.object({
@@ -13,6 +14,17 @@ const AddItemsSchema = z.object({
})).min(1),
});
// Bulk-update a set of existing items (sortOrder and/or aisle) in one request —
// used for drag-and-drop reordering (avoids one PUT per item on every drag) and
// for the "auto-categorize" action on lists whose items predate aisle-guessing.
const BulkUpdateSchema = z.object({
items: z.array(z.object({
id: z.string().min(1),
sortOrder: z.number().int().optional(),
aisle: z.string().nullable().optional(),
})).min(1),
});
type Params = { params: Promise<{ id: string }> };
export async function POST(req: NextRequest, { params }: Params) {
@@ -35,10 +47,39 @@ export async function POST(req: NextRequest, { params }: Params) {
rawName: item.rawName,
quantity: item.quantity,
unit: item.unit,
aisle: item.aisle,
// Only guess when the caller didn't provide an explicit aisle — never override.
aisle: item.aisle ?? guessAisle(item.rawName) ?? undefined,
checked: false,
}))
);
return NextResponse.json({ ok: true }, { status: 201 });
}
export async function PATCH(req: NextRequest, { params }: Params) {
const { session, response } = await requireSession();
if (response) return response;
const { id } = await params;
const access = await getShoppingListAccess(id, session!.user.id);
if (!access) return NextResponse.json({ error: "Not found" }, { status: 404 });
if (!canWriteShoppingList(access.role)) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
const body = await req.json() as unknown;
const parsed = BulkUpdateSchema.safeParse(body);
if (!parsed.success) return NextResponse.json({ error: "Validation error" }, { status: 400 });
await Promise.all(
parsed.data.items.map((item) => {
const patch: Partial<typeof shoppingListItems.$inferInsert> = {};
if (item.sortOrder !== undefined) patch.sortOrder = item.sortOrder;
if (item.aisle !== undefined) patch.aisle = item.aisle;
if (Object.keys(patch).length === 0) return Promise.resolve();
return db.update(shoppingListItems)
.set(patch)
.where(and(eq(shoppingListItems.id, item.id), eq(shoppingListItems.listId, id)));
})
);
return NextResponse.json({ ok: true });
}
@@ -23,7 +23,10 @@ export async function GET(_req: NextRequest, { params }: Params) {
return NextResponse.json(list);
}
const PatchSchema = z.object({ completed: z.boolean() });
const PatchSchema = z.object({
completed: z.boolean().optional(),
name: z.string().min(1).max(100).optional(),
});
export async function PATCH(req: NextRequest, { params }: Params) {
const { session, response } = await requireSession();
@@ -32,12 +35,18 @@ export async function PATCH(req: NextRequest, { params }: Params) {
const access = await getShoppingListAccess(id, session!.user.id);
if (!access) return NextResponse.json({ error: "Not found" }, { status: 404 });
if (!canWriteShoppingList(access.role)) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
const body = PatchSchema.safeParse(await req.json());
if (!body.success) return NextResponse.json({ error: "Validation error" }, { status: 400 });
if (body.data.name !== undefined) {
// Renaming is owner-only, same as delete
if (access.role !== "owner") return NextResponse.json({ error: "Forbidden" }, { status: 403 });
await db.update(shoppingLists).set({ name: body.data.name }).where(eq(shoppingLists.id, id));
}
if (body.data.completed) {
if (!canWriteShoppingList(access.role)) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
// Mark all items as checked
await db.update(shoppingListItems).set({ checked: true }).where(eq(shoppingListItems.listId, id));
void dispatchWebhook(session!.user.id, "shopping_list.completed", { id, name: access.list.name });
+6 -2
View File
@@ -3,6 +3,7 @@ import { z } from "zod";
import { db, shoppingLists, shoppingListItems, mealPlans, mealPlanEntries, recipeIngredients, pantryItems, eq, and, desc, inArray } from "@epicure/db";
import { requireSession } from "@/lib/api-auth";
import { applyPantryToItems, mergeIngredients } from "@/lib/pantry-shopping-match";
import { guessAisle } from "@/lib/grocery-categories";
const CreateSchema = z.object({
name: z.string().min(1).max(100),
@@ -77,15 +78,18 @@ export async function POST(req: NextRequest) {
if (items.length > 0) {
await db.insert(shoppingListItems).values(
items.map((item) => ({
items.map((item, index) => ({
id: crypto.randomUUID(),
listId,
rawName: item.rawName,
quantity: item.quantity,
unit: item.unit,
aisle: item.aisle,
// Never override an explicit aisle — only guess one when the item doesn't have one
// (which today is every item coming from the meal-plan generation path).
aisle: item.aisle ?? guessAisle(item.rawName) ?? undefined,
checked: item.inPantry === true && !item.quantity,
inPantry: item.inPantry ?? false,
sortOrder: index,
}))
);
}
@@ -1,12 +1,56 @@
"use client";
import { useState } from "react";
import { useMemo, useState } from "react";
import { useTranslations } from "next-intl";
import { cn } from "@/lib/utils";
import { Check, Package, Loader2 } from "lucide-react";
import { Check, Package, Loader2, GripVertical, MoreVertical, Trash2, Search, Sparkles } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
DropdownMenuSeparator,
} from "@/components/ui/dropdown-menu";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { hasQuantity } from "@/lib/fractions";
import { guessAisle, GROCERY_CATEGORIES } from "@/lib/grocery-categories";
import {
DndContext,
DragEndEvent,
PointerSensor,
useSensor,
useSensors,
closestCenter,
} from "@dnd-kit/core";
import {
SortableContext,
useSortable,
verticalListSortingStrategy,
arrayMove,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
type Item = {
id: string;
@@ -16,8 +60,11 @@ type Item = {
aisle: string | null;
checked: boolean;
inPantry?: boolean;
sortOrder?: number;
};
type SortMode = "category" | "alpha" | "unchecked";
export function ShoppingListView({
listId,
initialItems,
@@ -32,8 +79,19 @@ export function ShoppingListView({
const tCommon = useTranslations("common");
const [items, setItems] = useState<Item[]>(initialItems);
const [movingToPantry, setMovingToPantry] = useState(false);
const [query, setQuery] = useState("");
const [sortMode, setSortMode] = useState<SortMode>("category");
const [deleteTarget, setDeleteTarget] = useState<Item | null>(null);
const [deleting, setDeleting] = useState(false);
const [autoCategorizing, setAutoCategorizing] = useState(false);
const categoryOptions = useMemo(() => [...GROCERY_CATEGORIES, t("aisleOther")], [t]);
const checkedItems = items.filter((i) => i.checked);
const isSearching = query.trim().length > 0;
// Any item generated without an aisle (e.g. from an older list, before auto-categorization
// existed, or added manually without one) — offering a one-click fix for those.
const uncategorizedCount = items.filter((i) => !i.aisle).length;
async function moveToPantry() {
if (checkedItems.length === 0) return;
@@ -74,11 +132,114 @@ export function ShoppingListView({
}
}
const grouped = items.reduce<Record<string, Item[]>>((acc, item) => {
const key = item.aisle ?? t("aisleOther");
(acc[key] ??= []).push(item);
return acc;
}, {});
async function changeCategory(item: Item, category: string) {
if (readOnly) return;
const nextAisle = category === t("aisleOther") ? null : category;
const prevAisle = item.aisle;
setItems((prev) => prev.map((i) => i.id === item.id ? { ...i, aisle: nextAisle } : i));
try {
const res = await fetch(`/api/v1/shopping-lists/${listId}/items/${item.id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ aisle: nextAisle }),
});
if (!res.ok) throw new Error();
} catch {
setItems((prev) => prev.map((i) => i.id === item.id ? { ...i, aisle: prevAisle } : i));
toast.error(tCommon("updateFailed"));
}
}
async function deleteItem(item: Item) {
if (readOnly) return;
setDeleting(true);
const prevItems = items;
setItems((prev) => prev.filter((i) => i.id !== item.id));
try {
const res = await fetch(`/api/v1/shopping-lists/${listId}/items/${item.id}`, { method: "DELETE" });
if (!res.ok) throw new Error();
setDeleteTarget(null);
} catch {
setItems(prevItems);
toast.error(t("removeFailed"));
} finally {
setDeleting(false);
}
}
// One-click fix for lists whose items predate auto-categorization (or were added
// manually without a category) — guesses an aisle for every uncategorized item and
// persists all of them in a single batched request rather than one PUT per item.
async function autoCategorize() {
const targets = items.filter((i) => !i.aisle);
if (targets.length === 0) return;
setAutoCategorizing(true);
const updates = targets
.map((i) => ({ id: i.id, aisle: guessAisle(i.rawName) }))
.filter((u) => u.aisle !== null) as { id: string; aisle: string }[];
if (updates.length === 0) {
setAutoCategorizing(false);
toast.error(t("autoCategorizeNoneFound"));
return;
}
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));
try {
const res = await fetch(`/api/v1/shopping-lists/${listId}/items`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ items: updates }),
});
if (!res.ok) throw new Error();
toast.success(t("autoCategorizeSuccess", { count: updates.length }));
} catch {
toast.error(tCommon("updateFailed"));
} finally {
setAutoCategorizing(false);
}
}
// Persists a new within-group order for a set of reordered items in a single batched
// request (rather than firing one PUT per dragged item).
async function persistOrder(reordered: Item[]) {
const updates = reordered.map((item, index) => ({ id: item.id, sortOrder: index }));
try {
const res = await fetch(`/api/v1/shopping-lists/${listId}/items`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ items: updates }),
});
if (!res.ok) throw new Error();
} catch {
toast.error(tCommon("updateFailed"));
}
}
function handleGroupDragEnd(groupItems: Item[], event: DragEndEvent) {
const { active, over } = event;
if (!over || active.id === over.id) return;
const oldIndex = groupItems.findIndex((i) => i.id === active.id);
const newIndex = groupItems.findIndex((i) => i.id === over.id);
if (oldIndex === -1 || newIndex === -1) return;
const reordered = arrayMove(groupItems, oldIndex, newIndex);
const reorderedIds = new Set(reordered.map((i) => i.id));
setItems((prev) => {
// Splice the reordered group items back into their original positions within
// the full list, leaving every other item untouched.
let cursor = 0;
return prev.map((i) => (reorderedIds.has(i.id) ? reordered[cursor++]! : i));
});
void persistOrder(reordered);
}
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return items;
return items.filter((i) => i.rawName.toLowerCase().includes(q));
}, [items, query]);
const checkedCount = items.filter((i) => i.checked).length;
@@ -86,55 +247,309 @@ export function ShoppingListView({
return <p className="text-muted-foreground text-sm">{t("listEmptyState")}</p>;
}
// Search flattens category grouping — when hunting for a specific item you want to
// find it regardless of which aisle it landed in, and headers-with-one-match reads
// worse than a simple flat result list. Outside of search, "category" sort mode keeps
// the grouped view (and is the only mode where drag-to-reorder applies, since
// "within category order" is the only ordering concept reordering makes sense for).
const showGroups = !isSearching && sortMode === "category";
let flatItems: Item[] = [];
if (!showGroups) {
flatItems = [...filtered];
if (sortMode === "alpha" || isSearching) {
flatItems.sort((a, b) => a.rawName.localeCompare(b.rawName));
} else if (sortMode === "unchecked") {
flatItems.sort((a, b) => {
if (a.checked !== b.checked) return a.checked ? 1 : -1;
return (a.sortOrder ?? 0) - (b.sortOrder ?? 0) || a.rawName.localeCompare(b.rawName);
});
}
}
const grouped = showGroups
? filtered.reduce<Record<string, Item[]>>((acc, item) => {
const key = item.aisle ?? t("aisleOther");
(acc[key] ??= []).push(item);
return acc;
}, {})
: {};
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">{tShopping("checkedCount", { checked: checkedCount, total: items.length })}</p>
{checkedCount > 0 && (
<Button size="sm" variant="outline" onClick={moveToPantry} disabled={movingToPantry}>
{movingToPantry ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Package className="h-3.5 w-3.5" />}
{t("moveToPantry", { count: checkedCount })}
</Button>
)}
<p className="text-sm text-muted-foreground">{t("checkedCount", { checked: checkedCount, total: items.length })}</p>
<div className="flex items-center gap-2">
{!readOnly && uncategorizedCount > 0 && (
<Button size="sm" variant="ghost" onClick={autoCategorize} disabled={autoCategorizing}>
{autoCategorizing ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Sparkles className="h-3.5 w-3.5" />}
{t("autoCategorize")}
</Button>
)}
{checkedCount > 0 && (
<Button size="sm" variant="outline" onClick={moveToPantry} disabled={movingToPantry}>
{movingToPantry ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Package className="h-3.5 w-3.5" />}
{t("moveToPantry", { count: checkedCount })}
</Button>
)}
</div>
</div>
{Object.entries(grouped).sort(([a], [b]) => a.localeCompare(b)).map(([aisle, aisleItems]) => (
<div key={aisle} className="space-y-2">
{Object.keys(grouped).length > 1 && (
<h2 className="text-xs font-semibold uppercase tracking-widest text-muted-foreground">{aisle}</h2>
)}
<div className="rounded-xl border divide-y">
{aisleItems.map((item) => (
<button
key={item.id}
onClick={() => toggleItem(item)}
disabled={readOnly}
className="w-full flex items-center gap-3 px-4 py-3 hover:bg-muted/30 text-left transition-colors disabled:cursor-default disabled:hover:bg-transparent"
>
<div className={cn(
"h-5 w-5 rounded border-2 flex items-center justify-center shrink-0 transition-colors",
item.checked ? "bg-primary border-primary" : "border-input"
)}>
{item.checked && <Check className="h-3 w-3 text-primary-foreground" />}
</div>
<span className={cn("flex-1 text-sm", item.checked && "line-through text-muted-foreground")}>
{item.rawName}
</span>
{item.inPantry && (
<span className="text-[10px] font-medium uppercase tracking-wide text-emerald-600 dark:text-emerald-400 bg-emerald-500/10 rounded px-1.5 py-0.5 shrink-0">
{tShopping("alreadyInPantry")}
</span>
)}
{(hasQuantity(item.quantity) || item.unit) && (
<span className={cn("text-xs text-muted-foreground tabular-nums shrink-0", item.checked && "opacity-50")}>
{hasQuantity(item.quantity) ? item.quantity : ""}{item.unit ? ` ${item.unit}` : ""}
</span>
)}
</button>
))}
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<div className="relative flex-1">
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={t("searchItemsPlaceholder")}
className="pl-8"
/>
</div>
<Select value={sortMode} onValueChange={(v) => setSortMode(v as SortMode)}>
<SelectTrigger className="sm:w-48">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="category">{t("sortByCategory")}</SelectItem>
<SelectItem value="alpha">{t("sortByAlpha")}</SelectItem>
<SelectItem value="unchecked">{t("sortByUnchecked")}</SelectItem>
</SelectContent>
</Select>
</div>
{filtered.length === 0 ? (
<p className="text-muted-foreground text-sm">{t("noSearchResults")}</p>
) : showGroups ? (
Object.entries(grouped).sort(([a], [b]) => a.localeCompare(b)).map(([aisle, aisleItems]) => (
<ItemGroup
key={aisle}
aisle={aisle}
items={aisleItems}
showHeader={Object.keys(grouped).length > 1}
readOnly={readOnly}
categoryOptions={categoryOptions}
tShopping={tShopping}
onToggle={toggleItem}
onChangeCategory={changeCategory}
onDeleteRequest={setDeleteTarget}
onDragEnd={(event) => handleGroupDragEnd(aisleItems, event)}
/>
))
) : (
<FlatItemList
items={flatItems}
readOnly={readOnly}
categoryOptions={categoryOptions}
tShopping={tShopping}
onToggle={toggleItem}
onChangeCategory={changeCategory}
onDeleteRequest={setDeleteTarget}
/>
)}
<AlertDialog open={deleteTarget !== null} onOpenChange={(open) => !open && setDeleteTarget(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("removeItemConfirmTitle")}</AlertDialogTitle>
<AlertDialogDescription>
{deleteTarget ? t("removeItemConfirmDescription", { name: deleteTarget.rawName }) : ""}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{tCommon("cancel")}</AlertDialogCancel>
<AlertDialogAction
disabled={deleting}
onClick={() => deleteTarget && deleteItem(deleteTarget)}
>
{deleting ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : tCommon("delete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
function ItemGroup({
aisle,
items,
showHeader,
readOnly,
categoryOptions,
tShopping,
onToggle,
onChangeCategory,
onDeleteRequest,
onDragEnd,
}: {
aisle: string;
items: Item[];
showHeader: boolean;
readOnly: boolean;
categoryOptions: string[];
tShopping: ReturnType<typeof useTranslations>;
onToggle: (item: Item) => void;
onChangeCategory: (item: Item, category: string) => void;
onDeleteRequest: (item: Item) => void;
onDragEnd: (event: DragEndEvent) => void;
}) {
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 4 } }));
return (
<div className="space-y-2">
{showHeader && (
<h2 className="text-xs font-semibold uppercase tracking-widest text-muted-foreground">{aisle}</h2>
)}
<div className="rounded-xl border divide-y">
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={onDragEnd}>
<SortableContext items={items.map((i) => i.id)} strategy={verticalListSortingStrategy}>
{items.map((item) => (
<SortableItemRow
key={item.id}
item={item}
readOnly={readOnly}
draggable={!readOnly}
categoryOptions={categoryOptions}
tShopping={tShopping}
onToggle={onToggle}
onChangeCategory={onChangeCategory}
onDeleteRequest={onDeleteRequest}
/>
))}
</SortableContext>
</DndContext>
</div>
</div>
);
}
function FlatItemList({
items,
readOnly,
categoryOptions,
tShopping,
onToggle,
onChangeCategory,
onDeleteRequest,
}: {
items: Item[];
readOnly: boolean;
categoryOptions: string[];
tShopping: ReturnType<typeof useTranslations>;
onToggle: (item: Item) => void;
onChangeCategory: (item: Item, category: string) => void;
onDeleteRequest: (item: Item) => void;
}) {
return (
<div className="rounded-xl border divide-y">
{items.map((item) => (
<ItemRow
key={item.id}
item={item}
readOnly={readOnly}
categoryOptions={categoryOptions}
tShopping={tShopping}
onToggle={onToggle}
onChangeCategory={onChangeCategory}
onDeleteRequest={onDeleteRequest}
/>
))}
</div>
);
}
function SortableItemRow(props: ItemRowProps & { draggable: boolean }) {
const { item, draggable } = props;
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: item.id, disabled: !draggable });
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
return (
<div ref={setNodeRef} style={style} className={cn(isDragging && "z-10 relative bg-background")}>
<ItemRow
{...props}
dragHandle={draggable ? (
<button
type="button"
className="shrink-0 cursor-grab touch-none text-muted-foreground hover:text-foreground active:cursor-grabbing"
{...attributes}
{...listeners}
>
<GripVertical className="h-4 w-4" />
</button>
) : null}
/>
</div>
);
}
type ItemRowProps = {
item: Item;
readOnly: boolean;
categoryOptions: string[];
tShopping: ReturnType<typeof useTranslations>;
onToggle: (item: Item) => void;
onChangeCategory: (item: Item, category: string) => void;
onDeleteRequest: (item: Item) => void;
dragHandle?: React.ReactNode;
};
function ItemRow({ item, readOnly, categoryOptions, tShopping, onToggle, onChangeCategory, onDeleteRequest, dragHandle }: ItemRowProps) {
return (
<div className="w-full flex items-center gap-3 px-4 py-3 hover:bg-muted/30 transition-colors">
{dragHandle}
<button
onClick={() => onToggle(item)}
disabled={readOnly}
className="flex flex-1 items-center gap-3 text-left disabled:cursor-default"
>
<div className={cn(
"h-5 w-5 rounded border-2 flex items-center justify-center shrink-0 transition-colors",
item.checked ? "bg-primary border-primary" : "border-input"
)}>
{item.checked && <Check className="h-3 w-3 text-primary-foreground" />}
</div>
<span className={cn("flex-1 text-sm", item.checked && "line-through text-muted-foreground")}>
{item.rawName}
</span>
{item.inPantry && (
<span className="text-[10px] font-medium uppercase tracking-wide text-emerald-600 dark:text-emerald-400 bg-emerald-500/10 rounded px-1.5 py-0.5 shrink-0">
{tShopping("alreadyInPantry")}
</span>
)}
{(hasQuantity(item.quantity) || item.unit) && (
<span className={cn("text-xs text-muted-foreground tabular-nums shrink-0", item.checked && "opacity-50")}>
{hasQuantity(item.quantity) ? item.quantity : ""}{item.unit ? ` ${item.unit}` : ""}
</span>
)}
</button>
{!readOnly && (
<DropdownMenu>
<DropdownMenuTrigger className="shrink-0 rounded p-1 text-muted-foreground hover:bg-muted hover:text-foreground">
<MoreVertical className="h-4 w-4" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuSub>
<DropdownMenuSubTrigger>{tShopping("changeCategory")}</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
{categoryOptions.map((category) => (
<DropdownMenuItem key={category} onClick={() => onChangeCategory(item, category)}>
{category}
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive" onClick={() => onDeleteRequest(item)}>
<Trash2 className="h-4 w-4" />
{tShopping("deleteItem")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
);
}
@@ -0,0 +1,174 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { MoreVertical, Pencil, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { Button, buttonVariants } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
type Props = {
listId: string;
name: string;
/** Called after a successful rename, so the caller can update its own state. If omitted, falls back to router.refresh(). */
onRenamed?: (name: string) => void;
/** Called after a successful delete, so the caller can update its own state (e.g. remove the row). */
onDeleted?: () => void;
/** Path to navigate to after deleting (e.g. back to the list index from the detail page). */
redirectAfterDeleteTo?: string;
className?: string;
};
/** Owner-only rename/delete menu for a shopping list. Used on both the list index page and the list detail page. */
export function ShoppingListActionsMenu({ listId, name, onRenamed, onDeleted, redirectAfterDeleteTo, className }: Props) {
const t = useTranslations("shoppingLists");
const tCommon = useTranslations("common");
const router = useRouter();
const [renameOpen, setRenameOpen] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
const [newName, setNewName] = useState(name);
const [saving, setSaving] = useState(false);
const [deleting, setDeleting] = useState(false);
function openRename() {
setNewName(name);
setRenameOpen(true);
}
async function handleRename() {
const trimmed = newName.trim();
if (!trimmed || trimmed === name) {
setRenameOpen(false);
return;
}
setSaving(true);
try {
const res = await fetch(`/api/v1/shopping-lists/${listId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: trimmed }),
});
if (!res.ok) throw new Error("failed");
toast.success(t("listRenamed"));
setRenameOpen(false);
if (onRenamed) onRenamed(trimmed);
else router.refresh();
} catch {
toast.error(t("listRenameFailed"));
} finally {
setSaving(false);
}
}
async function handleDelete() {
setDeleting(true);
try {
const res = await fetch(`/api/v1/shopping-lists/${listId}`, { method: "DELETE" });
if (!res.ok) throw new Error("failed");
toast.success(t("listDeleted"));
setDeleteOpen(false);
onDeleted?.();
if (redirectAfterDeleteTo) router.push(redirectAfterDeleteTo);
else router.refresh();
} catch {
toast.error(t("listDeleteFailed"));
setDeleting(false);
}
}
return (
<>
<DropdownMenu>
<DropdownMenuTrigger
aria-label={t("actionsMenuLabel")}
className={cn(buttonVariants({ variant: "ghost", size: "icon" }), className)}
onClick={(e: React.MouseEvent) => {
// Rows on the index page are wrapped in a Link — stop the click
// from bubbling to it and prevent the anchor's default navigation.
e.preventDefault();
e.stopPropagation();
}}
>
<MoreVertical className="h-4 w-4" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={openRename}>
<Pencil className="h-4 w-4 mr-2" /> {t("rename")}
</DropdownMenuItem>
<DropdownMenuItem variant="destructive" onClick={() => setDeleteOpen(true)}>
<Trash2 className="h-4 w-4 mr-2" /> {tCommon("delete")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Dialog open={renameOpen} onOpenChange={setRenameOpen}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{t("renameListTitle")}</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label>{t("renameListLabel")}</Label>
<Input
value={newName}
onChange={(e) => setNewName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") void handleRename();
}}
/>
</div>
<div className="flex gap-2 justify-end">
<Button variant="outline" onClick={() => setRenameOpen(false)}>
{tCommon("cancel")}
</Button>
<Button onClick={() => void handleRename()} disabled={!newName.trim() || saving}>
{saving ? t("renaming") : tCommon("save")}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("deleteListConfirmTitle")}</AlertDialogTitle>
<AlertDialogDescription>{t("deleteListConfirmDescription")}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{tCommon("cancel")}</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
void handleDelete();
}}
disabled={deleting}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{tCommon("delete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}
@@ -1,8 +1,10 @@
"use client";
import { useState } from "react";
import { useTranslations } from "next-intl";
import Link from "next/link";
import { ShoppingCart } from "lucide-react";
import { NewShoppingListButton } from "@/components/meal-plan/new-shopping-list-button";
import { ShoppingListActionsMenu } from "@/components/shopping-lists/shopping-list-actions-menu";
type ShoppingListItem = {
id: string;
@@ -24,9 +26,10 @@ type Props = {
sharedLists?: SharedListItem[];
};
export function ShoppingListsPageContent({ lists, sharedLists = [] }: Props) {
export function ShoppingListsPageContent({ lists: initialLists, sharedLists = [] }: Props) {
const t = useTranslations("shoppingLists");
const ts = useTranslations("shareDialog");
const [lists, setLists] = useState(initialLists);
return (
<div className="space-y-6">
@@ -49,10 +52,10 @@ export function ShoppingListsPageContent({ lists, sharedLists = [] }: Props) {
<Link
key={list.id}
href={`/shopping-lists/${list.id}`}
className="flex items-center justify-between rounded-xl border p-4 hover:shadow-sm transition-shadow"
className="flex items-center justify-between gap-2 rounded-xl border p-4 hover:shadow-sm transition-shadow"
>
<div className="space-y-1">
<h2 className="font-semibold">{list.name}</h2>
<div className="space-y-1 min-w-0">
<h2 className="font-semibold truncate">{list.name}</h2>
<p className="text-sm text-muted-foreground">
{list.totalItems === 0
? t("listEmpty")
@@ -60,8 +63,18 @@ export function ShoppingListsPageContent({ lists, sharedLists = [] }: Props) {
{list.generatedAt && ` · ${t("generated")}`}
</p>
</div>
<div className="h-8 w-8 rounded-full bg-muted flex items-center justify-center text-xs font-bold">
{list.totalItems === 0 ? "—" : `${Math.round((list.checkedItems / list.totalItems) * 100)}%`}
<div className="flex items-center gap-1 shrink-0">
<div className="h-8 w-8 rounded-full bg-muted flex items-center justify-center text-xs font-bold">
{list.totalItems === 0 ? "—" : `${Math.round((list.checkedItems / list.totalItems) * 100)}%`}
</div>
<ShoppingListActionsMenu
listId={list.id}
name={list.name}
onRenamed={(name) =>
setLists((prev) => prev.map((l) => (l.id === list.id ? { ...l, name } : l)))
}
onDeleted={() => setLists((prev) => prev.filter((l) => l.id !== list.id))}
/>
</div>
</Link>
))}
@@ -10,9 +10,9 @@ const MealPlanSchema = z.object({
title: z.string().max(150),
description: z.string().max(300),
ingredients: z.array(z.object({
rawName: z.string(),
quantity: z.number().optional(),
unit: z.string().optional(),
rawName: z.string().describe("Ingredient name only, e.g. 'flour' — never include the quantity or unit here."),
quantity: z.number().optional().describe("A number only — never combined with the unit."),
unit: z.string().optional().describe("The unit only, e.g. 'cup', 'g' — never combined with the quantity or name."),
})).max(20),
steps: z.array(z.object({
instruction: z.string(),
+7 -3
View File
@@ -17,9 +17,13 @@ export const stepSchema = z.object({
export function ingredientSchema<Q extends z.ZodTypeAny>(quantity: Q) {
return z.object({
rawName: z.string(),
quantity: quantity.optional(),
unit: z.string().optional(),
rawName: z.string().describe(
"The ingredient name ONLY — e.g. 'flour', 'egg', 'olive oil'. Never include the " +
"quantity or unit here (not '2 cups flour', not '3 eggs'); those go in the separate " +
"quantity and unit fields."
),
quantity: quantity.optional().describe("A number only, e.g. 0.25, 1.5, 2 — never combined with the unit."),
unit: z.string().optional().describe("The unit only, e.g. 'cup', 'tbsp', 'g', 'ml' — never combined with the quantity or name."),
note: z.string().optional(),
});
}
+2 -2
View File
@@ -5,8 +5,8 @@ import { resolveModel, type AiConfig } from "../factory";
const ScaledIngredientsSchema = z.object({
ingredients: z.array(
z.object({
rawName: z.string(),
quantity: z.string(),
rawName: z.string().describe("Ingredient name only, e.g. 'flour' — never include the scaled quantity or unit here."),
quantity: z.string().describe("The scaled quantity as a number only, e.g. '3' or '1.5'."),
unit: z.string().nullable(),
note: z.string().optional(),
})
+1 -1
View File
@@ -6,7 +6,7 @@ const TranslationOutputSchema = z.object({
title: z.string(),
description: z.string(),
ingredients: z.array(z.object({
rawName: z.string(),
rawName: z.string().describe("Translated ingredient name only — never add the quantity or unit, those are handled separately and unaffected by translation."),
note: z.string().optional(),
})),
steps: z.array(z.object({
@@ -0,0 +1,68 @@
import { parseQuantity } from "./parse-quantity";
const KNOWN_UNITS = new Set([
"cup", "cups", "c",
"tablespoon", "tablespoons", "tbsp", "tbsps", "tbs",
"teaspoon", "teaspoons", "tsp", "tsps",
"gram", "grams", "g",
"kilogram", "kilograms", "kg",
"ounce", "ounces", "oz",
"pound", "pounds", "lb", "lbs",
"milliliter", "milliliters", "millilitre", "millilitres", "ml",
"liter", "liters", "litre", "litres", "l",
"pinch", "pinches", "dash", "dashes",
"clove", "cloves", "slice", "slices", "piece", "pieces",
"can", "cans", "jar", "jars", "package", "packages", "pkg",
"bunch", "bunches", "sprig", "sprigs", "stick", "sticks",
"quart", "quarts", "qt", "pint", "pints", "pt",
"fl", // matches the first token of "fl oz" — handled below
]);
// Leading numeric token: plain int/decimal, unicode fraction, mixed number ("1 1/2"), or
// simple fraction ("1/2") — mirrors what parseQuantity already knows how to parse.
const LEADING_NUMBER =
/^\s*(\d+\s+\d+\s*\/\s*\d+|\d+\s*\/\s*\d+|\d+(?:\.\d+)?|[¼½¾⅓⅔⅕⅖⅗⅘⅙⅚⅛⅜⅝⅞])\s*/;
/**
* Some AI-generated (or pasted/imported) ingredients arrive with the quantity baked into
* the name itself (e.g. rawName: "2 cups flour", quantity/unit left empty) instead of the
* expected separate fields. Detects a leading quantity (+ optional recognized unit) in
* rawName and pulls it out, so the ingredient name shown to the user is just "flour", not
* "2 cups flour". Never runs when an explicit quantity was already provided — an entry
* with real quantity/unit fields is trusted as-is, this is only a fallback for the case
* where the model (or a copy-pasted ingredient line) merged everything into the name.
*/
export function extractIngredientQuantity(
rawName: string,
quantity: string | undefined,
unit: string | undefined
): { rawName: string; quantity: string | undefined; unit: string | undefined } {
if (quantity !== undefined && quantity !== "") return { rawName, quantity, unit };
const match = rawName.match(LEADING_NUMBER);
if (!match) return { rawName, quantity, unit };
const numberToken = match[1]!.trim();
const parsedQuantity = parseQuantity(numberToken);
if (parsedQuantity === undefined) return { rawName, quantity, unit };
let rest = rawName.slice(match[0].length).trim();
if (!rest) return { rawName, quantity, unit }; // nothing left — not actually a name+quantity string
let extractedUnit = unit;
const unitMatch = rest.match(/^([a-zA-Z.]+)\s+(.*)$/);
if (unitMatch && !extractedUnit) {
const candidate = unitMatch[1]!.replace(/\.$/, "").toLowerCase();
if (candidate === "fl" && unitMatch[2]!.toLowerCase().startsWith("oz")) {
extractedUnit = "fl oz";
rest = unitMatch[2]!.replace(/^oz\.?\s*/i, "").trim();
} else if (KNOWN_UNITS.has(candidate)) {
extractedUnit = candidate;
rest = unitMatch[2]!.trim();
}
}
if (!rest) return { rawName, quantity, unit }; // stripping the unit ate the whole string — bail out
return { rawName: rest, quantity: parsedQuantity, unit: extractedUnit };
}
+96
View File
@@ -0,0 +1,96 @@
/**
* Lightweight keyword-based aisle guesser for shopping list items.
*
* This is intentionally NOT an exhaustive ingredient database — just a few dozen
* common keyword -> category mappings covering typical recipe ingredients, so
* items generated from a meal plan (which never have an explicit `aisle` set
* today) land in a reasonable category instead of an undifferentiated "Other"
* bucket. When nothing matches, returns `null` and the caller falls back to
* "Other" as before.
*
* Only ever used as a FALLBACK when an item doesn't already have an explicit
* `aisle` — never overrides a user- or API-provided value.
*/
export const GROCERY_CATEGORIES = [
"Produce",
"Dairy & Eggs",
"Meat & Seafood",
"Bakery",
"Frozen",
"Pantry",
"Spices & Condiments",
"Beverages",
] as const;
export type GroceryCategory = (typeof GROCERY_CATEGORIES)[number];
// Ordered map of category -> keywords. Checked in order, first match wins, so
// more specific keywords should generally come before more generic ones.
const CATEGORY_KEYWORDS: [GroceryCategory, string[]][] = [
["Produce", [
"lettuce", "spinach", "kale", "arugula", "cabbage", "carrot", "celery", "onion",
"garlic", "shallot", "scallion", "leek", "potato", "sweet potato", "tomato",
"cucumber", "zucchini", "squash", "pepper", "chili", "chile", "broccoli",
"cauliflower", "mushroom", "avocado", "lemon", "lime", "orange", "apple",
"banana", "berry", "berries", "grape", "melon", "peach", "pear", "plum",
"mango", "pineapple", "cilantro", "parsley", "basil", "mint", "dill",
"thyme", "rosemary", "ginger", "corn", "peas", "beans", "asparagus",
"radish", "beet", "fennel", "herb", "greens",
]],
["Dairy & Eggs", [
"milk", "cream", "yogurt", "yoghurt", "butter", "cheese", "egg", "eggs",
"sour cream", "cottage cheese", "mascarpone", "ricotta", "buttermilk",
"half and half", "creme fraiche",
]],
["Meat & Seafood", [
"chicken", "beef", "pork", "lamb", "turkey", "bacon", "sausage", "ham",
"steak", "ground beef", "mince", "salmon", "tuna", "shrimp", "prawn",
"cod", "tilapia", "fish", "crab", "lobster", "scallop", "mussel", "clam",
"chorizo", "prosciutto", "duck",
]],
["Bakery", [
"bread", "baguette", "roll", "bun", "bagel", "tortilla", "pita", "naan",
"croissant", "muffin", "brioche", "loaf",
]],
["Frozen", [
"frozen", "ice cream", "popsicle", "frozen peas", "frozen berries",
]],
["Beverages", [
"juice", "soda", "water", "coffee", "tea", "wine", "beer", "sparkling",
"kombucha", "cider",
]],
["Spices & Condiments", [
"salt", "pepper flakes", "cumin", "paprika", "cinnamon", "nutmeg",
"oregano", "turmeric", "cayenne", "curry powder", "chili powder", "spice",
"vanilla", "ketchup", "mustard", "mayo", "mayonnaise", "soy sauce",
"hot sauce", "vinegar", "olive oil", "vegetable oil", "sesame oil",
"honey", "maple syrup", "jam", "sauce", "dressing", "salsa",
]],
["Pantry", [
"flour", "sugar", "rice", "pasta", "noodle", "spaghetti", "quinoa",
"oats", "oatmeal", "cereal", "beans", "lentil", "chickpea", "canned",
"stock", "broth", "bouillon", "yeast", "baking powder", "baking soda",
"cornstarch", "breadcrumb", "nut", "almond", "walnut", "peanut", "cashew",
"chocolate", "cocoa", "coconut milk", "tomato paste", "tomato sauce",
"crushed tomato",
]],
];
/**
* Guesses a grocery aisle/category from a raw ingredient name via simple
* keyword matching. Returns `null` when nothing matches (caller should fall
* back to "Other").
*/
export function guessAisle(rawName: string): GroceryCategory | null {
const name = rawName.toLowerCase().trim();
if (!name) return null;
for (const [category, keywords] of CATEGORY_KEYWORDS) {
for (const keyword of keywords) {
if (name.includes(keyword)) return category;
}
}
return null;
}
+24 -1
View File
@@ -654,6 +654,16 @@
"aisleOther": "Other",
"listEmptyState": "This list is empty.",
"checkedCount": "{checked}/{total} checked",
"searchItemsPlaceholder": "Search items…",
"sortByCategory": "By category",
"sortByAlpha": "Alphabetical",
"sortByUnchecked": "Unchecked first",
"noSearchResults": "No items match your search.",
"autoCategorize": "Auto-categorize",
"autoCategorizeSuccess": "{count, plural, one {1 item categorized} other {{count} items categorized}}",
"autoCategorizeNoneFound": "Couldn't guess a category for the remaining items",
"removeItemConfirmTitle": "Remove item?",
"removeItemConfirmDescription": "Remove \"{name}\" from this list?",
"moveToPantry": "Move {count} to pantry",
"listCreated": "List created",
"days": {
@@ -803,7 +813,20 @@
"ingredientsWillBeAdded": "{count, plural, one {1 ingredient will be added.} other {{count} ingredients will be added.}}",
"adding": "Adding…",
"addIngredients": "Add ingredients",
"addedCount": "{count} ingredients added to list"
"addedCount": "{count} ingredients added to list",
"actionsMenuLabel": "List actions",
"rename": "Rename",
"renameListTitle": "Rename list",
"renameListLabel": "List name",
"renaming": "Saving…",
"listRenamed": "List renamed",
"listRenameFailed": "Failed to rename list",
"deleteListConfirmTitle": "Delete list?",
"deleteListConfirmDescription": "This action cannot be undone. The list and all its items will be permanently deleted.",
"listDeleted": "List deleted",
"listDeleteFailed": "Failed to delete list",
"changeCategory": "Change category",
"deleteItem": "Delete item"
},
"collections": {
"title": "Collections",
+24 -1
View File
@@ -642,6 +642,16 @@
"aisleOther": "Autre",
"listEmptyState": "Cette liste est vide.",
"checkedCount": "{checked}/{total} cochés",
"searchItemsPlaceholder": "Rechercher des articles…",
"sortByCategory": "Par catégorie",
"sortByAlpha": "Alphabétique",
"sortByUnchecked": "Non cochés d'abord",
"noSearchResults": "Aucun article ne correspond à votre recherche.",
"autoCategorize": "Catégoriser automatiquement",
"autoCategorizeSuccess": "{count, plural, one {1 article catégorisé} other {{count} articles catégorisés}}",
"autoCategorizeNoneFound": "Impossible de deviner une catégorie pour les articles restants",
"removeItemConfirmTitle": "Supprimer l'article ?",
"removeItemConfirmDescription": "Supprimer « {name} » de cette liste ?",
"moveToPantry": "Déplacer {count} vers le garde-manger",
"listCreated": "Liste créée",
"days": {
@@ -791,7 +801,20 @@
"ingredientsWillBeAdded": "{count, plural, one {1 ingrédient sera ajouté.} other {{count} ingrédients seront ajoutés.}}",
"adding": "Ajout…",
"addIngredients": "Ajouter les ingrédients",
"addedCount": "{count} ingrédients ajoutés à la liste"
"addedCount": "{count} ingrédients ajoutés à la liste",
"actionsMenuLabel": "Actions de la liste",
"rename": "Renommer",
"renameListTitle": "Renommer la liste",
"renameListLabel": "Nom de la liste",
"renaming": "Enregistrement…",
"listRenamed": "Liste renommée",
"listRenameFailed": "Échec du renommage de la liste",
"deleteListConfirmTitle": "Supprimer la liste ?",
"deleteListConfirmDescription": "Cette action est irréversible. La liste et tous ses articles seront définitivement supprimés.",
"listDeleted": "Liste supprimée",
"listDeleteFailed": "Échec de la suppression de la liste",
"changeCategory": "Changer de catégorie",
"deleteItem": "Supprimer l'article"
},
"collections": {
"title": "Collections",
+3
View File
@@ -19,6 +19,9 @@
"@aws-sdk/client-s3": "^3.1075.0",
"@aws-sdk/s3-request-presigner": "^3.1075.0",
"@base-ui/react": "^1.6.0",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@epicure/db": "workspace:*",
"@hookform/resolvers": "^5.4.0",
"@zxing/browser": "^0.2.1",
@@ -0,0 +1 @@
ALTER TABLE "shopping_list_items" ADD COLUMN "sort_order" integer DEFAULT 0 NOT NULL;
File diff suppressed because it is too large Load Diff
@@ -197,6 +197,13 @@
"when": 1783667700330,
"tag": "0027_special_siren",
"breakpoints": true
},
{
"idx": 28,
"version": "7",
"when": 1783671061049,
"tag": "0028_smiling_ezekiel_stane",
"breakpoints": true
}
]
}
+1
View File
@@ -73,6 +73,7 @@ export const shoppingListItems = pgTable("shopping_list_items", {
aisle: text("aisle"),
checked: boolean("checked").notNull().default(false),
inPantry: boolean("in_pantry").notNull().default(false),
sortOrder: integer("sort_order").notNull().default(0),
});
export const shoppingListMembers = pgTable("shopping_list_members", {
+56
View File
@@ -32,6 +32,15 @@ importers:
'@base-ui/react':
specifier: ^1.6.0
version: 1.6.0(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@dnd-kit/core':
specifier: ^6.3.1
version: 6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@dnd-kit/sortable':
specifier: ^10.0.0
version: 10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)
'@dnd-kit/utilities':
specifier: ^3.2.2
version: 3.2.2(react@19.2.4)
'@epicure/db':
specifier: workspace:*
version: link:../../packages/db
@@ -629,6 +638,28 @@ packages:
resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==}
engines: {node: '>=20.19.0'}
'@dnd-kit/accessibility@3.1.1':
resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==}
peerDependencies:
react: '>=16.8.0'
'@dnd-kit/core@6.3.1':
resolution: {integrity: sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==}
peerDependencies:
react: '>=16.8.0'
react-dom: '>=16.8.0'
'@dnd-kit/sortable@10.0.0':
resolution: {integrity: sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==}
peerDependencies:
'@dnd-kit/core': ^6.3.0
react: '>=16.8.0'
'@dnd-kit/utilities@3.2.2':
resolution: {integrity: sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==}
peerDependencies:
react: '>=16.8.0'
'@dotenvx/dotenvx@1.75.1':
resolution: {integrity: sha512-/BITOC9dmS/edY2zQwZNicQ059O6RKabtQfyEafV0nGtfYRNHYy1DIPiYVcov40+tob9hfmBnbR963dS+EQ1DQ==}
hasBin: true
@@ -6010,6 +6041,31 @@ snapshots:
'@csstools/css-tokenizer@4.0.0': {}
'@dnd-kit/accessibility@3.1.1(react@19.2.4)':
dependencies:
react: 19.2.4
tslib: 2.8.1
'@dnd-kit/core@6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
'@dnd-kit/accessibility': 3.1.1(react@19.2.4)
'@dnd-kit/utilities': 3.2.2(react@19.2.4)
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
tslib: 2.8.1
'@dnd-kit/sortable@10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)':
dependencies:
'@dnd-kit/core': 6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@dnd-kit/utilities': 3.2.2(react@19.2.4)
react: 19.2.4
tslib: 2.8.1
'@dnd-kit/utilities@3.2.2(react@19.2.4)':
dependencies:
react: 19.2.4
tslib: 2.8.1
'@dotenvx/dotenvx@1.75.1':
dependencies:
'@dotenvx/primitives': 0.8.0