Merge pull request #31 from kyuno053/feat/recipe-portions

feat(recipes): nombre de portions par recette
This commit is contained in:
kyuno053 2026-08-19 22:09:37 +02:00 committed by GitHub
commit 102f6846d5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 155 additions and 30 deletions

View file

@ -0,0 +1,10 @@
-- Adds how many portions a recipe yields as written (`Recipe.portions`) —
-- the planning recipe picker now pre-fills its own "how many portions?"
-- step from this (see `RecipePickerDialog`), closing the gap noted on
-- `PlanningItem.portions`'s own migration ("no such default exists ... on
-- `Recipe` either"). Backfills any pre-existing row with 4 portions (a
-- reasonable default recipe yield) via a transient DEFAULT, then drops it
-- so it isn't implicitly reused for new inserts going forward — same
-- pattern as `20260819064721_planning_item_portions`.
ALTER TABLE "recipe" ADD COLUMN "portions" INTEGER NOT NULL DEFAULT 4;
ALTER TABLE "recipe" ALTER COLUMN "portions" DROP DEFAULT;

View file

@ -241,6 +241,13 @@ model Recipe {
sourceId Int? @map("source_id") sourceId Int? @map("source_id")
description String? description String?
picture String? picture String?
/// How many portions this recipe yields as written (its ingredient
/// quantities/steps assume this count) — distinct from
/// `PlanningItem.portions`, which is how many to actually prepare for one
/// planning slot and now defaults to this value client-side but is still
/// entered/stored independently (a planning slot may scale the recipe
/// up/down).
portions Int
/// Creator — not in the original spec doc, required once recipes carry a /// Creator — not in the original spec doc, required once recipes carry a
/// visibility level (`PERSONAL`/`HOUSE` need someone to scope against). /// visibility level (`PERSONAL`/`HOUSE` need someone to scope against).
authorId Int @map("author_id") authorId Int @map("author_id")

View file

@ -78,6 +78,7 @@ function toRecipeSummaryView(recipe: RecipeWithDetails): RecipeSummaryView {
name: recipe.name, name: recipe.name,
description: recipe.description, description: recipe.description,
picture: recipe.picture, picture: recipe.picture,
portions: recipe.portions,
authorId: recipe.authorId, authorId: recipe.authorId,
visibility: recipe.visibility, visibility: recipe.visibility,
allergens, allergens,
@ -307,6 +308,7 @@ export async function createRecipe(
name: input.name, name: input.name,
description: input.description ?? null, description: input.description ?? null,
picture: input.picture ?? null, picture: input.picture ?? null,
portions: input.portions,
authorId, authorId,
authorHouseId, authorHouseId,
visibility: input.visibility, visibility: input.visibility,
@ -363,6 +365,7 @@ export async function updateRecipe(
name: input.name, name: input.name,
description: input.description ?? null, description: input.description ?? null,
picture: input.picture ?? null, picture: input.picture ?? null,
portions: input.portions,
visibility: input.visibility, visibility: input.visibility,
ingredients: { ingredients: {
create: input.ingredients.map((ingredient) => ({ create: input.ingredients.map((ingredient) => ({

View file

@ -98,7 +98,7 @@ describe("Planning", () => {
const houseId: number = houseRes.body.id; const houseId: number = houseRes.body.id;
const recipe = await prisma.recipe.create({ const recipe = await prisma.recipe.create({
data: { name: "Ratatouille", authorId: houseRes.body.adminId }, data: { name: "Ratatouille", authorId: houseRes.body.adminId, portions: 4 },
}); });
const planning = await prisma.planning.create({ const planning = await prisma.planning.create({
data: { data: {
@ -154,7 +154,7 @@ describe("Planning", () => {
const houseId: number = houseRes.body.id; const houseId: number = houseRes.body.id;
const recipe = await prisma.recipe.create({ const recipe = await prisma.recipe.create({
data: { name: "Curry de lentilles", authorId: houseRes.body.adminId }, data: { name: "Curry de lentilles", authorId: houseRes.body.adminId, portions: 4 },
}); });
const nextWeek = TEST_REFERENCE_DATE.plus({ weeks: 1 }); const nextWeek = TEST_REFERENCE_DATE.plus({ weeks: 1 });
const planning = await prisma.planning.create({ const planning = await prisma.planning.create({

View file

@ -72,10 +72,10 @@ describe("Recipes", () => {
it("filters the catalog by name when ?search= is given", async () => { it("filters the catalog by name when ?search= is given", async () => {
const { agent, profileId } = await signup(); const { agent, profileId } = await signup();
await prisma.recipe.create({ await prisma.recipe.create({
data: { name: "Ratatouille", authorId: profileId, visibility: "PUBLIC" }, data: { name: "Ratatouille", authorId: profileId, visibility: "PUBLIC", portions: 4 },
}); });
await prisma.recipe.create({ await prisma.recipe.create({
data: { name: "Tarte aux pommes", authorId: profileId, visibility: "PUBLIC" }, data: { name: "Tarte aux pommes", authorId: profileId, visibility: "PUBLIC", portions: 6 },
}); });
const res = await agent.get("/recipes").query({ tab: "publique", search: "rata" }); const res = await agent.get("/recipes").query({ tab: "publique", search: "rata" });
@ -87,8 +87,10 @@ describe("Recipes", () => {
it("perso tab only returns the viewer's own PERSONAL recipes", async () => { it("perso tab only returns the viewer's own PERSONAL recipes", async () => {
const { agent, profileId } = await signup(); const { agent, profileId } = await signup();
const { profileId: otherId } = await signup(); const { profileId: otherId } = await signup();
await prisma.recipe.create({ data: { name: "La mienne", authorId: profileId } }); await prisma.recipe.create({ data: { name: "La mienne", authorId: profileId, portions: 4 } });
await prisma.recipe.create({ data: { name: "Pas la mienne", authorId: otherId } }); await prisma.recipe.create({
data: { name: "Pas la mienne", authorId: otherId, portions: 4 },
});
const res = await agent.get("/recipes").query({ tab: "perso" }); const res = await agent.get("/recipes").query({ tab: "perso" });
@ -107,6 +109,7 @@ describe("Recipes", () => {
authorId: profileId, authorId: profileId,
visibility: "HOUSE", visibility: "HOUSE",
authorHouseId: houseRes.body.id, authorHouseId: houseRes.body.id,
portions: 4,
}, },
}); });
await prisma.recipe.create({ await prisma.recipe.create({
@ -115,6 +118,7 @@ describe("Recipes", () => {
authorId: otherId, authorId: otherId,
visibility: "HOUSE", visibility: "HOUSE",
authorHouseId: otherHouseRes.body.id, authorHouseId: otherHouseRes.body.id,
portions: 4,
}, },
}); });
@ -135,10 +139,10 @@ describe("Recipes", () => {
it("favoris tab only returns recipes the viewer has favorited", async () => { it("favoris tab only returns recipes the viewer has favorited", async () => {
const { agent, profileId } = await signup(); const { agent, profileId } = await signup();
const favorited = await prisma.recipe.create({ const favorited = await prisma.recipe.create({
data: { name: "Favorite", authorId: profileId, visibility: "PUBLIC" }, data: { name: "Favorite", authorId: profileId, visibility: "PUBLIC", portions: 4 },
}); });
await prisma.recipe.create({ await prisma.recipe.create({
data: { name: "Pas favorite", authorId: profileId, visibility: "PUBLIC" }, data: { name: "Pas favorite", authorId: profileId, visibility: "PUBLIC", portions: 4 },
}); });
await agent.post(`/recipes/${favorited.id}/favorite`); await agent.post(`/recipes/${favorited.id}/favorite`);
@ -150,7 +154,7 @@ describe("Recipes", () => {
it("a PERSONAL recipe from another author is invisible in the publique tab", async () => { it("a PERSONAL recipe from another author is invisible in the publique tab", async () => {
const { agent } = await signup(); const { agent } = await signup();
const { profileId: otherId } = await signup(); const { profileId: otherId } = await signup();
await prisma.recipe.create({ data: { name: "Secrète", authorId: otherId } }); await prisma.recipe.create({ data: { name: "Secrète", authorId: otherId, portions: 4 } });
const res = await agent.get("/recipes").query({ tab: "publique" }); const res = await agent.get("/recipes").query({ tab: "publique" });
@ -170,6 +174,7 @@ describe("Recipes", () => {
const res = await agent.post("/recipes").send({ const res = await agent.post("/recipes").send({
name: "Omelette provençale", name: "Omelette provençale",
description: "Rapide et savoureuse", description: "Rapide et savoureuse",
portions: 2,
dietIds: [vegetarien.id], dietIds: [vegetarien.id],
ingredients: [ ingredients: [
{ ingredientId: tomate, quantity: 2, unit: "unité" }, { ingredientId: tomate, quantity: 2, unit: "unité" },
@ -180,6 +185,7 @@ describe("Recipes", () => {
expect(res.status).to.equal(201); expect(res.status).to.equal(201);
expect(res.body.name).to.equal("Omelette provençale"); expect(res.body.name).to.equal("Omelette provençale");
expect(res.body.portions).to.equal(2);
expect(res.body.ingredients).to.have.length(2); expect(res.body.ingredients).to.have.length(2);
expect( expect(
res.body.steps.map((s: { description: string; order: number }) => s.order), res.body.steps.map((s: { description: string; order: number }) => s.order),
@ -196,6 +202,7 @@ describe("Recipes", () => {
const res = await agent.post("/recipes").send({ const res = await agent.post("/recipes").send({
name: "Test", name: "Test",
portions: 4,
dietIds: [], dietIds: [],
ingredients: [{ ingredientId: tomate, quantity: 1, unit: "unité" }], ingredients: [{ ingredientId: tomate, quantity: 1, unit: "unité" }],
steps: [{ description: "Étape" }], steps: [{ description: "Étape" }],
@ -207,6 +214,7 @@ describe("Recipes", () => {
const houseRecipe = await agent.post("/recipes").send({ const houseRecipe = await agent.post("/recipes").send({
name: "Foyer", name: "Foyer",
visibility: "HOUSE", visibility: "HOUSE",
portions: 4,
dietIds: [], dietIds: [],
ingredients: [{ ingredientId: tomate, quantity: 1, unit: "unité" }], ingredients: [{ ingredientId: tomate, quantity: 1, unit: "unité" }],
steps: [{ description: "Étape" }], steps: [{ description: "Étape" }],
@ -221,6 +229,7 @@ describe("Recipes", () => {
const res = await agent.post("/recipes").send({ const res = await agent.post("/recipes").send({
name: "Test", name: "Test",
portions: 4,
dietIds: [], dietIds: [],
ingredients: [{ ingredientId: 999_999, quantity: 1, unit: "g" }], ingredients: [{ ingredientId: 999_999, quantity: 1, unit: "g" }],
steps: [{ description: "Étape" }], steps: [{ description: "Étape" }],
@ -236,6 +245,7 @@ describe("Recipes", () => {
const res = await agent.post("/recipes").send({ const res = await agent.post("/recipes").send({
name: "Test", name: "Test",
portions: 4,
dietIds: [999_999], dietIds: [999_999],
ingredients: [{ ingredientId: tomate, quantity: 1, unit: "g" }], ingredients: [{ ingredientId: tomate, quantity: 1, unit: "g" }],
steps: [{ description: "Étape" }], steps: [{ description: "Étape" }],
@ -248,13 +258,36 @@ describe("Recipes", () => {
it("rejects an empty ingredients or steps list with 400 VALIDATION_ERROR", async () => { it("rejects an empty ingredients or steps list with 400 VALIDATION_ERROR", async () => {
const { agent } = await signup(); const { agent } = await signup();
const res = await agent const res = await agent.post("/recipes").send({
.post("/recipes") name: "Test",
.send({ name: "Test", dietIds: [], ingredients: [], steps: [{ description: "Étape" }] }); portions: 4,
dietIds: [],
ingredients: [],
steps: [{ description: "Étape" }],
});
expect(res.status).to.equal(400); expect(res.status).to.equal(400);
expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR); expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
}); });
it("rejects a missing or non-positive portions with 400 VALIDATION_ERROR", async () => {
const { agent } = await signup();
const tomate = await ingredientId("tomato");
const basePayload = {
name: "Test",
dietIds: [],
ingredients: [{ ingredientId: tomate, quantity: 1, unit: "unité" }],
steps: [{ description: "Étape" }],
};
const missing = await agent.post("/recipes").send(basePayload);
const zero = await agent.post("/recipes").send({ ...basePayload, portions: 0 });
expect(missing.status).to.equal(400);
expect(missing.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
expect(zero.status).to.equal(400);
expect(zero.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
});
}); });
describe("GET /recipes/:id", () => { describe("GET /recipes/:id", () => {
@ -272,6 +305,7 @@ describe("Recipes", () => {
const tomate = await ingredientId("tomato"); const tomate = await ingredientId("tomato");
const created = await agent.post("/recipes").send({ const created = await agent.post("/recipes").send({
name: "Salade", name: "Salade",
portions: 4,
dietIds: [], dietIds: [],
ingredients: [{ ingredientId: tomate, quantity: 1, unit: "unité" }], ingredients: [{ ingredientId: tomate, quantity: 1, unit: "unité" }],
steps: [{ description: "Couper" }], steps: [{ description: "Couper" }],
@ -281,6 +315,7 @@ describe("Recipes", () => {
expect(res.status).to.equal(200); expect(res.status).to.equal(200);
expect(res.body.name).to.equal("Salade"); expect(res.body.name).to.equal("Salade");
expect(res.body.portions).to.equal(4);
expect(res.body.ingredients[0].ingredient.key).to.equal("tomato"); expect(res.body.ingredients[0].ingredient.key).to.equal("tomato");
expect(res.body.isFavorite).to.equal(false); expect(res.body.isFavorite).to.equal(false);
}); });
@ -288,7 +323,9 @@ describe("Recipes", () => {
it("returns 404 for a PERSONAL recipe belonging to someone else", async () => { it("returns 404 for a PERSONAL recipe belonging to someone else", async () => {
const { agent } = await signup(); const { agent } = await signup();
const { profileId: otherId } = await signup(); const { profileId: otherId } = await signup();
const recipe = await prisma.recipe.create({ data: { name: "Secrète", authorId: otherId } }); const recipe = await prisma.recipe.create({
data: { name: "Secrète", authorId: otherId, portions: 4 },
});
const res = await agent.get(`/recipes/${recipe.id}`); const res = await agent.get(`/recipes/${recipe.id}`);
@ -300,7 +337,7 @@ describe("Recipes", () => {
const { agent } = await signup(); const { agent } = await signup();
const { profileId: otherId } = await signup(); const { profileId: otherId } = await signup();
const recipe = await prisma.recipe.create({ const recipe = await prisma.recipe.create({
data: { name: "Ouverte", authorId: otherId, visibility: "PUBLIC" }, data: { name: "Ouverte", authorId: otherId, visibility: "PUBLIC", portions: 4 },
}); });
const res = await agent.get(`/recipes/${recipe.id}`); const res = await agent.get(`/recipes/${recipe.id}`);
@ -318,6 +355,7 @@ describe("Recipes", () => {
authorId: otherId, authorId: otherId,
visibility: "HOUSE", visibility: "HOUSE",
authorHouseId: houseRes.body.id, authorHouseId: houseRes.body.id,
portions: 4,
}, },
}); });
const otherHouseId = ( const otherHouseId = (
@ -331,6 +369,7 @@ describe("Recipes", () => {
authorId: otherId, authorId: otherId,
visibility: "HOUSE", visibility: "HOUSE",
authorHouseId: otherHouseId, authorHouseId: otherHouseId,
portions: 4,
}, },
}); });
@ -349,6 +388,7 @@ describe("Recipes", () => {
const oignon = await ingredientId("onion"); const oignon = await ingredientId("onion");
const created = await agent.post("/recipes").send({ const created = await agent.post("/recipes").send({
name: "Salade", name: "Salade",
portions: 4,
dietIds: [], dietIds: [],
ingredients: [{ ingredientId: tomate, quantity: 1, unit: "unité" }], ingredients: [{ ingredientId: tomate, quantity: 1, unit: "unité" }],
steps: [{ description: "Couper" }], steps: [{ description: "Couper" }],
@ -356,6 +396,7 @@ describe("Recipes", () => {
const res = await agent.patch(`/recipes/${created.body.id}`).send({ const res = await agent.patch(`/recipes/${created.body.id}`).send({
name: "Salade composée", name: "Salade composée",
portions: 6,
visibility: "PUBLIC", visibility: "PUBLIC",
dietIds: [], dietIds: [],
ingredients: [{ ingredientId: oignon, quantity: 2, unit: "unité" }], ingredients: [{ ingredientId: oignon, quantity: 2, unit: "unité" }],
@ -364,6 +405,7 @@ describe("Recipes", () => {
expect(res.status).to.equal(200); expect(res.status).to.equal(200);
expect(res.body.name).to.equal("Salade composée"); expect(res.body.name).to.equal("Salade composée");
expect(res.body.portions).to.equal(6);
expect(res.body.visibility).to.equal("PUBLIC"); expect(res.body.visibility).to.equal("PUBLIC");
expect(res.body.ingredients).to.have.length(1); expect(res.body.ingredients).to.have.length(1);
expect(res.body.ingredients[0].ingredient.key).to.equal("onion"); expect(res.body.ingredients[0].ingredient.key).to.equal("onion");
@ -376,6 +418,7 @@ describe("Recipes", () => {
const res = await agent.patch("/recipes/999999").send({ const res = await agent.patch("/recipes/999999").send({
name: "Test", name: "Test",
portions: 4,
dietIds: [], dietIds: [],
ingredients: [{ ingredientId: tomate, quantity: 1, unit: "unité" }], ingredients: [{ ingredientId: tomate, quantity: 1, unit: "unité" }],
steps: [{ description: "Étape" }], steps: [{ description: "Étape" }],
@ -390,6 +433,7 @@ describe("Recipes", () => {
const tomate = await ingredientId("tomato"); const tomate = await ingredientId("tomato");
const created = await agent.post("/recipes").send({ const created = await agent.post("/recipes").send({
name: "Salade", name: "Salade",
portions: 4,
dietIds: [], dietIds: [],
ingredients: [{ ingredientId: tomate, quantity: 1, unit: "unité" }], ingredients: [{ ingredientId: tomate, quantity: 1, unit: "unité" }],
steps: [{ description: "Couper" }], steps: [{ description: "Couper" }],
@ -397,6 +441,7 @@ describe("Recipes", () => {
const res = await agent.patch(`/recipes/${created.body.id}`).send({ const res = await agent.patch(`/recipes/${created.body.id}`).send({
name: "Salade", name: "Salade",
portions: 4,
dietIds: [999_999], dietIds: [999_999],
ingredients: [{ ingredientId: tomate, quantity: 1, unit: "unité" }], ingredients: [{ ingredientId: tomate, quantity: 1, unit: "unité" }],
steps: [{ description: "Couper" }], steps: [{ description: "Couper" }],
@ -411,11 +456,12 @@ describe("Recipes", () => {
const { agent: otherAgent } = await signup(); const { agent: otherAgent } = await signup();
const tomate = await ingredientId("tomato"); const tomate = await ingredientId("tomato");
const recipe = await prisma.recipe.create({ const recipe = await prisma.recipe.create({
data: { name: "Publique", authorId: profileId, visibility: "PUBLIC" }, data: { name: "Publique", authorId: profileId, visibility: "PUBLIC", portions: 4 },
}); });
const res = await otherAgent.patch(`/recipes/${recipe.id}`).send({ const res = await otherAgent.patch(`/recipes/${recipe.id}`).send({
name: "Hack", name: "Hack",
portions: 4,
dietIds: [], dietIds: [],
ingredients: [{ ingredientId: tomate, quantity: 1, unit: "unité" }], ingredients: [{ ingredientId: tomate, quantity: 1, unit: "unité" }],
steps: [{ description: "Étape" }], steps: [{ description: "Étape" }],
@ -430,7 +476,7 @@ describe("Recipes", () => {
it("deletes a recipe not referenced by any planning item", async () => { it("deletes a recipe not referenced by any planning item", async () => {
const { agent, profileId } = await signup(); const { agent, profileId } = await signup();
const recipe = await prisma.recipe.create({ const recipe = await prisma.recipe.create({
data: { name: "À supprimer", authorId: profileId }, data: { name: "À supprimer", authorId: profileId, portions: 4 },
}); });
const res = await agent.delete(`/recipes/${recipe.id}`); const res = await agent.delete(`/recipes/${recipe.id}`);
@ -444,7 +490,7 @@ describe("Recipes", () => {
const { agent, profileId } = await signup(); const { agent, profileId } = await signup();
const houseRes = await agent.post("/house").send({ name: "Chez moi" }); const houseRes = await agent.post("/house").send({ name: "Chez moi" });
const recipe = await prisma.recipe.create({ const recipe = await prisma.recipe.create({
data: { name: "Ratatouille", authorId: profileId }, data: { name: "Ratatouille", authorId: profileId, portions: 4 },
}); });
const planning = await prisma.planning.create({ const planning = await prisma.planning.create({
data: { data: {
@ -482,7 +528,7 @@ describe("Recipes", () => {
const { profileId } = await signup(); const { profileId } = await signup();
const { agent: otherAgent } = await signup(); const { agent: otherAgent } = await signup();
const recipe = await prisma.recipe.create({ const recipe = await prisma.recipe.create({
data: { name: "Publique", authorId: profileId, visibility: "PUBLIC" }, data: { name: "Publique", authorId: profileId, visibility: "PUBLIC", portions: 4 },
}); });
const res = await otherAgent.delete(`/recipes/${recipe.id}`); const res = await otherAgent.delete(`/recipes/${recipe.id}`);
@ -496,7 +542,7 @@ describe("Recipes", () => {
it("adds and removes a recipe from the viewer's favorites", async () => { it("adds and removes a recipe from the viewer's favorites", async () => {
const { agent, profileId } = await signup(); const { agent, profileId } = await signup();
const recipe = await prisma.recipe.create({ const recipe = await prisma.recipe.create({
data: { name: "Recette", authorId: profileId, visibility: "PUBLIC" }, data: { name: "Recette", authorId: profileId, visibility: "PUBLIC", portions: 4 },
}); });
const addRes = await agent.post(`/recipes/${recipe.id}/favorite`); const addRes = await agent.post(`/recipes/${recipe.id}/favorite`);
@ -511,7 +557,7 @@ describe("Recipes", () => {
it("is idempotent — favoriting an already-favorited recipe doesn't error", async () => { it("is idempotent — favoriting an already-favorited recipe doesn't error", async () => {
const { agent, profileId } = await signup(); const { agent, profileId } = await signup();
const recipe = await prisma.recipe.create({ const recipe = await prisma.recipe.create({
data: { name: "Recette", authorId: profileId, visibility: "PUBLIC" }, data: { name: "Recette", authorId: profileId, visibility: "PUBLIC", portions: 4 },
}); });
await agent.post(`/recipes/${recipe.id}/favorite`); await agent.post(`/recipes/${recipe.id}/favorite`);
@ -523,7 +569,9 @@ describe("Recipes", () => {
it("rejects favoriting a recipe the viewer can't see with 404 RECIPE_NOT_FOUND", async () => { it("rejects favoriting a recipe the viewer can't see with 404 RECIPE_NOT_FOUND", async () => {
const { agent } = await signup(); const { agent } = await signup();
const { profileId: otherId } = await signup(); const { profileId: otherId } = await signup();
const recipe = await prisma.recipe.create({ data: { name: "Secrète", authorId: otherId } }); const recipe = await prisma.recipe.create({
data: { name: "Secrète", authorId: otherId, portions: 4 },
});
const res = await agent.post(`/recipes/${recipe.id}/favorite`); const res = await agent.post(`/recipes/${recipe.id}/favorite`);

View file

@ -21,7 +21,7 @@ Feature: Recipe form — associating ingredients
And I fill in the step description with "Couper les tomates." And I fill in the step description with "Couper les tomates."
Then the "Enregistrer" button should not be disabled Then the "Enregistrer" button should not be disabled
When I click the button "Enregistrer" When I click the button "Enregistrer"
Then the recipe creation request should have included name "Salade de tomates" and ingredient 1 with quantity 3 and unit "unité" Then the recipe creation request should have included name "Salade de tomates", portions 4, and ingredient 1 with quantity 3 and unit "unité"
And the URL should include "/recettes/42" And the URL should include "/recettes/42"
# Regression test for the exact bug reported: `crypto.randomUUID()` (used # Regression test for the exact bug reported: `crypto.randomUUID()` (used

View file

@ -114,12 +114,13 @@ Then("there should be {int} step editor items", (count: number) => {
}); });
Then( Then(
"the recipe creation request should have included name {string} and ingredient {int} with quantity {int} and unit {string}", "the recipe creation request should have included name {string}, portions {int}, and ingredient {int} with quantity {int} and unit {string}",
(name: string, ingredientId: number, quantity: number, unit: string) => { (name: string, portions: number, ingredientId: number, quantity: number, unit: string) => {
cy.wait("@createRecipe") cy.wait("@createRecipe")
.its("request.body") .its("request.body")
.should("deep.include", { .should("deep.include", {
name, name,
portions,
ingredients: [{ ingredientId, quantity, unit }], ingredients: [{ ingredientId, quantity, unit }],
}); });
}, },
@ -131,6 +132,7 @@ Given("recipe 7 exists with an egg omelette", () => {
name: "Omelette", name: "Omelette",
description: null, description: null,
picture: null, picture: null,
portions: 2,
authorId: 1, authorId: 1,
visibility: "PERSONAL", visibility: "PERSONAL",
allergens: [{ id: 1, key: "eggs", kind: "ALLERGY" }], allergens: [{ id: 1, key: "eggs", kind: "ALLERGY" }],

View file

@ -24,6 +24,7 @@ const ratatouille = {
name: "Ratatouille", name: "Ratatouille",
description: null, description: null,
picture: null, picture: null,
portions: 4,
authorId: 1, authorId: 1,
visibility: "PERSONAL", visibility: "PERSONAL",
allergens: [], allergens: [],
@ -36,6 +37,7 @@ const omelette = {
name: "Omelette", name: "Omelette",
description: null, description: null,
picture: null, picture: null,
portions: 2,
authorId: 1, authorId: 1,
visibility: "PERSONAL", visibility: "PERSONAL",
allergens: [oeufs], allergens: [oeufs],

View file

@ -7,6 +7,7 @@ const omelette = {
name: "Omelette", name: "Omelette",
description: null, description: null,
picture: null, picture: null,
portions: 2,
authorId: 1, authorId: 1,
visibility: "PERSONAL", visibility: "PERSONAL",
allergens: [oeufs], allergens: [oeufs],

View file

@ -273,9 +273,14 @@ export function RecipePickerDialog({
<RecipeTable <RecipeTable
recipes={listState.recipes} recipes={listState.recipes}
selectedId={null} selectedId={null}
onSelect={(id) => onSelect={(id) => {
setSelectedRecipe(listState.recipes.find((recipe) => recipe.id === id) ?? null) const recipe = listState.recipes.find((r) => r.id === id) ?? null;
} setSelectedRecipe(recipe);
// Pre-fill from the recipe's own written yield rather than
// always starting at 1 — still freely editable below, this is
// just a better starting point (see `Recipe.portions`).
if (recipe) setPortions(String(recipe.portions));
}}
/> />
)} )}
</Dialog> </Dialog>

View file

@ -90,7 +90,12 @@ export function RecipeDetailPanel({
</div> </div>
<div className="recipe-detail-panel__title-row"> <div className="recipe-detail-panel__title-row">
<div className="recipe-detail-panel__title-main">
<h2>{recipe.name}</h2> <h2>{recipe.name}</h2>
<p className="recipe-detail-panel__portions">
{t("recipes.detail.portions", { count: recipe.portions })}
</p>
</div>
<div className="recipe-detail-panel__title-badges"> <div className="recipe-detail-panel__title-badges">
<AllergenBadges allergens={recipe.allergens} /> <AllergenBadges allergens={recipe.allergens} />
{dislikedIngredients.length > 0 && ( {dislikedIngredients.length > 0 && (

View file

@ -396,6 +396,23 @@
} }
} }
&__title-main {
display: flex;
flex-direction: column;
gap: 0.2rem;
min-width: 0;
h2 {
margin: 0;
}
}
&__portions {
margin: 0;
color: var(--color-text-muted);
font-size: var(--font-size-sm);
}
&__title-badges { &__title-badges {
display: flex; display: flex;
flex-direction: column; flex-direction: column;

View file

@ -166,7 +166,8 @@
"detail": { "detail": {
"empty": "Sélectionnez une recette dans le tableau pour voir son détail ici.", "empty": "Sélectionnez une recette dans le tableau pour voir son détail ici.",
"favorite": "Ajouter aux favoris", "favorite": "Ajouter aux favoris",
"unfavorite": "Retirer des favoris" "unfavorite": "Retirer des favoris",
"portions": "{{count}} portion(s)"
}, },
"form": { "form": {
"newTitle": "Nouvelle recette", "newTitle": "Nouvelle recette",
@ -174,6 +175,7 @@
"nameLabel": "Nom de la recette", "nameLabel": "Nom de la recette",
"descriptionLabel": "Description", "descriptionLabel": "Description",
"pictureLabel": "Photo (URL)", "pictureLabel": "Photo (URL)",
"portionsLabel": "Nombre de portions",
"visibilityLabel": "Visible par", "visibilityLabel": "Visible par",
"visibility": { "visibility": {
"PERSONAL": "Moi uniquement", "PERSONAL": "Moi uniquement",

View file

@ -54,6 +54,11 @@ export function RecipeFormPage() {
const [name, setName] = useState(""); const [name, setName] = useState("");
const [description, setDescription] = useState(""); const [description, setDescription] = useState("");
const [picture, setPicture] = useState(""); const [picture, setPicture] = useState("");
// Pre-filled with a sensible default (same posture as `visibility`
// defaulting to `PERSONAL`) rather than starting empty — this is a
// required field, but the user shouldn't have to type a value just to
// get past the gate if 4 is already right for their recipe.
const [portions, setPortions] = useState("4");
const [visibility, setVisibility] = useState<RecipeVisibility>("PERSONAL"); const [visibility, setVisibility] = useState<RecipeVisibility>("PERSONAL");
const [dietIds, setDietIds] = useState<number[]>([]); const [dietIds, setDietIds] = useState<number[]>([]);
const [ingredientLines, setIngredientLines] = useState<IngredientLine[]>([]); const [ingredientLines, setIngredientLines] = useState<IngredientLine[]>([]);
@ -79,6 +84,7 @@ export function RecipeFormPage() {
setName(recipe.name); setName(recipe.name);
setDescription(recipe.description ?? ""); setDescription(recipe.description ?? "");
setPicture(recipe.picture ?? ""); setPicture(recipe.picture ?? "");
setPortions(String(recipe.portions));
setVisibility(recipe.visibility); setVisibility(recipe.visibility);
setDietIds(recipe.diets.map((diet) => diet.id)); setDietIds(recipe.diets.map((diet) => diet.id));
setIngredientLines( setIngredientLines(
@ -133,6 +139,8 @@ export function RecipeFormPage() {
// that doesn't need a round trip through zod on every keystroke. // that doesn't need a round trip through zod on every keystroke.
const canSubmit = const canSubmit =
name.trim().length > 0 && name.trim().length > 0 &&
Number.isInteger(Number(portions)) &&
Number(portions) > 0 &&
ingredientLines.length > 0 && ingredientLines.length > 0 &&
ingredientLines.every((line) => Number(line.quantity) > 0 && line.unit.trim().length > 0) && ingredientLines.every((line) => Number(line.quantity) > 0 && line.unit.trim().length > 0) &&
steps.length > 0 && steps.length > 0 &&
@ -146,6 +154,7 @@ export function RecipeFormPage() {
name: name.trim(), name: name.trim(),
description: description.trim() || null, description: description.trim() || null,
picture: picture.trim() || null, picture: picture.trim() || null,
portions: Number(portions),
visibility, visibility,
dietIds, dietIds,
ingredients: ingredientLines.map((line) => ({ ingredients: ingredientLines.map((line) => ({
@ -222,6 +231,16 @@ export function RecipeFormPage() {
placeholder="https://…" placeholder="https://…"
/> />
<label htmlFor="recipe-portions">{t("recipes.form.portionsLabel")}</label>
<input
id="recipe-portions"
type="number"
min="1"
step="1"
value={portions}
onChange={(e) => setPortions(e.target.value)}
/>
<label htmlFor="recipe-visibility">{t("recipes.form.visibilityLabel")}</label> <label htmlFor="recipe-visibility">{t("recipes.form.visibilityLabel")}</label>
<select <select
id="recipe-visibility" id="recipe-visibility"

View file

@ -40,6 +40,8 @@ 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(),
/** How many portions this recipe yields as written — see `Recipe.portions` in schema.prisma. */
portions: z.number().int().positive("Le nombre de portions doit être positif"),
visibility: recipeVisibilitySchema.default("PERSONAL"), visibility: recipeVisibilitySchema.default("PERSONAL"),
/** `dietId`s tagged as "this recipe suits this regime" — a manual reminder, not computed from ingredients. Empty = no regime associated. */ /** `dietId`s tagged as "this recipe suits this regime" — a manual reminder, not computed from ingredients. Empty = no regime associated. */
dietIds: z.array(z.number().int().positive()), dietIds: z.array(z.number().int().positive()),

View file

@ -37,7 +37,7 @@ export interface PlanningItemView {
weekDay: string; weekDay: string;
/** Which meal of the day this item is for — see {@link Meal} (free-form for now, same reason). */ /** Which meal of the day this item is for — see {@link Meal} (free-form for now, same reason). */
meal: string; meal: string;
/** How many portions to prepare for this slot — entered by whoever assigns the recipe (`addPlanningItemSchema`'s `portions`), not derived from any recipe default (no such default exists, see `Recipe` in schema.prisma). */ /** How many portions to prepare for this slot — entered by whoever assigns the recipe (`addPlanningItemSchema`'s `portions`). The picker UI pre-fills this from `Recipe.portions` (see {@link RecipeSummaryView.portions}) but it's still stored independently here, since a slot may scale the recipe up/down from its written yield. */
portions: number; portions: number;
recipe: { recipe: {
id: number; id: number;

View file

@ -50,6 +50,8 @@ export interface RecipeSummaryView {
name: string; name: string;
description: string | null; description: string | null;
picture: string | null; picture: string | null;
/** How many portions this recipe yields as written. Surfaced on the summary (not just the full detail) so the planning recipe picker can pre-fill its own "how many portions?" step from it — see `RecipePickerDialog`. */
portions: number;
authorId: number; authorId: number;
visibility: RecipeVisibility; visibility: RecipeVisibility;
allergens: AllergyView[]; allergens: AllergyView[];