fix(recipes): corrige plusieurs bugs d'import TheMealDB

- Les instructions TheMealDB numérotées sur leur propre ligne ("1\n\ntexte...\n\n2\n\ntexte...") créaient des étapes parasites ne contenant qu'un chiffre — filtrées désormais (#52).
- Un ingrédient compté sans mot d'unité dans le texte source (ex. "4 Egg Yolks") laissait l'import bloqué sur "Importer" indéfiniment, sans indication visuelle de la ligne en cause — matchUnit retombe maintenant sur l'unité générique "piece" quand une quantité a été extraite, et RecipeImportForm/RecipeFormPage surlignent désormais toute ligne dont l'unité manque, avec un message explicite (#53).
- Ajout de INGREDIENT_LABEL_SYNONYMS_EN pour reconnaître des formulations alternatives fréquentes chez les sources anglophones ("vanilla pod" en plus de "vanilla bean") sans élargir INGREDIENT_LABELS_EN à un tableau pour ses ~550 entrées (#54).
- Effet de bord découvert en vérifiant #53 de bout en bout : deux lignes source résolues vers le même ingrédient catalogue (ex. "Egg Yolks"/"Eggs" -> "Œuf") faisaient planter la création en 500 (contrainte unique recipe_id+ingredient_id) au lieu d'un 400 propre. createRecipeSchema rejette maintenant les ingredientId en double, et le formulaire d'import surligne les doublons avant même de soumettre.

Vérifié de bout en bout dans le navigateur (import réel de la recette "Flan" depuis TheMealDB, jusqu'au planning) en plus des tests ajoutés.

Closes #52, #53, #54

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Nicolas 2026-08-21 07:55:00 +02:00
parent f7d7664397
commit 9083770d57
15 changed files with 287 additions and 20 deletions

View file

@ -1,4 +1,8 @@
import { INGREDIENT_LABELS_EN, UNIT_LABELS_EN } from "@batch-cooking/shared"; import {
INGREDIENT_LABELS_EN,
INGREDIENT_LABEL_SYNONYMS_EN,
UNIT_LABELS_EN,
} from "@batch-cooking/shared";
import { prisma } from "../db/prisma.js"; import { prisma } from "../db/prisma.js";
import { normalizeText } from "./tech-step-matcher.js"; import { normalizeText } from "./tech-step-matcher.js";
@ -168,13 +172,17 @@ export function extractQuantity(rawText: string): ExtractedQuantity {
return { quantity, remainder: trimmed.slice(match[0].length).trim() }; return { quantity, remainder: trimmed.slice(match[0].length).trim() };
} }
/** Loads the full `Ingredient` catalog as {@link IngredientMatchEntry}s — one entry per key with an authored English label (see `INGREDIENT_LABELS_EN`); an ingredient with no English label yet is silently skipped, never a matching target. Meant to be fetched once per request and reused across every ingredient line, not re-queried per line. */ /** Loads the full `Ingredient` catalog as {@link IngredientMatchEntry}s — one entry per key with an authored English label (see `INGREDIENT_LABELS_EN`), plus one extra entry per alternate wording (`INGREDIENT_LABEL_SYNONYMS_EN`, e.g. "Vanilla pod" alongside "Vanilla bean" — see issue #54) sharing the same `ingredientId`; `matchIngredientName` doesn't need to know synonyms exist, it just sees more candidate labels for the same ingredient. An ingredient with no English label yet is silently skipped, never a matching target. Meant to be fetched once per request and reused across every ingredient line, not re-queried per line. */
export async function loadIngredientCatalog(): Promise<IngredientMatchEntry[]> { export async function loadIngredientCatalog(): Promise<IngredientMatchEntry[]> {
const ingredients = await prisma.ingredient.findMany({ select: { id: true, key: true } }); const ingredients = await prisma.ingredient.findMany({ select: { id: true, key: true } });
const catalog: IngredientMatchEntry[] = []; const catalog: IngredientMatchEntry[] = [];
for (const ingredient of ingredients) { for (const ingredient of ingredients) {
const label = INGREDIENT_LABELS_EN[ingredient.key]; const label = INGREDIENT_LABELS_EN[ingredient.key];
if (label !== undefined) catalog.push({ ingredientId: ingredient.id, label }); if (label === undefined) continue;
catalog.push({ ingredientId: ingredient.id, label });
for (const synonym of INGREDIENT_LABEL_SYNONYMS_EN[ingredient.key] ?? []) {
catalog.push({ ingredientId: ingredient.id, label: synonym });
}
} }
return catalog; return catalog;
} }

View file

@ -92,6 +92,14 @@ export function translateRecipeSteps(
}; };
} }
/**
* English label {@link matchUnit} is fed when a quantity was found but no
* unit word was see the `unitId` fallback below. `"piece"` (`UNIT_LABELS_EN`,
* `packages/shared`) is the catalog's generic "counted, no further unit"
* entry (French "unité").
*/
const FALLBACK_COUNT_UNIT_LABEL = "piece";
/** /**
* Resolves each of `ingredients`' free-text `name`/`unit`/`quantity` * Resolves each of `ingredients`' free-text `name`/`unit`/`quantity`
* against `ingredientCatalog`/`unitCatalog` (see `ingredient-matcher.ts`). * against `ingredientCatalog`/`unitCatalog` (see `ingredient-matcher.ts`).
@ -101,6 +109,17 @@ export function translateRecipeSteps(
* same for `unit` falling back to `extractQuantity`'s `remainder` before * same for `unit` falling back to `extractQuantity`'s `remainder` before
* being matched against `unitCatalog` a source that already states a * being matched against `unitCatalog` a source that already states a
* clean unit/quantity is trusted over re-deriving it from `rawText`. * clean unit/quantity is trusted over re-deriving it from `rawText`.
*
* When a quantity was found but nothing in the remaining text matched a
* unit (e.g. `"4 Egg Yolks"` quantity `4`, remainder `"Egg Yolks"`, no
* unit word anywhere in it), `unitId` falls back to the catalog's generic
* `piece` unit rather than staying `null`: a bare count with no explicit
* unit word is overwhelmingly "N of them" (eggs, onions, cloves not
* spelled out as "clove") in practice, not a genuinely missing unit see
* issue #53, where this previously left the import review form's submit
* button disabled with no indication why on almost any recipe with a
* whole-item ingredient. No fallback when `quantity` itself is `null`
* (e.g. `"To taste"`) there's nothing to count, so nothing to default.
*/ */
export function translateRecipeIngredients( export function translateRecipeIngredients(
ingredients: ParsedRecipeIngredient[], ingredients: ParsedRecipeIngredient[],
@ -112,7 +131,9 @@ export function translateRecipeIngredients(
const extracted = extractQuantity(ingredient.rawText); const extracted = extractQuantity(ingredient.rawText);
const quantity = ingredient.quantity ?? extracted.quantity; const quantity = ingredient.quantity ?? extracted.quantity;
const unitText = ingredient.unit ?? extracted.remainder; const unitText = ingredient.unit ?? extracted.remainder;
const unitId = matchUnit(unitText, unitCatalog); const unitId =
matchUnit(unitText, unitCatalog) ??
(quantity !== null ? matchUnit(FALLBACK_COUNT_UNIT_LABEL, unitCatalog) : null);
return { ...ingredient, quantity, ingredientId, unitId }; return { ...ingredient, quantity, ingredientId, unitId };
}); });
} }

View file

@ -129,10 +129,17 @@ export const theMealDbAdapter: RecipeSourceAdapter<TheMealDbMeal> = {
// Free-text instructions, usually one step per line — splitting on // Free-text instructions, usually one step per line — splitting on
// blank/newlines is the closest this source gets to discrete steps. // blank/newlines is the closest this source gets to discrete steps.
// TheMealDB frequently numbers each step on its own line ahead of the
// paragraph that follows (e.g. "…melted.\n\n2\n\nPreheat oven…") rather
// than inline ("1. Preheat oven…") — the blank-line split above turns
// that lone number into its own "line", which would otherwise become a
// bogus step containing nothing but a digit. Drop those rather than
// keep them as steps in their own right (see issue #52).
const steps = (meal.strInstructions ?? "") const steps = (meal.strInstructions ?? "")
.split(/\r?\n+/) .split(/\r?\n+/)
.map((line) => line.trim()) .map((line) => line.trim())
.filter((line) => line.length > 0) .filter((line) => line.length > 0)
.filter((line) => !/^\d+\.?$/.test(line))
.map((description) => ({ description, picture: null })); .map((description) => ({ description, picture: null }));
if (steps.length === 0) { if (steps.length === 0) {
throw new RecipeSourceParseError( throw new RecipeSourceParseError(

View file

@ -1,3 +1,4 @@
import { INGREDIENT_LABEL_SYNONYMS_EN } from "@batch-cooking/shared";
import { expect } from "chai"; import { expect } from "chai";
import { prisma } from "../src/db/prisma.js"; import { prisma } from "../src/db/prisma.js";
import { import {
@ -75,6 +76,15 @@ describe("ingredient-matcher", () => {
expect(matchIngredientName("", catalog)).to.equal(null); expect(matchIngredientName("", catalog)).to.equal(null);
}); });
it("matches an alternate wording of the same ingredient via a second catalog entry sharing its ingredientId (issue #54)", () => {
const vanillaBean: IngredientMatchEntry = { ingredientId: 8, label: "Vanilla bean" };
const vanillaBeanSynonym: IngredientMatchEntry = { ingredientId: 8, label: "Vanilla pod" };
const synonymCatalog = [vanillaBean, vanillaBeanSynonym];
expect(matchIngredientName("1 vanilla pod", synonymCatalog)).to.equal(8);
expect(matchIngredientName("1 vanilla bean", synonymCatalog)).to.equal(8);
});
it("breaks a same-specificity tie by the lowest ingredientId", () => { it("breaks a same-specificity tie by the lowest ingredientId", () => {
const onionA: IngredientMatchEntry = { ingredientId: 20, label: "Onion" }; const onionA: IngredientMatchEntry = { ingredientId: 20, label: "Onion" };
const onionB: IngredientMatchEntry = { ingredientId: 21, label: "Onion" }; const onionB: IngredientMatchEntry = { ingredientId: 21, label: "Onion" };
@ -190,18 +200,33 @@ describe("ingredient-matcher", () => {
await prisma.$disconnect(); await prisma.$disconnect();
}); });
it("loads one entry per Ingredient that has an English label, keyed by real ingredientId", async () => { it("loads one entry per Ingredient that has an English label, plus one per alternate wording (INGREDIENT_LABEL_SYNONYMS_EN), keyed by real ingredientId", async () => {
const tomato = await prisma.ingredient.findFirstOrThrow({ where: { key: "tomato" } }); const tomato = await prisma.ingredient.findFirstOrThrow({ where: { key: "tomato" } });
const vanillaBean = await prisma.ingredient.findFirstOrThrow({
where: { key: "vanillaBean" },
});
const ingredientCount = await prisma.ingredient.count(); const ingredientCount = await prisma.ingredient.count();
const synonymCount = Object.values(INGREDIENT_LABEL_SYNONYMS_EN).reduce(
(sum, synonyms) => sum + synonyms.length,
0,
);
const catalog = await loadIngredientCatalog(); const catalog = await loadIngredientCatalog();
// Every seeded ingredient has an authored English label (verified at // Every seeded ingredient has an authored English label (verified at
// generation time — see packages/shared/src/data/catalog-labels-en.ts), // generation time — see packages/shared/src/data/catalog-labels-en.ts),
// so nothing should be silently skipped. // so nothing should be silently skipped — plus one extra entry per
expect(catalog).to.have.length(ingredientCount); // synonym (issue #54), sharing the same ingredientId as the primary
// label's entry.
expect(catalog).to.have.length(ingredientCount + synonymCount);
const tomatoEntry = catalog.find((entry) => entry.ingredientId === tomato.id); const tomatoEntry = catalog.find((entry) => entry.ingredientId === tomato.id);
expect(tomatoEntry?.label).to.equal("Tomato"); expect(tomatoEntry?.label).to.equal("Tomato");
const vanillaBeanEntries = catalog.filter((entry) => entry.ingredientId === vanillaBean.id);
expect(vanillaBeanEntries.map((entry) => entry.label)).to.deep.equal([
"Vanilla bean",
"Vanilla pod",
]);
}); });
it("loads one entry per Unit that has English synonyms, keyed by real unitId", async () => { it("loads one entry per Unit that has English synonyms, keyed by real unitId", async () => {

View file

@ -196,6 +196,32 @@ describe("recipe-translation", () => {
expect(translated[0].unitId).to.equal(gram.unitId); expect(translated[0].unitId).to.equal(gram.unitId);
}); });
it("falls back to the generic 'piece' unit when a quantity was found but no unit word was (issue #53)", () => {
const piece: UnitMatchEntry = { unitId: 12, synonyms: ["piece", "pieces", "pc", "pcs"] };
const translated = translateRecipeIngredients(
[buildIngredient({ rawText: "4 Egg Yolks", name: "Egg Yolks" })],
[],
[gram, cup, piece],
);
expect(translated[0].quantity).to.equal(4);
expect(translated[0].unitId).to.equal(piece.unitId);
});
it("does not fall back to 'piece' when there's no quantity at all to count", () => {
const piece: UnitMatchEntry = { unitId: 12, synonyms: ["piece", "pieces", "pc", "pcs"] };
const translated = translateRecipeIngredients(
[buildIngredient({ rawText: "salt to taste", name: "salt" })],
[],
[piece],
);
expect(translated[0].quantity).to.equal(null);
expect(translated[0].unitId).to.equal(null);
});
it("leaves quantity/unitId null when rawText has neither a leading number nor a recognizable unit", () => { it("leaves quantity/unitId null when rawText has neither a leading number nor a recognizable unit", () => {
const translated = translateRecipeIngredients( const translated = translateRecipeIngredients(
[buildIngredient({ rawText: "salt to taste", name: "salt" })], [buildIngredient({ rawText: "salt to taste", name: "salt" })],

View file

@ -568,6 +568,32 @@ describe("Recipes", () => {
expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR); expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
}); });
it("rejects the same ingredientId listed twice with 400 VALIDATION_ERROR, not a 500 (issue #53 follow-up)", async () => {
// `RecipeIngredient`'s primary key is `(recipeId, ingredientId)` — a
// manual creation can't reach this via the web UI (`IngredientPicker`
// hides an already-picked ingredient), but nothing stops a raw
// request (or a source import, whose lines aren't deduplicated) from
// sending it — must fail cleanly instead of crashing on the DB's
// unique-constraint violation.
const { agent } = await signup();
const tomate = await ingredientId("tomato");
const piece = await unitId("piece");
const res = await agent.post("/recipes").send({
name: "Test",
portions: 4,
dietIds: [],
ingredients: [
{ ingredientId: tomate, quantity: 1, unitId: piece },
{ ingredientId: tomate, quantity: 2, unitId: piece },
],
steps: [{ description: "Étape" }],
});
expect(res.status).to.equal(400);
expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
});
it("rejects a missing or non-positive portions with 400 VALIDATION_ERROR", async () => { it("rejects a missing or non-positive portions with 400 VALIDATION_ERROR", async () => {
const { agent } = await signup(); const { agent } = await signup();
const tomate = await ingredientId("tomato"); const tomate = await ingredientId("tomato");

View file

@ -357,6 +357,23 @@ describe("Sources", () => {
expect(res.body.code).to.equal(ErrorCode.INGREDIENT_NOT_FOUND); expect(res.body.code).to.equal(ErrorCode.INGREDIENT_NOT_FOUND);
}); });
it("rejects the same ingredientId listed twice with 400 VALIDATION_ERROR, not a 500 (issue #53 follow-up)", async () => {
// A source's raw ingredient lines aren't deduplicated by the matcher
// (see `ingredient-matcher.ts`) — two different lines (e.g. "Egg
// Yolks" and "Eggs") can resolve to the same catalog ingredient, same
// as `recipe.test.ts`'s equivalent for a manual creation, just
// reached here through the review screen's pre-filled payload
// instead.
const { agent } = await enableFakeSource();
const payload = await buildImportPayload();
payload.ingredients.push({ ...payload.ingredients[0] });
const res = await agent.post("/sources/fakeSource/import/1").send(payload);
expect(res.status).to.equal(400);
expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
});
it("rejects a second household's import of the same item too — the item's identity is global, not per-household", async () => { it("rejects a second household's import of the same item too — the item's identity is global, not per-household", async () => {
// Registers/syncs the adapter once — enableFakeSource() itself does // Registers/syncs the adapter once — enableFakeSource() itself does
// this too, and registerRecipeSource() throws on a duplicate key, so // this too, and registerRecipeSource() throws on a duplicate key, so

View file

@ -154,6 +154,20 @@ describe("theMealDbAdapter", () => {
]); ]);
}); });
it("drops lone step-number lines instead of turning them into bogus steps (issue #52)", () => {
const parsed = theMealDbAdapter.parse({
...baseMeal,
strInstructions:
"For the caramel, melt the sugar.\r\n\r\n2\r\n\r\nPreheat the oven.\r\n\r\n3\r\n\r\nBake it.",
});
expect(parsed.steps).to.deep.equal([
{ description: "For the caramel, melt the sugar.", picture: null },
{ description: "Preheat the oven.", picture: null },
{ description: "Bake it.", picture: null },
]);
});
it("throws RecipeSourceParseError when the meal has no name", () => { it("throws RecipeSourceParseError when the meal has no name", () => {
expect(() => theMealDbAdapter.parse({ ...baseMeal, strMeal: null })).to.throw( expect(() => theMealDbAdapter.parse({ ...baseMeal, strMeal: null })).to.throw(
RecipeSourceParseError, RecipeSourceParseError,

View file

@ -12,6 +12,7 @@ export function IngredientRow({
quantity, quantity,
unitId, unitId,
unitsCatalog, unitsCatalog,
duplicate = false,
onQuantityChange, onQuantityChange,
onUnitChange, onUnitChange,
onRemove, onRemove,
@ -20,14 +21,24 @@ export function IngredientRow({
quantity: string; quantity: string;
unitId: number | null; unitId: number | null;
unitsCatalog: UnitView[]; unitsCatalog: UnitView[];
/** 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; onQuantityChange: (quantity: string) => void;
onUnitChange: (unitId: number) => void; onUnitChange: (unitId: number) => void;
onRemove: () => void; onRemove: () => void;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
// No unit picked yet — surfaced with a highlighted `<select>` + inline
// hint rather than silently keeping the form's submit button disabled
// (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;
const needsAttention = unitMissing || duplicate;
return ( return (
<li className="ingredient-row"> <li className={needsAttention ? "ingredient-row ingredient-row--incomplete" : "ingredient-row"}>
<span className="ingredient-row__icon" aria-hidden="true"> <span className="ingredient-row__icon" aria-hidden="true">
<IngredientTypeIcon icon={ingredient.icon} /> <IngredientTypeIcon icon={ingredient.icon} />
</span> </span>
@ -46,6 +57,7 @@ export function IngredientRow({
value={unitId ?? ""} value={unitId ?? ""}
onChange={(e) => onUnitChange(Number(e.target.value))} onChange={(e) => onUnitChange(Number(e.target.value))}
aria-label={t("recipes.form.unitLabel")} aria-label={t("recipes.form.unitLabel")}
aria-invalid={unitMissing}
> >
<option value="" disabled> <option value="" disabled>
{t("recipes.form.unitPlaceholder")} {t("recipes.form.unitPlaceholder")}
@ -70,6 +82,14 @@ export function IngredientRow({
> >
</button> </button>
{unitMissing && (
<p className="field-error ingredient-row__error">{t("recipes.form.unitMissingHint")}</p>
)}
{duplicate && (
<p className="field-error ingredient-row__error">
{t("recipes.form.duplicateIngredientHint")}
</p>
)}
</li> </li>
); );
} }

View file

@ -212,12 +212,38 @@ export function RecipeImportForm({
setResolvingKey((current) => (current === key ? null : current)); setResolvingKey((current) => (current === key ? null : current));
} }
// Surfaced next to the submit button (see the `unitMissingHint` per-row
// hint in `IngredientRow` for the same thing at the line level) — without
// 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);
// Two raw source lines can independently resolve to the same catalog
// ingredient (e.g. "Egg Yolks" and "Eggs" both matching "Egg") —
// `RecipeIngredient`'s primary key is `(recipeId, ingredientId)`, one row
// per ingredient (schema.prisma), so submitting both would otherwise fail
// (`createRecipeSchema` now rejects it, see its own doc comment). Blocked
// here too, with each duplicate row highlighted, rather than letting the
// user find out only after clicking "Importer".
const duplicateIngredientIds = (() => {
const seen = new Set<number>();
const duplicates = new Set<number>();
for (const line of ingredientLines) {
if (seen.has(line.ingredient.id)) duplicates.add(line.ingredient.id);
seen.add(line.ingredient.id);
}
return duplicates;
})();
const hasDuplicateIngredient = duplicateIngredientIds.size > 0;
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) => Number(line.quantity) > 0 && line.unitId !== null) &&
!hasDuplicateIngredient &&
unresolvedIngredients.length === 0 && unresolvedIngredients.length === 0 &&
steps.length > 0 && steps.length > 0 &&
steps.every((step) => step.description.trim().length > 0); steps.every((step) => step.description.trim().length > 0);
@ -368,6 +394,7 @@ export function RecipeImportForm({
quantity={line.quantity} quantity={line.quantity}
unitId={line.unitId} unitId={line.unitId}
unitsCatalog={unitsCatalog} unitsCatalog={unitsCatalog}
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 })}
onRemove={() => removeIngredientLine(line.key)} onRemove={() => removeIngredientLine(line.key)}
@ -424,6 +451,12 @@ export function RecipeImportForm({
</section> </section>
{formError && <p className="form-error">{formError}</p>} {formError && <p className="form-error">{formError}</p>}
{!canSubmit && hasIngredientMissingUnit && (
<p className="form-error">{t("recipes.form.incompleteIngredientsHint")}</p>
)}
{!canSubmit && hasDuplicateIngredient && (
<p className="form-error">{t("recipes.form.duplicateIngredientsHint")}</p>
)}
<div className="recipe-form__actions"> <div className="recipe-form__actions">
<button type="submit" disabled={isSubmitting || !canSubmit}> <button type="submit" disabled={isSubmitting || !canSubmit}>

View file

@ -923,6 +923,20 @@
border-color: var(--color-error); border-color: var(--color-error);
} }
} }
// A resolved ingredient with no unit picked the form's submit stays
// disabled until this is fixed, so it needs to be obvious *which* row is
// the reason why (see issue #53). `__error` takes the row's full width
// (it comes after the `flex-wrap`ped controls above, so it naturally
// drops to its own line).
&--incomplete &__unit {
border-color: var(--color-error);
}
&__error {
width: 100%;
margin: 0;
}
} }
// --- Ingredient picker (recipe form + disliked-ingredients field) ---------- // --- Ingredient picker (recipe form + disliked-ingredients field) ----------

View file

@ -260,6 +260,10 @@
"quantityLabel": "Quantité", "quantityLabel": "Quantité",
"unitLabel": "Unité", "unitLabel": "Unité",
"unitPlaceholder": "Choisir une unité", "unitPlaceholder": "Choisir une unité",
"unitMissingHint": "Choisissez une unité pour cet ingrédient.",
"duplicateIngredientHint": "Cet ingrédient apparaît plusieurs fois — retirez les doublons.",
"incompleteIngredientsHint": "Complétez la quantité et l'unité des ingrédients surlignés ci-dessus avant de continuer.",
"duplicateIngredientsHint": "Retirez les doublons parmi les ingrédients surlignés ci-dessus avant de continuer.",
"removeIngredient": "Retirer cet ingrédient", "removeIngredient": "Retirer cet ingrédient",
"stepDescriptionPlaceholder": "Décrivez cette étape…", "stepDescriptionPlaceholder": "Décrivez cette étape…",
"stepPicturePlaceholder": "Photo de l'étape (URL, optionnel)", "stepPicturePlaceholder": "Photo de l'étape (URL, optionnel)",

View file

@ -138,6 +138,13 @@ export function RecipeFormPage() {
setIngredientLines((lines) => lines.filter((line) => line.key !== key)); setIngredientLines((lines) => lines.filter((line) => line.key !== key));
} }
// Surfaced next to the submit button when it's the reason `canSubmit` is
// false — same "don't leave the button silently disabled" reasoning as
// `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);
// 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.
@ -294,6 +301,9 @@ export function RecipeFormPage() {
</section> </section>
{formError && <p className="form-error">{formError}</p>} {formError && <p className="form-error">{formError}</p>}
{!canSubmit && hasIngredientMissingUnit && (
<p className="form-error">{t("recipes.form.incompleteIngredientsHint")}</p>
)}
<div className="recipe-form__actions"> <div className="recipe-form__actions">
<button type="submit" disabled={isSubmitting || !canSubmit}> <button type="submit" disabled={isSubmitting || !canSubmit}>

View file

@ -600,6 +600,24 @@ export const INGREDIENT_LABELS_EN: Record<string, string> = {
caneSyrup: "Cane syrup", caneSyrup: "Cane syrup",
}; };
/**
* Extra English matching phrases for a handful of {@link INGREDIENT_LABELS_EN}
* entries whose primary label doesn't cover a common alternate wording used
* by real recipe sources (e.g. TheMealDB's `"Vanilla Pod"` vs. this
* catalog's own `"Vanilla bean"`) see issue #54. Deliberately a *second*,
* mostly-empty map rather than widening `INGREDIENT_LABELS_EN` itself to
* `string | string[]` for every one of its ~550 entries: only entries that
* actually need an alternate wording are listed here, so this stays a
* short, easy-to-scan list of exceptions instead of turning every other
* entry into a single-item array for no reason. `apps/api`'s
* `ingredient-matcher.ts` (`loadIngredientCatalog`) adds one extra catalog
* entry per synonym, alongside the primary label matched exactly the same
* way, `matchIngredientName` itself doesn't need to know synonyms exist.
*/
export const INGREDIENT_LABEL_SYNONYMS_EN: Record<string, string[]> = {
vanillaBean: ["Vanilla pod"],
};
/** /**
* English matching synonyms for the `Unit` reference catalog * English matching synonyms for the `Unit` reference catalog
* (`apps/api/src/db/reference-seed-data.ts`'s `UNITS`), keyed by * (`apps/api/src/db/reference-seed-data.ts`'s `UNITS`), keyed by

View file

@ -37,7 +37,8 @@ const recipeStepInputSchema = z.object({
const recipeVisibilitySchema = z.enum(["PERSONAL", "HOUSE", "PUBLIC"]); const recipeVisibilitySchema = z.enum(["PERSONAL", "HOUSE", "PUBLIC"]);
/** Payload accepted by `POST /recipes` and `PATCH /recipes/:id` (a full replace, not a partial merge — see the API's `recipe.service.ts`). */ /** Payload accepted by `POST /recipes` and `PATCH /recipes/:id` (a full replace, not a partial merge — see the API's `recipe.service.ts`). */
export const createRecipeSchema = z.object({ export const createRecipeSchema = z
.object({
name: z.string().trim().min(1, "Le nom de la recette est requis").max(150), name: z.string().trim().min(1, "Le nom de la recette est requis").max(150),
description: z.string().trim().max(2000).nullable().optional(), description: z.string().trim().max(2000).nullable().optional(),
picture: z.string().trim().url("URL invalide").nullable().optional(), picture: z.string().trim().url("URL invalide").nullable().optional(),
@ -48,7 +49,30 @@ export const createRecipeSchema = z.object({
dietIds: z.array(z.number().int().positive()), dietIds: z.array(z.number().int().positive()),
ingredients: z.array(recipeIngredientInputSchema).min(1, "Au moins un ingrédient est requis"), ingredients: z.array(recipeIngredientInputSchema).min(1, "Au moins un ingrédient est requis"),
steps: z.array(recipeStepInputSchema).min(1, "Au moins une étape est requise"), steps: z.array(recipeStepInputSchema).min(1, "Au moins une étape est requise"),
}); })
// `RecipeIngredient`'s primary key is `(recipeId, ingredientId)` — one row
// per ingredient per recipe (schema.prisma) — so two lines with the same
// `ingredientId` would otherwise reach `prisma.recipe.create()` and crash
// with an unhandled `P2002` unique-constraint 500, instead of the clean
// 400 every other invalid shape gets here. The manual recipe form can't
// produce this (`IngredientPicker`'s `excludeIds` hides an
// already-selected ingredient), but a source import can: two raw lines
// ("Egg Yolks", "Eggs") can independently resolve to the same catalog
// ingredient (see `RecipeImportForm.tsx`, issue #53's `matchUnit`
// fallback made this reachable in practice) — rejected here rather than
// silently summed, since two lines resolving to the same ingredient
// aren't necessarily interchangeable quantities (different units,
// different confidence in the match).
.refine(
(input) => {
const ingredientIds = input.ingredients.map((ingredient) => ingredient.ingredientId);
return new Set(ingredientIds).size === ingredientIds.length;
},
{
message: "Un même ingrédient ne peut pas apparaître plusieurs fois dans une recette",
path: ["ingredients"],
},
);
/** Inferred TS type for {@link createRecipeSchema}'s validated output. */ /** Inferred TS type for {@link createRecipeSchema}'s validated output. */
export type CreateRecipeInput = z.infer<typeof createRecipeSchema>; export type CreateRecipeInput = z.infer<typeof createRecipeSchema>;