Gestion des restes : portions demandées vs préparées + panier de restes en drag & drop sur le planning #34

Closed
opened 2026-09-05 14:39:16 +02:00 by kyuno · 0 comments
Owner

Résumé

Aujourd'hui, PlanningItem.portions est un unique nombre saisi librement à
l'ajout d'une recette au planning : « combien de portions préparer pour ce
créneau », sans lien imposé avec Recipe.portions (le rendement écrit de la
recette). La liste de courses et l'optimiseur de session de cuisine mettent
juste les quantités d'ingrédients à l'échelle (portions / recipe.portions),
y compris fractionnaire (ex. 3/4 d'une recette 4 portions).

Cette fonctionnalité introduit une deuxième valeur — combien de portions
sont réellement nécessaires pour ce créneau — et fait ressortir l'écart
avec ce qui est effectivement préparé (toujours un multiple entier du
rendement de la recette, on ne cuisine jamais une fraction d'un plat) comme
des restes, rassemblés semaine par semaine dans un panier accessible
depuis le planning, attribuables ensuite à un autre créneau par glisser-déposer.

Comportement actuel (rappel)

  • Recipe.portions : rendement de la recette telle qu'écrite (ex. 4 pour des
    lasagnes).
  • PlanningItem.portions : saisi indépendamment à l'ajout (pré-rempli avec
    Recipe.portions, mais librement modifiable) — voir
    planning.service.ts et
    RecipePickerDialog.tsx:376-409.
  • Aucune règle de cohérence entre les deux : rien n'empêche de demander 3
    portions d'une recette pour 4, ni 10. La liste de courses et l'optimiseur de
    cuisine scalent alors les quantités d'ingrédients par ce ratio, y compris
    fractionnaire — voir aggregateShoppingList
    (shopping-list.service.ts:62-90)
    et toOptimizerRecipe
    (cooking-session.service.ts:75-118).
  • Aucune notion de reste n'existe nulle part dans le modèle.

Comportement souhaité

À l'ajout d'une recette sur un créneau, on ne saisit plus qu'un seul
nombre : les portions réellement demandées
(ex. « on est 3 » / « il en faut
10 »). L'application calcule elle-même combien de portions seront
effectivement préparées, en arrondissant au multiple supérieur du
rendement de la recette — on ne cuisine jamais une fraction de plat, mais on
peut en cuisiner plusieurs exemplaires :

préparées = arrondiSupérieur(demandées / recette.portions) × recette.portions
reste     = préparées − demandées

Vérification sur les deux exemples de la demande initiale (recette 4
portions) :

demandées préparées reste
3 arrondiSup(3/4)=1 × 4 = 4 1
10 arrondiSup(10/4)=3 × 4 = 12 2

Ce reste alimente un panier — une icône en haut de PlanningPage, qui
ouvre un popover listant, pour la semaine affichée, chaque créneau ayant
encore des portions de reste disponibles (recette, jour/repas d'origine,
quantité restante). Depuis ce panier :

  • Glisser-déposer une carte de reste sur un créneau du planning
    l'y attribue : au dépôt, une petite étape confirme la quantité à assigner
    (par défaut la totalité disponible, ajustable jusqu'à ce maximum — même
    esprit que l'étape « combien de portions ? » déjà existante), puis crée une
    vraie entrée dans la grille pour ce créneau, sur la même recette, marquée
    visuellement comme « restes ». Rien à cuisiner ni à acheter pour ce
    créneau-là : il est exclu de la liste de courses et de la session de
    cuisine (voir plus bas).
  • Jeter un reste directement depuis le panier (bouton dédié par ligne) —
    le retire du panier sans l'attribuer à un créneau (ex. denrée jetée,
    finalement pas mangée).

Décisions de conception (validées avec l'utilisateur)

Question Décision Pourquoi
Comment calcule-t-on préparées/reste ? Arrondi au lot (ceil(demandées / recette.portions) × recette.portions), un seul champ saisi Seule règle reproduisant exactement les deux exemples fournis (3→1 reste, 10→2 restes) ; cohérent avec l'esprit « batch cooking » (on cuisine des lots entiers d'une recette, jamais une fraction)
Que produit le dépôt d'un reste sur un créneau ? Une vraie entrée PlanningItem dans la grille, même recette, marquée « restes », exclue liste de courses + session de cuisine Réutilise l'affichage existant de la grille quasi gratuitement ; garde une seule source de vérité (planning.items) plutôt qu'un modèle de restes parallèle
Techno de glisser-déposer ? @dnd-kit/core (nouvelle dépendance) Premier vrai glisser-déposer de l'app (l'unique réordonnancement existant, StepListEditor, utilise des boutons haut/bas pour éviter d'en ajouter une) ; support tactile/accessibilité clavier inclus, et interactions pilotables en Cypress (pointer events simulables) — le HTML5 natif est notoirement difficile à tester en e2e
Jeter un reste non attribué, dès la V1 ? Oui Bouton dédié par ligne du panier

