fix: shopping list categories were English-only and untranslated, page too narrow

Confirmed from a real screenshot: every item in an all-French shopping list
was uncategorized because guessAisle() only matched English keywords —
"sometimes reorganize doesn't work" was actually "always fails on non-English
ingredient names". Also fixed:

- Categories are now translated (grocery-categories.ts stores locale-
  independent slugs like "produce", translated for display via
  shoppingLists.categories.* instead of storing/rendering the English label
  directly)
- Added French keyword coverage to the aisle guesser so auto-categorization
  actually works for French recipes
- Users can now type a custom category name from the item's category menu
- The sort-mode dropdown was showing the raw value ("category") instead of
  its label — base-ui's Select.Value has no built-in value->label lookup,
  it needs an explicit render function, which no Select in this call site
  had
- Widened the shopping list detail page (max-w-lg -> max-w-2xl)
- Root-caused a second bug visible in the same screenshot: ingredients with
  the quantity embedded in the name (e.g. "1 avocat") still showed that way
  even though quantity/unit were already populated separately, because the
  extraction helper only fired when quantity was empty. Now it always
  cleans the name but only backfills quantity/unit when they're missing,
  and runs at shopping-list generation time too (not just recipe save) so
  it also cleans up already-contaminated recipes, not just new ones.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Arnaud
