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:
@@ -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 />
|
||||
|
||||
Reference in New Issue
Block a user