Ingrédients d'assaisonnement : lignes de recette « selon le goût » sans quantité ni unité #128

Closed
opened 2026-09-09 13:17:30 +02:00 by kyuno · 1 comment
Owner

Contexte

Origine : ticket #3 (« Améliorations diverses »), puce 1 — « Unités pour les ingrédients → Ajouter la possibilité de définir des ingrédients d'assaisonnement (sans quantité, selon le goût) ».

Aujourd'hui, toute ligne d'ingrédient d'une recette porte une quantité et une unité obligatoires :

Résultat : impossible de saisir « sel, poivre — selon le goût » sans inventer une quantité factice.

À noter : le chemin clause-de-technique tolère déjà quantity/unit nuls (StepTechStepIngredient.quantity est nullable, toOptimizerRecipe fait line.quantity === null ? null : Number(...), cooking-session.service.ts:86-90). C'est seulement la liste d'ingrédients d'en-tête de recette qui est stricte.

Comportement souhaité

Une ligne d'ingrédient peut être marquée « assaisonnement / selon le goût » : ni quantité, ni unité. Elle apparaît dans la recette et dans la liste de courses comme un simple rappel (« Sel — selon le goût »), sans quantité agrégée, et n'entre pas dans la mise à l'échelle portions.

Modèle de données

RecipeIngredient (apps/api/prisma/schema.prisma) — quantity et unitId deviennent nullable :

model RecipeIngredient {
  recipeId     Int      @map("recipe_id")
  ingredientId Int      @map("ingredient_id")
  /// NULL = ingrédient d'assaisonnement « selon le goût » — pas de quantité
  /// à mettre à l'échelle ni à agréger. Non-null ⇒ unitId non-null aussi
  /// (invariant vérifié côté service, pas par le schéma — même posture que
  /// les autres invariants inter-champs de ce fichier, ex. displayName/isPlaceholder).
  quantity     Decimal? @db.Decimal(10, 2)
  unitId       Int?     @map("unit_id")
  ...
}

Migration purement additive : ALTER TABLE "recipe_ingredient" ALTER COLUMN "quantity" DROP NOT NULL; + idem unit_id. Aucune ligne existante touchée (toutes gardent leur quantité).

Contrat partagé (packages/shared)

  • recipeIngredientInputSchema (schemas/recipe.ts:22-34) : quantity/unitId deviennent optionnels, avec un .refine — soit les deux présents, soit les deux absents (jamais une quantité sans unité). L'exclusivité ingredientId xor placeholderName existante est conservée.
  • RecipeIngredientView (types/recipe.ts:28-32) : quantity: number | null, unit: UnitView | null.
  • ShoppingListItemView (types/shopping-list.ts:19-23) : voir « Points à trancher ».

Backend

  • recipe.service.ts toRecipeView (recipe.service.ts:244-249) : quantity/unit conditionnels.
  • resolveIngredientLines / createRecipeInternal / updateRecipe : stocker null/null pour une ligne d'assaisonnement ; assertUnitsExist ne reçoit que les unitId non-null.
  • shopping-list.service.ts aggregateShoppingList (shopping-list.service.ts:67-95) : une ligne quantity === null est listée une fois (dédupliquée par ingredientId), sans somme ni unité. Test de non-régression obligatoire.
  • cooking-session.service.ts : le chemin clause-de-technique tolère déjà les nuls — vérifier qu'aucun accès à la liste d'en-tête ne casse.

Frontend

  • IngredientRow (IngredientRow.tsx) : un toggle « selon le goût » par ligne ; coché ⇒ champs quantité + unité masqués/grisés, la ligne n'est plus --incomplete.
  • IngredientLine (state de RecipeFormPage + RecipeImportForm) : quantity/unitId « non applicables ». canSubmit : une ligne « selon le goût » est toujours valide (RecipeFormPage.tsx:180-187, RecipeImportForm.tsx:268-277).
  • Submit (RecipeFormPage.tsx:200-215, RecipeImportForm.tsx:290-302) : omettre quantity/unitId pour ces lignes.
  • RecipeDetailPanel : afficher « selon le goût » au lieu de « 0 g ».
  • ShoppingListPage : ces lignes à part / sans quantité.
  • i18n : recipes.form.toTasteLabel, shoppingList.toTaste, etc.

Découpage proposé

  1. Modèle + contrat — migration nullable, schémas packages/shared, toRecipeView, création/édition, fixtures Cypress du formulaire à jour. Pas encore de toggle visible.
  2. UI formulaire — toggle « selon le goût » dans IngredientRow, câblé dans RecipeFormPage + RecipeImportForm, e2e Gherkin.
  3. Liste de courses & session de cuisine — exclusion de la mise à l'échelle, affichage dédié, non-régression.

Tests (obligatoires par PR, cf. specs/dev-conventions.md)

  • Mocha : createRecipe/updateRecipe acceptent une ligne sans quantité/unité et la relisent en null ; recipeIngredientInputSchema rejette quantité-sans-unité ; aggregateShoppingList ignore les lignes null (non-régression) ; getCookingPlanForDate idem.
  • Cypress : formulaire — cocher « selon le goût » retire les champs et permet l'enregistrement (mode layout) ; parcours Gherkin création → détail → liste de courses.