Modèle de données

Colonnes ajoutées sur PlanningItem (apps/api/prisma/schema.prisma) —
purement additives, aucune colonne existante renommée :

model PlanningItem {
  id                   Int      @id @default(autoincrement())
  planningId           Int      @map("planning_id")
  weekDay              String   @map("week_day")
  meal                 String
  recipeId             Int      @map("recipe_id")
  portions             Int      // inchangé en forme ; sens affiné, voir plus bas
  /// NEW — combien de portions ce créneau doit réellement nourrir. Pour un
  /// item "frais" (sourcePlanningItemId nul), ≤ portions — l'écart est le
  /// pool de restes de CET item. Pour un item "restes", toujours == portions
  /// (servir des restes ne prépare rien de nouveau, donc n'en génère pas
  /// d'autres — pas de restes-sur-restes).
  portionsRequested    Int      @map("portions_requested")
  /// NEW — non nul uniquement sur un item "restes" : l'item d'origine dont
  /// il consomme le pool. onDelete: Cascade — supprimer l'item d'origine
  /// supprime aussi tout ce qui a été servi depuis ses restes (même logique
  /// que Planning -> PlanningItem existant).
  sourcePlanningItemId Int?     @map("source_planning_item_id")
  /// NEW — portions du pool de CET item explicitement jetées (bouton
  /// "jeter" du panier). Toujours 0 sur un item "restes" (son propre pool
  /// est nul par construction).
  discardedPortions    Int      @default(0) @map("discarded_portions")
  createdAt            DateTime @default(now()) @map("created_at")

  planning           Planning       @relation(fields: [planningId], references: [id], onDelete: Cascade)
  recipe             Recipe         @relation(fields: [recipeId], references: [id])
  sourcePlanningItem PlanningItem?  @relation("PlanningItemLeftoverSource", fields: [sourcePlanningItemId], references: [id], onDelete: Cascade)
  /// Items "restes" servis depuis le pool de celui-ci — voir sourcePlanningItem.
  leftoverUses       PlanningItem[] @relation("PlanningItemLeftoverSource")

  @@map("planning_item")
}

Migration — portions_requested doit être NOT NULL mais la table a des
lignes existantes ; suivre la même discipline que la migration
ingredient_unit_catalog documentée dans schema.prisma (ajouter nullable,
backfill, puis verrouiller) :

