fix(recipes): overflow des préférences + régimes liés aux ingrédients

- Fix overflow horizontal sur /parametres/preferences : <fieldset> a un
  min-width: min-content par défaut du navigateur, ce qui empêchait la
  grille de l'IngredientPicker de wrapper (page entière poussée à
  ~3100px de large). Reset min-width: 0 sur .disliked-ingredients-field.
- Recatégorise les laits/crèmes végétaux (coco, amande, avoine) de
  PRODUITS_LAITIERS_OEUFS vers LIQUIDES_BOISSONS — ce ne sont pas des
  produits laitiers.
- Nouveau modèle IngredientDiet (many-to-many ingrédient <-> régime) :
  quels régimes (Végétarien, Végan, Pescétarien) chaque ingrédient
  respecte. Omnivore volontairement absent (trivial) et Sans gluten
  aussi (déjà dérivable de l'allergène Gluten existant).
- reference-seed-data.ts : chaque groupe de catégorie porte un
  defaultDiets, avec dietNames en override pour les exceptions
  (fromages, viandes, poissons, sauces à base d'œuf/poisson...).
- IngredientView.diets exposé par /reference/ingredients et /recipes,
  affiché via DietBadges dans IngredientPicker et IngredientRow.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Nicolas 2026-08-18 11:01:47 +02:00
parent ea86a4a7f1
commit 5890c62462
10 changed files with 330 additions and 59 deletions

View file

@ -0,0 +1,14 @@
-- CreateTable
CREATE TABLE "ingredient_diet" (
"ingredient_id" INTEGER NOT NULL,
"diet_id" INTEGER NOT NULL,
CONSTRAINT "ingredient_diet_pkey" PRIMARY KEY ("ingredient_id","diet_id")
);
-- AddForeignKey
ALTER TABLE "ingredient_diet" ADD CONSTRAINT "ingredient_diet_ingredient_id_fkey" FOREIGN KEY ("ingredient_id") REFERENCES "ingredients"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ingredient_diet" ADD CONSTRAINT "ingredient_diet_diet_id_fkey" FOREIGN KEY ("diet_id") REFERENCES "diet"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View file

@ -44,6 +44,7 @@ model Diet {
users UserProfile[]
recipes RecipeDiet[]
ingredients IngredientDiet[]
@@map("diet")
}
@ -336,10 +337,35 @@ model Ingredient {
allergies IngredientAllergy[]
/// Profiles that personally dislike this ingredient — see {@link UserProfileDislikedIngredient}.
dislikedBy UserProfileDislikedIngredient[]
/// Diet regimes this ingredient is compatible with — see {@link IngredientDiet}.
diets IngredientDiet[]
@@map("ingredients")
}
/// Explicit join table for the ingredients <-> diet regime association —
/// which regimes (Végétarien, Végan, Pescétarien…) this ingredient is safe
/// for, so the picker (apps/web's `IngredientPicker`/`IngredientRow`) can
/// flag e.g. an ingredient as vegan without the user having to open its
/// packaging. Seeded by category in `reference-seed-data.ts` (most
/// ingredients in a category share the same compatible regimes, with
/// per-item overrides for exceptions — meat cuts, dairy, seafood…), same as
/// `IngredientAllergy`. Deliberately omits `Omnivore` (every ingredient is
/// trivially compatible — storing it would be pure noise) and `Sans gluten`
/// (already fully derivable from whether `IngredientAllergy` links this
/// ingredient to the `Gluten` allergen — a second, hand-maintained source
/// for the same fact would only risk drifting out of sync with it).
model IngredientDiet {
ingredientId Int @map("ingredient_id")
dietId Int @map("diet_id")
ingredient Ingredient @relation(fields: [ingredientId], references: [id], onDelete: Cascade)
diet Diet @relation(fields: [dietId], references: [id], onDelete: Cascade)
@@id([ingredientId, dietId])
@@map("ingredient_diet")
}
/// Explicit join table for the ingredients <-> allergy association — not in
/// the original spec doc, added so the recipe catalog can surface which
/// allergens an ingredient (and by extension a recipe) carries. Same shape

View file

@ -32,6 +32,17 @@ interface IngredientSeed {
name: string;
icon?: string;
allergenNames: string[];
/**
* Diet regimes this ingredient is compatible with, overriding its group's
* `defaultDiets` below only needed for the exceptions within a category
* (a dairy cheese inside an otherwise-vegan cuisine group, a meat-based
* stock inside an otherwise-plant condiments group). References `DIETS`
* by name, same as `allergenNames` references `ALLERGENS`. Deliberately
* never includes `"Omnivore"` (trivial, every ingredient qualifies) or
* `"Sans gluten"` (derived from `allergenNames` instead see
* `IngredientDiet` in schema.prisma for why).
*/
dietNames?: string[];
}
// A broad pantry list — the goal is to cover the large majority of what a
@ -52,9 +63,21 @@ interface IngredientSeed {
// alone isn't enough to actually *find* something. Each group's key is the
// single source of truth for that mapping; `INGREDIENTS` below just flattens
// it back to one array for the seeding loop.
const INGREDIENT_GROUPS: Array<{ category: IngredientCategory; items: IngredientSeed[] }> = [
//
// `defaultDiets` is the regime compatibility every item in the group shares
// unless it sets its own `dietNames` — most categories are diet-homogeneous
// (a vegetable is always vegan, a cut of meat never is), so this avoids
// repeating the same three diet names on hundreds of items; only the
// category's exceptions (a cheese inside "cuisine italienne", a fish sauce
// inside "cuisine asiatique"…) need a per-item override.
const INGREDIENT_GROUPS: Array<{
category: IngredientCategory;
defaultDiets: string[];
items: IngredientSeed[];
}> = [
{
category: "CEREALES_FECULENTS",
defaultDiets: ["Végétarien", "Végan", "Pescétarien"],
items: [
{ name: "Farine de blé", icon: "🌾", allergenNames: ["Gluten"] },
{ name: "Farine complète", icon: "🌾", allergenNames: ["Gluten"] },
@ -87,6 +110,7 @@ const INGREDIENT_GROUPS: Array<{ category: IngredientCategory; items: Ingredient
},
{
category: "LEGUMINEUSES",
defaultDiets: ["Végétarien", "Végan", "Pescétarien"],
items: [
{ name: "Lentilles vertes", icon: "🫘", allergenNames: [] },
{ name: "Lentilles corail", icon: "🫘", allergenNames: [] },
@ -101,6 +125,7 @@ const INGREDIENT_GROUPS: Array<{ category: IngredientCategory; items: Ingredient
},
{
category: "VIANDES_VOLAILLES",
defaultDiets: [],
items: [
{ name: "Poulet", icon: "🍗", allergenNames: [] },
{ name: "Dinde", icon: "🍗", allergenNames: [] },
@ -126,6 +151,7 @@ const INGREDIENT_GROUPS: Array<{ category: IngredientCategory; items: Ingredient
},
{
category: "POISSONS_FRUITS_DE_MER",
defaultDiets: ["Pescétarien"],
items: [
{ name: "Saumon", icon: "🐟", allergenNames: ["Poissons"] },
{ name: "Thon", icon: "🐟", allergenNames: ["Poissons"] },
@ -172,6 +198,11 @@ const INGREDIENT_GROUPS: Array<{ category: IngredientCategory; items: Ingredient
},
{
category: "PRODUITS_LAITIERS_OEUFS",
// Real dairy/egg products only — plant-based "milks" (coconut, almond,
// oat…) live under `LIQUIDES_BOISSONS` instead: they're not dairy, and
// grouping them here purely because the French name says "lait" was a
// categorization bug (they're vegan; nothing in this group is).
defaultDiets: ["Végétarien", "Pescétarien"],
items: [
{ name: "Œuf", icon: "🥚", allergenNames: ["Œufs"] },
{ name: "Lait", icon: "🥛", allergenNames: ["Lait"] },
@ -189,14 +220,11 @@ const INGREDIENT_GROUPS: Array<{ category: IngredientCategory; items: Ingredient
{ name: "Fromage blanc", icon: "🥣", allergenNames: ["Lait"] },
{ name: "Mascarpone", icon: "🧀", allergenNames: ["Lait"] },
{ name: "Yaourt", icon: "🥣", allergenNames: ["Lait"] },
{ name: "Lait de coco", icon: "🥥", allergenNames: [] },
{ name: "Crème de coco", icon: "🥥", allergenNames: [] },
{ name: "Lait d'amande", icon: "🥛", allergenNames: ["Fruits à coque"] },
{ name: "Lait d'avoine", icon: "🥛", allergenNames: ["Gluten"] },
],
},
{
category: "LEGUMES",
defaultDiets: ["Végétarien", "Végan", "Pescétarien"],
items: [
{ name: "Tomate", icon: "🍅", allergenNames: [] },
{ name: "Oignon", icon: "🧅", allergenNames: [] },
@ -240,6 +268,7 @@ const INGREDIENT_GROUPS: Array<{ category: IngredientCategory; items: Ingredient
},
{
category: "FRUITS",
defaultDiets: ["Végétarien", "Végan", "Pescétarien"],
items: [
{ name: "Citron", icon: "🍋", allergenNames: [] },
{ name: "Citron vert", icon: "🍋", allergenNames: [] },
@ -273,6 +302,7 @@ const INGREDIENT_GROUPS: Array<{ category: IngredientCategory; items: Ingredient
},
{
category: "FRUITS_SECS_OLEAGINEUX",
defaultDiets: ["Végétarien", "Végan", "Pescétarien"],
items: [
{ name: "Cacahuètes", icon: "🥜", allergenNames: ["Arachides"] },
{ name: "Amandes", icon: "🌰", allergenNames: ["Fruits à coque"] },
@ -293,6 +323,7 @@ const INGREDIENT_GROUPS: Array<{ category: IngredientCategory; items: Ingredient
},
{
category: "CONDIMENTS_SAUCES",
defaultDiets: ["Végétarien", "Végan", "Pescétarien"],
items: [
{ name: "Sel", icon: "🧂", allergenNames: [] },
{ name: "Sucre", icon: "🍬", allergenNames: [] },
@ -307,20 +338,35 @@ const INGREDIENT_GROUPS: Array<{ category: IngredientCategory; items: Ingredient
{ name: "Sauce soja", icon: "🍶", allergenNames: ["Soja"] },
{ name: "Tofu", icon: "🧊", allergenNames: ["Soja"] },
{ name: "Moutarde", icon: "🟡", allergenNames: ["Moutarde"] },
{ name: "Mayonnaise", icon: "🫙", allergenNames: ["Œufs"] },
{
name: "Mayonnaise",
icon: "🫙",
allergenNames: ["Œufs"],
dietNames: ["Végétarien", "Pescétarien"],
},
{ name: "Ketchup", icon: "🫙", allergenNames: [] },
{ name: "Miel", icon: "🍯", allergenNames: [] },
{ name: "Miel", icon: "🍯", allergenNames: [], dietNames: ["Végétarien", "Pescétarien"] },
{ name: "Sirop d'érable", icon: "🍁", allergenNames: [] },
{ name: "Câpres", allergenNames: [] },
{ name: "Olives", icon: "🫒", allergenNames: [] },
{ name: "Tabasco", icon: "🌶️", allergenNames: [] },
{ name: "Sauce Worcestershire", icon: "🫙", allergenNames: ["Poissons"] },
{ name: "Sauce nuoc-mâm", icon: "🫙", allergenNames: ["Poissons"] },
{
name: "Sauce Worcestershire",
icon: "🫙",
allergenNames: ["Poissons"],
dietNames: ["Pescétarien"],
},
{
name: "Sauce nuoc-mâm",
icon: "🫙",
allergenNames: ["Poissons"],
dietNames: ["Pescétarien"],
},
{ name: "Wasabi", allergenNames: [] },
{ name: "Harissa", icon: "🌶️", allergenNames: [] },
{ name: "Pâte de curry", icon: "🍛", allergenNames: [] },
{ name: "Bouillon cube légumes", icon: "🫙", allergenNames: ["Céleri"] },
{ name: "Bouillon cube volaille", icon: "🫙", allergenNames: ["Céleri"] },
{ name: "Bouillon cube volaille", icon: "🫙", allergenNames: ["Céleri"], dietNames: [] },
{ name: "Concentré de tomate", icon: "🍅", allergenNames: [] },
{ name: "Coulis de tomate", icon: "🍅", allergenNames: [] },
{ name: "Tomates pelées (conserve)", icon: "🍅", allergenNames: [] },
@ -338,34 +384,81 @@ const INGREDIENT_GROUPS: Array<{ category: IngredientCategory; items: Ingredient
{ name: "Moutarde de Dijon", icon: "🟡", allergenNames: ["Moutarde"] },
{ name: "Moutarde à l'ancienne", icon: "🟡", allergenNames: ["Moutarde"] },
{ name: "Sauce barbecue", icon: "🫙", allergenNames: [] },
{ name: "Sauce tartare", icon: "🫙", allergenNames: ["Œufs"] },
{ name: "Sauce cocktail", icon: "🫙", allergenNames: ["Œufs"] },
{ name: "Sauce béarnaise", icon: "🫙", allergenNames: ["Œufs", "Lait"] },
{ name: "Sauce hollandaise", icon: "🫙", allergenNames: ["Œufs", "Lait"] },
{ name: "Sauce béchamel", icon: "🫙", allergenNames: ["Lait", "Gluten"] },
{
name: "Sauce tartare",
icon: "🫙",
allergenNames: ["Œufs"],
dietNames: ["Végétarien", "Pescétarien"],
},
{
name: "Sauce cocktail",
icon: "🫙",
allergenNames: ["Œufs"],
dietNames: ["Végétarien", "Pescétarien"],
},
{
name: "Sauce béarnaise",
icon: "🫙",
allergenNames: ["Œufs", "Lait"],
dietNames: ["Végétarien", "Pescétarien"],
},
{
name: "Sauce hollandaise",
icon: "🫙",
allergenNames: ["Œufs", "Lait"],
dietNames: ["Végétarien", "Pescétarien"],
},
{
name: "Sauce béchamel",
icon: "🫙",
allergenNames: ["Lait", "Gluten"],
dietNames: ["Végétarien", "Pescétarien"],
},
{ name: "Sauce teriyaki", icon: "🫙", allergenNames: ["Soja"] },
{ name: "Sauce ponzu", icon: "🫙", allergenNames: ["Soja", "Poissons"] },
{
name: "Sauce ponzu",
icon: "🫙",
allergenNames: ["Soja", "Poissons"],
dietNames: ["Pescétarien"],
},
{ name: "Chimichurri", icon: "🌿", allergenNames: [] },
{
name: "Pesto rouge (tomates séchées)",
icon: "🫙",
allergenNames: ["Lait", "Fruits à coque"],
dietNames: ["Végétarien", "Pescétarien"],
},
{ name: "Tomates séchées", icon: "🍅", allergenNames: [] },
{ name: "Fond de veau", icon: "🫙", allergenNames: [] },
{ name: "Fond de volaille", icon: "🫙", allergenNames: [] },
{ name: "Bouillon cube bœuf", icon: "🫙", allergenNames: ["Céleri"] },
{ name: "Bouillon cube poisson", icon: "🫙", allergenNames: ["Poissons", "Céleri"] },
{ name: "Fond de veau", icon: "🫙", allergenNames: [], dietNames: [] },
{ name: "Fond de volaille", icon: "🫙", allergenNames: [], dietNames: [] },
{ name: "Bouillon cube bœuf", icon: "🫙", allergenNames: ["Céleri"], dietNames: [] },
{
name: "Bouillon cube poisson",
icon: "🫙",
allergenNames: ["Poissons", "Céleri"],
dietNames: ["Pescétarien"],
},
{ name: "Bouillon de légumes", icon: "🫙", allergenNames: ["Céleri"] },
{ name: "Bouillon de volaille", icon: "🫙", allergenNames: ["Céleri"] },
{ name: "Bouillon de bœuf", icon: "🫙", allergenNames: ["Céleri"] },
{ name: "Bouillon de volaille", icon: "🫙", allergenNames: ["Céleri"], dietNames: [] },
{ name: "Bouillon de bœuf", icon: "🫙", allergenNames: ["Céleri"], dietNames: [] },
{ name: "Court-bouillon", icon: "🫙", allergenNames: [] },
{ name: "Dashi (bouillon japonais)", icon: "🫙", allergenNames: ["Poissons"] },
{ name: "Bisque de crustacés", icon: "🫙", allergenNames: ["Crustacés"] },
{
name: "Dashi (bouillon japonais)",
icon: "🫙",
allergenNames: ["Poissons"],
dietNames: ["Pescétarien"],
},
{
name: "Bisque de crustacés",
icon: "🫙",
allergenNames: ["Crustacés"],
dietNames: ["Pescétarien"],
},
],
},
{
category: "EPICES_HERBES",
defaultDiets: ["Végétarien", "Végan", "Pescétarien"],
items: [
{ name: "Basilic", icon: "🌿", allergenNames: [] },
{ name: "Persil", icon: "🌿", allergenNames: [] },
@ -418,21 +511,36 @@ const INGREDIENT_GROUPS: Array<{ category: IngredientCategory; items: Ingredient
},
{
category: "SUCRE_PATISSERIE",
defaultDiets: ["Végétarien", "Végan", "Pescétarien"],
items: [
{ name: "Sucre roux", icon: "🍬", allergenNames: [] },
{ name: "Sucre glace", icon: "🍬", allergenNames: [] },
{ name: "Cassonade", icon: "🍬", allergenNames: [] },
{ name: "Chocolat noir", icon: "🍫", allergenNames: [] },
{ name: "Chocolat au lait", icon: "🍫", allergenNames: ["Lait"] },
{ name: "Chocolat blanc", icon: "🍫", allergenNames: ["Lait"] },
{
name: "Chocolat au lait",
icon: "🍫",
allergenNames: ["Lait"],
dietNames: ["Végétarien", "Pescétarien"],
},
{
name: "Chocolat blanc",
icon: "🍫",
allergenNames: ["Lait"],
dietNames: ["Végétarien", "Pescétarien"],
},
{ name: "Pépites de chocolat", icon: "🍫", allergenNames: [] },
{ name: "Cacao en poudre", icon: "🍫", allergenNames: [] },
{ name: "Gélatine", icon: "🫙", allergenNames: [] },
// Animal collagen (bones/skin, usually pork or beef) — not
// vegetarian/vegan, and not reliably fish-derived either, so no
// pescetarian flag.
{ name: "Gélatine", icon: "🫙", allergenNames: [], dietNames: [] },
{ name: "Extrait de vanille", icon: "🌿", allergenNames: [] },
],
},
{
category: "CUISINE_ITALIENNE",
defaultDiets: ["Végétarien", "Végan", "Pescétarien"],
items: [
{ name: "Spaghetti", icon: "🍝", allergenNames: ["Gluten"] },
{ name: "Penne", icon: "🍝", allergenNames: ["Gluten"] },
@ -440,15 +548,40 @@ const INGREDIENT_GROUPS: Array<{ category: IngredientCategory; items: Ingredient
{ name: "Lasagnes (feuilles)", icon: "🍝", allergenNames: ["Gluten"] },
{ name: "Gnocchi", icon: "🍝", allergenNames: ["Gluten"] },
{ name: "Riz arborio", icon: "🍚", allergenNames: [] },
{ name: "Burrata", icon: "🧀", allergenNames: ["Lait"] },
{ name: "Ricotta", icon: "🧀", allergenNames: ["Lait"] },
{ name: "Pecorino", icon: "🧀", allergenNames: ["Lait"] },
{ name: "Gorgonzola", icon: "🧀", allergenNames: ["Lait"] },
{ name: "Prosciutto", icon: "🍖", allergenNames: [] },
{ name: "Pancetta", icon: "🥓", allergenNames: [] },
{ name: "Mortadelle", icon: "🍖", allergenNames: [] },
{ name: "Salami", icon: "🍖", allergenNames: [] },
{ name: "Pesto", icon: "🫙", allergenNames: ["Lait", "Fruits à coque"] },
{
name: "Burrata",
icon: "🧀",
allergenNames: ["Lait"],
dietNames: ["Végétarien", "Pescétarien"],
},
{
name: "Ricotta",
icon: "🧀",
allergenNames: ["Lait"],
dietNames: ["Végétarien", "Pescétarien"],
},
{
name: "Pecorino",
icon: "🧀",
allergenNames: ["Lait"],
dietNames: ["Végétarien", "Pescétarien"],
},
{
name: "Gorgonzola",
icon: "🧀",
allergenNames: ["Lait"],
dietNames: ["Végétarien", "Pescétarien"],
},
{ name: "Prosciutto", icon: "🍖", allergenNames: [], dietNames: [] },
{ name: "Pancetta", icon: "🥓", allergenNames: [], dietNames: [] },
{ name: "Mortadelle", icon: "🍖", allergenNames: [], dietNames: [] },
{ name: "Salami", icon: "🍖", allergenNames: [], dietNames: [] },
{
name: "Pesto",
icon: "🫙",
allergenNames: ["Lait", "Fruits à coque"],
dietNames: ["Végétarien", "Pescétarien"],
},
{ name: "Tomates cerises", icon: "🍅", allergenNames: [] },
{ name: "Focaccia", icon: "🍞", allergenNames: ["Gluten"] },
{ name: "Ciabatta", icon: "🍞", allergenNames: ["Gluten"] },
@ -456,8 +589,14 @@ const INGREDIENT_GROUPS: Array<{ category: IngredientCategory; items: Ingredient
},
{
category: "CUISINE_ASIATIQUE",
defaultDiets: ["Végétarien", "Végan", "Pescétarien"],
items: [
{ name: "Sauce huître", icon: "🫙", allergenNames: ["Mollusques"] },
{
name: "Sauce huître",
icon: "🫙",
allergenNames: ["Mollusques"],
dietNames: ["Pescétarien"],
},
{ name: "Sauce hoisin", icon: "🫙", allergenNames: ["Soja"] },
{ name: "Sauce sriracha", icon: "🫙", allergenNames: [] },
{ name: "Sauce sweet chili", icon: "🫙", allergenNames: [] },
@ -496,14 +635,25 @@ const INGREDIENT_GROUPS: Array<{ category: IngredientCategory; items: Ingredient
{ name: "Sucre de palme", icon: "🍬", allergenNames: [] },
{ name: "Pousses de bambou", allergenNames: [] },
{ name: "Châtaignes d'eau", allergenNames: [] },
{ name: "Poisson séché", icon: "🐟", allergenNames: ["Poissons"] },
{ name: "Pâte de crevettes", icon: "🫙", allergenNames: ["Crustacés"] },
{
name: "Poisson séché",
icon: "🐟",
allergenNames: ["Poissons"],
dietNames: ["Pescétarien"],
},
{
name: "Pâte de crevettes",
icon: "🫙",
allergenNames: ["Crustacés"],
dietNames: ["Pescétarien"],
},
{ name: "Pâte de curry rouge (thaï)", icon: "🍛", allergenNames: [] },
{ name: "Pâte de curry vert (thaï)", icon: "🍛", allergenNames: [] },
],
},
{
category: "CUISINE_MEXICAINE",
defaultDiets: ["Végétarien", "Végan", "Pescétarien"],
items: [
{ name: "Tortilla de maïs", icon: "🌮", allergenNames: [] },
{ name: "Tortilla de blé", icon: "🌮", allergenNames: ["Gluten"] },
@ -513,11 +663,17 @@ const INGREDIENT_GROUPS: Array<{ category: IngredientCategory; items: Ingredient
{ name: "Piment poblano", icon: "🌶️", allergenNames: [] },
{ name: "Piment habanero", icon: "🌶️", allergenNames: [] },
{ name: "Masa harina", icon: "🌽", allergenNames: [] },
{ name: "Cheddar", icon: "🧀", allergenNames: ["Lait"] },
{
name: "Cheddar",
icon: "🧀",
allergenNames: ["Lait"],
dietNames: ["Végétarien", "Pescétarien"],
},
],
},
{
category: "MAGHREB_MOYEN_ORIENT",
defaultDiets: ["Végétarien", "Végan", "Pescétarien"],
items: [
{ name: "Ras el hanout", icon: "🌿", allergenNames: [] },
{ name: "Za'atar", icon: "🌿", allergenNames: ["Graines de sésame"] },
@ -530,15 +686,31 @@ const INGREDIENT_GROUPS: Array<{ category: IngredientCategory; items: Ingredient
// seeded under `CEREALES_FECULENTS` above — this category covers the
// shapes specifically reached for to build a sandwich, plus a few
// international flatbreads not tied to one cuisine category above.
defaultDiets: ["Végétarien", "Végan", "Pescétarien"],
items: [
{ name: "Pain à burger", icon: "🍔", allergenNames: ["Gluten", "Lait", "Œufs"] },
{ name: "Pain brioché", icon: "🍞", allergenNames: ["Gluten", "Lait", "Œufs"] },
{
name: "Pain à burger",
icon: "🍔",
allergenNames: ["Gluten", "Lait", "Œufs"],
dietNames: ["Végétarien", "Pescétarien"],
},
{
name: "Pain brioché",
icon: "🍞",
allergenNames: ["Gluten", "Lait", "Œufs"],
dietNames: ["Végétarien", "Pescétarien"],
},
{ name: "Pain à hot-dog", icon: "🌭", allergenNames: ["Gluten"] },
{ name: "Pain pita", icon: "🫓", allergenNames: ["Gluten"] },
{ name: "Pain bagel", icon: "🥯", allergenNames: ["Gluten"] },
{ name: "Naan", icon: "🫓", allergenNames: ["Gluten"] },
{ name: "Pain wrap", icon: "🫓", allergenNames: ["Gluten"] },
{ name: "Pain viennois", icon: "🍞", allergenNames: ["Gluten", "Lait"] },
{
name: "Pain viennois",
icon: "🍞",
allergenNames: ["Gluten", "Lait"],
dietNames: ["Végétarien", "Pescétarien"],
},
{ name: "Pain de campagne", icon: "🍞", allergenNames: ["Gluten"] },
{ name: "Pain aux céréales", icon: "🍞", allergenNames: ["Gluten"] },
{ name: "Petit pain", icon: "🍞", allergenNames: ["Gluten"] },
@ -550,6 +722,7 @@ const INGREDIENT_GROUPS: Array<{ category: IngredientCategory; items: Ingredient
},
{
category: "EPICERIE_DIVERS",
defaultDiets: ["Végétarien", "Végan", "Pescétarien"],
items: [
{ name: "Bicarbonate de soude", icon: "🫙", allergenNames: [] },
{ name: "Fécule de pomme de terre", icon: "🫙", allergenNames: [] },
@ -557,6 +730,7 @@ const INGREDIENT_GROUPS: Array<{ category: IngredientCategory; items: Ingredient
},
{
category: "LIQUIDES_BOISSONS",
defaultDiets: ["Végétarien", "Végan", "Pescétarien"],
items: [
{ name: "Eau", icon: "💧", allergenNames: [] },
{ name: "Eau gazeuse", icon: "💧", allergenNames: [] },
@ -581,13 +755,27 @@ const INGREDIENT_GROUPS: Array<{ category: IngredientCategory; items: Ingredient
{ name: "Whisky", icon: "🥃", allergenNames: [] },
{ name: "Vodka", icon: "🥃", allergenNames: [] },
{ name: "Sirop de sucre de canne", icon: "🫙", allergenNames: [] },
{ name: "Fumet de poisson", icon: "🫙", allergenNames: ["Poissons"] },
{
name: "Fumet de poisson",
icon: "🫙",
allergenNames: ["Poissons"],
dietNames: ["Pescétarien"],
},
// Plant-based milk/cream substitutes — moved here from
// `PRODUITS_LAITIERS_OEUFS` (they're not dairy; see that group's
// comment) alongside the other drinkable/pourable liquids.
{ name: "Lait de coco", icon: "🥥", allergenNames: [] },
{ name: "Crème de coco", icon: "🥥", allergenNames: [] },
{ name: "Lait d'amande", icon: "🥛", allergenNames: ["Fruits à coque"] },
{ name: "Lait d'avoine", icon: "🥛", allergenNames: ["Gluten"] },
],
},
];
const INGREDIENTS: Array<IngredientSeed & { category: IngredientCategory }> =
INGREDIENT_GROUPS.flatMap(({ category, items }) => items.map((item) => ({ ...item, category })));
const INGREDIENTS: Array<IngredientSeed & { category: IngredientCategory; dietNames: string[] }> =
INGREDIENT_GROUPS.flatMap(({ category, defaultDiets, items }) =>
items.map((item) => ({ ...item, category, dietNames: item.dietNames ?? defaultDiets })),
);
/**
* Populates the `Diet`/`Category`/`Allergy` reference tables. Idempotent
@ -675,4 +863,23 @@ export async function seedReferenceData(prisma: PrismaClient): Promise<void> {
if (links.length > 0) {
await prisma.ingredientAllergy.createMany({ data: links, skipDuplicates: true });
}
// Same bulk-insert approach as the allergy links above, resolved against
// `dietNames` (item override, falling back to its group's `defaultDiets`
// in the `INGREDIENTS` flatten step) instead of `allergenNames`.
const diets = await prisma.diet.findMany();
const dietIdByName = new Map(diets.map((d) => [d.name, d.id]));
const dietLinks: Array<{ ingredientId: number; dietId: number }> = [];
for (const { name, dietNames } of INGREDIENTS) {
const ingredientId = ingredientIdByName.get(name);
if (ingredientId === undefined) continue;
for (const dietName of dietNames) {
const dietId = dietIdByName.get(dietName);
if (dietId !== undefined) dietLinks.push({ ingredientId, dietId });
}
}
if (dietLinks.length > 0) {
await prisma.ingredientDiet.createMany({ data: dietLinks, skipDuplicates: true });
}
}

View file

@ -19,7 +19,10 @@ function recipeInclude(viewerId: number) {
ingredients: {
include: {
ingredient: {
include: { allergies: { include: { allergy: { include: { category: true } } } } },
include: {
allergies: { include: { allergy: { include: { category: true } } } },
diets: { include: { diet: true } },
},
},
},
},
@ -30,10 +33,10 @@ function recipeInclude(viewerId: number) {
}
type RecipeWithDetails = Prisma.RecipeGetPayload<{ include: ReturnType<typeof recipeInclude> }>;
type IngredientWithAllergies = RecipeWithDetails["ingredients"][number]["ingredient"];
type IngredientWithDetails = RecipeWithDetails["ingredients"][number]["ingredient"];
/** Shapes a Prisma `Ingredient` (with its `allergies` relation included) into the public {@link IngredientView} — same aplattening as `reference.service.ts`'s `getIngredients`. */
function toIngredientView(ingredient: IngredientWithAllergies): IngredientView {
/** Shapes a Prisma `Ingredient` (with its `allergies`/`diets` relations included) into the public {@link IngredientView} — same aplattening as `reference.service.ts`'s `getIngredients`. */
function toIngredientView(ingredient: IngredientWithDetails): IngredientView {
return {
id: ingredient.id,
name: ingredient.name,
@ -44,6 +47,7 @@ function toIngredientView(ingredient: IngredientWithAllergies): IngredientView {
name: allergy.category.name,
kind: allergy.category.kind,
})),
diets: ingredient.diets.map(({ diet }) => ({ id: diet.id, name: diet.name })),
};
}

View file

@ -26,14 +26,16 @@ export async function getAllergies(): Promise<AllergyView[]> {
/**
* All reference ingredients, alphabetically, each resolved to its allergens
* (see `IngredientAllergy` in schema.prisma) same aplattening approach as
* {@link getAllergies}. Ingredients with no linked allergen come back with
* `allergens: []`.
* (see `IngredientAllergy` in schema.prisma) and compatible diet regimes
* (see `IngredientDiet`) same aplattening approach as {@link getAllergies}.
* Ingredients with no linked allergen/diet come back with `allergens: []`/
* `diets: []`.
*/
export async function getIngredients(): Promise<IngredientView[]> {
const ingredients = await prisma.ingredient.findMany({
include: {
allergies: { include: { allergy: { include: { category: true } } } },
diets: { include: { diet: true } },
},
orderBy: { name: "asc" },
});
@ -47,5 +49,6 @@ export async function getIngredients(): Promise<IngredientView[]> {
name: allergy.category.name,
kind: allergy.category.kind,
})),
diets: ingredient.diets.map(({ diet }) => ({ id: diet.id, name: diet.name })),
}));
}

View file

@ -54,7 +54,7 @@ describe("Reference data", () => {
expect(res.status).to.equal(200);
expect(res.body.length).to.be.greaterThan(0);
expect(res.body.map((i: { name: string }) => i.name)).to.include("Tomate");
expect(res.body[0]).to.have.keys(["id", "name", "icon", "category", "allergens"]);
expect(res.body[0]).to.have.keys(["id", "name", "icon", "category", "allergens", "diets"]);
});
it("resolves each ingredient's linked allergens, empty for one with none", async () => {

View file

@ -6,6 +6,7 @@ import {
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { AllergenBadges } from "./AllergenBadges";
import { DietBadges } from "./DietBadges";
import "./recipes.scss";
/** "No category filter" — a UI-only pseudo-category, never sent to/received from the API (see {@link INGREDIENT_CATEGORIES} for the real, closed set). */
@ -100,6 +101,7 @@ export function IngredientPicker({
</span>
<span className="ingredient-picker__card-name">{ingredient.name}</span>
<AllergenBadges allergens={ingredient.allergens} />
<DietBadges diets={ingredient.diets} />
</button>
))}
</div>

View file

@ -1,6 +1,7 @@
import type { IngredientView } from "@batch-cooking/shared";
import { useTranslation } from "react-i18next";
import { AllergenBadges } from "./AllergenBadges";
import { DietBadges } from "./DietBadges";
import "./recipes.scss";
/** One selected ingredient line in the recipe form — the ingredient itself (picked via `IngredientPicker`) plus its quantity/unit for this recipe. Quantity/unit are kept as raw strings while editing (not parsed to a number until submit) so an in-progress/invalid value doesn't fight the input. */
@ -45,6 +46,7 @@ export function IngredientRow({
aria-label={t("recipes.form.unitLabel")}
/>
<AllergenBadges allergens={ingredient.allergens} />
<DietBadges diets={ingredient.diets} />
<button
type="button"
className="ingredient-row__remove"

View file

@ -889,6 +889,12 @@
// ingredient picker but without quantity/unit see
// `features/profile/DislikedIngredientsField.tsx`.
.disliked-ingredients-field {
// `<fieldset>` carries a browser-default `min-width: min-content` that
// ordinary block elements don't — it refuses to shrink for its content,
// so `.ingredient-picker__grid` below (a `repeat(auto-fill, )` grid)
// never gets narrow enough to wrap and blows out the whole page's width
// instead. Reset it back to the normal shrinkable behavior.
min-width: 0;
border: none;
padding: 0;
margin: var(--space-md) 0 0;

View file

@ -72,9 +72,15 @@ export type IngredientCategory = (typeof INGREDIENT_CATEGORIES)[number];
*
* `allergens` is resolved server-side from the `IngredientAllergy` join
* table empty for an ingredient that carries none of the 14 EU-regulated
* allergens. Used by the recipe catalog (`apps/web`'s recipe form and detail
* page) to pick ingredients and to surface which allergens a recipe
* contains, aggregated across its ingredients.
* allergens. `diets` is resolved from `IngredientDiet` the same way the
* regimes this ingredient is compatible with (e.g. `Végétarien`, `Végan`),
* so the picker can flag it without the user opening its packaging. Omits
* `Omnivore` (every ingredient qualifies, so it's never stored) and
* `Sans gluten` (already derivable from whether `allergens` contains
* `Gluten` see `IngredientDiet` in schema.prisma). Used by the recipe
* catalog (`apps/web`'s recipe form and detail page) to pick ingredients and
* to surface which allergens/regimes a recipe contains, aggregated across
* its ingredients.
*/
export interface IngredientView {
id: number;
@ -82,4 +88,5 @@ export interface IngredientView {
icon: string | null;
category: IngredientCategory;
allergens: AllergyView[];
diets: DietView[];
}