From ba030ec18c196a8b4c2a6731518b23adb847bb22 Mon Sep 17 00:00:00 2001 From: Nicolas Date: Wed, 9 Sep 2026 14:15:28 +0200 Subject: [PATCH] =?UTF-8?q?feat(recipes):=20ingr=C3=A9dients=20d'assaisonn?= =?UTF-8?q?ement=20=C2=AB=20selon=20le=20go=C3=BBt=20=C2=BB=20sans=20quant?= =?UTF-8?q?it=C3=A9=20(#128)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `RecipeIngredient.quantity`/`unitId` deviennent nullable (migration purement additive) : une ligne de recette peut n'avoir ni quantité ni unité — sel, poivre… ajoutés selon le goût. Le schéma partagé impose « quantité et unité, ou aucune des deux » ; `toRecipeView` renvoie `null`/`null`. `aggregateShoppingList` fait apparaître une telle ligne une seule fois, sans quantité ni unité, jamais sommée ni mise à l'échelle. `ShoppingListPage` affiche « selon le goût » à la place de la quantité. Formulaire : `IngredientRow` gagne une case « Selon le goût » qui masque quantité/unité ; `RecipeFormPage` et `RecipeImportForm` n'exigent plus quantité/unité pour ces lignes et les envoient sans. Un import dont la source laisse une ligne résolue sans quantité (« salt to taste ») arrive directement en « selon le goût ». Le chemin clause-de-technique (session de cuisine) tolérait déjà `quantity` null — inchangé. Tests : Mocha (création/édition d'une ligne sans quantité, rejet quantité-sans-unité, liste de courses non sommée) ; Cypress (case à cocher + parcours), régression recette/liste de courses OK. Co-Authored-By: Claude Sonnet 5 --- .../migration.sql | 8 ++ apps/api/prisma/schema.prisma | 15 ++-- apps/api/src/modules/recipe/recipe.service.ts | 33 +++++--- .../shopping-list/shopping-list.service.ts | 25 ++++-- apps/api/test/recipe.test.ts | 83 +++++++++++++++++++ apps/api/test/shopping-list.test.ts | 70 ++++++++++++++++ apps/web/cypress/e2e/recipe-form.feature | 14 ++++ apps/web/cypress/e2e/recipe-form.ts | 19 +++++ apps/web/cypress/e2e/shopping-list.feature | 5 ++ apps/web/cypress/e2e/shopping-list.ts | 32 +++++++ .../recipes/ingredients/IngredientRow.tsx | 75 +++++++++++------ apps/web/src/features/recipes/recipes.scss | 10 +++ .../recipes/sources/RecipeImportForm.tsx | 40 ++++++--- apps/web/src/locales/fr/translation.json | 4 +- apps/web/src/pages/recipes/RecipeFormPage.tsx | 44 ++++++---- .../pages/shopping-list/ShoppingListPage.tsx | 11 ++- packages/shared/src/schemas/recipe.ts | 17 ++-- packages/shared/src/types/recipe.ts | 12 ++- packages/shared/src/types/shopping-list.ts | 10 ++- 19 files changed, 437 insertions(+), 90 deletions(-) create mode 100644 apps/api/prisma/migrations/20260909120000_recipe_ingredient_quantity_optional/migration.sql diff --git a/apps/api/prisma/migrations/20260909120000_recipe_ingredient_quantity_optional/migration.sql b/apps/api/prisma/migrations/20260909120000_recipe_ingredient_quantity_optional/migration.sql new file mode 100644 index 0000000..53cb39b --- /dev/null +++ b/apps/api/prisma/migrations/20260909120000_recipe_ingredient_quantity_optional/migration.sql @@ -0,0 +1,8 @@ +-- Ingrédients d'assaisonnement « selon le goût » (issue #128, puce 1 de #3) : +-- une ligne de recette peut désormais n'avoir ni quantité ni unité. Purement +-- additif — toutes les lignes existantes gardent leurs valeurs, aucune n'est +-- réécrite. + +-- AlterTable +ALTER TABLE "recipe_ingredient" ALTER COLUMN "quantity" DROP NOT NULL; +ALTER TABLE "recipe_ingredient" ALTER COLUMN "unit_id" DROP NOT NULL; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index cf5026d..d7cb5b8 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -698,14 +698,19 @@ model Unit { /// quantity per recipe, so this join table carries quantity + unit /// (project decision, not in the original spec doc). model RecipeIngredient { - recipeId Int @map("recipe_id") - ingredientId Int @map("ingredient_id") - quantity Decimal @db.Decimal(10, 2) - unitId Int @map("unit_id") + recipeId Int @map("recipe_id") + ingredientId Int @map("ingredient_id") + /// `null` (with `unitId` also `null`) = a "selon le goût" seasoning line — + /// salt, pepper… added to taste, with nothing to scale by portions or to + /// aggregate on a shopping list. The "both null or neither" invariant is + /// enforced service-side (and by `recipeIngredientInputSchema`), not by + /// the schema — same posture as this file's other cross-field invariants. + quantity Decimal? @db.Decimal(10, 2) + unitId Int? @map("unit_id") recipe Recipe @relation(fields: [recipeId], references: [id], onDelete: Cascade) ingredient Ingredient @relation(fields: [ingredientId], references: [id], onDelete: Cascade) - unit Unit @relation(fields: [unitId], references: [id]) + unit Unit? @relation(fields: [unitId], references: [id]) @@id([recipeId, ingredientId]) @@map("recipe_ingredient") diff --git a/apps/api/src/modules/recipe/recipe.service.ts b/apps/api/src/modules/recipe/recipe.service.ts index 92440ea..24389ed 100644 --- a/apps/api/src/modules/recipe/recipe.service.ts +++ b/apps/api/src/modules/recipe/recipe.service.ts @@ -84,8 +84,8 @@ type RecipeWithDetails = Prisma.RecipeGetPayload<{ }>; /** Exported — `shopping-list.service.ts` fetches its own, narrower ingredient include (no need for a whole `RecipeWithDetails`) but shapes the same `allergies`/`diets` nesting, so it reuses {@link toIngredientView} directly instead of re-deriving this type. */ export type IngredientWithDetails = RecipeWithDetails["ingredients"][number]["ingredient"]; -/** Exported — see {@link IngredientWithDetails}, same reuse by `shopping-list.service.ts`. */ -export type UnitWithDetails = RecipeWithDetails["ingredients"][number]["unit"]; +/** Exported — see {@link IngredientWithDetails}, same reuse by `shopping-list.service.ts`. `NonNullable` since a `RecipeIngredient.unit` is now `null` on a "selon le goût" line — callers guard that before calling {@link toUnitView}. */ +export type UnitWithDetails = NonNullable; /** Shapes a Prisma `Unit` row into the public {@link UnitView} — same "Decimal → number" conversion `reference.service.ts`'s `getUnits` does. Exported — reused as-is by `shopping-list.service.ts` (a shopping list resolves the same reference data, no need for a second copy of this mapping). */ export function toUnitView(unit: UnitWithDetails): UnitView { @@ -244,8 +244,9 @@ export function toStepTechStepViews( function toRecipeView(recipe: RecipeWithDetails): RecipeView { const ingredients = recipe.ingredients.map((recipeIngredient) => ({ ingredient: toIngredientView(recipeIngredient.ingredient), - quantity: Number(recipeIngredient.quantity), - unit: toUnitView(recipeIngredient.unit), + // Both `null` on a "selon le goût" seasoning line — see `RecipeIngredientView`. + quantity: recipeIngredient.quantity === null ? null : Number(recipeIngredient.quantity), + unit: recipeIngredient.unit === null ? null : toUnitView(recipeIngredient.unit), })); return { ...toRecipeSummaryView(recipe), @@ -578,11 +579,11 @@ async function matchStepsTechSteps( ); } -/** One resolved ingredient line — every `placeholderName` has been turned into a real (placeholder) `ingredientId`, ready for a `RecipeIngredient` create. */ +/** One resolved ingredient line — every `placeholderName` has been turned into a real (placeholder) `ingredientId`, ready for a `RecipeIngredient` create. `quantity`/`unitId` are both `null` for a "selon le goût" seasoning line. */ interface ResolvedIngredientLine { ingredientId: number; - quantity: number; - unitId: number; + quantity: number | null; + unitId: number | null; } /** @@ -615,8 +616,8 @@ async function resolveIngredientLines( if (line.ingredientId !== undefined) { resolved.push({ ingredientId: line.ingredientId, - quantity: line.quantity, - unitId: line.unitId, + quantity: line.quantity ?? null, + unitId: line.unitId ?? null, }); continue; } @@ -632,7 +633,11 @@ async function resolveIngredientLines( }, select: { id: true }, }); - resolved.push({ ingredientId: placeholder.id, quantity: line.quantity, unitId: line.unitId }); + resolved.push({ + ingredientId: placeholder.id, + quantity: line.quantity ?? null, + unitId: line.unitId ?? null, + }); createdPlaceholders.push({ id: placeholder.id, name }); } return { resolved, createdPlaceholders }; @@ -651,7 +656,9 @@ async function createRecipeInternal( await assertIngredientsExist( input.ingredients.map((i) => i.ingredientId).filter((id): id is number => id !== undefined), ); - await assertUnitsExist(input.ingredients.map((i) => i.unitId)); + await assertUnitsExist( + input.ingredients.flatMap((i) => (i.unitId === undefined ? [] : [i.unitId])), + ); await assertDietsExist(input.dietIds); // Matched up front (one call per step, in parallel) rather than inline // inside the `steps.create` map below — `techStepClassifier` is async @@ -752,7 +759,9 @@ export async function updateRecipe( .map((i) => i.ingredientId) .filter((ingredientId): ingredientId is number => ingredientId !== undefined), ); - await assertUnitsExist(input.ingredients.map((i) => i.unitId)); + await assertUnitsExist( + input.ingredients.flatMap((i) => (i.unitId === undefined ? [] : [i.unitId])), + ); await assertDietsExist(input.dietIds); const stepsWithTechSteps = await matchStepsTechSteps(input.steps, DEFAULT_TECH_STEP_LOCALE); diff --git a/apps/api/src/modules/shopping-list/shopping-list.service.ts b/apps/api/src/modules/shopping-list/shopping-list.service.ts index a66c1bc..e28d497 100644 --- a/apps/api/src/modules/shopping-list/shopping-list.service.ts +++ b/apps/api/src/modules/shopping-list/shopping-list.service.ts @@ -44,11 +44,11 @@ type PlanningWithIngredients = Prisma.PlanningGetPayload<{ include: ReturnType; }>; -/** Accumulates a running sum per `(ingredientId, unitId)` pair while walking every planning item's ingredient lines — see {@link aggregateShoppingList}. */ +/** Accumulates a running sum per `(ingredientId, unitId)` pair while walking every planning item's ingredient lines — see {@link aggregateShoppingList}. `unit`/`quantity` are both `null` for a "selon le goût" seasoning line: nothing to sum, surfaced once. */ interface RunningTotal { ingredient: IngredientView; - unit: UnitView; - quantity: number; + unit: UnitView | null; + quantity: number | null; } /** @@ -70,16 +70,31 @@ function aggregateShoppingList(items: PlanningWithIngredients["items"]): Shoppin for (const item of items) { const scale = item.portions / item.recipe.portions; for (const recipeIngredient of item.recipe.ingredients) { + // A "selon le goût" seasoning line (no quantity/unit) — surfaced once + // per ingredient, never scaled or summed. Its key can't collide with a + // real `(ingredient, unit)` line for the same ingredient. + if (recipeIngredient.quantity === null) { + const seasoningKey = `${recipeIngredient.ingredientId}:toTaste`; + if (!totals.has(seasoningKey)) { + totals.set(seasoningKey, { + ingredient: toIngredientView(recipeIngredient.ingredient), + unit: null, + quantity: null, + }); + } + continue; + } + const key = `${recipeIngredient.ingredientId}:${recipeIngredient.unitId}`; const addedQuantity = Number(recipeIngredient.quantity) * scale; const existing = totals.get(key); - if (existing) { + if (existing && existing.quantity !== null) { existing.quantity += addedQuantity; } else { totals.set(key, { ingredient: toIngredientView(recipeIngredient.ingredient), - unit: toUnitView(recipeIngredient.unit), + unit: recipeIngredient.unit === null ? null : toUnitView(recipeIngredient.unit), quantity: addedQuantity, }); } diff --git a/apps/api/test/recipe.test.ts b/apps/api/test/recipe.test.ts index 16db426..13e8985 100644 --- a/apps/api/test/recipe.test.ts +++ b/apps/api/test/recipe.test.ts @@ -652,6 +652,57 @@ describe("Recipes", () => { expect(zero.status).to.equal(400); expect(zero.body.code).to.equal(ErrorCode.VALIDATION_ERROR); }); + + it("creates a « selon le goût » seasoning line with no quantity or unit (issue #128)", async () => { + const { agent } = await signup(); + const tomate = await ingredientId("tomato"); + const sel = await ingredientId("salt"); + const gram = await unitId("gram"); + + const res = await agent.post("/recipes").send({ + name: "Tomates au sel", + portions: 2, + dietIds: [], + ingredients: [ + { ingredientId: tomate, quantity: 200, unitId: gram }, + { ingredientId: sel }, // selon le goût — no quantity/unit + ], + steps: [{ description: "Saler les tomates" }], + }); + + expect(res.status).to.equal(201); + const seasoning = res.body.ingredients.find( + (line: { ingredient: { key: string } }) => line.ingredient.key === "salt", + ); + expect(seasoning.quantity).to.equal(null); + expect(seasoning.unit).to.equal(null); + }); + + it("rejects an ingredient line with a quantity but no unit (or vice versa) with 400 VALIDATION_ERROR", async () => { + const { agent } = await signup(); + const tomate = await ingredientId("tomato"); + const gram = await unitId("gram"); + const base = { + name: "Test", + portions: 2, + dietIds: [], + steps: [{ description: "Étape" }], + }; + + const quantityOnly = await agent.post("/recipes").send({ + ...base, + ingredients: [{ ingredientId: tomate, quantity: 100 }], + }); + const unitOnly = await agent.post("/recipes").send({ + ...base, + ingredients: [{ ingredientId: tomate, unitId: gram }], + }); + + expect(quantityOnly.status).to.equal(400); + expect(quantityOnly.body.code).to.equal(ErrorCode.VALIDATION_ERROR); + expect(unitOnly.status).to.equal(400); + expect(unitOnly.body.code).to.equal(ErrorCode.VALIDATION_ERROR); + }); }); describe("GET /recipes/:id", () => { @@ -781,6 +832,38 @@ describe("Recipes", () => { expect(res.body.steps).to.have.length(2); }); + it("carries a « selon le goût » seasoning line through an edit (issue #128)", async () => { + const { agent } = await signup(); + const tomate = await ingredientId("tomato"); + const poivre = await ingredientId("blackPepper"); + const gram = await unitId("gram"); + const created = await agent.post("/recipes").send({ + name: "Salade", + portions: 4, + dietIds: [], + ingredients: [{ ingredientId: tomate, quantity: 100, unitId: gram }], + steps: [{ description: "Couper" }], + }); + + const res = await agent.patch(`/recipes/${created.body.id}`).send({ + name: "Salade poivrée", + portions: 4, + dietIds: [], + ingredients: [ + { ingredientId: tomate, quantity: 100, unitId: gram }, + { ingredientId: poivre }, // selon le goût + ], + steps: [{ description: "Couper" }, { description: "Poivrer" }], + }); + + expect(res.status).to.equal(200); + const seasoning = res.body.ingredients.find( + (line: { ingredient: { key: string } }) => line.ingredient.key === "blackPepper", + ); + expect(seasoning.quantity).to.equal(null); + expect(seasoning.unit).to.equal(null); + }); + it("recomputes each replaced step's technique sequence", async () => { const { agent } = await signup(); const tomate = await ingredientId("tomato"); diff --git a/apps/api/test/shopping-list.test.ts b/apps/api/test/shopping-list.test.ts index 5c282dc..1795677 100644 --- a/apps/api/test/shopping-list.test.ts +++ b/apps/api/test/shopping-list.test.ts @@ -214,6 +214,76 @@ describe("Shopping list", () => { expect(res.body.items[0].quantity).to.equal(300); }); + it("lists a « selon le goût » seasoning ingredient once, with no quantity, never summed (issue #128)", async () => { + const agent = request.agent(app); + await agent.post("/auth/signup").send(buildSignupPayload()); + const houseRes = await agent.post("/house").send({ name: "Chez moi" }); + const houseId: number = houseRes.body.id; + const authorId: number = houseRes.body.adminId; + + const tomatoId = await ingredientId("tomato"); + const saltId = await ingredientId("salt"); + const gramId = await unitId("gram"); + + // 100g tomato + salt "selon le goût" (quantity/unit null), planned + // twice at 4 portions each (recipe yields 2): tomato scales to 400g, + // salt stays a single unquantified line. + const recipe = await prisma.recipe.create({ + data: { + name: "Tomates au sel", + authorId, + portions: 2, + ingredients: { + create: [ + { ingredientId: tomatoId, quantity: 100, unitId: gramId }, + { ingredientId: saltId, quantity: null, unitId: null }, + ], + }, + }, + }); + const planning = await prisma.planning.create({ + data: { + houseId, + startDate: TEST_REFERENCE_DATE.minus({ days: 2 }).toJSDate(), + finishDate: TEST_REFERENCE_DATE.plus({ days: 2 }).toJSDate(), + }, + }); + await prisma.planningItem.createMany({ + data: [ + { + planningId: planning.id, + weekDay: "lundi", + meal: "dejeuner", + recipeId: recipe.id, + portions: 4, + portionsRequested: 4, + }, + { + planningId: planning.id, + weekDay: "mercredi", + meal: "diner", + recipeId: recipe.id, + portions: 4, + portionsRequested: 4, + }, + ], + }); + + const res = await agent.get("/shopping-list").query({ date: today() }); + + expect(res.status).to.equal(200); + const salt = res.body.items.filter( + (item: { ingredient: { key: string } }) => item.ingredient.key === "salt", + ); + expect(salt).to.have.length(1); + expect(salt[0].quantity).to.equal(null); + expect(salt[0].unit).to.equal(null); + const tomato = res.body.items.find( + (item: { ingredient: { key: string } }) => item.ingredient.key === "tomato", + ); + expect(tomato.quantity).to.equal(400); + }); + it("sums the same ingredient across two different recipes sharing a unit", async () => { const agent = request.agent(app); await agent.post("/auth/signup").send(buildSignupPayload()); diff --git a/apps/web/cypress/e2e/recipe-form.feature b/apps/web/cypress/e2e/recipe-form.feature index cb01ce4..dded0fa 100644 --- a/apps/web/cypress/e2e/recipe-form.feature +++ b/apps/web/cypress/e2e/recipe-form.feature @@ -68,6 +68,20 @@ Feature: Recipe form — associating ingredients Then the ingredient "Carotte" should be visible in the picker And there should be 0 ingredient rows + Scenario: Marks an ingredient as « selon le goût » and saves it with no quantity + Given creating the recipe will succeed and return id 44 + When I visit "/recettes/nouvelle" + And I fill in the "recipe-name" field with "Tomates au sel" + And I select the ingredient "Tomate" from the picker + And I mark the ingredient "Tomate" as seasoning + Then the ingredient "Tomate" should have no quantity field + When I add a step + And I fill in the step description with "Saler." + Then the "Enregistrer" button should not be disabled + When I click the button "Enregistrer" + Then the recipe creation request should have included the seasoning-only ingredient 1 + And the URL should include "/recettes/44" + Scenario: The save/cancel bar stays pinned to the foot of a long form When I visit "/recettes/nouvelle" And I add a step diff --git a/apps/web/cypress/e2e/recipe-form.ts b/apps/web/cypress/e2e/recipe-form.ts index f021be7..0b0af45 100644 --- a/apps/web/cypress/e2e/recipe-form.ts +++ b/apps/web/cypress/e2e/recipe-form.ts @@ -95,6 +95,25 @@ Then( }, ); +When("I mark the ingredient {string} as seasoning", (name: string) => { + cy.contains(".ingredient-row", name) + .find(".ingredient-row__to-taste input[type=checkbox]") + .check({ force: true }); +}); + +Then("the ingredient {string} should have no quantity field", (name: string) => { + cy.contains(".ingredient-row", name).find(".ingredient-row__quantity").should("not.exist"); +}); + +Then( + "the recipe creation request should have included the seasoning-only ingredient {int}", + (ingredientId: number) => { + cy.wait("@createRecipe") + .its("request.body.ingredients") + .should("deep.equal", [{ ingredientId }]); + }, +); + When("I scroll the recipe form to the top", () => { // `.app-content` is the one scroll container (AppLayout.scss) — the sticky // action bar pins against it, so it's what has to be scrolled to prove it. diff --git a/apps/web/cypress/e2e/shopping-list.feature b/apps/web/cypress/e2e/shopping-list.feature index 0148b67..9317fd7 100644 --- a/apps/web/cypress/e2e/shopping-list.feature +++ b/apps/web/cypress/e2e/shopping-list.feature @@ -22,6 +22,11 @@ Feature: Shopping list And the shopping list should show "Tomate" at quantity "400 g" And the shopping list should show "Oeuf" at quantity "6 unité" + Scenario: A « selon le goût » seasoning line shows no quantity (issue #128) + Given the shopping list for "2026-08-17" has a « selon le goût » line for "salt" + When I visit "/liste-de-courses" + Then the shopping list should show "Sel" at quantity "selon le goût" + Scenario: Navigating to another week fetches and shows that week's own list Given the shopping list for "2026-08-17" is empty And the shopping list for "2026-08-24" contains: diff --git a/apps/web/cypress/e2e/shopping-list.ts b/apps/web/cypress/e2e/shopping-list.ts index a47fd23..f368385 100644 --- a/apps/web/cypress/e2e/shopping-list.ts +++ b/apps/web/cypress/e2e/shopping-list.ts @@ -43,6 +43,38 @@ Given("the shopping list for {string} contains:", (date: string, dataTable: Data }); }); +// A "selon le goût" line — `quantity`/`unit` both null (issue #128). Its own +// step rather than a `buildShoppingListItem` data-table column so that +// helper's `quantity`/`unitKey` contract stays required for every other row. +Given( + "the shopping list for {string} has a « selon le goût » line for {string}", + (date: string, ingredientKey: string) => { + cy.intercept("GET", `**/shopping-list?date=${date}`, { + statusCode: 200, + body: { + startDate: date, + finishDate: date, + items: [ + { + ingredient: { + id: 1, + key: ingredientKey, + icon: "JAR", + category: "condimentsAndSpices", + subcategory: "spices", + reproducible: false, + allergens: [], + diets: [], + }, + quantity: null, + unit: null, + }, + ], + }, + }); + }, +); + // Same class as PlanningPage's own week navigator (`WeekNavigator`, now // shared between the two pages) — `planning-page.cy.ts` already exercises // the prev/next arrows directly by class, same approach here. diff --git a/apps/web/src/features/recipes/ingredients/IngredientRow.tsx b/apps/web/src/features/recipes/ingredients/IngredientRow.tsx index a88e6b8..a906c1a 100644 --- a/apps/web/src/features/recipes/ingredients/IngredientRow.tsx +++ b/apps/web/src/features/recipes/ingredients/IngredientRow.tsx @@ -1,5 +1,6 @@ import type { IngredientView, UnitView } from "@batch-cooking/shared"; import { useTranslation } from "react-i18next"; +import { CheckboxOption } from "../../../components/ui/Checkbox"; import { AllergenBadges } from "../badges/AllergenBadges"; import { DietBadges } from "../badges/DietBadges"; import { ReproducibleBadge } from "../badges/ReproducibleBadge"; @@ -7,25 +8,30 @@ import { IngredientTypeIcon } from "./ingredient-icons"; import { ingredientLabel } from "./ingredient-label"; 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 is kept as a raw string while editing (not parsed to a number until submit) so an in-progress/invalid value doesn't fight the input; `unit` picks from `unitsCatalog` (a closed reference list, see `Unit` in schema.prisma) rather than free text. */ +/** One selected ingredient line in the recipe form — the ingredient itself (picked via `IngredientPicker`) plus its quantity/unit for this recipe. Quantity is kept as a raw string while editing (not parsed to a number until submit) so an in-progress/invalid value doesn't fight the input; `unit` picks from `unitsCatalog` (a closed reference list, see `Unit` in schema.prisma) rather than free text. A line marked `toTaste` ("selon le goût") carries no quantity/unit at all — see {@link toTaste}. */ export function IngredientRow({ ingredient, quantity, unitId, unitsCatalog, + toTaste, duplicate = false, onQuantityChange, onUnitChange, + onToTasteChange, onRemove, }: { ingredient: IngredientView; quantity: string; unitId: number | null; unitsCatalog: UnitView[]; + /** `true` = a "selon le goût" seasoning line (salt, pepper…): the quantity/unit controls are hidden and the line submits with neither, stored as `null`/`null` on `RecipeIngredient` (schema.prisma). Never `--incomplete` for a missing unit while this is set. */ + toTaste: boolean; /** This ingredient is also picked by another line in the same form — `RecipeIngredient`'s primary key is `(recipeId, ingredientId)`, one row per ingredient (schema.prisma), so submitting two lines for it would fail. Never true for a manually-added line (`IngredientPicker`'s `excludeIds` already prevents picking the same ingredient twice) — only reachable via a source import, whose raw lines aren't deduplicated (e.g. "Egg Yolks" and "Eggs" both resolving to "Egg" — see issue #53's `RecipeImportForm`). */ duplicate?: boolean; onQuantityChange: (quantity: string) => void; onUnitChange: (unitId: number) => void; + onToTasteChange: (toTaste: boolean) => void; onRemove: () => void; }) { const { t } = useTranslation(); @@ -34,8 +40,9 @@ export function IngredientRow({ // (see issue #53: an import pre-filled from a source can land here with // an ingredient resolved but no unit — e.g. a bare count like "4 Egg // Yolks" — and nothing used to tell the user which line was blocking - // them, or why). - const unitMissing = unitId === null; + // them, or why). A "selon le goût" line has no unit *by design*, so it's + // never treated as incomplete. + const unitMissing = !toTaste && unitId === null; const needsAttention = unitMissing || duplicate; // A free-text placeholder line (the catalog had nothing matching) — shown // with an "à compléter" badge and none of the allergen/diet/reproducible @@ -56,31 +63,45 @@ export function IngredientRow({ )} - onQuantityChange(e.target.value)} - aria-label={t("recipes.form.quantityLabel")} - /> - onQuantityChange(e.target.value)} + aria-label={t("recipes.form.quantityLabel")} + /> + + + )} + {/* "Selon le goût" — a seasoning line with nothing to measure. Toggling + it on hides the quantity/unit controls above and submits the line + with neither. */} + - - {unitsCatalog.map((unit) => ( - - ))} - + {t("recipes.form.toTasteToggle")} + {!isPlaceholder && ( <> diff --git a/apps/web/src/features/recipes/recipes.scss b/apps/web/src/features/recipes/recipes.scss index db51a4e..ccbb704 100644 --- a/apps/web/src/features/recipes/recipes.scss +++ b/apps/web/src/features/recipes/recipes.scss @@ -1573,6 +1573,16 @@ width: 11rem; } + // "Selon le goût" toggle — the app's selectable-card checkbox + // (global.scss's `label:has(> input[type="checkbox"])`), shrunk to sit + // inline in the row without dominating it. + &__to-taste { + margin-top: 0; + font-size: var(--font-size-xs); + font-weight: 400; + white-space: nowrap; + } + &__remove { padding: var(--space-xs) var(--space-sm); font-size: var(--font-size-sm); diff --git a/apps/web/src/features/recipes/sources/RecipeImportForm.tsx b/apps/web/src/features/recipes/sources/RecipeImportForm.tsx index 1bb0401..ff8880c 100644 --- a/apps/web/src/features/recipes/sources/RecipeImportForm.tsx +++ b/apps/web/src/features/recipes/sources/RecipeImportForm.tsx @@ -29,12 +29,13 @@ import "../recipes.scss"; /** In display order — mirrors `RecipeVisibility` (schema.prisma/shared types). Same list as `RecipeFormPage`. */ const VISIBILITY_OPTIONS: RecipeVisibility[] = ["PERSONAL", "HOUSE", "PUBLIC"]; -/** A resolved ingredient line — identical shape to `RecipeFormPage`'s own `IngredientLine` (`ingredient` may be a synthetic placeholder view, see there). */ +/** A resolved ingredient line — identical shape to `RecipeFormPage`'s own `IngredientLine` (`ingredient` may be a synthetic placeholder view, see there). `toTaste` = a "selon le goût" seasoning line, submitted with no quantity/unit. */ interface IngredientLine { key: string; ingredient: IngredientView; quantity: string; unitId: number | null; + toTaste: boolean; } /** One draft line that didn't resolve to a real `Ingredient` on preview (`DraftRecipeIngredientView.ingredient === null`) — still needs a person to pick the right one, keep it as a free-text placeholder, or discard it, before this recipe can be saved. */ @@ -146,6 +147,12 @@ export function RecipeImportForm({ ingredient: line.ingredient, quantity: line.quantity !== null ? String(line.quantity) : "", unitId: line.unit?.id ?? null, + // A source line that resolved to a real ingredient but carried + // no quantity (e.g. "salt to taste") — see + // `translateRecipeIngredients`, which leaves `quantity`/`unitId` + // null in exactly that case. Land it as a "selon le goût" line + // rather than blocking submit on a missing quantity. + toTaste: line.quantity === null, }); } else { unresolved.push({ @@ -179,13 +186,13 @@ export function RecipeImportForm({ function addIngredient(ingredient: IngredientView) { setIngredientLines((lines) => [ ...lines, - { key: makeClientKey(), ingredient, quantity: "", unitId: null }, + { key: makeClientKey(), ingredient, quantity: "", unitId: null, toTaste: false }, ]); } function updateIngredientLine( key: string, - patch: Partial>, + patch: Partial>, ) { setIngredientLines((lines) => lines.map((line) => (line.key === key ? { ...line, ...patch } : line)), @@ -203,7 +210,13 @@ export function RecipeImportForm({ if (line) { setIngredientLines((resolved) => [ ...resolved, - { key: makeClientKey(), ingredient, quantity: line.quantity, unitId: null }, + { + key: makeClientKey(), + ingredient, + quantity: line.quantity, + unitId: null, + toTaste: false, + }, ]); } return lines.filter((l) => l.key !== unresolvedKey); @@ -223,6 +236,7 @@ export function RecipeImportForm({ ingredient: makePlaceholderIngredientView(line.rawText.trim()), quantity: line.quantity, unitId: null, + toTaste: false, }, ]); } @@ -241,7 +255,9 @@ export function RecipeImportForm({ // this, a pre-filled import whose auto-matched ingredients are otherwise // complete could leave `canSubmit` false with nothing on the page saying // why (see issue #53). - const hasIngredientMissingUnit = ingredientLines.some((line) => line.unitId === null); + const hasIngredientMissingUnit = ingredientLines.some( + (line) => !line.toTaste && line.unitId === null, + ); // Two raw source lines can independently resolve to the same catalog // ingredient (e.g. "Egg Yolks" and "Eggs" both matching "Egg") — @@ -270,7 +286,9 @@ export function RecipeImportForm({ Number.isInteger(Number(portions)) && Number(portions) > 0 && ingredientLines.length > 0 && - ingredientLines.every((line) => Number(line.quantity) > 0 && line.unitId !== null) && + ingredientLines.every( + (line) => line.toTaste || (Number(line.quantity) > 0 && line.unitId !== null), + ) && !hasDuplicateIngredient && unresolvedIngredients.length === 0 && steps.length > 0 && @@ -294,11 +312,9 @@ export function RecipeImportForm({ ...(isUnsavedPlaceholder(line.ingredient) ? { placeholderName: line.ingredient.displayName ?? "" } : { ingredientId: line.ingredient.id }), - quantity: Number(line.quantity), - // `canSubmit` already requires every line to have a unit picked — - // same "?? 0, the schema rejects it if ever reached" reasoning as - // RecipeFormPage's identical submit. - unitId: line.unitId ?? 0, + // A "selon le goût" line submits with neither quantity nor unit — + // same branch as RecipeFormPage's identical submit. + ...(line.toTaste ? {} : { quantity: Number(line.quantity), unitId: line.unitId ?? 0 }), })), steps: steps.map((step) => ({ description: step.description.trim(), @@ -432,9 +448,11 @@ export function RecipeImportForm({ quantity={line.quantity} unitId={line.unitId} unitsCatalog={unitsCatalog} + toTaste={line.toTaste} duplicate={duplicateIngredientIds.has(line.ingredient.id)} onQuantityChange={(quantity) => updateIngredientLine(line.key, { quantity })} onUnitChange={(unitId) => updateIngredientLine(line.key, { unitId })} + onToTasteChange={(toTaste) => updateIngredientLine(line.key, { toTaste })} onRemove={() => removeIngredientLine(line.key)} /> ))} diff --git a/apps/web/src/locales/fr/translation.json b/apps/web/src/locales/fr/translation.json index 6abb9ac..f0f7f36 100644 --- a/apps/web/src/locales/fr/translation.json +++ b/apps/web/src/locales/fr/translation.json @@ -357,13 +357,15 @@ "submit": "Enregistrer", "submitting": "Enregistrement…", "cancel": "Annuler", + "toTasteToggle": "Selon le goût", "genericError": "Le formulaire contient des erreurs" } }, "shoppingList": { "title": "Liste de courses", "loading": "Chargement de la liste de courses…", - "empty": "Aucun ingrédient à acheter pour cette semaine — ajoutez des recettes à votre planning." + "empty": "Aucun ingrédient à acheter pour cette semaine — ajoutez des recettes à votre planning.", + "toTaste": "selon le goût" }, "cookingSession": { "title": "Cuisiner cette semaine", diff --git a/apps/web/src/pages/recipes/RecipeFormPage.tsx b/apps/web/src/pages/recipes/RecipeFormPage.tsx index 5258e9d..d3c34e9 100644 --- a/apps/web/src/pages/recipes/RecipeFormPage.tsx +++ b/apps/web/src/pages/recipes/RecipeFormPage.tsx @@ -44,6 +44,8 @@ interface IngredientLine { ingredient: IngredientView; quantity: string; unitId: number | null; + /** `true` = a "selon le goût" seasoning line — submits with no quantity/unit (stored as `null`/`null`), and `canSubmit` doesn't require either. */ + toTaste: boolean; } /** Load state for the reference ingredient list (+ the existing recipe, when editing) this form needs before it can render. */ @@ -111,8 +113,9 @@ export function RecipeFormPage() { recipe.ingredients.map((line) => ({ key: makeClientKey(), ingredient: line.ingredient, - quantity: String(line.quantity), - unitId: line.unit.id, + quantity: line.quantity === null ? "" : String(line.quantity), + unitId: line.unit?.id ?? null, + toTaste: line.quantity === null, })), ); setSteps( @@ -137,7 +140,7 @@ export function RecipeFormPage() { function addIngredient(ingredient: IngredientView) { setIngredientLines((lines) => [ ...lines, - { key: makeClientKey(), ingredient, quantity: "", unitId: null }, + { key: makeClientKey(), ingredient, quantity: "", unitId: null, toTaste: false }, ]); } @@ -150,13 +153,14 @@ export function RecipeFormPage() { ingredient: makePlaceholderIngredientView(name), quantity: "", unitId: null, + toTaste: false, }, ]); } function updateIngredientLine( key: string, - patch: Partial>, + patch: Partial>, ) { setIngredientLines((lines) => lines.map((line) => (line.key === key ? { ...line, ...patch } : line)), @@ -172,17 +176,22 @@ export function RecipeFormPage() { // `RecipeImportForm` (issue #53), just less likely to bite here since a // manually-added line starts with no unit by design, right where the // person is already looking. - const hasIngredientMissingUnit = ingredientLines.some((line) => line.unitId === null); + const hasIngredientMissingUnit = ingredientLines.some( + (line) => !line.toTaste && line.unitId === null, + ); // Gates the submit button — the schema (checked again on submit, see // `handleSubmit`) is the source of truth, this is just instant feedback - // that doesn't need a round trip through zod on every keystroke. + // that doesn't need a round trip through zod on every keystroke. A "selon + // le goût" line needs neither quantity nor unit. const canSubmit = name.trim().length > 0 && Number.isInteger(Number(portions)) && Number(portions) > 0 && ingredientLines.length > 0 && - ingredientLines.every((line) => Number(line.quantity) > 0 && line.unitId !== null) && + ingredientLines.every( + (line) => line.toTaste || (Number(line.quantity) > 0 && line.unitId !== null), + ) && steps.length > 0 && steps.every((step) => step.description.trim().length > 0); @@ -216,13 +225,18 @@ export function RecipeFormPage() { ...(isUnsavedPlaceholder(line.ingredient) ? { placeholderName: line.ingredient.displayName ?? "" } : { ingredientId: line.ingredient.id }), - quantity: Number(line.quantity), - // `canSubmit` already requires every line to have a unit picked - // before the button is enabled — `?? 0` is just to satisfy the - // type here; if it's ever reached with no unit set, the schema's - // `positive()` check rejects it the same way an invalid quantity - // already does. - unitId: line.unitId ?? 0, + // A "selon le goût" line submits with neither — the schema requires + // quantity and unit to be both present or both absent. + ...(line.toTaste + ? {} + : { + quantity: Number(line.quantity), + // `canSubmit` already requires every non-toTaste line to have a + // unit — `?? 0` only satisfies the type; the schema's + // `positive()` check rejects a 0 the same way it rejects an + // invalid quantity. + unitId: line.unitId ?? 0, + }), })), steps: steps.map((step) => ({ description: step.description.trim(), @@ -333,8 +347,10 @@ export function RecipeFormPage() { quantity={line.quantity} unitId={line.unitId} unitsCatalog={unitsCatalog} + toTaste={line.toTaste} onQuantityChange={(quantity) => updateIngredientLine(line.key, { quantity })} onUnitChange={(unitId) => updateIngredientLine(line.key, { unitId })} + onToTasteChange={(toTaste) => updateIngredientLine(line.key, { toTaste })} onRemove={() => removeIngredientLine(line.key)} /> ))} diff --git a/apps/web/src/pages/shopping-list/ShoppingListPage.tsx b/apps/web/src/pages/shopping-list/ShoppingListPage.tsx index 911a448..917eb54 100644 --- a/apps/web/src/pages/shopping-list/ShoppingListPage.tsx +++ b/apps/web/src/pages/shopping-list/ShoppingListPage.tsx @@ -20,9 +20,9 @@ type ShoppingListState = | { status: "loaded"; list: ShoppingListView } | { status: "error" }; -/** React list key for a `(ingredient, unit)` line — also the key the "in the cart" checklist is stored under (see {@link shoppingListProgress}). */ +/** React list key for a `(ingredient, unit)` line — also the key the "in the cart" checklist is stored under (see {@link shoppingListProgress}). A "selon le goût" line has no unit; `"tt"` keeps its key distinct from any real unit line for the same ingredient. */ function itemKey(item: ShoppingListItemView): string { - return `${item.ingredient.id}-${item.unit.id}`; + return `${item.ingredient.id}-${item.unit?.id ?? "tt"}`; } /** @@ -149,8 +149,11 @@ function ShoppingListItems({ weekKey, items }: { weekKey: string; items: Shoppin {ingredientLabel(item.ingredient, t)} - {formatShoppingListQuantity(item.quantity)}{" "} - {t(`catalog.units.${item.unit.key}`)} + {item.quantity === null || item.unit === null + ? t("shoppingList.toTaste") + : `${formatShoppingListQuantity(item.quantity)} ${t( + `catalog.units.${item.unit.key}`, + )}`} diff --git a/packages/shared/src/schemas/recipe.ts b/packages/shared/src/schemas/recipe.ts index 9969b3d..45ad0d1 100644 --- a/packages/shared/src/schemas/recipe.ts +++ b/packages/shared/src/schemas/recipe.ts @@ -16,21 +16,28 @@ import { z } from "zod"; * row for this line (see `Ingredient.isPlaceholder` in schema.prisma and * `recipe.service.ts`'s `createRecipeInternal`); it is never browsable. * - * Requiring exactly one keeps `RecipeIngredient` unchanged (still a real - * `ingredientId` after the service resolves the line). + * `quantity`/`unitId` are **both present or both absent** — absent means a + * "selon le goût" seasoning line (salt, pepper…), stored as `null`/`null` on + * `RecipeIngredient`, excluded from portion scaling and shopping-list + * aggregation. A quantity without a unit (or the reverse) is rejected here. */ const recipeIngredientInputSchema = z .object({ ingredientId: z.number().int().positive().optional(), /** Free-text ingredient name for a line the catalog couldn't cover — see this schema's doc comment. Mutually exclusive with `ingredientId`. */ placeholderName: z.string().trim().min(1).max(120).optional(), - quantity: z.number().positive("La quantité doit être positive"), - /** References a reference `Unit` row (see `GET /reference/units`) — free-text units were replaced by this closed catalog, see `Unit` in schema.prisma. An unknown id is rejected service-side with `UNIT_NOT_FOUND`, same posture as `ingredientId`. */ - unitId: z.number().int().positive(), + /** Omitted for a "selon le goût" line — see this schema's doc comment. Present ⇒ `unitId` present too. */ + quantity: z.number().positive("La quantité doit être positive").optional(), + /** References a reference `Unit` row (see `GET /reference/units`) — free-text units were replaced by this closed catalog, see `Unit` in schema.prisma. An unknown id is rejected service-side with `UNIT_NOT_FOUND`, same posture as `ingredientId`. Omitted for a "selon le goût" line. */ + unitId: z.number().int().positive().optional(), }) .refine((line) => (line.ingredientId === undefined) !== (line.placeholderName === undefined), { message: "Chaque ingrédient doit avoir soit un identifiant catalogue, soit un nom libre", path: ["ingredientId"], + }) + .refine((line) => (line.quantity === undefined) === (line.unitId === undefined), { + message: "Indiquez une quantité et une unité, ou aucune des deux (« selon le goût »)", + path: ["quantity"], }); /** diff --git a/packages/shared/src/types/recipe.ts b/packages/shared/src/types/recipe.ts index b3e9cb8..1e3f6ec 100644 --- a/packages/shared/src/types/recipe.ts +++ b/packages/shared/src/types/recipe.ts @@ -22,13 +22,17 @@ export type RecipeVisibility = "PERSONAL" | "HOUSE" | "PUBLIC"; * allergens), plus the quantity specific to this recipe (carried by * `RecipeIngredient` in schema.prisma, not by `Ingredient` itself). `unit` * is likewise resolved to its full reference data (`Unit`) rather than a - * raw key — same "resolve at read time" treatment as `ingredient`, now that - * it's a catalog reference instead of free text (see `UnitView`). + * raw key — same "resolve at read time" treatment as `ingredient`. + * + * `quantity` and `unit` are **both `null`** for a "selon le goût" seasoning + * line (salt, pepper… added to taste) — see `RecipeIngredient.quantity` in + * schema.prisma. A consumer showing a quantity must handle that case (e.g. + * render "selon le goût" instead of a number). */ export interface RecipeIngredientView { ingredient: IngredientView; - quantity: number; - unit: UnitView; + quantity: number | null; + unit: UnitView | null; } /** diff --git a/packages/shared/src/types/shopping-list.ts b/packages/shared/src/types/shopping-list.ts index ab1587d..007f81c 100644 --- a/packages/shared/src/types/shopping-list.ts +++ b/packages/shared/src/types/shopping-list.ts @@ -15,11 +15,17 @@ import type { IngredientView, UnitView } from "./reference.js"; * built (see that field's own doc comment), so the same ingredient * requested in two different units surfaces as two separate lines rather * than silently guessing a conversion. + * + * `quantity` and `unit` are **both `null`** on a "selon le goût" line — a + * seasoning ingredient (salt, pepper…) used by at least one planned recipe + * with no quantity to buy by (see `RecipeIngredient.quantity`, + * schema.prisma). Such a line appears once, deduplicated by ingredient, and + * is never summed. */ export interface ShoppingListItemView { ingredient: IngredientView; - quantity: number; - unit: UnitView; + quantity: number | null; + unit: UnitView | null; } /** -- 2.45.2