ALTER TABLE "planning_item" ADD COLUMN "portions_requested" INTEGER;
-- Chaque ligne existante date d'avant cette fonctionnalité : on considère
-- qu'il n'y avait aucun reste (demandé == préparé à l'époque).
UPDATE "planning_item" SET "portions_requested" = "portions";
ALTER TABLE "planning_item" ALTER COLUMN "portions_requested" SET NOT NULL;

PlanningItemView (packages/shared/src/types/planning.ts) — le JSDoc de
portions doit aussi être corrigé (il n'est plus "une saisie manuelle libre"
pour un item frais, mais toujours un multiple de recipe.portions) :

export interface PlanningItemView {
  id: number;
  weekDay: string;
  meal: string;
  portions: number;
  /** NEW — portions réellement demandées pour ce créneau. */
  portionsRequested: number;
  /** NEW — portions du pool de cet item déjà explicitement jetées. */
  discardedPortions: number;
  /** NEW — non nul seulement si cet item sert des restes d'un autre. */
  sourceItem: { id: number } | null;
  recipe: { id: number; name: string };
}

Pas besoin d'un include Prisma supplémentaire côté lecture : ces 4 champs
suffisent, calculés à la volée côté front à partir du tableau plat
planning.items déjà chargé (voir logique partagée ci-dessous) — aucun aller
DB de plus pour GET /planning.

Logique pure partagée (packages/shared — colocaliser avec
types/planning.ts, à côté de WEEK_DAYS/MEALS qui y vivent déjà, ou dans
un nouveau fichier voisin si la revue préfère séparer types et logique) :

/** ceil(demandées / recette.portions) × recette.portions, et le reste associé. */
export function computeBatchedPortions(
  recipePortions: number,
  requestedPortions: number,
): { portions: number; leftover: number };

/** Pool de restes encore disponible sur un item, déduit de ses propres champs + de ce qui a déjà été servi ailleurs. Même fonction utilisée côté API (validation d'écriture, via `leftoverUses` chargé par Prisma) et côté front (affichage panier, dérivé de `planning.items` déjà en mémoire) — une seule formule, deux appelants. */
export function computeAvailableLeftover(item: {
  portions: number;
  portionsRequested: number;
  discardedPortions: number;
  leftoverUses: { portions: number }[];
}): number;

Backend

Nouvelles routes sur apps/api/src/modules/planning/planning.routes.ts :

Route Service Rôle
POST /planning/items/leftover assignLeftoverPlanningItem Crée un item "restes" sur (date, weekDay, meal) depuis sourceItemId, pour portions (≤ disponible, revalidé serveur via computeAvailableLeftover — jamais confiance dans une valeur envoyée par le client). 404 PLANNING_ITEM_NOT_FOUND si la source n'existe pas / n'appartient pas au foyer, 409 LEFTOVER_QUANTITY_EXCEEDED (nouveau code, plage 402x) si portions dépasse le disponible. recipeId n'est jamais envoyé par le client : copié depuis la source.
PATCH /planning/items/:id/discard-leftover discardLeftover Jette la totalité du pool encore disponible de l'item :id (discardedPortions += disponible) ; idempotent si déjà à 0. 404 même garde que removePlanningItem.

Changements sur l'existant :

  • addPlanningItem — applique computeBatchedPortions, stocke portions
    (calculé) et portionsRequested (saisi) au lieu du portions brut
    d'aujourd'hui.
  • getPlanningForDate — ajoute portionsRequested/discardedPortions/
    sourceItem à la forme de PlanningItemView (pas de nouvel include, voir
    plus haut).
  • removePlanningItem — aucun changement de code : onDelete: Cascade sur
    sourcePlanningItemId supprime déjà les items "restes" dépendants.
  • shopping-list.service.ts (aggregateShoppingList /
    getShoppingListForDate) — doit exclure les items "restes"
    (sourcePlanningItemId !== null) avant agrégation : rien de nouveau à
    acheter pour un créneau qui ne fait que resservir un plat déjà cuisiné.
    Sans ce filtre, les ingrédients seraient comptés en double. Test de
    non-régression obligatoire (facile à casser silencieusement).
  • cooking-session.service.ts (getCookingPlanForDate, avant
    planning.items.map(toOptimizerRecipe)) — même exclusion : un créneau
    "restes" n'a aucune étape à cuisiner, il ne doit pas apparaître dans le
    plan optimisé de la session de cuisine.
  • packages/shared/src/errors/error-codes.ts — nouveau membre
    LEFTOVER_QUANTITY_EXCEEDED = 4024 (plage 4020-4029, conflit d'état — prochain
    numéro libre après RETRAIN_ALREADY_RUNNING = 4023), + traduction dans
    apps/web/src/locales/fr/translation.json (errors.*).
  • analytics.service.ts — deux nouveaux événements, même esprit que
    planning.item_added : planning.leftover_assigned
    ({ recipeId, portions, sourceItemId }) et planning.leftover_discarded
    ({ recipeId, portions }).

Frontend

  • apps/web/src/pages/planning/PlanningPage.tsx — ajoute l'icône panier
    dans .planning-page__header (à côté de WeekNavigator/bouton
    "cuisiner") ; englobe la grille + le panier dans un <DndContext>
    (@dnd-kit/core) pour que le glisser-source (carte du panier) et les
    dépôts (cellules de la grille) communiquent.
  • apps/web/src/features/planning/LeftoversBasket.tsx (nouveau) — icône
    • popover, même patron que WeekNavigator's CalendarPopover
      (WeekNavigator.tsx:93-174) :
      état isOpen local, montage conditionnel, fermeture au clic extérieur via
      useRef+listener mousedown. Liste chaque item de planning.items dont
      sourceItem === null et computeAvailableLeftover(...) > 0 : recette,
      jour/repas d'origine, quantité disponible, carte draggable
      (useDraggable), bouton "jeter" (→ PATCH .../discard-leftover).
  • PlanningGrid/MealCell (PlanningPage.tsx) — chaque cellule devient
    une zone de dépôt (useDroppable), retour visuel au survol pendant un
    drag ; un item "restes" (sourceItem !== null) affiche un badge distinct
    (ex. ♻️) sur son recipe-chip. Au dépôt, petite étape de confirmation de
    quantité (par défaut le disponible, plafonné à celui-ci) avant l'appel à
    POST /planning/items/leftover.
  • RecipePickerDialog.tsx (étape "combien de portions ?",
    lignes 376-409)
    et RecipeImportForm.tsx — champ renommé "portions demandées" ; ajoute
    un texte dérivé en direct via computeBatchedPortions (ex. « → 12 portions
    préparées, 2 en restes ») affiché seulement si leftover > 0 ; envoient
    portionsRequested (plus portions) au lieu de portions seul.
  • apps/web/package.json — ajoute @dnd-kit/core (+ @dnd-kit/utilities
    si besoin des helpers de transform CSS), pnpm install à la racine.
  • i18n — nouvelles clés planning.leftovers.* dans
    apps/web/src/locales/fr/translation.json : icône/tooltip du panier, titre
    popover, état vide, libellé par ligne, bouton "jeter" (+ confirmation),
    invite de quantité au dépôt, badge "restes" sur un chip, libellés
    d'accessibilité des poignées de drag (support clavier @dnd-kit).
  • Accessibilité — brancher le KeyboardSensor de @dnd-kit en plus du
    PointerSensor (Espace pour saisir, flèches pour déplacer, Espace pour
    déposer) : ce n'est quasiment pas de travail en plus avec cette lib et
    évite une fonctionnalité clavier-inaccessible.

Découpage proposé (chantier en plusieurs PRs, comme les précédents)

Même logique que les chantiers "cooking-session" (#22-28) ou "tech-step
metadata editor" (#29-32) : petites PRs scopées plutôt qu'une seule grosse.

  1. Fondations — migration + portionsRequested/calcul par lot
    (packages/shared, addPlanningItem, getPlanningForDate) + mise à jour
    des deux points d'appel front (RecipePickerDialog, RecipeImportForm) +
    specs Cypress existantes (fixtures POST /planning/items à mettre à
    jour). Aucune UI de panier/restes encore visible.
  2. Attribution & exclusions — routes POST .../leftover +
    PATCH .../discard-leftover, nouveau ErrorCode, exclusion
    liste de courses + session de cuisine, événements analytics.
  3. Panier (lecture seule) — LeftoversBasket.tsx (icône, popover, liste,
    bouton "jeter"), sans glisser-déposer — juste consultation + jet.
  4. Glisser-déposer — dépendance @dnd-kit/core, DndContext, cellules
    comme zones de dépôt, badge "restes" sur la grille, e2e Gherkin complet.

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

  • Backend (Mocha+Chai) : computeBatchedPortions/computeAvailableLeftover
    (cas limites : demandé multiple exact → reste 0 ; demandé < portions
    recette → 1 lot ; demandé > plusieurs lots) ; addPlanningItem stocke bien
    les deux valeurs ; assignLeftoverPlanningItem (succès, 409 sur
    dépassement, 404 source étrangère/inexistante, recipeId jamais pris du
    client) ; discardLeftover (réduit à 0, idempotent) ; suppression en
    cascade d'un item source ; régression aggregateShoppingList et
    getCookingPlanForDate ignorant bien les items "restes".
  • Frontend (Cypress) : spec "mode layout" pour LeftoversBasket (mock
    GET /planning via cy.intercept avec un item à reste disponible ;
    ouverture/fermeture popover, clic extérieur, bouton "jeter") ; scénario
    Gherkin e2e (nouveau .feature, ou extension de planning.feature) pour
    le parcours complet ajout → reste visible au panier → glisser sur un autre
    créneau → confirmation quantité → chip "restes" visible → panier décrémenté
    → jeter le reste restant → panier vide. @dnd-kit expose des interactions
    pilotables par événements pointer simulables en Cypress (contrairement au
    drag-and-drop HTML5 natif, notoirement pénible à tester en e2e) — un
    argument de plus en faveur de ce choix.

Documentation à mettre à jour

  • specs/batch-cooking-modele.md — entité planning_item (3 nouvelles
    colonnes + auto-relation).
  • specs/backend-architecture.md — section "planning — semaine, item,
    portions, import à la volée" (règle de lot, nouvelles routes, exclusions
    liste de courses/session de cuisine).
  • specs/frontend-architecture.md — arborescence features/planning/ (ajout
    LeftoversBasket.tsx), description de l'étape "portions" enrichie.

Hors scope pour cette itération

  • Restes inter-semaines (le panier ne montre que la semaine affichée ; un
    reste non attribué/non jeté ne "voyage" pas vers la semaine suivante dans
    cette V1 — à réévaluer si le besoin se confirme à l'usage).
  • Jet partiel d'un reste (le bouton "jeter" vide tout le disponible en un
    clic ; discardedPortions est modélisé comme une quantité, pas un
    booléen, donc un jet partiel reste possible à ajouter plus tard sans
    nouvelle migration).
  • Attribution d'un reste sur une recette différente de son origine (on ne
    sert que ce qui a été effectivement cuisiné).

