Merge pull request 'feat(recipes): ingrédients d'assaisonnement « selon le goût » (#128)' (#136) from feat/seasoning-ingredients into main
All checks were successful
CI / lint (push) Successful in 3m4s
CI / build (push) Successful in 3m6s
CI / intent-service-test (push) Successful in 5m9s
CI / e2e (push) Successful in 10m33s
CI / test (push) Successful in 19m6s
CI / build-android (push) Successful in 5m12s

This commit is contained in:
kyuno 2026-09-09 14:35:24 +02:00
commit 019e3fb5e5
19 changed files with 437 additions and 90 deletions

View file

@ -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;

View file

@ -700,12 +700,17 @@ model Unit {
model RecipeIngredient { model RecipeIngredient {
recipeId Int @map("recipe_id") recipeId Int @map("recipe_id")
ingredientId Int @map("ingredient_id") ingredientId Int @map("ingredient_id")
quantity Decimal @db.Decimal(10, 2) /// `null` (with `unitId` also `null`) = a "selon le goût" seasoning line —
unitId Int @map("unit_id") /// 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) recipe Recipe @relation(fields: [recipeId], references: [id], onDelete: Cascade)
ingredient Ingredient @relation(fields: [ingredientId], 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]) @@id([recipeId, ingredientId])
@@map("recipe_ingredient") @@map("recipe_ingredient")

View file

@ -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. */ /** 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"]; export type IngredientWithDetails = RecipeWithDetails["ingredients"][number]["ingredient"];
/** Exported — see {@link IngredientWithDetails}, same reuse by `shopping-list.service.ts`. */ /** 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 = RecipeWithDetails["ingredients"][number]["unit"]; export type UnitWithDetails = NonNullable<RecipeWithDetails["ingredients"][number]["unit"]>;
/** 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). */ /** 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 { export function toUnitView(unit: UnitWithDetails): UnitView {
@ -244,8 +244,9 @@ export function toStepTechStepViews(
function toRecipeView(recipe: RecipeWithDetails): RecipeView { function toRecipeView(recipe: RecipeWithDetails): RecipeView {
const ingredients = recipe.ingredients.map((recipeIngredient) => ({ const ingredients = recipe.ingredients.map((recipeIngredient) => ({
ingredient: toIngredientView(recipeIngredient.ingredient), ingredient: toIngredientView(recipeIngredient.ingredient),
quantity: Number(recipeIngredient.quantity), // Both `null` on a "selon le goût" seasoning line — see `RecipeIngredientView`.
unit: toUnitView(recipeIngredient.unit), quantity: recipeIngredient.quantity === null ? null : Number(recipeIngredient.quantity),
unit: recipeIngredient.unit === null ? null : toUnitView(recipeIngredient.unit),
})); }));
return { return {
...toRecipeSummaryView(recipe), ...toRecipeSummaryView(recipe),
@ -578,11 +579,11 @@ async function matchStepsTechSteps<T extends { description: string }>(
); );
} }
/** 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 { interface ResolvedIngredientLine {
ingredientId: number; ingredientId: number;
quantity: number; quantity: number | null;
unitId: number; unitId: number | null;
} }
/** /**
@ -615,8 +616,8 @@ async function resolveIngredientLines(
if (line.ingredientId !== undefined) { if (line.ingredientId !== undefined) {
resolved.push({ resolved.push({
ingredientId: line.ingredientId, ingredientId: line.ingredientId,
quantity: line.quantity, quantity: line.quantity ?? null,
unitId: line.unitId, unitId: line.unitId ?? null,
}); });
continue; continue;
} }
@ -632,7 +633,11 @@ async function resolveIngredientLines(
}, },
select: { id: true }, 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 }); createdPlaceholders.push({ id: placeholder.id, name });
} }
return { resolved, createdPlaceholders }; return { resolved, createdPlaceholders };
@ -651,7 +656,9 @@ async function createRecipeInternal(
await assertIngredientsExist( await assertIngredientsExist(
input.ingredients.map((i) => i.ingredientId).filter((id): id is number => id !== undefined), 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); await assertDietsExist(input.dietIds);
// Matched up front (one call per step, in parallel) rather than inline // Matched up front (one call per step, in parallel) rather than inline
// inside the `steps.create` map below — `techStepClassifier` is async // inside the `steps.create` map below — `techStepClassifier` is async
@ -752,7 +759,9 @@ export async function updateRecipe(
.map((i) => i.ingredientId) .map((i) => i.ingredientId)
.filter((ingredientId): ingredientId is number => ingredientId !== undefined), .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); await assertDietsExist(input.dietIds);
const stepsWithTechSteps = await matchStepsTechSteps(input.steps, DEFAULT_TECH_STEP_LOCALE); const stepsWithTechSteps = await matchStepsTechSteps(input.steps, DEFAULT_TECH_STEP_LOCALE);

View file

@ -44,11 +44,11 @@ type PlanningWithIngredients = Prisma.PlanningGetPayload<{
include: ReturnType<typeof shoppingListPlanningInclude>; include: ReturnType<typeof shoppingListPlanningInclude>;
}>; }>;
/** 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 { interface RunningTotal {
ingredient: IngredientView; ingredient: IngredientView;
unit: UnitView; unit: UnitView | null;
quantity: number; quantity: number | null;
} }
/** /**
@ -70,16 +70,31 @@ function aggregateShoppingList(items: PlanningWithIngredients["items"]): Shoppin
for (const item of items) { for (const item of items) {
const scale = item.portions / item.recipe.portions; const scale = item.portions / item.recipe.portions;
for (const recipeIngredient of item.recipe.ingredients) { 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 key = `${recipeIngredient.ingredientId}:${recipeIngredient.unitId}`;
const addedQuantity = Number(recipeIngredient.quantity) * scale; const addedQuantity = Number(recipeIngredient.quantity) * scale;
const existing = totals.get(key); const existing = totals.get(key);
if (existing) { if (existing && existing.quantity !== null) {
existing.quantity += addedQuantity; existing.quantity += addedQuantity;
} else { } else {
totals.set(key, { totals.set(key, {
ingredient: toIngredientView(recipeIngredient.ingredient), ingredient: toIngredientView(recipeIngredient.ingredient),
unit: toUnitView(recipeIngredient.unit), unit: recipeIngredient.unit === null ? null : toUnitView(recipeIngredient.unit),
quantity: addedQuantity, quantity: addedQuantity,
}); });
} }

View file

@ -652,6 +652,57 @@ describe("Recipes", () => {
expect(zero.status).to.equal(400); expect(zero.status).to.equal(400);
expect(zero.body.code).to.equal(ErrorCode.VALIDATION_ERROR); 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", () => { describe("GET /recipes/:id", () => {
@ -781,6 +832,38 @@ describe("Recipes", () => {
expect(res.body.steps).to.have.length(2); 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 () => { it("recomputes each replaced step's technique sequence", async () => {
const { agent } = await signup(); const { agent } = await signup();
const tomate = await ingredientId("tomato"); const tomate = await ingredientId("tomato");

View file

@ -214,6 +214,76 @@ describe("Shopping list", () => {
expect(res.body.items[0].quantity).to.equal(300); 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 () => { it("sums the same ingredient across two different recipes sharing a unit", async () => {
const agent = request.agent(app); const agent = request.agent(app);
await agent.post("/auth/signup").send(buildSignupPayload()); await agent.post("/auth/signup").send(buildSignupPayload());

View file

@ -68,6 +68,20 @@ Feature: Recipe form — associating ingredients
Then the ingredient "Carotte" should be visible in the picker Then the ingredient "Carotte" should be visible in the picker
And there should be 0 ingredient rows 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 Scenario: The save/cancel bar stays pinned to the foot of a long form
When I visit "/recettes/nouvelle" When I visit "/recettes/nouvelle"
And I add a step And I add a step

View file

@ -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", () => { When("I scroll the recipe form to the top", () => {
// `.app-content` is the one scroll container (AppLayout.scss) — the sticky // `.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. // action bar pins against it, so it's what has to be scrolled to prove it.

View file

@ -22,6 +22,11 @@ Feature: Shopping list
And the shopping list should show "Tomate" at quantity "400 g" And the shopping list should show "Tomate" at quantity "400 g"
And the shopping list should show "Oeuf" at quantity "6 unité" 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 Scenario: Navigating to another week fetches and shows that week's own list
Given the shopping list for "2026-08-17" is empty Given the shopping list for "2026-08-17" is empty
And the shopping list for "2026-08-24" contains: And the shopping list for "2026-08-24" contains:

View file

@ -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 // Same class as PlanningPage's own week navigator (`WeekNavigator`, now
// shared between the two pages) — `planning-page.cy.ts` already exercises // shared between the two pages) — `planning-page.cy.ts` already exercises
// the prev/next arrows directly by class, same approach here. // the prev/next arrows directly by class, same approach here.

View file

@ -1,5 +1,6 @@
import type { IngredientView, UnitView } from "@batch-cooking/shared"; import type { IngredientView, UnitView } from "@batch-cooking/shared";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { CheckboxOption } from "../../../components/ui/Checkbox";
import { AllergenBadges } from "../badges/AllergenBadges"; import { AllergenBadges } from "../badges/AllergenBadges";
import { DietBadges } from "../badges/DietBadges"; import { DietBadges } from "../badges/DietBadges";
import { ReproducibleBadge } from "../badges/ReproducibleBadge"; import { ReproducibleBadge } from "../badges/ReproducibleBadge";
@ -7,25 +8,30 @@ import { IngredientTypeIcon } from "./ingredient-icons";
import { ingredientLabel } from "./ingredient-label"; import { ingredientLabel } from "./ingredient-label";
import "../recipes.scss"; 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({ export function IngredientRow({
ingredient, ingredient,
quantity, quantity,
unitId, unitId,
unitsCatalog, unitsCatalog,
toTaste,
duplicate = false, duplicate = false,
onQuantityChange, onQuantityChange,
onUnitChange, onUnitChange,
onToTasteChange,
onRemove, onRemove,
}: { }: {
ingredient: IngredientView; ingredient: IngredientView;
quantity: string; quantity: string;
unitId: number | null; unitId: number | null;
unitsCatalog: UnitView[]; 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`). */ /** 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; duplicate?: boolean;
onQuantityChange: (quantity: string) => void; onQuantityChange: (quantity: string) => void;
onUnitChange: (unitId: number) => void; onUnitChange: (unitId: number) => void;
onToTasteChange: (toTaste: boolean) => void;
onRemove: () => void; onRemove: () => void;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
@ -34,8 +40,9 @@ export function IngredientRow({
// (see issue #53: an import pre-filled from a source can land here with // (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 // 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 // Yolks" — and nothing used to tell the user which line was blocking
// them, or why). // them, or why). A "selon le goût" line has no unit *by design*, so it's
const unitMissing = unitId === null; // never treated as incomplete.
const unitMissing = !toTaste && unitId === null;
const needsAttention = unitMissing || duplicate; const needsAttention = unitMissing || duplicate;
// A free-text placeholder line (the catalog had nothing matching) — shown // A free-text placeholder line (the catalog had nothing matching) — shown
// with an "à compléter" badge and none of the allergen/diet/reproducible // with an "à compléter" badge and none of the allergen/diet/reproducible
@ -56,6 +63,8 @@ export function IngredientRow({
</span> </span>
)} )}
</span> </span>
{!toTaste && (
<>
<input <input
type="number" type="number"
min="0" min="0"
@ -81,6 +90,18 @@ export function IngredientRow({
</option> </option>
))} ))}
</select> </select>
</>
)}
{/* "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. */}
<CheckboxOption
className="ingredient-row__to-taste"
checked={toTaste}
onChange={onToTasteChange}
>
{t("recipes.form.toTasteToggle")}
</CheckboxOption>
{!isPlaceholder && ( {!isPlaceholder && (
<> <>
<AllergenBadges allergens={ingredient.allergens} /> <AllergenBadges allergens={ingredient.allergens} />

View file

@ -1573,6 +1573,16 @@
width: 11rem; 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 { &__remove {
padding: var(--space-xs) var(--space-sm); padding: var(--space-xs) var(--space-sm);
font-size: var(--font-size-sm); font-size: var(--font-size-sm);

View file

@ -29,12 +29,13 @@ import "../recipes.scss";
/** In display order — mirrors `RecipeVisibility` (schema.prisma/shared types). Same list as `RecipeFormPage`. */ /** In display order — mirrors `RecipeVisibility` (schema.prisma/shared types). Same list as `RecipeFormPage`. */
const VISIBILITY_OPTIONS: RecipeVisibility[] = ["PERSONAL", "HOUSE", "PUBLIC"]; 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 { interface IngredientLine {
key: string; key: string;
ingredient: IngredientView; ingredient: IngredientView;
quantity: string; quantity: string;
unitId: number | null; 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. */ /** 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, ingredient: line.ingredient,
quantity: line.quantity !== null ? String(line.quantity) : "", quantity: line.quantity !== null ? String(line.quantity) : "",
unitId: line.unit?.id ?? null, 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 { } else {
unresolved.push({ unresolved.push({
@ -179,13 +186,13 @@ export function RecipeImportForm({
function addIngredient(ingredient: IngredientView) { function addIngredient(ingredient: IngredientView) {
setIngredientLines((lines) => [ setIngredientLines((lines) => [
...lines, ...lines,
{ key: makeClientKey(), ingredient, quantity: "", unitId: null }, { key: makeClientKey(), ingredient, quantity: "", unitId: null, toTaste: false },
]); ]);
} }
function updateIngredientLine( function updateIngredientLine(
key: string, key: string,
patch: Partial<Pick<IngredientLine, "quantity" | "unitId">>, patch: Partial<Pick<IngredientLine, "quantity" | "unitId" | "toTaste">>,
) { ) {
setIngredientLines((lines) => setIngredientLines((lines) =>
lines.map((line) => (line.key === key ? { ...line, ...patch } : line)), lines.map((line) => (line.key === key ? { ...line, ...patch } : line)),
@ -203,7 +210,13 @@ export function RecipeImportForm({
if (line) { if (line) {
setIngredientLines((resolved) => [ setIngredientLines((resolved) => [
...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); return lines.filter((l) => l.key !== unresolvedKey);
@ -223,6 +236,7 @@ export function RecipeImportForm({
ingredient: makePlaceholderIngredientView(line.rawText.trim()), ingredient: makePlaceholderIngredientView(line.rawText.trim()),
quantity: line.quantity, quantity: line.quantity,
unitId: null, unitId: null,
toTaste: false,
}, },
]); ]);
} }
@ -241,7 +255,9 @@ export function RecipeImportForm({
// this, a pre-filled import whose auto-matched ingredients are otherwise // this, a pre-filled import whose auto-matched ingredients are otherwise
// complete could leave `canSubmit` false with nothing on the page saying // complete could leave `canSubmit` false with nothing on the page saying
// why (see issue #53). // 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 // Two raw source lines can independently resolve to the same catalog
// ingredient (e.g. "Egg Yolks" and "Eggs" both matching "Egg") — // ingredient (e.g. "Egg Yolks" and "Eggs" both matching "Egg") —
@ -270,7 +286,9 @@ export function RecipeImportForm({
Number.isInteger(Number(portions)) && Number.isInteger(Number(portions)) &&
Number(portions) > 0 && Number(portions) > 0 &&
ingredientLines.length > 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 && !hasDuplicateIngredient &&
unresolvedIngredients.length === 0 && unresolvedIngredients.length === 0 &&
steps.length > 0 && steps.length > 0 &&
@ -294,11 +312,9 @@ export function RecipeImportForm({
...(isUnsavedPlaceholder(line.ingredient) ...(isUnsavedPlaceholder(line.ingredient)
? { placeholderName: line.ingredient.displayName ?? "" } ? { placeholderName: line.ingredient.displayName ?? "" }
: { ingredientId: line.ingredient.id }), : { ingredientId: line.ingredient.id }),
quantity: Number(line.quantity), // A "selon le goût" line submits with neither quantity nor unit —
// `canSubmit` already requires every line to have a unit picked — // same branch as RecipeFormPage's identical submit.
// same "?? 0, the schema rejects it if ever reached" reasoning as ...(line.toTaste ? {} : { quantity: Number(line.quantity), unitId: line.unitId ?? 0 }),
// RecipeFormPage's identical submit.
unitId: line.unitId ?? 0,
})), })),
steps: steps.map((step) => ({ steps: steps.map((step) => ({
description: step.description.trim(), description: step.description.trim(),
@ -432,9 +448,11 @@ export function RecipeImportForm({
quantity={line.quantity} quantity={line.quantity}
unitId={line.unitId} unitId={line.unitId}
unitsCatalog={unitsCatalog} unitsCatalog={unitsCatalog}
toTaste={line.toTaste}
duplicate={duplicateIngredientIds.has(line.ingredient.id)} duplicate={duplicateIngredientIds.has(line.ingredient.id)}
onQuantityChange={(quantity) => updateIngredientLine(line.key, { quantity })} onQuantityChange={(quantity) => updateIngredientLine(line.key, { quantity })}
onUnitChange={(unitId) => updateIngredientLine(line.key, { unitId })} onUnitChange={(unitId) => updateIngredientLine(line.key, { unitId })}
onToTasteChange={(toTaste) => updateIngredientLine(line.key, { toTaste })}
onRemove={() => removeIngredientLine(line.key)} onRemove={() => removeIngredientLine(line.key)}
/> />
))} ))}

View file

@ -357,13 +357,15 @@
"submit": "Enregistrer", "submit": "Enregistrer",
"submitting": "Enregistrement…", "submitting": "Enregistrement…",
"cancel": "Annuler", "cancel": "Annuler",
"toTasteToggle": "Selon le goût",
"genericError": "Le formulaire contient des erreurs" "genericError": "Le formulaire contient des erreurs"
} }
}, },
"shoppingList": { "shoppingList": {
"title": "Liste de courses", "title": "Liste de courses",
"loading": "Chargement de la 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": { "cookingSession": {
"title": "Cuisiner cette semaine", "title": "Cuisiner cette semaine",

View file

@ -44,6 +44,8 @@ interface IngredientLine {
ingredient: IngredientView; ingredient: IngredientView;
quantity: string; quantity: string;
unitId: number | null; 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. */ /** 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) => ({ recipe.ingredients.map((line) => ({
key: makeClientKey(), key: makeClientKey(),
ingredient: line.ingredient, ingredient: line.ingredient,
quantity: String(line.quantity), quantity: line.quantity === null ? "" : String(line.quantity),
unitId: line.unit.id, unitId: line.unit?.id ?? null,
toTaste: line.quantity === null,
})), })),
); );
setSteps( setSteps(
@ -137,7 +140,7 @@ export function RecipeFormPage() {
function addIngredient(ingredient: IngredientView) { function addIngredient(ingredient: IngredientView) {
setIngredientLines((lines) => [ setIngredientLines((lines) => [
...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), ingredient: makePlaceholderIngredientView(name),
quantity: "", quantity: "",
unitId: null, unitId: null,
toTaste: false,
}, },
]); ]);
} }
function updateIngredientLine( function updateIngredientLine(
key: string, key: string,
patch: Partial<Pick<IngredientLine, "quantity" | "unitId">>, patch: Partial<Pick<IngredientLine, "quantity" | "unitId" | "toTaste">>,
) { ) {
setIngredientLines((lines) => setIngredientLines((lines) =>
lines.map((line) => (line.key === key ? { ...line, ...patch } : line)), 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 // `RecipeImportForm` (issue #53), just less likely to bite here since a
// manually-added line starts with no unit by design, right where the // manually-added line starts with no unit by design, right where the
// person is already looking. // 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 // Gates the submit button — the schema (checked again on submit, see
// `handleSubmit`) is the source of truth, this is just instant feedback // `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 = const canSubmit =
name.trim().length > 0 && name.trim().length > 0 &&
Number.isInteger(Number(portions)) && Number.isInteger(Number(portions)) &&
Number(portions) > 0 && Number(portions) > 0 &&
ingredientLines.length > 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.length > 0 &&
steps.every((step) => step.description.trim().length > 0); steps.every((step) => step.description.trim().length > 0);
@ -216,13 +225,18 @@ export function RecipeFormPage() {
...(isUnsavedPlaceholder(line.ingredient) ...(isUnsavedPlaceholder(line.ingredient)
? { placeholderName: line.ingredient.displayName ?? "" } ? { placeholderName: line.ingredient.displayName ?? "" }
: { ingredientId: line.ingredient.id }), : { ingredientId: line.ingredient.id }),
// 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), quantity: Number(line.quantity),
// `canSubmit` already requires every line to have a unit picked // `canSubmit` already requires every non-toTaste line to have a
// before the button is enabled — `?? 0` is just to satisfy the // unit — `?? 0` only satisfies the type; the schema's
// type here; if it's ever reached with no unit set, the schema's // `positive()` check rejects a 0 the same way it rejects an
// `positive()` check rejects it the same way an invalid quantity // invalid quantity.
// already does.
unitId: line.unitId ?? 0, unitId: line.unitId ?? 0,
}),
})), })),
steps: steps.map((step) => ({ steps: steps.map((step) => ({
description: step.description.trim(), description: step.description.trim(),
@ -333,8 +347,10 @@ export function RecipeFormPage() {
quantity={line.quantity} quantity={line.quantity}
unitId={line.unitId} unitId={line.unitId}
unitsCatalog={unitsCatalog} unitsCatalog={unitsCatalog}
toTaste={line.toTaste}
onQuantityChange={(quantity) => updateIngredientLine(line.key, { quantity })} onQuantityChange={(quantity) => updateIngredientLine(line.key, { quantity })}
onUnitChange={(unitId) => updateIngredientLine(line.key, { unitId })} onUnitChange={(unitId) => updateIngredientLine(line.key, { unitId })}
onToTasteChange={(toTaste) => updateIngredientLine(line.key, { toTaste })}
onRemove={() => removeIngredientLine(line.key)} onRemove={() => removeIngredientLine(line.key)}
/> />
))} ))}

View file

@ -20,9 +20,9 @@ type ShoppingListState =
| { status: "loaded"; list: ShoppingListView } | { status: "loaded"; list: ShoppingListView }
| { status: "error" }; | { 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 { 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)} {ingredientLabel(item.ingredient, t)}
</span> </span>
<span className="shopping-list__item-quantity"> <span className="shopping-list__item-quantity">
{formatShoppingListQuantity(item.quantity)}{" "} {item.quantity === null || item.unit === null
{t(`catalog.units.${item.unit.key}`)} ? t("shoppingList.toTaste")
: `${formatShoppingListQuantity(item.quantity)} ${t(
`catalog.units.${item.unit.key}`,
)}`}
</span> </span>
</CheckboxOption> </CheckboxOption>
</li> </li>

View file

@ -16,21 +16,28 @@ import { z } from "zod";
* row for this line (see `Ingredient.isPlaceholder` in schema.prisma and * row for this line (see `Ingredient.isPlaceholder` in schema.prisma and
* `recipe.service.ts`'s `createRecipeInternal`); it is never browsable. * `recipe.service.ts`'s `createRecipeInternal`); it is never browsable.
* *
* Requiring exactly one keeps `RecipeIngredient` unchanged (still a real * `quantity`/`unitId` are **both present or both absent** — absent means a
* `ingredientId` after the service resolves the line). * "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 const recipeIngredientInputSchema = z
.object({ .object({
ingredientId: z.number().int().positive().optional(), 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`. */ /** 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(), placeholderName: z.string().trim().min(1).max(120).optional(),
quantity: z.number().positive("La quantité doit être positive"), /** Omitted for a "selon le goût" line — see this schema's doc comment. Present ⇒ `unitId` present too. */
/** 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`. */ quantity: z.number().positive("La quantité doit être positive").optional(),
unitId: z.number().int().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`. Omitted for a "selon le goût" line. */
unitId: z.number().int().positive().optional(),
}) })
.refine((line) => (line.ingredientId === undefined) !== (line.placeholderName === undefined), { .refine((line) => (line.ingredientId === undefined) !== (line.placeholderName === undefined), {
message: "Chaque ingrédient doit avoir soit un identifiant catalogue, soit un nom libre", message: "Chaque ingrédient doit avoir soit un identifiant catalogue, soit un nom libre",
path: ["ingredientId"], 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"],
}); });
/** /**

View file

@ -22,13 +22,17 @@ export type RecipeVisibility = "PERSONAL" | "HOUSE" | "PUBLIC";
* allergens), plus the quantity specific to this recipe (carried by * allergens), plus the quantity specific to this recipe (carried by
* `RecipeIngredient` in schema.prisma, not by `Ingredient` itself). `unit` * `RecipeIngredient` in schema.prisma, not by `Ingredient` itself). `unit`
* is likewise resolved to its full reference data (`Unit`) rather than a * is likewise resolved to its full reference data (`Unit`) rather than a
* raw key — same "resolve at read time" treatment as `ingredient`, now that * raw key — same "resolve at read time" treatment as `ingredient`.
* it's a catalog reference instead of free text (see `UnitView`). *
* `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 { export interface RecipeIngredientView {
ingredient: IngredientView; ingredient: IngredientView;
quantity: number; quantity: number | null;
unit: UnitView; unit: UnitView | null;
} }
/** /**

View file

@ -15,11 +15,17 @@ import type { IngredientView, UnitView } from "./reference.js";
* built (see that field's own doc comment), so the same ingredient * built (see that field's own doc comment), so the same ingredient
* requested in two different units surfaces as two separate lines rather * requested in two different units surfaces as two separate lines rather
* than silently guessing a conversion. * 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 { export interface ShoppingListItemView {
ingredient: IngredientView; ingredient: IngredientView;
quantity: number; quantity: number | null;
unit: UnitView; unit: UnitView | null;
} }
/** /**