From 978fe71a11425d4147fb0a3420827e5976db4600 Mon Sep 17 00:00:00 2001 From: Nicolas Date: Wed, 19 Aug 2026 19:09:39 +0200 Subject: [PATCH 1/2] =?UTF-8?q?feat(recipes):=20flag=20les=20ingr=C3=A9die?= =?UTF-8?q?nts=20faisables=20maison=20+=20suggestion=20de=20recherche?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remplace la FK morte `Ingredient.alternateRecipeId` (jamais branchée nulle part — confirmé par exploration : zéro usage en dehors de schema.prisma) par un flag booléen `reproducible`, plus simple : pas de liaison recette↔ingrédient en base, juste une info "ça vaut le coup d'être fait maison" plus un raccourci de recherche. - Migration : drop `alternate_recipe` (colonne + FK), ajoute `reproducible BOOLEAN NOT NULL DEFAULT false` sur `ingredients`. - `reference-seed-data.ts` : `IngredientSeed` gagne `reproducible?`, threadé dans le flatten + la réconciliation `seedReferenceData`. Premier lot de 27 ingrédients marqués (pains, pâtes à cuire, sauces de base, bouillons/fonds) — même logique que la curation Ciqual : un lot solide plutôt qu'exhaustif sur les 546 ingrédients. - `IngredientView` (shared) + les deux endroits qui la construisent (`reference.service.ts`, `recipe.service.ts`) gagnent `reproducible`. - `ReproducibleBadge` (nouveau) : pastille "Faisable maison" — simple dans `IngredientPicker` (avec son propre toggle d'affichage), lien cliquable dans `IngredientRow` vers `/recettes?search=` ouvert dans un nouvel onglet (pour ne jamais perdre le formulaire de recette en cours — pas de persistance de brouillon dans `RecipeFormPage`). - `RecipesPage` lit `?search=` au montage pour permettre ce deep-link. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 5 --- .../migration.sql | 9 ++ apps/api/prisma/schema.prisma | 33 +++--- apps/api/src/db/reference-seed-data.ts | 103 ++++++++++++++---- apps/api/src/modules/recipe/recipe.service.ts | 1 + .../modules/reference/reference.service.ts | 1 + apps/api/test/reference.test.ts | 1 + .../src/features/recipes/IngredientPicker.tsx | 6 + .../src/features/recipes/IngredientRow.tsx | 5 + .../features/recipes/ReproducibleBadge.tsx | 49 +++++++++ apps/web/src/features/recipes/recipes.scss | 28 +++++ apps/web/src/locales/fr/translation.json | 3 + apps/web/src/pages/RecipesPage.tsx | 15 ++- packages/shared/src/types/reference.ts | 2 + 13 files changed, 216 insertions(+), 40 deletions(-) create mode 100644 apps/api/prisma/migrations/20260819165510_ingredient_reproducible_flag/migration.sql create mode 100644 apps/web/src/features/recipes/ReproducibleBadge.tsx diff --git a/apps/api/prisma/migrations/20260819165510_ingredient_reproducible_flag/migration.sql b/apps/api/prisma/migrations/20260819165510_ingredient_reproducible_flag/migration.sql new file mode 100644 index 0000000..1e85df8 --- /dev/null +++ b/apps/api/prisma/migrations/20260819165510_ingredient_reproducible_flag/migration.sql @@ -0,0 +1,9 @@ +-- Replaces the never-wired-up `Ingredient.alternateRecipeId` FK (zero +-- usage anywhere outside schema.prisma — confirmed by repo-wide grep) +-- with a plain boolean flag: whether this ingredient is reasonably +-- makeable at home. Product decision: no ingredient↔recipe linking in +-- the database — the recipe form only nudges the author toward the +-- recipe catalog's own search, pre-filled with the ingredient's name. +ALTER TABLE "ingredients" DROP CONSTRAINT "ingredients_alternate_recipe_fkey"; +ALTER TABLE "ingredients" DROP COLUMN "alternate_recipe"; +ALTER TABLE "ingredients" ADD COLUMN "reproducible" BOOLEAN NOT NULL DEFAULT false; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index b7af9b9..5a42046 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -259,8 +259,6 @@ model Recipe { planningItems PlanningItem[] favoritedBy RecipeFavorite[] diets RecipeDiet[] - /// Ingredients for which this recipe is offered as a make-it-yourself alternative. - alternateFor Ingredient[] @relation("IngredientAlternateRecipe") @@map("recipe") } @@ -432,20 +430,29 @@ enum IngredientIcon { } model Ingredient { - id Int @id @default(autoincrement()) - key String @unique - icon IngredientIcon @default(JAR) - category IngredientCategory @default(EPICERIE_SECHE) - subcategory IngredientSubcategory @default(AUTRES) - alternateRecipeId Int? @map("alternate_recipe") + id Int @id @default(autoincrement()) + key String @unique + icon IngredientIcon @default(JAR) + category IngredientCategory @default(EPICERIE_SECHE) + subcategory IngredientSubcategory @default(AUTRES) + /// Whether this ingredient is reasonably makeable at home (a burger bun, + /// a béchamel) rather than something you'd only ever buy (a raw + /// vegetable, a specific cut of meat) — surfaced in the recipe form as a + /// badge/link nudging the author to go check the recipe catalog for a + /// "make it yourself" recipe (see `apps/web`'s `IngredientRow`/ + /// `IngredientPicker`). Deliberately just a flag, not a link to a + /// specific recipe — replaces an earlier, never-wired-up + /// `alternateRecipeId` FK (product decision discussed in chat: no + /// ingredient↔recipe linking in the database, the UI only pre-fills the + /// catalog's own search with this ingredient's name). + reproducible Boolean @default(false) - alternateRecipe Recipe? @relation("IngredientAlternateRecipe", fields: [alternateRecipeId], references: [id], onDelete: SetNull) - recipes RecipeIngredient[] - allergies IngredientAllergy[] + recipes RecipeIngredient[] + allergies IngredientAllergy[] /// Profiles that personally dislike this ingredient — see {@link UserProfileDislikedIngredient}. - dislikedBy UserProfileDislikedIngredient[] + dislikedBy UserProfileDislikedIngredient[] /// Diet regimes this ingredient is compatible with — see {@link IngredientDiet}. - diets IngredientDiet[] + diets IngredientDiet[] @@map("ingredients") } diff --git a/apps/api/src/db/reference-seed-data.ts b/apps/api/src/db/reference-seed-data.ts index 1d7d63a..a827435 100644 --- a/apps/api/src/db/reference-seed-data.ts +++ b/apps/api/src/db/reference-seed-data.ts @@ -64,6 +64,18 @@ interface IngredientSeed { * why). */ dietNames?: string[]; + /** + * Whether this ingredient is reasonably makeable at home (a burger bun, a + * béchamel) rather than something you'd only ever buy (a raw vegetable, a + * specific cut of meat) — see `Ingredient.reproducible` in schema.prisma. + * Omitted (falsy) by default; only set `true` on the curated subset this + * is actually true for. Never group-level (unlike `defaultDiets`/ + * `defaultIcon`) — even a homogeneous-looking group like "Pains" mixes + * genuinely home-bakeable items (`Pain`, `Naan`) with ones nobody + * realistically bakes from scratch (`Pain de seigle`, `Biscotte`), so this + * needs a per-item judgment call, not a group default. + */ + reproducible?: boolean; } // A broad pantry list — the goal is to cover the large majority of what a @@ -425,14 +437,15 @@ export const INGREDIENT_GROUPS: Array<{ defaultDiets: ["Végétarien", "Végan", "Pescétarien"], defaultIcon: "BREAD", items: [ - { name: "Pain", allergenNames: ["Gluten"] }, - { name: "Pain de mie", allergenNames: ["Gluten"] }, + { name: "Pain", reproducible: true, allergenNames: ["Gluten"] }, + { name: "Pain de mie", reproducible: true, allergenNames: ["Gluten"] }, { name: "Pain complet", allergenNames: ["Gluten"] }, { name: "Baguette", allergenNames: ["Gluten"] }, { name: "Pain de seigle", allergenNames: ["Gluten"] }, - { name: "Chapelure", allergenNames: ["Gluten"] }, + { name: "Chapelure", reproducible: true, allergenNames: ["Gluten"] }, { name: "Pain à burger", + reproducible: true, allergenNames: ["Gluten", "Lait", "Œufs"], dietNames: ["Végétarien", "Pescétarien"], }, @@ -441,10 +454,10 @@ export const INGREDIENT_GROUPS: Array<{ allergenNames: ["Gluten", "Lait", "Œufs"], dietNames: ["Végétarien", "Pescétarien"], }, - { name: "Pain à hot-dog", allergenNames: ["Gluten"] }, - { name: "Pain pita", allergenNames: ["Gluten"] }, - { name: "Pain bagel", allergenNames: ["Gluten"] }, - { name: "Naan", allergenNames: ["Gluten"] }, + { name: "Pain à hot-dog", reproducible: true, allergenNames: ["Gluten"] }, + { name: "Pain pita", reproducible: true, allergenNames: ["Gluten"] }, + { name: "Pain bagel", reproducible: true, allergenNames: ["Gluten"] }, + { name: "Naan", reproducible: true, allergenNames: ["Gluten"] }, { name: "Pain wrap", allergenNames: ["Gluten"] }, { name: "Pain viennois", @@ -457,9 +470,9 @@ export const INGREDIENT_GROUPS: Array<{ { name: "Pain suédois", allergenNames: ["Gluten"] }, { name: "Pain sans gluten", allergenNames: [] }, { name: "Biscotte", allergenNames: ["Gluten"] }, - { name: "Croûtons", allergenNames: ["Gluten"] }, - { name: "Focaccia", allergenNames: ["Gluten"] }, - { name: "Ciabatta", allergenNames: ["Gluten"] }, + { name: "Croûtons", reproducible: true, allergenNames: ["Gluten"] }, + { name: "Focaccia", reproducible: true, allergenNames: ["Gluten"] }, + { name: "Ciabatta", reproducible: true, allergenNames: ["Gluten"] }, { name: "Tortilla de maïs", allergenNames: [] }, { name: "Tortilla de blé", allergenNames: ["Gluten"] }, ], @@ -472,17 +485,20 @@ export const INGREDIENT_GROUPS: Array<{ items: [ { name: "Pâte feuilletée", + reproducible: true, allergenNames: ["Gluten", "Lait"], dietNames: ["Végétarien", "Pescétarien"], }, { name: "Pâte brisée", + reproducible: true, allergenNames: ["Gluten", "Lait"], dietNames: ["Végétarien", "Pescétarien"], }, - { name: "Pâte à pizza", allergenNames: ["Gluten"] }, + { name: "Pâte à pizza", reproducible: true, allergenNames: ["Gluten"] }, { name: "Pâte à tarte sablée", + reproducible: true, allergenNames: ["Gluten", "Lait"], dietNames: ["Végétarien", "Pescétarien"], }, @@ -604,10 +620,11 @@ export const INGREDIENT_GROUPS: Array<{ { name: "Moutarde", allergenNames: ["Moutarde"] }, { name: "Mayonnaise", + reproducible: true, allergenNames: ["Œufs"], dietNames: ["Végétarien", "Pescétarien"], }, - { name: "Ketchup", allergenNames: [] }, + { name: "Ketchup", reproducible: true, allergenNames: [] }, { name: "Tabasco", allergenNames: [] }, { name: "Sauce Worcestershire", @@ -625,7 +642,7 @@ export const INGREDIENT_GROUPS: Array<{ { name: "Beurre de cacahuète", allergenNames: ["Arachides"] }, { name: "Moutarde de Dijon", allergenNames: ["Moutarde"] }, { name: "Moutarde à l'ancienne", allergenNames: ["Moutarde"] }, - { name: "Sauce barbecue", allergenNames: [] }, + { name: "Sauce barbecue", reproducible: true, allergenNames: [] }, { name: "Sauce tartare", allergenNames: ["Œufs"], @@ -648,6 +665,7 @@ export const INGREDIENT_GROUPS: Array<{ }, { name: "Sauce béchamel", + reproducible: true, allergenNames: ["Lait", "Gluten"], dietNames: ["Végétarien", "Pescétarien"], }, @@ -665,6 +683,7 @@ export const INGREDIENT_GROUPS: Array<{ }, { name: "Pesto", + reproducible: true, allergenNames: ["Lait", "Fruits à coque"], dietNames: ["Végétarien", "Pescétarien"], }, @@ -684,7 +703,7 @@ export const INGREDIENT_GROUPS: Array<{ }, { name: "Pâte de curry rouge (thaï)", allergenNames: [] }, { name: "Pâte de curry vert (thaï)", allergenNames: [] }, - { name: "Tahini", allergenNames: ["Graines de sésame"] }, + { name: "Tahini", reproducible: true, allergenNames: ["Graines de sésame"] }, ], }, { @@ -764,8 +783,20 @@ export const INGREDIENT_GROUPS: Array<{ { name: "Coulis de tomate", icon: "JAR", allergenNames: [] }, { name: "Tomates pelées (conserve)", icon: "JAR", allergenNames: [] }, { name: "Tomates séchées", icon: "JAR", allergenNames: [] }, - { name: "Fond de veau", icon: "STOCK_POT", allergenNames: [], dietNames: [] }, - { name: "Fond de volaille", icon: "STOCK_POT", allergenNames: [], dietNames: [] }, + { + name: "Fond de veau", + icon: "STOCK_POT", + reproducible: true, + allergenNames: [], + dietNames: [], + }, + { + name: "Fond de volaille", + icon: "STOCK_POT", + reproducible: true, + allergenNames: [], + dietNames: [], + }, { name: "Bouillon cube bœuf", icon: "STOCK_POT", @@ -778,14 +809,26 @@ export const INGREDIENT_GROUPS: Array<{ allergenNames: ["Poissons", "Céleri"], dietNames: ["Pescétarien"], }, - { name: "Bouillon de légumes", icon: "STOCK_POT", allergenNames: ["Céleri"] }, + { + name: "Bouillon de légumes", + icon: "STOCK_POT", + reproducible: true, + allergenNames: ["Céleri"], + }, { name: "Bouillon de volaille", icon: "STOCK_POT", + reproducible: true, + allergenNames: ["Céleri"], + dietNames: [], + }, + { + name: "Bouillon de bœuf", + icon: "STOCK_POT", + reproducible: true, allergenNames: ["Céleri"], dietNames: [], }, - { name: "Bouillon de bœuf", icon: "STOCK_POT", allergenNames: ["Céleri"], dietNames: [] }, { name: "Court-bouillon", icon: "STOCK_POT", allergenNames: [] }, { name: "Dashi (bouillon japonais)", @@ -808,6 +851,7 @@ export const INGREDIENT_GROUPS: Array<{ { name: "Fumet de poisson", icon: "STOCK_POT", + reproducible: true, allergenNames: ["Poissons"], dietNames: ["Pescétarien"], }, @@ -864,11 +908,12 @@ export const INGREDIENT_GROUPS: Array<{ ]; const INGREDIENTS: Array< - Omit & { + Omit & { category: IngredientCategory; subcategory: IngredientSubcategory; icon: IngredientIcon; dietNames: string[]; + reproducible: boolean; } > = INGREDIENT_GROUPS.flatMap(({ category, subcategory, defaultDiets, defaultIcon, items }) => items.map((item) => ({ @@ -877,6 +922,7 @@ const INGREDIENTS: Array< subcategory, icon: item.icon ?? defaultIcon, dietNames: item.dietNames ?? defaultDiets, + reproducible: item.reproducible ?? false, })), ); @@ -934,18 +980,26 @@ export async function seedReferenceData(prisma: PrismaClient): Promise { const ingredientKeys = INGREDIENTS.map((i) => getEnglishKey(i.name)); const existingIngredients = await prisma.ingredient.findMany({ where: { key: { in: ingredientKeys } }, - select: { id: true, key: true, icon: true, category: true, subcategory: true }, + select: { + id: true, + key: true, + icon: true, + category: true, + subcategory: true, + reproducible: true, + }, }); const existingByKey = new Map(existingIngredients.map((i) => [i.key, i])); const missingIngredients = INGREDIENTS.filter((i) => !existingByKey.has(getEnglishKey(i.name))); if (missingIngredients.length > 0) { await prisma.ingredient.createMany({ - data: missingIngredients.map(({ name, icon, category, subcategory }) => ({ + data: missingIngredients.map(({ name, icon, category, subcategory, reproducible }) => ({ key: getEnglishKey(name), icon, category, subcategory, + reproducible, })), }); } @@ -956,13 +1010,14 @@ export async function seedReferenceData(prisma: PrismaClient): Promise { existing && (existing.icon !== i.icon || existing.category !== i.category || - existing.subcategory !== i.subcategory) + existing.subcategory !== i.subcategory || + existing.reproducible !== i.reproducible) ); }); - for (const { name, icon, category, subcategory } of changed) { + for (const { name, icon, category, subcategory, reproducible } of changed) { await prisma.ingredient.update({ where: { key: getEnglishKey(name) }, - data: { icon, category, subcategory }, + data: { icon, category, subcategory, reproducible }, }); } diff --git a/apps/api/src/modules/recipe/recipe.service.ts b/apps/api/src/modules/recipe/recipe.service.ts index bcb1608..aa8baa4 100644 --- a/apps/api/src/modules/recipe/recipe.service.ts +++ b/apps/api/src/modules/recipe/recipe.service.ts @@ -43,6 +43,7 @@ function toIngredientView(ingredient: IngredientWithDetails): IngredientView { icon: ingredient.icon, category: ingredient.category, subcategory: ingredient.subcategory, + reproducible: ingredient.reproducible, allergens: ingredient.allergies.map(({ allergy }) => ({ id: allergy.id, key: allergy.category.key, diff --git a/apps/api/src/modules/reference/reference.service.ts b/apps/api/src/modules/reference/reference.service.ts index 247d4d2..749285a 100644 --- a/apps/api/src/modules/reference/reference.service.ts +++ b/apps/api/src/modules/reference/reference.service.ts @@ -53,6 +53,7 @@ export async function getIngredients(): Promise { icon: ingredient.icon, category: ingredient.category, subcategory: ingredient.subcategory, + reproducible: ingredient.reproducible, allergens: ingredient.allergies.map(({ allergy }) => ({ id: allergy.id, key: allergy.category.key, diff --git a/apps/api/test/reference.test.ts b/apps/api/test/reference.test.ts index 8fb423d..5d4a867 100644 --- a/apps/api/test/reference.test.ts +++ b/apps/api/test/reference.test.ts @@ -62,6 +62,7 @@ describe("Reference data", () => { "icon", "category", "subcategory", + "reproducible", "allergens", "diets", ]); diff --git a/apps/web/src/features/recipes/IngredientPicker.tsx b/apps/web/src/features/recipes/IngredientPicker.tsx index 307d3a6..20ac680 100644 --- a/apps/web/src/features/recipes/IngredientPicker.tsx +++ b/apps/web/src/features/recipes/IngredientPicker.tsx @@ -11,6 +11,7 @@ import { CheckboxOption } from "../../components/ui/Checkbox"; import { SettingsIcon } from "../../layouts/nav-icons"; import { AllergenBadges } from "./AllergenBadges"; import { DietBadges } from "./DietBadges"; +import { ReproducibleBadge } from "./ReproducibleBadge"; import { CategoryIcon, IngredientTypeIcon, SubcategoryIcon } from "./ingredient-icons"; import "./recipes.scss"; @@ -60,6 +61,7 @@ export function IngredientPicker({ // badges per card too noisy while just browsing/searching by name. const [showAllergens, setShowAllergens] = useState(true); const [showDiets, setShowDiets] = useState(true); + const [showReproducible, setShowReproducible] = useState(true); const [isDisplayMenuOpen, setIsDisplayMenuOpen] = useState(false); function selectCategory(next: IngredientCategory | typeof ALL) { @@ -116,6 +118,9 @@ export function IngredientPicker({ {t("recipes.form.showAllergensLabel")} + + {t("recipes.form.showReproducibleLabel")} + )} @@ -184,6 +189,7 @@ export function IngredientPicker({ {showAllergens && } {showDiets && } + {showReproducible && } ))} diff --git a/apps/web/src/features/recipes/IngredientRow.tsx b/apps/web/src/features/recipes/IngredientRow.tsx index 7cb4a7e..7410f52 100644 --- a/apps/web/src/features/recipes/IngredientRow.tsx +++ b/apps/web/src/features/recipes/IngredientRow.tsx @@ -2,6 +2,7 @@ import type { IngredientView } from "@batch-cooking/shared"; import { useTranslation } from "react-i18next"; import { AllergenBadges } from "./AllergenBadges"; import { DietBadges } from "./DietBadges"; +import { ReproducibleBadge } from "./ReproducibleBadge"; import { IngredientTypeIcon } from "./ingredient-icons"; import "./recipes.scss"; @@ -48,6 +49,10 @@ export function IngredientRow({ /> +