feat(recipes): ingrédients d'assaisonnement « selon le goût » sans quantité (#128)
All checks were successful
CI / lint (push) Successful in 3m5s
CI / build (push) Successful in 3m15s
CI / intent-service-test (push) Successful in 5m12s
CI / e2e (push) Successful in 11m1s
CI / test (push) Successful in 19m13s
CI / build-android (push) Has been skipped

`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 <noreply@anthropic.com>
This commit is contained in:
Nicolas 2026-09-09 14:15:28 +02:00
parent c3560eccec
commit ba030ec18c
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 {
recipeId Int @map("recipe_id")
ingredientId Int @map("ingredient_id")
quantity Decimal @db.Decimal(10, 2)
unitId Int @map("unit_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")

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. */
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<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). */
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<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 {
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);

View file

@ -44,11 +44,11 @@ type PlanningWithIngredients = Prisma.PlanningGetPayload<{
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 {
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,
});
}

View file

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

View file

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

View file

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

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", () => {
// `.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.

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 "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:

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
// shared between the two pages) — `planning-page.cy.ts` already exercises
// 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 { 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,6 +63,8 @@ export function IngredientRow({
</span>
)}
</span>
{!toTaste && (
<>
<input
type="number"
min="0"
@ -81,6 +90,18 @@ export function IngredientRow({
</option>
))}
</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 && (
<>
<AllergenBadges allergens={ingredient.allergens} />

View file

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

View file

@ -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<Pick<IngredientLine, "quantity" | "unitId">>,
patch: Partial<Pick<IngredientLine, "quantity" | "unitId" | "toTaste">>,
) {
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)}
/>
))}

View file

@ -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",

View file

@ -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<Pick<IngredientLine, "quantity" | "unitId">>,
patch: Partial<Pick<IngredientLine, "quantity" | "unitId" | "toTaste">>,
) {
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 }),
// 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 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.
// `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)}
/>
))}

View file

@ -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)}
</span>
<span className="shopping-list__item-quantity">
{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}`,
)}`}
</span>
</CheckboxOption>
</li>

View file

@ -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"],
});
/**

View file

@ -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;
}
/**

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
* 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;
}
/**