Points restant à trancher en revue de code (non bloquants pour démarrer)

  • Nom exact des 2 nouvelles routes (POST /planning/items/leftover proposé
    par symétrie avec POST /planning/items — pas de collision possible avec
    DELETE /planning/items/:id, méthodes différentes, mais à valider que ça
    lit bien).
  • Emplacement exact de computeBatchedPortions/computeAvailableLeftover
    dans packages/shared (proposé : types/planning.ts, aux côtés de
    WEEK_DAYS/MEALS).
  • UX précise de l'étape de confirmation de quantité au dépôt (réutiliser le
    style de .recipe-picker-confirm, ou un composant dédié plus léger).

🤖 Filed by Claude Code

## Résumé Aujourd'hui, `PlanningItem.portions` est un unique nombre saisi librement à l'ajout d'une recette au planning : « combien de portions préparer pour ce créneau », sans lien imposé avec `Recipe.portions` (le rendement écrit de la recette). La liste de courses et l'optimiseur de session de cuisine mettent juste les quantités d'ingrédients à l'échelle (`portions / recipe.portions`), y compris fractionnaire (ex. 3/4 d'une recette 4 portions). Cette fonctionnalité introduit une **deuxième valeur** — combien de portions sont **réellement nécessaires** pour ce créneau — et fait ressortir l'écart avec ce qui est **effectivement préparé** (toujours un multiple entier du rendement de la recette, on ne cuisine jamais une fraction d'un plat) comme des **restes**, rassemblés semaine par semaine dans un panier accessible depuis le planning, attribuables ensuite à un autre créneau par glisser-déposer. ## Comportement actuel (rappel) - `Recipe.portions` : rendement de la recette telle qu'écrite (ex. 4 pour des lasagnes). - `PlanningItem.portions` : saisi indépendamment à l'ajout (pré-rempli avec `Recipe.portions`, mais librement modifiable) — voir [planning.service.ts](apps/api/src/modules/planning/planning.service.ts) et [RecipePickerDialog.tsx:376-409](apps/web/src/features/planning/RecipePickerDialog.tsx#L376-L409). - Aucune règle de cohérence entre les deux : rien n'empêche de demander 3 portions d'une recette pour 4, ni 10. La liste de courses et l'optimiseur de cuisine scalent alors les quantités d'ingrédients par ce ratio, y compris fractionnaire — voir `aggregateShoppingList` ([shopping-list.service.ts:62-90](apps/api/src/modules/shopping-list/shopping-list.service.ts#L62-L90)) et `toOptimizerRecipe` ([cooking-session.service.ts:75-118](apps/api/src/modules/cooking-session/cooking-session.service.ts#L75-L118)). - Aucune notion de reste n'existe nulle part dans le modèle. ## Comportement souhaité À l'ajout d'une recette sur un créneau, on ne saisit plus qu'**un seul nombre : les portions réellement demandées** (ex. « on est 3 » / « il en faut 10 »). L'application calcule elle-même combien de portions seront effectivement **préparées**, en arrondissant au multiple supérieur du rendement de la recette — on ne cuisine jamais une fraction de plat, mais on peut en cuisiner plusieurs exemplaires : ``` préparées = arrondiSupérieur(demandées / recette.portions) × recette.portions reste = préparées − demandées ``` Vérification sur les deux exemples de la demande initiale (recette 4 portions) : | demandées | préparées | reste | |---|---|---| | 3 | arrondiSup(3/4)=1 × 4 = **4** | **1** | | 10 | arrondiSup(10/4)=3 × 4 = **12** | **2** | Ce reste alimente un **panier** — une icône en haut de `PlanningPage`, qui ouvre un popover listant, pour la semaine affichée, chaque créneau ayant encore des portions de reste disponibles (recette, jour/repas d'origine, quantité restante). Depuis ce panier : - **Glisser-déposer** une carte de reste sur un créneau du planning l'y attribue : au dépôt, une petite étape confirme la quantité à assigner (par défaut la totalité disponible, ajustable jusqu'à ce maximum — même esprit que l'étape « combien de portions ? » déjà existante), puis crée une vraie entrée dans la grille pour ce créneau, sur la même recette, marquée visuellement comme « restes ». Rien à cuisiner ni à acheter pour ce créneau-là : il est exclu de la liste de courses et de la session de cuisine (voir plus bas). - **Jeter** un reste directement depuis le panier (bouton dédié par ligne) — le retire du panier sans l'attribuer à un créneau (ex. denrée jetée, finalement pas mangée). ## Décisions de conception (validées avec l'utilisateur) | Question | Décision | Pourquoi | |---|---|---| | Comment calcule-t-on préparées/reste ? | **Arrondi au lot** (`ceil(demandées / recette.portions) × recette.portions`), un seul champ saisi | Seule règle reproduisant exactement les deux exemples fournis (3→1 reste, 10→2 restes) ; cohérent avec l'esprit « batch cooking » (on cuisine des lots entiers d'une recette, jamais une fraction) | | Que produit le dépôt d'un reste sur un créneau ? | Une **vraie entrée `PlanningItem`** dans la grille, même recette, marquée « restes », exclue liste de courses + session de cuisine | Réutilise l'affichage existant de la grille quasi gratuitement ; garde une seule source de vérité (`planning.items`) plutôt qu'un modèle de restes parallèle | | Techno de glisser-déposer ? | **`@dnd-kit/core`** (nouvelle dépendance) | Premier vrai glisser-déposer de l'app (l'unique réordonnancement existant, `StepListEditor`, utilise des boutons haut/bas pour *éviter* d'en ajouter une) ; support tactile/accessibilité clavier inclus, et interactions pilotables en Cypress (pointer events simulables) — le HTML5 natif est notoirement difficile à tester en e2e | | Jeter un reste non attribué, dès la V1 ? | **Oui** | Bouton dédié par ligne du panier | ## Modèle de données Colonnes ajoutées sur `PlanningItem` (`apps/api/prisma/schema.prisma`) — purement additives, aucune colonne existante renommée : ```prisma model PlanningItem { id Int @id @default(autoincrement()) planningId Int @map("planning_id") weekDay String @map("week_day") meal String recipeId Int @map("recipe_id") portions Int // inchangé en forme ; sens affiné, voir plus bas /// NEW — combien de portions ce créneau doit réellement nourrir. Pour un /// item "frais" (sourcePlanningItemId nul), ≤ portions — l'écart est le /// pool de restes de CET item. Pour un item "restes", toujours == portions /// (servir des restes ne prépare rien de nouveau, donc n'en génère pas /// d'autres — pas de restes-sur-restes). portionsRequested Int @map("portions_requested") /// NEW — non nul uniquement sur un item "restes" : l'item d'origine dont /// il consomme le pool. onDelete: Cascade — supprimer l'item d'origine /// supprime aussi tout ce qui a été servi depuis ses restes (même logique /// que Planning -> PlanningItem existant). sourcePlanningItemId Int? @map("source_planning_item_id") /// NEW — portions du pool de CET item explicitement jetées (bouton /// "jeter" du panier). Toujours 0 sur un item "restes" (son propre pool /// est nul par construction). discardedPortions Int @default(0) @map("discarded_portions") createdAt DateTime @default(now()) @map("created_at") planning Planning @relation(fields: [planningId], references: [id], onDelete: Cascade) recipe Recipe @relation(fields: [recipeId], references: [id]) sourcePlanningItem PlanningItem? @relation("PlanningItemLeftoverSource", fields: [sourcePlanningItemId], references: [id], onDelete: Cascade) /// Items "restes" servis depuis le pool de celui-ci — voir sourcePlanningItem. leftoverUses PlanningItem[] @relation("PlanningItemLeftoverSource") @@map("planning_item") } ``` **Migration** — `portions_requested` doit être `NOT NULL` mais la table a des lignes existantes ; suivre la même discipline que la migration `ingredient_unit_catalog` documentée dans schema.prisma (ajouter nullable, backfill, puis verrouiller) : ```sql ALTER TABLE "planning_item" ADD COLUMN "portions_requested" INTEGER; -- Chaque ligne existante date d'avant cette fonctionnalité : on considère -- qu'il n'y avait aucun reste (demandé == préparé à l'époque). UPDATE "planning_item" SET "portions_requested" = "portions"; ALTER TABLE "planning_item" ALTER COLUMN "portions_requested" SET NOT NULL; ``` **`PlanningItemView`** (`packages/shared/src/types/planning.ts`) — le JSDoc de `portions` doit aussi être corrigé (il n'est plus "une saisie manuelle libre" pour un item frais, mais toujours un multiple de `recipe.portions`) : ```ts export interface PlanningItemView { id: number; weekDay: string; meal: string; portions: number; /** NEW — portions réellement demandées pour ce créneau. */ portionsRequested: number; /** NEW — portions du pool de cet item déjà explicitement jetées. */ discardedPortions: number; /** NEW — non nul seulement si cet item sert des restes d'un autre. */ sourceItem: { id: number } | null; recipe: { id: number; name: string }; } ``` Pas besoin d'un `include` Prisma supplémentaire côté lecture : ces 4 champs suffisent, calculés à la volée côté front à partir du tableau plat `planning.items` déjà chargé (voir logique partagée ci-dessous) — aucun aller DB de plus pour `GET /planning`. **Logique pure partagée** (`packages/shared` — colocaliser avec `types/planning.ts`, à côté de `WEEK_DAYS`/`MEALS` qui y vivent déjà, ou dans un nouveau fichier voisin si la revue préfère séparer types et logique) : ```ts /** ceil(demandées / recette.portions) × recette.portions, et le reste associé. */ export function computeBatchedPortions( recipePortions: number, requestedPortions: number, ): { portions: number; leftover: number }; /** Pool de restes encore disponible sur un item, déduit de ses propres champs + de ce qui a déjà été servi ailleurs. Même fonction utilisée côté API (validation d'écriture, via `leftoverUses` chargé par Prisma) et côté front (affichage panier, dérivé de `planning.items` déjà en mémoire) — une seule formule, deux appelants. */ export function computeAvailableLeftover(item: { portions: number; portionsRequested: number; discardedPortions: number; leftoverUses: { portions: number }[]; }): number; ``` ## Backend Nouvelles routes sur `apps/api/src/modules/planning/planning.routes.ts` : | Route | Service | Rôle | |---|---|---| | `POST /planning/items/leftover` | `assignLeftoverPlanningItem` | Crée un item "restes" sur `(date, weekDay, meal)` depuis `sourceItemId`, pour `portions` (≤ disponible, revalidé serveur via `computeAvailableLeftover` — jamais confiance dans une valeur envoyée par le client). 404 `PLANNING_ITEM_NOT_FOUND` si la source n'existe pas / n'appartient pas au foyer, 409 `LEFTOVER_QUANTITY_EXCEEDED` (nouveau code, plage 402x) si `portions` dépasse le disponible. `recipeId` n'est jamais envoyé par le client : copié depuis la source. | | `PATCH /planning/items/:id/discard-leftover` | `discardLeftover` | Jette la totalité du pool encore disponible de l'item `:id` (`discardedPortions += disponible`) ; idempotent si déjà à 0. 404 même garde que `removePlanningItem`. | Changements sur l'existant : - `addPlanningItem` — applique `computeBatchedPortions`, stocke `portions` (calculé) **et** `portionsRequested` (saisi) au lieu du `portions` brut d'aujourd'hui. - `getPlanningForDate` — ajoute `portionsRequested`/`discardedPortions`/ `sourceItem` à la forme de `PlanningItemView` (pas de nouvel `include`, voir plus haut). - `removePlanningItem` — aucun changement de code : `onDelete: Cascade` sur `sourcePlanningItemId` supprime déjà les items "restes" dépendants. - **`shopping-list.service.ts`** (`aggregateShoppingList` / `getShoppingListForDate`) — **doit** exclure les items "restes" (`sourcePlanningItemId !== null`) avant agrégation : rien de nouveau à acheter pour un créneau qui ne fait que resservir un plat déjà cuisiné. Sans ce filtre, les ingrédients seraient comptés en double. Test de non-régression obligatoire (facile à casser silencieusement). - **`cooking-session.service.ts`** (`getCookingPlanForDate`, avant `planning.items.map(toOptimizerRecipe)`) — même exclusion : un créneau "restes" n'a aucune étape à cuisiner, il ne doit pas apparaître dans le plan optimisé de la session de cuisine. - `packages/shared/src/errors/error-codes.ts` — nouveau membre `LEFTOVER_QUANTITY_EXCEEDED = 4024` (plage 4020-4029, conflit d'état — prochain numéro libre après `RETRAIN_ALREADY_RUNNING = 4023`), + traduction dans `apps/web/src/locales/fr/translation.json` (`errors.*`). - `analytics.service.ts` — deux nouveaux événements, même esprit que `planning.item_added` : `planning.leftover_assigned` (`{ recipeId, portions, sourceItemId }`) et `planning.leftover_discarded` (`{ recipeId, portions }`). ## Frontend - **`apps/web/src/pages/planning/PlanningPage.tsx`** — ajoute l'icône panier dans `.planning-page__header` (à côté de `WeekNavigator`/bouton "cuisiner") ; englobe la grille + le panier dans un `<DndContext>` (`@dnd-kit/core`) pour que le glisser-source (carte du panier) et les dépôts (cellules de la grille) communiquent. - **`apps/web/src/features/planning/LeftoversBasket.tsx`** (nouveau) — icône + popover, même patron que `WeekNavigator`'s `CalendarPopover` ([WeekNavigator.tsx:93-174](apps/web/src/features/planning/WeekNavigator.tsx#L93-L174)) : état `isOpen` local, montage conditionnel, fermeture au clic extérieur via `useRef`+listener `mousedown`. Liste chaque item de `planning.items` dont `sourceItem === null` et `computeAvailableLeftover(...) > 0` : recette, jour/repas d'origine, quantité disponible, carte draggable (`useDraggable`), bouton "jeter" (→ `PATCH .../discard-leftover`). - **`PlanningGrid`/`MealCell`** (`PlanningPage.tsx`) — chaque cellule devient une zone de dépôt (`useDroppable`), retour visuel au survol pendant un drag ; un item "restes" (`sourceItem !== null`) affiche un badge distinct (ex. ♻️) sur son `recipe-chip`. Au dépôt, petite étape de confirmation de quantité (par défaut le disponible, plafonné à celui-ci) avant l'appel à `POST /planning/items/leftover`. - **`RecipePickerDialog.tsx`** (étape "combien de portions ?", [lignes 376-409](apps/web/src/features/planning/RecipePickerDialog.tsx#L376-L409)) et **`RecipeImportForm.tsx`** — champ renommé "portions demandées" ; ajoute un texte dérivé en direct via `computeBatchedPortions` (ex. « → 12 portions préparées, 2 en restes ») affiché seulement si `leftover > 0` ; envoient `portionsRequested` (plus `portions`) au lieu de `portions` seul. - **`apps/web/package.json`** — ajoute `@dnd-kit/core` (+ `@dnd-kit/utilities` si besoin des helpers de transform CSS), `pnpm install` à la racine. - **i18n** — nouvelles clés `planning.leftovers.*` dans `apps/web/src/locales/fr/translation.json` : icône/tooltip du panier, titre popover, état vide, libellé par ligne, bouton "jeter" (+ confirmation), invite de quantité au dépôt, badge "restes" sur un chip, libellés d'accessibilité des poignées de drag (support clavier `@dnd-kit`). - **Accessibilité** — brancher le `KeyboardSensor` de `@dnd-kit` en plus du `PointerSensor` (Espace pour saisir, flèches pour déplacer, Espace pour déposer) : ce n'est quasiment pas de travail en plus avec cette lib et évite une fonctionnalité clavier-inaccessible. ## Découpage proposé (chantier en plusieurs PRs, comme les précédents) Même logique que les chantiers "cooking-session" (#22-28) ou "tech-step metadata editor" (#29-32) : petites PRs scopées plutôt qu'une seule grosse. 1. **Fondations** — migration + `portionsRequested`/calcul par lot (`packages/shared`, `addPlanningItem`, `getPlanningForDate`) + mise à jour des deux points d'appel front (`RecipePickerDialog`, `RecipeImportForm`) + specs Cypress existantes (fixtures `POST /planning/items` à mettre à jour). Aucune UI de panier/restes encore visible. 2. **Attribution & exclusions** — routes `POST .../leftover` + `PATCH .../discard-leftover`, nouveau `ErrorCode`, exclusion liste de courses + session de cuisine, événements analytics. 3. **Panier (lecture seule)** — `LeftoversBasket.tsx` (icône, popover, liste, bouton "jeter"), sans glisser-déposer — juste consultation + jet. 4. **Glisser-déposer** — dépendance `@dnd-kit/core`, `DndContext`, cellules comme zones de dépôt, badge "restes" sur la grille, e2e Gherkin complet. ## Tests (obligatoires par PR, cf. `specs/dev-conventions.md`) - **Backend (Mocha+Chai)** : `computeBatchedPortions`/`computeAvailableLeftover` (cas limites : demandé multiple exact → reste 0 ; demandé < portions recette → 1 lot ; demandé > plusieurs lots) ; `addPlanningItem` stocke bien les deux valeurs ; `assignLeftoverPlanningItem` (succès, 409 sur dépassement, 404 source étrangère/inexistante, `recipeId` jamais pris du client) ; `discardLeftover` (réduit à 0, idempotent) ; suppression en cascade d'un item source ; **régression** `aggregateShoppingList` et `getCookingPlanForDate` ignorant bien les items "restes". - **Frontend (Cypress)** : spec "mode layout" pour `LeftoversBasket` (mock `GET /planning` via `cy.intercept` avec un item à reste disponible ; ouverture/fermeture popover, clic extérieur, bouton "jeter") ; scénario Gherkin e2e (nouveau `.feature`, ou extension de `planning.feature`) pour le parcours complet ajout → reste visible au panier → glisser sur un autre créneau → confirmation quantité → chip "restes" visible → panier décrémenté → jeter le reste restant → panier vide. `@dnd-kit` expose des interactions pilotables par événements pointer simulables en Cypress (contrairement au drag-and-drop HTML5 natif, notoirement pénible à tester en e2e) — un argument de plus en faveur de ce choix. ## Documentation à mettre à jour - `specs/batch-cooking-modele.md` — entité `planning_item` (3 nouvelles colonnes + auto-relation). - `specs/backend-architecture.md` — section "planning — semaine, item, portions, import à la volée" (règle de lot, nouvelles routes, exclusions liste de courses/session de cuisine). - `specs/frontend-architecture.md` — arborescence `features/planning/` (ajout `LeftoversBasket.tsx`), description de l'étape "portions" enrichie. ## Hors scope pour cette itération - Restes inter-semaines (le panier ne montre que la semaine affichée ; un reste non attribué/non jeté ne "voyage" pas vers la semaine suivante dans cette V1 — à réévaluer si le besoin se confirme à l'usage). - Jet **partiel** d'un reste (le bouton "jeter" vide tout le disponible en un clic ; `discardedPortions` est modélisé comme une quantité, pas un booléen, donc un jet partiel reste possible à ajouter plus tard sans nouvelle migration). - Attribution d'un reste sur une recette différente de son origine (on ne sert que ce qui a été effectivement cuisiné). ## Points restant à trancher en revue de code (non bloquants pour démarrer) - Nom exact des 2 nouvelles routes (`POST /planning/items/leftover` proposé par symétrie avec `POST /planning/items` — pas de collision possible avec `DELETE /planning/items/:id`, méthodes différentes, mais à valider que ça lit bien). - Emplacement exact de `computeBatchedPortions`/`computeAvailableLeftover` dans `packages/shared` (proposé : `types/planning.ts`, aux côtés de `WEEK_DAYS`/`MEALS`). - UX précise de l'étape de confirmation de quantité au dépôt (réutiliser le style de `.recipe-picker-confirm`, ou un composant dédié plus léger). --- 🤖 Filed by [Claude Code](https://claude.com/claude-code)
kyuno added the
enhancement
planned
labels 2026-09-05 14:39:16 +02:00
kyuno changed title from Gestion des restes : portions demandées vs préparées + panier de restes en drag &amp; drop sur le planning to Gestion des restes : portions demandées vs préparées + panier de restes en drag & drop sur le planning 2026-09-05 14:39:31 +02:00
kyuno added the
High
label 2026-09-05 18:52:29 +02:00
kyuno closed this issue 2026-09-06 01:06:13 +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#34
No description provided.