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
+11 -4
View File
@@ -12,6 +12,8 @@
* untouched and just flag `inPantry` so the user can decide for themselves.
*/
import { extractIngredientQuantity } from "./extract-ingredient-quantity";
export type PantrySourceItem = {
ingredientId: string | null;
rawName: string;
@@ -138,17 +140,22 @@ export function mergeIngredients(ingredients: MergeSourceIngredient[]): MergedIn
const groups = new Map<string, { rawName: string; ingredientId: string | null; unit: string | null; entries: (string | null)[] }>();
for (const ing of ingredients) {
const unitKey = normalizeUnit(ing.unit);
// Clean up ingredients whose stored rawName still has a quantity baked into it
// (older AI-generated recipes, before that was fixed at the source) BEFORE grouping —
// otherwise "1 avocat" and a cleanly-named "avocat" from another recipe would
// normalize to different group keys and never merge.
const cleaned = extractIngredientQuantity(ing.rawName, ing.quantity ?? undefined, ing.unit ?? undefined);
const unitKey = normalizeUnit(cleaned.unit);
const groupKey = ing.ingredientId
? `id:${ing.ingredientId}:${unitKey}`
: `name:${normalizeName(ing.rawName)}:${unitKey}`;
: `name:${normalizeName(cleaned.rawName)}:${unitKey}`;
let group = groups.get(groupKey);
if (!group) {
group = { rawName: ing.rawName, ingredientId: ing.ingredientId, unit: ing.unit, entries: [] };
group = { rawName: cleaned.rawName, ingredientId: ing.ingredientId, unit: cleaned.unit ?? null, entries: [] };
groups.set(groupKey, group);
}
group.entries.push(ing.quantity);
group.entries.push(cleaned.quantity ?? null);
}
return Array.from(groups.values()).map((group) => {