From eba0ea70bdaba1fc26294337f5a5d4fc6280e631 Mon Sep 17 00:00:00 2001 From: Nicolas Date: Sat, 5 Sep 2026 23:29:58 +0200 Subject: [PATCH] =?UTF-8?q?feat(planning):=20s=C3=A9pare=20portions=20dema?= =?UTF-8?q?nd=C3=A9es=20et=20portions=20pr=C3=A9par=C3=A9es?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Première PR du chantier « gestion des restes » (issue #34) — les fondations, sans encore d'UI de panier. À l'ajout d'une recette au planning on ne saisit plus qu'un seul nombre, `portionsRequested` (« combien ce créneau doit nourrir »). Le serveur en dérive `portions` (ce qui est réellement préparé) via `computeBatchedPortions` : arrondi au multiple supérieur du rendement de la recette (on cuisine des lots entiers, jamais une fraction de plat). L'écart est le futur pool de restes de l'item. Modèle (`PlanningItem`, purement additif) : - `portionsRequested` NOT NULL — migration nullable → backfill (`= portions` pour les lignes existantes, sans restes à l'époque) → verrou, même discipline que `ingredient_unit_catalog`. - `sourcePlanningItemId` (auto-FK nullable, `onDelete: Cascade`) et `discardedPortions` (défaut 0) — posés maintenant, utilisés plus tard. Partagé (`@batch-cooking/shared/logic/planning-portions.ts`) : - `computeBatchedPortions(recipePortions, requested)` → `{ portions, leftover }` - `computeAvailableLeftover(item)` → pool encore disponible (posé pour les PRs suivantes : validation d'écriture côté API, affichage du panier côté web — une seule formule, deux appelants) API : `addPlanningItemSchema` prend `portionsRequested` ; `addPlanningItem` calcule et stocke les deux valeurs + l'évènement analytics ; `PlanningItemView` et `getPlanningForDate` exposent `portionsRequested`/`discardedPortions`/ `sourceItem`. Front : `RecipePickerDialog` — champ « portions demandées » + indice en direct « → N préparées, M en restes » quand l'arrondi crée un reste ; envoie `portionsRequested`. `RecipeImportForm` — envoie `portionsRequested` (un import atterrit à son propre rendement, donc reste nul). Tests : `planning-portions.test.ts` (les deux exemples de #34 + bornes) ; nouveau bloc `POST /planning/items` dans `planning.test.ts` (arrondi au lot, plusieurs lots, borne exacte, 400 sur non-positif) ; fixtures `planningItem.create` des suites existantes mises à jour ; scénario Cypress planning ajusté (`portionsRequested` dans le corps). Docs : `batch-cooking-modele.md`, `backend-architecture.md`, `frontend-architecture.md`. Réf #34 Co-Authored-By: Claude Sonnet 5 --- .../migration.sql | 27 ++++++ apps/api/prisma/schema.prisma | 36 ++++++-- .../src/modules/planning/planning.service.ts | 28 +++++- apps/api/test/cooking-session.test.ts | 3 + apps/api/test/planning-portions.test.ts | 73 +++++++++++++++ apps/api/test/planning.test.ts | 90 ++++++++++++++++++- apps/api/test/recipe.test.ts | 1 + apps/api/test/shopping-list.test.ts | 7 ++ apps/web/cypress/e2e/planning.feature | 4 +- apps/web/cypress/e2e/planning.ts | 9 +- .../features/planning/RecipePickerDialog.tsx | 26 +++++- .../planning/recipe-picker-dialog.scss | 10 +++ .../recipes/sources/RecipeImportForm.tsx | 5 +- apps/web/src/locales/fr/translation.json | 3 +- packages/shared/src/index.ts | 1 + .../shared/src/logic/planning-portions.ts | 68 ++++++++++++++ packages/shared/src/schemas/planning.ts | 9 +- packages/shared/src/types/planning.ts | 21 ++++- specs/backend-architecture.md | 25 ++++-- specs/batch-cooking-modele.md | 8 +- specs/frontend-architecture.md | 8 +- 21 files changed, 429 insertions(+), 33 deletions(-) create mode 100644 apps/api/prisma/migrations/20260905120000_leftovers_portions_requested/migration.sql create mode 100644 apps/api/test/planning-portions.test.ts create mode 100644 packages/shared/src/logic/planning-portions.ts diff --git a/apps/api/prisma/migrations/20260905120000_leftovers_portions_requested/migration.sql b/apps/api/prisma/migrations/20260905120000_leftovers_portions_requested/migration.sql new file mode 100644 index 0000000..2120440 --- /dev/null +++ b/apps/api/prisma/migrations/20260905120000_leftovers_portions_requested/migration.sql @@ -0,0 +1,27 @@ +-- Splits "portions to prepare" from "portions actually needed" on a +-- planning slot, and adds the two columns a leftovers-serving item needs +-- (its source item + how much of its own pool was thrown away). Purely +-- additive — no existing column renamed. See the `PlanningItem` model doc +-- comments and issue #34. + +-- `discarded_portions` and `source_planning_item_id` are trivial adds. +ALTER TABLE "planning_item" + ADD COLUMN "discarded_portions" INTEGER NOT NULL DEFAULT 0, + ADD COLUMN "source_planning_item_id" INTEGER; + +-- `portions_requested` is NOT NULL but the table may have rows already: +-- add it nullable, backfill, then lock it down — same discipline as the +-- `ingredient_unit_catalog` / `planning_item_portions` migrations. Every +-- pre-existing row predates this feature, so it had no leftovers: +-- requested == prepared at the time it was added. +ALTER TABLE "planning_item" ADD COLUMN "portions_requested" INTEGER; +UPDATE "planning_item" SET "portions_requested" = "portions"; +ALTER TABLE "planning_item" ALTER COLUMN "portions_requested" SET NOT NULL; + +-- Self-FK for a "leftovers" item pointing back at the item it draws from. +-- ON DELETE CASCADE: removing the source item removes everything served +-- from its pool too (same as the Planning -> PlanningItem cascade). +ALTER TABLE "planning_item" + ADD CONSTRAINT "planning_item_source_planning_item_id_fkey" + FOREIGN KEY ("source_planning_item_id") REFERENCES "planning_item"("id") + ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index bf18b9e..0102fbf 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -213,17 +213,41 @@ model Planning { } model PlanningItem { - id Int @id @default(autoincrement()) - planningId Int @map("planning_id") - weekDay String @map("week_day") + id Int @id @default(autoincrement()) + planningId Int @map("planning_id") + weekDay String @map("week_day") meal String - recipeId Int @map("recipe_id") + recipeId Int @map("recipe_id") + /// Portions actually *prepared* for this slot — always a whole multiple + /// of `recipe.portions` (a dish is cooked in whole batches). Derived from + /// `portionsRequested` + the recipe's yield via `computeBatchedPortions` + /// (`@batch-cooking/shared`) in `addPlanningItem`, never sent raw by the + /// client. On a "leftovers" item (`sourcePlanningItemId` set) it always + /// equals `portionsRequested`. portions Int + /// How many portions this slot must actually feed. For a fresh item + /// (`sourcePlanningItemId` null), `<= portions` — the gap is this item's + /// leftover pool. For a "leftovers" item, always `== portions` (serving + /// leftovers prepares nothing new, so it can't generate more — no + /// leftovers-on-leftovers). + portionsRequested Int @map("portions_requested") + /// Non-null only on a "leftovers" item: the item whose leftover pool it + /// draws from. `onDelete: Cascade` — deleting the source item also + /// deletes everything served from its leftovers (same reasoning as the + /// existing `Planning` -> `PlanningItem` cascade). + sourcePlanningItemId Int? @map("source_planning_item_id") + /// Portions of *this* item's leftover pool explicitly thrown away (the + /// basket's "jeter" button). Always 0 on a "leftovers" item (its own + /// pool is null by construction). + discardedPortions Int @default(0) @map("discarded_portions") /// See `Planning.createdAt` — same admin-metrics-only timestamp. createdAt DateTime @default(now()) @map("created_at") - planning Planning @relation(fields: [planningId], references: [id], onDelete: Cascade) - recipe Recipe @relation(fields: [recipeId], references: [id]) + 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) + /// The "leftovers" items served from this item's own pool — inverse of `sourcePlanningItem`. + leftoverUses PlanningItem[] @relation("PlanningItemLeftoverSource") @@map("planning_item") } diff --git a/apps/api/src/modules/planning/planning.service.ts b/apps/api/src/modules/planning/planning.service.ts index 2fcaaf9..30c02cc 100644 --- a/apps/api/src/modules/planning/planning.service.ts +++ b/apps/api/src/modules/planning/planning.service.ts @@ -2,6 +2,7 @@ import { type DateTime, getWeekStart, toDateOnly } from "@batch-cooking/date-too import { HttpError } from "@batch-cooking/error-tools"; import { type AddPlanningItemInput, + computeBatchedPortions, ErrorCode, type PlanningItemView, type PlanningView, @@ -69,6 +70,9 @@ export async function getPlanningForDate( weekDay: item.weekDay, meal: item.meal, portions: item.portions, + portionsRequested: item.portionsRequested, + discardedPortions: item.discardedPortions, + sourceItem: item.sourcePlanningItemId !== null ? { id: item.sourcePlanningItemId } : null, recipe: item.recipe, })), }; @@ -144,6 +148,18 @@ export async function addPlanningItem( } await assertRecipeVisible(input.recipeId, viewerId, viewerHouseId); + // `assertRecipeVisible` already guaranteed the recipe exists and is + // visible — this second read just pulls its written yield, needed to + // turn "portions requested" into "portions to actually prepare" + // (a whole number of batches, see `computeBatchedPortions`). The + // `?? 1` never triggers in practice (the assert above threw otherwise); + // it only keeps TypeScript happy about `findUnique`'s `| null`. + const recipe = await prisma.recipe.findUnique({ + where: { id: input.recipeId }, + select: { portions: true }, + }); + const { portions } = computeBatchedPortions(recipe?.portions ?? 1, input.portionsRequested); + const weekStart = getWeekStart(toDateOnly(date)); const planning = await findOrCreatePlanningForWeek(houseId, weekStart); @@ -153,14 +169,19 @@ export async function addPlanningItem( weekDay: input.weekDay, meal: input.meal, recipeId: input.recipeId, - portions: input.portions, + portions, + portionsRequested: input.portionsRequested, }, include: { recipe: { select: { id: true, name: true } } }, }); analytics.recordEvent("planning.item_added", { actorId: viewerId, - context: { recipeId: input.recipeId, portions: input.portions }, + context: { + recipeId: input.recipeId, + portions, + portionsRequested: input.portionsRequested, + }, }); return { @@ -168,6 +189,9 @@ export async function addPlanningItem( weekDay: item.weekDay, meal: item.meal, portions: item.portions, + portionsRequested: item.portionsRequested, + discardedPortions: item.discardedPortions, + sourceItem: item.sourcePlanningItemId !== null ? { id: item.sourcePlanningItemId } : null, recipe: item.recipe, }; } catch (err) { diff --git a/apps/api/test/cooking-session.test.ts b/apps/api/test/cooking-session.test.ts index 3dda1b3..a20c038 100644 --- a/apps/api/test/cooking-session.test.ts +++ b/apps/api/test/cooking-session.test.ts @@ -177,6 +177,7 @@ describe("Cooking session", () => { meal: "dejeuner", recipeId: soupe.id, portions: 4, + portionsRequested: 4, }, { planningId: planning.id, @@ -184,6 +185,7 @@ describe("Cooking session", () => { meal: "diner", recipeId: tarte.id, portions: 4, + portionsRequested: 4, }, ], }); @@ -280,6 +282,7 @@ describe("Cooking session", () => { meal: i === 0 ? "dejeuner" : "diner", recipeId, portions: 4, + portionsRequested: 4, })), }); } diff --git a/apps/api/test/planning-portions.test.ts b/apps/api/test/planning-portions.test.ts new file mode 100644 index 0000000..2cfed0f --- /dev/null +++ b/apps/api/test/planning-portions.test.ts @@ -0,0 +1,73 @@ +import { computeAvailableLeftover, computeBatchedPortions } from "@batch-cooking/shared"; +import { expect } from "chai"; + +/** + * Pure portion arithmetic from `@batch-cooking/shared` — no DB, so this + * suite skips `resetDatabase`. The API (`addPlanningItem`, and later the + * leftover-assignment endpoints) and the web app both call these; the + * numbers below are the two worked examples from issue #34 plus the + * boundary cases. + */ +describe("planning portion arithmetic", () => { + describe("computeBatchedPortions", () => { + it("rounds up to one batch when fewer portions are requested than the recipe yields", () => { + expect(computeBatchedPortions(4, 3)).to.deep.equal({ portions: 4, leftover: 1 }); + }); + + it("cooks several batches when more portions are requested than one batch yields", () => { + expect(computeBatchedPortions(4, 10)).to.deep.equal({ portions: 12, leftover: 2 }); + }); + + it("leaves no leftover on an exact batch boundary", () => { + expect(computeBatchedPortions(4, 4)).to.deep.equal({ portions: 4, leftover: 0 }); + expect(computeBatchedPortions(4, 8)).to.deep.equal({ portions: 8, leftover: 0 }); + }); + + it("still prepares one batch when a single portion is requested", () => { + expect(computeBatchedPortions(6, 1)).to.deep.equal({ portions: 6, leftover: 5 }); + }); + + it("handles a one-portion recipe (every request is an exact number of batches)", () => { + expect(computeBatchedPortions(1, 5)).to.deep.equal({ portions: 5, leftover: 0 }); + }); + }); + + describe("computeAvailableLeftover", () => { + const base = { portions: 12, portionsRequested: 10, discardedPortions: 0, leftoverUses: [] }; + + it("is the prepared-minus-requested pool when nothing has been discarded or served", () => { + expect(computeAvailableLeftover(base)).to.equal(2); + }); + + it("subtracts portions already served to other slots", () => { + expect( + computeAvailableLeftover({ ...base, leftoverUses: [{ portions: 1 }, { portions: 1 }] }), + ).to.equal(0); + }); + + it("subtracts explicitly discarded portions", () => { + expect(computeAvailableLeftover({ ...base, discardedPortions: 2 })).to.equal(0); + }); + + it("never goes negative", () => { + expect( + computeAvailableLeftover({ + ...base, + discardedPortions: 5, + leftoverUses: [{ portions: 3 }], + }), + ).to.equal(0); + }); + + it("is 0 for an item whose request landed exactly on a batch (no pool at all)", () => { + expect( + computeAvailableLeftover({ + portions: 8, + portionsRequested: 8, + discardedPortions: 0, + leftoverUses: [], + }), + ).to.equal(0); + }); + }); +}); diff --git a/apps/api/test/planning.test.ts b/apps/api/test/planning.test.ts index 8b29062..70bc1c2 100644 --- a/apps/api/test/planning.test.ts +++ b/apps/api/test/planning.test.ts @@ -114,6 +114,7 @@ describe("Planning", () => { meal: "diner", recipeId: recipe.id, portions: 4, + portionsRequested: 3, }, }); @@ -122,7 +123,14 @@ describe("Planning", () => { expect(res.status).to.equal(200); expect(res.body.id).to.equal(planning.id); expect(res.body.items).to.have.length(1); - expect(res.body.items[0]).to.include({ weekDay: "lundi", meal: "diner" }); + expect(res.body.items[0]).to.include({ + weekDay: "lundi", + meal: "diner", + portions: 4, + portionsRequested: 3, + discardedPortions: 0, + }); + expect(res.body.items[0].sourceItem).to.equal(null); expect(res.body.items[0].recipe).to.include({ id: recipe.id, name: "Ratatouille" }); }); @@ -171,6 +179,7 @@ describe("Planning", () => { meal: "dejeuner", recipeId: recipe.id, portions: 2, + portionsRequested: 2, }, }); @@ -183,4 +192,83 @@ describe("Planning", () => { expect(thisWeekRes.body).to.equal(null); }); }); + + describe("POST /planning/items", () => { + /** Signs up, creates a household, and returns an authenticated agent + a saved recipe with the given yield. */ + async function setup(recipePortions: number) { + const agent = request.agent(app); + await agent.post("/auth/signup").send(buildSignupPayload()); + const houseRes = await agent.post("/house").send({ name: "Chez moi" }); + const recipe = await prisma.recipe.create({ + data: { name: "Lasagnes", authorId: houseRes.body.adminId, portions: recipePortions }, + }); + return { agent, recipe }; + } + + it("rounds the prepared portions up to a whole batch of the recipe and reports the leftover", async () => { + const { agent, recipe } = await setup(4); + + const res = await agent.post("/planning/items").send({ + date: today(), + weekDay: "lundi", + meal: "diner", + recipeId: recipe.id, + portionsRequested: 3, + }); + + expect(res.status).to.equal(201); + // 3 requested, 4-portion recipe -> one batch of 4 prepared, 1 leftover. + expect(res.body).to.include({ portions: 4, portionsRequested: 3, discardedPortions: 0 }); + expect(res.body.sourceItem).to.equal(null); + + const stored = await prisma.planningItem.findUnique({ where: { id: res.body.id } }); + expect(stored).to.include({ portions: 4, portionsRequested: 3 }); + }); + + it("cooks several batches when more portions are requested than one batch yields", async () => { + const { agent, recipe } = await setup(4); + + const res = await agent.post("/planning/items").send({ + date: today(), + weekDay: "mardi", + meal: "dejeuner", + recipeId: recipe.id, + portionsRequested: 10, + }); + + expect(res.status).to.equal(201); + // 10 requested, 4-portion recipe -> three batches (12) prepared, 2 leftover. + expect(res.body).to.include({ portions: 12, portionsRequested: 10 }); + }); + + it("leaves no leftover when the request lands exactly on a batch boundary", async () => { + const { agent, recipe } = await setup(4); + + const res = await agent.post("/planning/items").send({ + date: today(), + weekDay: "mercredi", + meal: "diner", + recipeId: recipe.id, + portionsRequested: 8, + }); + + expect(res.status).to.equal(201); + expect(res.body).to.include({ portions: 8, portionsRequested: 8 }); + }); + + it("rejects a non-positive portionsRequested with 400 VALIDATION_ERROR", async () => { + const { agent, recipe } = await setup(4); + + const res = await agent.post("/planning/items").send({ + date: today(), + weekDay: "lundi", + meal: "diner", + recipeId: recipe.id, + portionsRequested: 0, + }); + + expect(res.status).to.equal(400); + expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR); + }); + }); }); diff --git a/apps/api/test/recipe.test.ts b/apps/api/test/recipe.test.ts index d4e50ec..16db426 100644 --- a/apps/api/test/recipe.test.ts +++ b/apps/api/test/recipe.test.ts @@ -1031,6 +1031,7 @@ describe("Recipes", () => { meal: "diner", recipeId: recipe.id, portions: 4, + portionsRequested: 4, }, }); diff --git a/apps/api/test/shopping-list.test.ts b/apps/api/test/shopping-list.test.ts index 27b7f15..8f5f1ef 100644 --- a/apps/api/test/shopping-list.test.ts +++ b/apps/api/test/shopping-list.test.ts @@ -150,6 +150,7 @@ describe("Shopping list", () => { meal: "dejeuner", recipeId: recipe.id, portions: 4, + portionsRequested: 4, }, { planningId: planning.id, @@ -157,6 +158,7 @@ describe("Shopping list", () => { meal: "diner", recipeId: recipe.id, portions: 4, + portionsRequested: 4, }, ], }); @@ -211,6 +213,7 @@ describe("Shopping list", () => { meal: "dejeuner", recipeId: recipeA.id, portions: 4, + portionsRequested: 4, }, { planningId: planning.id, @@ -218,6 +221,7 @@ describe("Shopping list", () => { meal: "diner", recipeId: recipeB.id, portions: 4, + portionsRequested: 4, }, ], }); @@ -272,6 +276,7 @@ describe("Shopping list", () => { meal: "dejeuner", recipeId: recipeA.id, portions: 2, + portionsRequested: 2, }, { planningId: planning.id, @@ -279,6 +284,7 @@ describe("Shopping list", () => { meal: "diner", recipeId: recipeB.id, portions: 2, + portionsRequested: 2, }, ], }); @@ -323,6 +329,7 @@ describe("Shopping list", () => { meal: "dejeuner", recipeId: recipe.id, portions: 2, + portionsRequested: 2, }, }); diff --git a/apps/web/cypress/e2e/planning.feature b/apps/web/cypress/e2e/planning.feature index 93796bb..f97c99b 100644 --- a/apps/web/cypress/e2e/planning.feature +++ b/apps/web/cypress/e2e/planning.feature @@ -40,7 +40,7 @@ Feature: Adding a recipe to the planning And I select unit "unité" for the first ingredient And I fill in the last ingredient's quantity with "1" and unit "unité" And I click the button "Importer" - Then the planning add request should have included recipe 99, weekDay "lundi", meal "petit-dejeuner", and portions 4 + Then the planning add request should have included recipe 99, weekDay "lundi", meal "petit-dejeuner", and requested portions 4 And the recipe picker dialog should be closed And the recipe "Fish Pie" should appear in the first planning slot with 4 portions @@ -54,6 +54,6 @@ Feature: Adding a recipe to the planning And I click the source item "Ratatouille" And I click the button "Confirmer" Then the import request for "7777" should have included the name "Ratatouille" - And the planning add request should have included recipe 100, weekDay "lundi", meal "petit-dejeuner", and portions 4 + And the planning add request should have included recipe 100, weekDay "lundi", meal "petit-dejeuner", and requested portions 4 And the recipe picker dialog should be closed And the recipe "Ratatouille" should appear in the first planning slot with 4 portions diff --git a/apps/web/cypress/e2e/planning.ts b/apps/web/cypress/e2e/planning.ts index b9ea1d3..508e809 100644 --- a/apps/web/cypress/e2e/planning.ts +++ b/apps/web/cypress/e2e/planning.ts @@ -152,6 +152,9 @@ Given("adding recipe {int} to the planning will succeed", (recipeId: number) => weekDay: "lundi", meal: "petit-dejeuner", portions: 4, + portionsRequested: 4, + discardedPortions: 0, + sourceItem: null, recipe: { id: recipeId, name: "Ratatouille" }, }, }).as("addPlanningItem"); @@ -250,11 +253,11 @@ When("I choose an ingredient for the unresolved line {string}", (rawText: string }); Then( - "the planning add request should have included recipe {int}, weekDay {string}, meal {string}, and portions {int}", - (recipeId: number, weekDay: string, meal: string, portions: number) => { + "the planning add request should have included recipe {int}, weekDay {string}, meal {string}, and requested portions {int}", + (recipeId: number, weekDay: string, meal: string, portionsRequested: number) => { cy.wait("@addPlanningItem") .its("request.body") - .should("deep.include", { recipeId, weekDay, meal, portions }); + .should("deep.include", { recipeId, weekDay, meal, portionsRequested }); }, ); diff --git a/apps/web/src/features/planning/RecipePickerDialog.tsx b/apps/web/src/features/planning/RecipePickerDialog.tsx index 50ff366..494247f 100644 --- a/apps/web/src/features/planning/RecipePickerDialog.tsx +++ b/apps/web/src/features/planning/RecipePickerDialog.tsx @@ -1,4 +1,5 @@ import { + computeBatchedPortions, type DietView, ErrorCode, type IngredientView, @@ -306,7 +307,9 @@ export function RecipePickerDialog({ weekDay: slot.weekDay, meal: slot.meal, recipeId: saved.id, - portions: payload.portions, + // A freshly imported recipe is added at exactly its own yield, so + // requested == prepared and there's no leftover to compute here. + portionsRequested: payload.portions, }); onAdded(planningItem); onClose(); @@ -334,7 +337,7 @@ export function RecipePickerDialog({ weekDay: slot.weekDay, meal: slot.meal, recipeId: selectedRecipe.id, - portions: parsedPortions, + portionsRequested: parsedPortions, }); onAdded(item); onClose(); @@ -374,13 +377,22 @@ export function RecipePickerDialog({ } if (selectedRecipe) { + // A whole number of batches of this recipe is what actually gets + // cooked; the hint below only shows when that leaves a leftover. + const parsedRequested = Number(portions); + const batched = + Number.isInteger(parsedRequested) && parsedRequested > 0 + ? computeBatchedPortions(selectedRecipe.portions, parsedRequested) + : null; return (
- + setPortions(e.target.value)} /> + {batched && batched.leftover > 0 && ( +

+ {t("planning.picker.batchedHint", { + prepared: batched.portions, + leftover: batched.leftover, + })} +

+ )} {submitError &&

{submitError}

}