Documentation à mettre à jour

  • specs/batch-cooking-modele.md — recipe_ingredient (colonnes nullable + invariant).
  • specs/backend-architecture.md — sections liste de courses & session de cuisine.

Points restant à trancher en revue

  • Représentation UI : toggle par ligne, ou une « unité » spéciale toTaste dans le catalogue Unit (éviterait une migration, mais mélange un concept d'unité avec une absence de quantité — a priori non).
  • ShoppingListItemView : nouveau champ toTaste: boolean avec quantity/unit nullable, ou lignes regroupées à part dans ShoppingListView.
  • Une ligne « selon le goût » compte-t-elle dans le minimum .min(1) « au moins un ingrédient » ? (oui a priori.)

🤖 Filed by Claude Code

## Contexte Origine : ticket #3 (« Améliorations diverses »), puce 1 — *« Unités pour les ingrédients → Ajouter la possibilité de définir des ingrédients d'assaisonnement (sans quantité, selon le goût) »*. Aujourd'hui, **toute** ligne d'ingrédient d'une recette porte une quantité et une unité obligatoires : - `RecipeIngredient.quantity` est `Decimal @db.Decimal(10, 2)` **NOT NULL** et `unitId Int` **NOT NULL** ([schema.prisma:700-712](apps/api/prisma/schema.prisma#L700-L712)). - `recipeIngredientInputSchema` impose `quantity: z.number().positive(...)` et `unitId: z.number().int().positive()` ([packages/shared/src/schemas/recipe.ts:22-34](packages/shared/src/schemas/recipe.ts#L22-L34)). - Le formulaire bloque l'enregistrement tant qu'une ligne n'a pas `quantity > 0` **et** une unité : `canSubmit` ([RecipeFormPage.tsx:180-187](apps/web/src/pages/recipes/RecipeFormPage.tsx#L180-L187)), et `IngredientRow` marque `--incomplete` sur l'unité manquante ([IngredientRow.tsx:38-47](apps/web/src/features/recipes/ingredients/IngredientRow.tsx#L38-L47)). - La liste de courses met à l'échelle `Number(recipeIngredient.quantity) * scale` sans jamais tester un null ([shopping-list.service.ts:70-87](apps/api/src/modules/shopping-list/shopping-list.service.ts#L70-L87)). Résultat : impossible de saisir « sel, poivre — selon le goût » sans inventer une quantité factice. À noter : le chemin **clause-de-technique** tolère déjà `quantity`/`unit` nuls (`StepTechStepIngredient.quantity` est nullable, `toOptimizerRecipe` fait `line.quantity === null ? null : Number(...)`, [cooking-session.service.ts:86-90](apps/api/src/modules/cooking-session/cooking-session.service.ts#L86-L90)). C'est seulement la **liste d'ingrédients d'en-tête de recette** qui est stricte. ## Comportement souhaité Une ligne d'ingrédient peut être marquée **« assaisonnement / selon le goût »** : ni quantité, ni unité. Elle apparaît dans la recette et dans la liste de courses comme un simple rappel (« Sel — selon le goût »), sans quantité agrégée, et n'entre pas dans la mise à l'échelle portions. ## Modèle de données `RecipeIngredient` (`apps/api/prisma/schema.prisma`) — `quantity` et `unitId` deviennent **nullable** : ```prisma model RecipeIngredient { recipeId Int @map("recipe_id") ingredientId Int @map("ingredient_id") /// NULL = ingrédient d'assaisonnement « selon le goût » — pas de quantité /// à mettre à l'échelle ni à agréger. Non-null ⇒ unitId non-null aussi /// (invariant vérifié côté service, pas par le schéma — même posture que /// les autres invariants inter-champs de ce fichier, ex. displayName/isPlaceholder). quantity Decimal? @db.Decimal(10, 2) unitId Int? @map("unit_id") ... } ``` Migration purement additive : `ALTER TABLE "recipe_ingredient" ALTER COLUMN "quantity" DROP NOT NULL;` + idem `unit_id`. Aucune ligne existante touchée (toutes gardent leur quantité). ## Contrat partagé (`packages/shared`) - `recipeIngredientInputSchema` ([schemas/recipe.ts:22-34](packages/shared/src/schemas/recipe.ts#L22-L34)) : `quantity`/`unitId` deviennent optionnels, avec un `.refine` — soit les deux présents, soit les deux absents (jamais une quantité sans unité). L'exclusivité `ingredientId` xor `placeholderName` existante est conservée. - `RecipeIngredientView` ([types/recipe.ts:28-32](packages/shared/src/types/recipe.ts#L28-L32)) : `quantity: number | null`, `unit: UnitView | null`. - `ShoppingListItemView` ([types/shopping-list.ts:19-23](packages/shared/src/types/shopping-list.ts#L19-L23)) : voir « Points à trancher ». ## Backend - `recipe.service.ts` `toRecipeView` ([recipe.service.ts:244-249](apps/api/src/modules/recipe/recipe.service.ts#L244-L249)) : `quantity`/`unit` conditionnels. - `resolveIngredientLines` / `createRecipeInternal` / `updateRecipe` : stocker `null`/`null` pour une ligne d'assaisonnement ; `assertUnitsExist` ne reçoit que les `unitId` non-null. - **`shopping-list.service.ts` `aggregateShoppingList`** ([shopping-list.service.ts:67-95](apps/api/src/modules/shopping-list/shopping-list.service.ts#L67-L95)) : une ligne `quantity === null` est listée une fois (dédupliquée par `ingredientId`), sans somme ni unité. Test de non-régression obligatoire. - **`cooking-session.service.ts`** : le chemin clause-de-technique tolère déjà les nuls — vérifier qu'aucun accès à la liste d'en-tête ne casse. ## Frontend - `IngredientRow` ([IngredientRow.tsx](apps/web/src/features/recipes/ingredients/IngredientRow.tsx)) : un toggle « selon le goût » par ligne ; coché ⇒ champs quantité + unité masqués/grisés, la ligne n'est plus `--incomplete`. - `IngredientLine` (state de `RecipeFormPage` + `RecipeImportForm`) : `quantity`/`unitId` « non applicables ». `canSubmit` : une ligne « selon le goût » est toujours valide ([RecipeFormPage.tsx:180-187](apps/web/src/pages/recipes/RecipeFormPage.tsx#L180-L187), [RecipeImportForm.tsx:268-277](apps/web/src/features/recipes/sources/RecipeImportForm.tsx#L268-L277)). - Submit ([RecipeFormPage.tsx:200-215](apps/web/src/pages/recipes/RecipeFormPage.tsx#L200-L215), [RecipeImportForm.tsx:290-302](apps/web/src/features/recipes/sources/RecipeImportForm.tsx#L290-L302)) : omettre `quantity`/`unitId` pour ces lignes. - `RecipeDetailPanel` : afficher « selon le goût » au lieu de « 0 g ». - `ShoppingListPage` : ces lignes à part / sans quantité. - i18n : `recipes.form.toTasteLabel`, `shoppingList.toTaste`, etc. ## Découpage proposé 1. **Modèle + contrat** — migration nullable, schémas `packages/shared`, `toRecipeView`, création/édition, fixtures Cypress du formulaire à jour. Pas encore de toggle visible. 2. **UI formulaire** — toggle « selon le goût » dans `IngredientRow`, câblé dans `RecipeFormPage` + `RecipeImportForm`, e2e Gherkin. 3. **Liste de courses & session de cuisine** — exclusion de la mise à l'échelle, affichage dédié, non-régression. ## Tests (obligatoires par PR, cf. `specs/dev-conventions.md`) - **Mocha** : `createRecipe`/`updateRecipe` acceptent une ligne sans quantité/unité et la relisent en `null` ; `recipeIngredientInputSchema` rejette quantité-sans-unité ; `aggregateShoppingList` ignore les lignes `null` (non-régression) ; `getCookingPlanForDate` idem. - **Cypress** : formulaire — cocher « selon le goût » retire les champs et permet l'enregistrement (mode layout) ; parcours Gherkin création → détail → liste de courses. ## Documentation à mettre à jour - `specs/batch-cooking-modele.md` — `recipe_ingredient` (colonnes nullable + invariant). - `specs/backend-architecture.md` — sections liste de courses & session de cuisine. ## Points restant à trancher en revue - Représentation UI : toggle par ligne, ou une « unité » spéciale `toTaste` dans le catalogue `Unit` (éviterait une migration, mais mélange un concept d'unité avec une absence de quantité — a priori non). - `ShoppingListItemView` : nouveau champ `toTaste: boolean` avec `quantity`/`unit` nullable, ou lignes regroupées à part dans `ShoppingListView`. - Une ligne « selon le goût » compte-t-elle dans le minimum `.min(1)` « au moins un ingrédient » ? (oui a priori.) --- 🤖 Filed by [Claude Code](https://claude.com/claude-code)
kyuno added the
enhancement
planned
labels 2026-09-09 13:17:30 +02:00
kyuno added the
High
label 2026-09-09 13:24:47 +02:00
Author
Owner

Implémenté par la PR #136 (feat/seasoning-ingredients), mergée dans main (019e3fb). RecipeIngredient.quantity / unitId sont désormais nullable (schema.prisma), avec la logique d'assaisonnement « selon le goût » côté schéma partagé, backend (liste de courses / session de cuisine) et formulaires. Vérifié présent sur main.

Implémenté par la PR #136 (`feat/seasoning-ingredients`), mergée dans `main` (`019e3fb`). `RecipeIngredient.quantity` / `unitId` sont désormais nullable (`schema.prisma`), avec la logique d'assaisonnement « selon le goût » côté schéma partagé, backend (liste de courses / session de cuisine) et formulaires. Vérifié présent sur `main`.
kyuno closed this issue 2026-09-09 20:49:09 +02:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: kyuno/batchCooking#128
No description provided.