2026-07-10 11:03:11 +02:00
parent 5afc7cd182
commit 51a323b054
7 changed files with 202 additions and 61 deletions
@@ -85,7 +85,23 @@ export function ShoppingListView({
const [deleting, setDeleting] = useState(false);
const [autoCategorizing, setAutoCategorizing] = useState(false);
const categoryOptions = useMemo(() => [...GROCERY_CATEGORIES, t("aisleOther")], [t]);
// Predefined categories, plus any custom category already in use on this list
// (so a custom category someone created stays selectable for other items too),
// plus "Other" (represented as `null` under the hood) always last.
const customCategoriesInUse = useMemo(
() => Array.from(new Set(items.map((i) => i.aisle).filter((a): a is string => !!a && !(GROCERY_CATEGORIES as readonly string[]).includes(a)))).sort(),
[items]
);
const categoryOptions = useMemo(
() => [...GROCERY_CATEGORIES, ...customCategoriesInUse],
[customCategoriesInUse]
);
function categoryLabel(category: string): string {
return (GROCERY_CATEGORIES as readonly string[]).includes(category)
? tShopping(`categories.${category}`)
: category;
}
const checkedItems = items.filter((i) => i.checked);
const isSearching = query.trim().length > 0;
@@ -132,9 +148,8 @@ export function ShoppingListView({
}
}
async function changeCategory(item: Item, category: string) {
async function changeCategory(item: Item, nextAisle: string | null) {
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 {
@@ -307,7 +322,9 @@ export function ShoppingListView({
</div>
<Select value={sortMode} onValueChange={(v) => setSortMode(v as SortMode)}>
<SelectTrigger className="sm:w-48">
<SelectValue />
<SelectValue>
{(v: SortMode) => ({ category: t("sortByCategory"), alpha: t("sortByAlpha"), unchecked: t("sortByUnchecked") })[v]}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="category">{t("sortByCategory")}</SelectItem>
@@ -323,11 +340,12 @@ export function ShoppingListView({
Object.entries(grouped).sort(([a], [b]) => a.localeCompare(b)).map(([aisle, aisleItems]) => (
<ItemGroup
key={aisle}
aisle={aisle}
aisleLabel={categoryLabel(aisle)}
items={aisleItems}
showHeader={Object.keys(grouped).length > 1}
readOnly={readOnly}
categoryOptions={categoryOptions}
categoryLabel={categoryLabel}
tShopping={tShopping}
onToggle={toggleItem}
onChangeCategory={changeCategory}
@@ -340,6 +358,7 @@ export function ShoppingListView({
items={flatItems}
readOnly={readOnly}
categoryOptions={categoryOptions}
categoryLabel={categoryLabel}
tShopping={tShopping}
onToggle={toggleItem}
onChangeCategory={changeCategory}
@@ -371,25 +390,27 @@ export function ShoppingListView({
}
function ItemGroup({
aisle,
aisleLabel,
items,
showHeader,
readOnly,
categoryOptions,
categoryLabel,
tShopping,
onToggle,
onChangeCategory,
onDeleteRequest,
onDragEnd,
}: {
aisle: string;
aisleLabel: string;
items: Item[];
showHeader: boolean;
readOnly: boolean;
categoryOptions: string[];
categoryLabel: (category: string) => string;
tShopping: ReturnType<typeof useTranslations>;
onToggle: (item: Item) => void;
onChangeCategory: (item: Item, category: string) => void;
onChangeCategory: (item: Item, category: string | null) => void;
onDeleteRequest: (item: Item) => void;
onDragEnd: (event: DragEndEvent) => void;
}) {
@@ -398,7 +419,7 @@ function ItemGroup({
return (
<div className="space-y-2">
{showHeader && (
<h2 className="text-xs font-semibold uppercase tracking-widest text-muted-foreground">{aisle}</h2>
<h2 className="text-xs font-semibold uppercase tracking-widest text-muted-foreground">{aisleLabel}</h2>
)}
<div className="rounded-xl border divide-y">
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={onDragEnd}>
@@ -410,6 +431,7 @@ function ItemGroup({
readOnly={readOnly}
draggable={!readOnly}
categoryOptions={categoryOptions}
categoryLabel={categoryLabel}
tShopping={tShopping}
onToggle={onToggle}
onChangeCategory={onChangeCategory}
@@ -427,6 +449,7 @@ function FlatItemList({
items,
readOnly,
categoryOptions,
categoryLabel,
tShopping,
onToggle,
onChangeCategory,
@@ -435,9 +458,10 @@ function FlatItemList({
items: Item[];
readOnly: boolean;
categoryOptions: string[];
categoryLabel: (category: string) => string;
tShopping: ReturnType<typeof useTranslations>;
onToggle: (item: Item) => void;
onChangeCategory: (item: Item, category: string) => void;
onChangeCategory: (item: Item, category: string | null) => void;
onDeleteRequest: (item: Item) => void;
}) {
return (
@@ -448,6 +472,7 @@ function FlatItemList({
item={item}
readOnly={readOnly}
categoryOptions={categoryOptions}
categoryLabel={categoryLabel}
tShopping={tShopping}
onToggle={onToggle}
onChangeCategory={onChangeCategory}
@@ -490,14 +515,16 @@ type ItemRowProps = {
item: Item;
readOnly: boolean;
categoryOptions: string[];
categoryLabel: (category: string) => string;
tShopping: ReturnType<typeof useTranslations>;
onToggle: (item: Item) => void;
onChangeCategory: (item: Item, category: string) => void;
onChangeCategory: (item: Item, category: string | null) => void;
onDeleteRequest: (item: Item) => void;
dragHandle?: React.ReactNode;
};
function ItemRow({ item, readOnly, categoryOptions, tShopping, onToggle, onChangeCategory, onDeleteRequest, dragHandle }: ItemRowProps) {
function ItemRow({ item, readOnly, categoryOptions, categoryLabel, tShopping, onToggle, onChangeCategory, onDeleteRequest, dragHandle }: ItemRowProps) {
const [newCategory, setNewCategory] = useState("");
return (
<div className="w-full flex items-center gap-3 px-4 py-3 hover:bg-muted/30 transition-colors">
{dragHandle}
@@ -534,12 +561,39 @@ function ItemRow({ item, readOnly, categoryOptions, tShopping, onToggle, onChang
<DropdownMenuContent align="end">
<DropdownMenuSub>
<DropdownMenuSubTrigger>{tShopping("changeCategory")}</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuSubContent className="w-56">
{categoryOptions.map((category) => (
<DropdownMenuItem key={category} onClick={() => onChangeCategory(item, category)}>
{category}
{categoryLabel(category)}
</DropdownMenuItem>
))}
<DropdownMenuItem onClick={() => onChangeCategory(item, null)}>
{tShopping("aisleOther")}
</DropdownMenuItem>
<DropdownMenuSeparator />
<div className="flex items-center gap-1 p-1.5" onClick={(e) => e.stopPropagation()}>
<Input
value={newCategory}
onChange={(e) => setNewCategory(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && newCategory.trim()) {
onChangeCategory(item, newCategory.trim());
setNewCategory("");
}
}}
placeholder={tShopping("newCategoryPlaceholder")}
className="h-7 text-xs"
/>
<Button
size="sm"
variant="ghost"
className="h-7 shrink-0 px-2 text-xs"
disabled={!newCategory.trim()}
onClick={() => { onChangeCategory(item, newCategory.trim()); setNewCategory(""); }}
>
{tShopping("addCategory")}
</Button>
</div>
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSeparator />