From de500e1a8ab46c7098f2ce07552e44fb3c9f5be4 Mon Sep 17 00:00:00 2001 From: Nicolas Date: Wed, 19 Aug 2026 22:34:57 +0200 Subject: [PATCH 1/2] =?UTF-8?q?feat(recipes):=20catalogue=20de=20r=C3=A9f?= =?UTF-8?q?=C3=A9rence=20pour=20les=20unit=C3=A9s=20d'ingr=C3=A9dients?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remplace l'unité texte libre de RecipeIngredient (max 20 caractères, "g"/"grammes"/"G"... jamais fiable à additionner) par une référence vers un nouveau catalogue Unit (id/key/type/toBaseFactor), même traitement que Diet/Allergy/Ingredient : GET /reference/units, seedé par reference-seed-data.ts (14 unités : gram/kilogram/milliliter/ centiliter/liter/tablespoon/teaspoon/piece/pinch/slice/clove/bunch/ sachet/sprig), sélectionnable uniquement via un onUnitChange(e.target.value)} - placeholder={t("recipes.form.unitPlaceholder")} + value={unitId ?? ""} + onChange={(e) => onUnitChange(Number(e.target.value))} aria-label={t("recipes.form.unitLabel")} - /> + > + + {unitsCatalog.map((unit) => ( + + ))} + ("loading"); const [ingredientsCatalog, setIngredientsCatalog] = useState([]); const [dietsCatalog, setDietsCatalog] = useState([]); + const [unitsCatalog, setUnitsCatalog] = useState([]); const [name, setName] = useState(""); const [description, setDescription] = useState(""); @@ -74,12 +76,14 @@ export function RecipeFormPage() { Promise.all([ apiClient.getIngredients(), apiClient.getDiets(), + apiClient.getUnits(), recipeId !== null ? apiClient.getRecipe(recipeId) : Promise.resolve(null), ]) - .then(([ingredients, diets, recipe]) => { + .then(([ingredients, diets, units, recipe]) => { if (cancelled) return; setIngredientsCatalog(ingredients); setDietsCatalog(diets); + setUnitsCatalog(units); if (recipe) { setName(recipe.name); setDescription(recipe.description ?? ""); @@ -92,7 +96,7 @@ export function RecipeFormPage() { key: makeClientKey(), ingredient: line.ingredient, quantity: String(line.quantity), - unit: line.unit, + unitId: line.unit.id, })), ); setSteps( @@ -117,13 +121,13 @@ export function RecipeFormPage() { function addIngredient(ingredient: IngredientView) { setIngredientLines((lines) => [ ...lines, - { key: makeClientKey(), ingredient, quantity: "", unit: "" }, + { key: makeClientKey(), ingredient, quantity: "", unitId: null }, ]); } function updateIngredientLine( key: string, - patch: Partial>, + patch: Partial>, ) { setIngredientLines((lines) => lines.map((line) => (line.key === key ? { ...line, ...patch } : line)), @@ -142,7 +146,7 @@ export function RecipeFormPage() { Number.isInteger(Number(portions)) && Number(portions) > 0 && ingredientLines.length > 0 && - ingredientLines.every((line) => Number(line.quantity) > 0 && line.unit.trim().length > 0) && + ingredientLines.every((line) => Number(line.quantity) > 0 && line.unitId !== null) && steps.length > 0 && steps.every((step) => step.description.trim().length > 0); @@ -160,7 +164,12 @@ export function RecipeFormPage() { ingredients: ingredientLines.map((line) => ({ ingredientId: line.ingredient.id, quantity: Number(line.quantity), - unit: line.unit.trim(), + // `canSubmit` already requires every line to have a unit picked + // before the button is enabled — `?? 0` is just to satisfy the + // type here; if it's ever reached with no unit set, the schema's + // `positive()` check rejects it the same way an invalid quantity + // already does. + unitId: line.unitId ?? 0, })), steps: steps.map((step) => ({ description: step.description.trim(), @@ -264,9 +273,10 @@ export function RecipeFormPage() { key={line.key} ingredient={line.ingredient} quantity={line.quantity} - unit={line.unit} + unitId={line.unitId} + unitsCatalog={unitsCatalog} onQuantityChange={(quantity) => updateIngredientLine(line.key, { quantity })} - onUnitChange={(unit) => updateIngredientLine(line.key, { unit })} + onUnitChange={(unitId) => updateIngredientLine(line.key, { unitId })} onRemove={() => removeIngredientLine(line.key)} /> ))} diff --git a/packages/shared/src/errors/error-codes.ts b/packages/shared/src/errors/error-codes.ts index 2f6f409..b421f4b 100644 --- a/packages/shared/src/errors/error-codes.ts +++ b/packages/shared/src/errors/error-codes.ts @@ -58,6 +58,8 @@ export enum ErrorCode { INGREDIENT_NOT_FOUND = 4046, /** `DELETE /planning/items/:id` given an id that doesn't match any planning item visible to the caller's household. */ PLANNING_ITEM_NOT_FOUND = 4047, + /** A recipe payload's `unitId` doesn't match any reference `Unit` row. */ + UNIT_NOT_FOUND = 4048, /** Unexpected/unhandled failure — the catch-all, always logged server-side. */ INTERNAL_ERROR = 5000, } diff --git a/packages/shared/src/schemas/recipe.ts b/packages/shared/src/schemas/recipe.ts index f18103e..de31a28 100644 --- a/packages/shared/src/schemas/recipe.ts +++ b/packages/shared/src/schemas/recipe.ts @@ -13,7 +13,8 @@ import { z } from "zod"; const recipeIngredientInputSchema = z.object({ ingredientId: z.number().int().positive(), quantity: z.number().positive("La quantité doit être positive"), - unit: z.string().trim().min(1, "L'unité est requise").max(20), + /** 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(), }); /** diff --git a/packages/shared/src/types/recipe.ts b/packages/shared/src/types/recipe.ts index 7d1c554..336a2b3 100644 --- a/packages/shared/src/types/recipe.ts +++ b/packages/shared/src/types/recipe.ts @@ -1,4 +1,4 @@ -import type { AllergyView, DietView, IngredientView } from "./reference.js"; +import type { AllergyView, DietView, IngredientView, UnitView } from "./reference.js"; /** * Who can *read* a recipe — mirrors `RecipeVisibility` in schema.prisma. @@ -12,13 +12,16 @@ export type RecipeVisibility = "PERSONAL" | "HOUSE" | "PUBLIC"; /** * One ingredient line within a recipe, as returned in {@link RecipeView} — * the ingredient resolved to its full reference data (name, icon, - * allergens), plus the quantity/unit specific to this recipe (carried by - * `RecipeIngredient` in schema.prisma, not by `Ingredient` itself). + * 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`). */ export interface RecipeIngredientView { ingredient: IngredientView; quantity: number; - unit: string; + unit: UnitView; } /** diff --git a/packages/shared/src/types/reference.ts b/packages/shared/src/types/reference.ts index adb56ed..5af48e3 100644 --- a/packages/shared/src/types/reference.ts +++ b/packages/shared/src/types/reference.ts @@ -157,6 +157,36 @@ export const INGREDIENT_ICONS = [ /** Inferred TS type for one {@link INGREDIENT_ICONS} member. */ export type IngredientIcon = (typeof INGREDIENT_ICONS)[number]; +/** + * Which physical quantity a {@link UnitView} measures — mirrors `UnitType` + * in schema.prisma, declared by hand for the same reason as + * {@link AllergenKind}. Only units of the same type are ever mutually + * convertible via `toBaseFactor` — see {@link UnitView}. + */ +export type UnitType = "MASS" | "VOLUME" | "COUNT"; + +/** + * A recipe ingredient unit, as returned by `GET /reference/units` — + * reference data (`Unit`, seeded via `reference-seed-data.ts`'s `UNITS`), + * same static/non-administrable status as {@link DietView}/ + * {@link AllergyView}. + * + * `key` is a stable English camelCase uid (e.g. `"tablespoon"`), not a + * display label — resolved via `t(\`catalog.units.${key}\`)`, same as + * {@link DietView.key}. `toBaseFactor` is how many of `type`'s base unit + * (gram for MASS, milliliter for VOLUME, itself for COUNT) one of this unit + * equals — groundwork for a future conversion feature (e.g. a shopping list + * summing "500g" + "0.5kg" into "1kg"), not that feature itself: COUNT + * units all carry `toBaseFactor: 1` and don't convert to one another (a + * "pincée" isn't a fixed fraction of a "gousse"). + */ +export interface UnitView { + id: number; + key: string; + type: UnitType; + toBaseFactor: number; +} + /** * A selectable ingredient, as returned by `GET /reference/ingredients` — * reference data (`Ingredient`, seeded via `apps/api/src/db/ From 0ff8775cf6fabcad6194a47e2f72ad560a1e0788 Mon Sep 17 00:00:00 2001 From: Nicolas Date: Wed, 19 Aug 2026 22:40:02 +0200 Subject: [PATCH 2/2] =?UTF-8?q?fix(recipes):=20=C3=A9largir=20le=20s=C3=A9?= =?UTF-8?q?lecteur=20d'unit=C3=A9=20pour=20les=20labels=20longs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit .ingredient-row__unit était encore dimensionné pour l'ancien champ texte libre (6rem, suffisant pour "g"/"unité") — trop étroit maintenant que c'est un over the reference catalog + // (see IngredientRow.tsx), left too narrow for that one. &__unit { - width: 6rem; + width: 11rem; } &__remove {