Compare commits

..

1 commit

Author SHA1 Message Date
33b8a85204 feat(cooking): moteur d'optimisation des etapes de batch-cooking
Some checks failed
CI / test (push) Failing after 3s
CI / lint (push) Successful in 1m59s
CI / build (push) Successful in 2m1s
CI / e2e (push) Successful in 9m42s
CI / intent-service-test (push) Successful in 15m43s
Coeur pur du module "Calcul batch-cooking" (specs/batch-cooking-architecture.md,
jusqu'ici TODO) : optimizeCookingPlan() prend les recettes planifiees d'une
semaine et les reorganise en phases ordonnees.

- Mutualisation de la mise en place : une meme technique de decoupe (chop, peel,
  mince...) appliquee au meme ingredient par >= 2 recettes est regroupee en une
  seule tache "merged-prep" (les oignons de plusieurs recettes = une decoupe).
- Parallelisme : les cuissons passives (simmer, braise, bake, marinate...) sont
  poussees en tache de fond des phases suivantes pendant qu'une autre recette
  avance en actif.
- Fonction pure sans base (meme split matchXxx pur / loadXxx DB-backed que
  ingredient-matcher / tech-step-matcher), testable en isolation.

Types partages : OptimizedCookingPlanView + schema getCookingSessionSchema.
Tests Mocha purs (6 cas) : merge, non-merge d'une decoupe solo, tache de fond,
mise a l'echelle par portions, plan vide, legende.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-28 09:45:19 +02:00
20 changed files with 14 additions and 1302 deletions

View file

@ -54,16 +54,14 @@ jobs:
POSTGRES_USER: ci
POSTGRES_PASSWORD: ci
POSTGRES_DB: batchcooking_ci
ports:
- 5433:5432
options: >-
--health-cmd pg_isready
--health-interval 5s
--health-timeout 5s
--health-retries 10
steps:
- name: Install curl and dependencies
run: |
apt-get update && apt-get install -y curl
- uses: https://github.com/actions/checkout@v4
- uses: pnpm/action-setup@v4

View file

@ -6,7 +6,6 @@ import { env } from "./config/env.js";
import { errorLogger } from "./middlewares/error-logger.js";
import { requestLogger } from "./middlewares/request-logger.js";
import { authRouter } from "./modules/auth/auth.routes.js";
import { cookingSessionRouter } from "./modules/cooking-session/cooking-session.routes.js";
import { houseRouter } from "./modules/house/house.routes.js";
import { techStepWorkerRouter } from "./modules/internal/tech-step-worker.routes.js";
import { planningRouter } from "./modules/planning/planning.routes.js";
@ -53,7 +52,6 @@ export function createServer(): ExpressServer {
server.mountRouter("/recipes", recipeRouter);
server.mountRouter("/reference", referenceRouter);
server.mountRouter("/shopping-list", shoppingListRouter);
server.mountRouter("/cooking-session", cookingSessionRouter);
server.mountRouter("/sources", sourcesRouter);
// Serves the built frontend (production Docker image only — see

View file

@ -1,37 +0,0 @@
import { parseDateOnly } from "@batch-cooking/date-tools";
import { HttpError } from "@batch-cooking/error-tools";
import { wrapAsyncHandler } from "@batch-cooking/express-tools";
import { ErrorCode, getCookingSessionSchema } from "@batch-cooking/shared";
import { Router } from "express";
import { type AuthLocals, requireAuth } from "../../middlewares/require-auth.js";
import { getCookingPlanForDate } from "./cooking-session.service.js";
/** Router mounted at `/cooking-session` in app.ts. */
export const cookingSessionRouter = Router();
/**
* Returns the authenticated user's household's optimized cooking plan for
* the week covering `?date=` (`YYYY-MM-DD`) every recipe planned that
* week reorganized into ordered phases (see {@link getCookingPlanForDate}).
* Always `200`, never `null` no household or nothing planned that week
* both come back as a normal `OptimizedCookingPlanView` with empty
* `recipes`/`phases`. Same request contract as `GET /shopping-list`.
*/
cookingSessionRouter.get(
"/",
requireAuth,
wrapAsyncHandler<unknown, AuthLocals>(async (req, res) => {
const input = getCookingSessionSchema.parse(req.query);
const date = parseDateOnly(input.date);
if (date === null) {
throw new HttpError(
400,
ErrorCode.VALIDATION_ERROR,
`Not a real calendar date: ${input.date}`,
);
}
const plan = await getCookingPlanForDate(res.locals.userProfile.houseId, date);
res.status(200).json(plan);
}),
);

View file

@ -1,168 +0,0 @@
import { type DateTime, getWeekStart, toDateOnly } from "@batch-cooking/date-tools";
import type { CookingTaskIngredientView, OptimizedCookingPlanView } from "@batch-cooking/shared";
import type { Prisma } from "@prisma/client";
import { prisma } from "../../db/prisma.js";
import {
type OptimizerRecipeInput,
type OptimizerStepInput,
optimizeCookingPlan,
} from "../../lib/recipe-matching/cooking-optimizer.js";
import { toIngredientView, toUnitView } from "../recipe/recipe.service.js";
/**
* Prisma `include` for a `Planning` query that needs, for every item, its
* recipe's ordered steps with the full detected-technique tree the raw
* material the optimizer works on (see `cooking-optimizer.ts`). It's the
* `steps` sub-tree of `recipe.service.ts`'s own `recipeInclude`, resolved
* the same way so {@link toIngredientView}/{@link toUnitView} can be reused
* as-is; deliberately narrower than a full `RecipeView` fetch (no
* diets/favorites/recipe-level ingredient list the optimizer reads
* quantities off the technique clauses, not the recipe header).
*/
function cookingSessionPlanningInclude() {
return {
items: {
include: {
recipe: {
select: {
id: true,
name: true,
portions: true,
steps: {
orderBy: { order: "asc" },
include: {
techSteps: {
orderBy: { order: "asc" },
include: {
techStep: true,
ingredients: {
include: {
ingredient: {
include: {
allergies: { include: { allergy: { include: { category: true } } } },
diets: { include: { diet: true } },
},
},
unit: true,
},
},
utensils: { include: { utensil: true } },
},
},
},
},
},
},
},
},
} satisfies Prisma.PlanningInclude;
}
type PlanningWithSteps = Prisma.PlanningGetPayload<{
include: ReturnType<typeof cookingSessionPlanningInclude>;
}>;
type PlanningItemWithSteps = PlanningWithSteps["items"][number];
/**
* Maps one planning item's recipe (with {@link cookingSessionPlanningInclude})
* to the optimizer's pure input shape ingredient/unit/technique/utensil
* rows resolved to their reference views here so the optimizer itself never
* touches Prisma. `Decimal` quantities become plain numbers (same
* `Number(...)` conversion as `recipe.service.ts`'s own view mappers); an
* unresolved-unit line keeps `unit: null`.
*/
function toOptimizerRecipe(item: PlanningItemWithSteps): OptimizerRecipeInput {
const steps: OptimizerStepInput[] = item.recipe.steps.map((step) => ({
stepId: step.id,
order: step.order,
description: step.description,
techSteps: step.techSteps.map((techStep) => {
const ingredients: CookingTaskIngredientView[] = techStep.ingredients.map((line) => ({
ingredient: toIngredientView(line.ingredient),
quantity: line.quantity === null ? null : Number(line.quantity),
unit: line.unit === null ? null : toUnitView(line.unit),
}));
return {
techStep: { id: techStep.techStep.id, key: techStep.techStep.key },
order: techStep.order,
ingredients,
utensils: techStep.utensils.map(({ utensil }) => ({ id: utensil.id, key: utensil.key })),
};
}),
}));
return {
recipeId: item.recipe.id,
name: item.recipe.name,
// The slot's own portion count vs. the recipe's as-written yield — the
// optimizer scales technique-clause quantities by the ratio, same
// reasoning as `shopping-list.service.ts`'s `aggregateShoppingList`.
portions: item.portions,
recipePortions: item.recipe.portions,
steps,
};
}
/**
* Builds the household's optimized cooking plan for the week covering
* `date` every recipe planned that week, reorganized into ordered phases
* that pool shared prep and float passive cooks into the background (see
* `cooking-optimizer.ts`). `date` follows the same convention as
* `planning.service.ts`'s `getPlanningForDate` (a caller-parsed `?date=`,
* not necessarily a Monday).
*
* Like `getShoppingListForDate` and unlike `getPlanningForDate`, this
* **never** returns `null` no household and "no planning covers this week
* yet" both degrade to an empty `phases`/`recipes` on an otherwise normal
* {@link OptimizedCookingPlanView} (the week's date range is always
* computable from `date` alone).
*/
export async function getCookingPlanForDate(
houseId: number | null,
date: DateTime,
): Promise<OptimizedCookingPlanView> {
try {
const weekStart = getWeekStart(toDateOnly(date));
const weekFinish = weekStart.plus({ days: 6 });
const emptyPlan: OptimizedCookingPlanView = {
startDate: weekStart.toJSDate().toISOString(),
finishDate: weekFinish.toJSDate().toISOString(),
recipes: [],
phases: [],
};
if (houseId === null) {
return emptyPlan;
}
// Same "covering range" lookup as getShoppingListForDate — see
// getPlanningForDate's doc comment for the UTC-midnight `Date` rationale.
const dateOnly = toDateOnly(date).toJSDate();
const planning = await prisma.planning.findFirst({
where: {
houseId,
startDate: { lte: dateOnly },
finishDate: { gte: dateOnly },
},
orderBy: { startDate: "desc" },
include: cookingSessionPlanningInclude(),
});
if (!planning) {
return emptyPlan;
}
const { recipes, phases } = optimizeCookingPlan(planning.items.map(toOptimizerRecipe));
return {
startDate: planning.startDate.toISOString(),
finishDate: planning.finishDate.toISOString(),
recipes,
phases,
};
} catch (err) {
// Rethrown as-is — `wrapAsyncHandler`/the error middleware handles it,
// this service layer just isn't allowed a bare `await` per the repo's
// async/try-catch convention.
throw err;
}
}

View file

@ -1,204 +0,0 @@
import type { DateTime } from "@batch-cooking/date-tools";
import { ErrorCode, type SignupInput } from "@batch-cooking/shared";
import { faker } from "@faker-js/faker";
import { expect } from "chai";
import request from "supertest";
import { createApp } from "../src/app.js";
import { prisma } from "../src/db/prisma.js";
import { TEST_REFERENCE_DATE } from "../test-support/reference-date.js";
import { resetDatabase } from "../test-support/reset-db.js";
/** See `auth.test.ts` — same rationale for generating rather than hardcoding. */
function buildSignupPayload(): SignupInput {
const firstName = faker.person.firstName();
const lastName = faker.person.lastName();
return {
firstName,
lastName,
email: faker.internet.email({ firstName, lastName }).toLowerCase(),
password: faker.internet.password({ length: 16 }),
};
}
/** `toISODate()` only returns `null` for an invalid `DateTime` — never the always-valid values here. */
function isoDate(date: DateTime): string {
const iso = date.toISODate();
if (iso === null) throw new Error("Unexpectedly invalid DateTime in a test helper");
return iso;
}
/** The fixed test "today", as the `YYYY-MM-DD` string the `?date=` query expects. */
function today(): string {
return isoDate(TEST_REFERENCE_DATE);
}
/** Resolves a reference row's id by its `reference-seed-data.ts` uid (also its DB `key`) — same helpers as `recipe.test.ts`. */
async function ingredientId(key: string): Promise<number> {
return (await prisma.ingredient.findFirstOrThrow({ where: { key } })).id;
}
async function unitId(key: string): Promise<number> {
return (await prisma.unit.findFirstOrThrow({ where: { key } })).id;
}
async function techStepId(key: string): Promise<number> {
return (await prisma.techStep.findFirstOrThrow({ where: { key } })).id;
}
describe("Cooking session", () => {
const app = createApp();
beforeEach(async () => {
await resetDatabase();
});
after(async () => {
await prisma.$disconnect();
});
describe("GET /cooking-session", () => {
it("rejects requests without a session cookie with 401 NOT_AUTHENTICATED", async () => {
const res = await request(app).get("/cooking-session").query({ date: today() });
expect(res.status).to.equal(401);
expect(res.body.code).to.equal(ErrorCode.NOT_AUTHENTICATED);
});
it("rejects a malformed date with 400 VALIDATION_ERROR", async () => {
const agent = request.agent(app);
await agent.post("/auth/signup").send(buildSignupPayload());
const res = await agent.get("/cooking-session").query({ date: "not-a-date" });
expect(res.status).to.equal(400);
expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
});
it("returns an empty plan when the profile has no household", async () => {
const agent = request.agent(app);
await agent.post("/auth/signup").send(buildSignupPayload());
const res = await agent.get("/cooking-session").query({ date: today() });
expect(res.status).to.equal(200);
expect(res.body.recipes).to.deep.equal([]);
expect(res.body.phases).to.deep.equal([]);
});
it("returns an empty plan when no planning covers that week", async () => {
const agent = request.agent(app);
await agent.post("/auth/signup").send(buildSignupPayload());
await agent.post("/house").send({ name: "Chez moi" });
const res = await agent.get("/cooking-session").query({ date: today() });
expect(res.status).to.equal(200);
expect(res.body.phases).to.deep.equal([]);
});
it("pools an identical prep step from two planned recipes into one merged-prep task", async () => {
const agent = request.agent(app);
await agent.post("/auth/signup").send(buildSignupPayload());
const houseRes = await agent.post("/house").send({ name: "Chez moi" });
const houseId: number = houseRes.body.id;
const authorId: number = houseRes.body.adminId;
const onionId = await ingredientId("onion");
const pieceId = await unitId("piece");
const chopId = await techStepId("chop");
const simmerId = await techStepId("simmer");
/** A recipe: one pure-prep "chop onion" step, then one simmer step. */
async function makeRecipe(name: string, onionQty: number) {
return prisma.recipe.create({
data: {
name,
authorId,
portions: 4,
steps: {
create: [
{
order: 0,
description: "Émincer les oignons",
techSteps: {
create: [
{
techStepId: chopId,
order: 0,
ingredients: {
create: [
{
ingredientId: onionId,
quantity: onionQty,
unitId: pieceId,
start: 0,
end: 1,
},
],
},
},
],
},
},
{
order: 1,
description: "Faire mijoter",
techSteps: { create: [{ techStepId: simmerId, order: 0 }] },
},
],
},
},
});
}
const soupe = await makeRecipe("Soupe", 2);
const tarte = await makeRecipe("Tarte", 3);
const planning = await prisma.planning.create({
data: {
houseId,
startDate: TEST_REFERENCE_DATE.minus({ days: 2 }).toJSDate(),
finishDate: TEST_REFERENCE_DATE.plus({ days: 2 }).toJSDate(),
},
});
await prisma.planningItem.createMany({
data: [
{
planningId: planning.id,
weekDay: "lundi",
meal: "dejeuner",
recipeId: soupe.id,
portions: 4,
},
{
planningId: planning.id,
weekDay: "mardi",
meal: "diner",
recipeId: tarte.id,
portions: 4,
},
],
});
const res = await agent.get("/cooking-session").query({ date: today() });
expect(res.status).to.equal(200);
expect(res.body.recipes.map((r: { name: string }) => r.name)).to.have.members([
"Soupe",
"Tarte",
]);
const mise = res.body.phases[0];
expect(mise.kind).to.equal("mise-en-place");
const merged = mise.tasks.filter((t: { kind: string }) => t.kind === "merged-prep");
expect(merged).to.have.length(1);
expect(merged[0].technique.key).to.equal("chop");
expect(merged[0].ingredients[0].ingredient.key).to.equal("onion");
expect(merged[0].ingredients[0].quantity).to.equal(5);
expect(merged[0].sourceRecipes).to.have.length(2);
// The simmer steps land in a later phase, and one shows as background.
const later = res.body.phases.slice(1);
const backgrounds = later.flatMap((p: { background: unknown[] }) => p.background);
expect(backgrounds.length).to.be.greaterThan(0);
});
});
});

View file

@ -1,174 +0,0 @@
// Mocks the API via cy.intercept — this job doesn't run a live backend (see
// .github/workflows/ci.yml); apps/api's own Mocha suite covers real
// `GET /cooking-session` behavior (including the optimizer) against a real
// database.
const authenticatedProfile = {
id: 1,
firstName: "Alice",
lastName: "Martin",
email: "alice@example.com",
tokenVersion: 0,
houseId: 1,
dietId: null,
};
// 2026-08-17 is a Monday — frozen so "this week" is deterministic.
const TODAY = new Date("2026-08-17T09:00:00Z");
/** Bare `IngredientView` — only `key` drives the page's label lookup. */
function ingredient(key: string) {
return {
id: 1,
key,
icon: "VEGETABLE",
category: "freshProduce",
subcategory: "vegetables",
reproducible: false,
allergens: [],
diets: [],
};
}
/** A plan with a pooled prep task in mise-en-place and a passive cook floated into a later phase. */
function planFixture() {
return {
startDate: "2026-08-17T00:00:00.000Z",
finishDate: "2026-08-23T00:00:00.000Z",
recipes: [
{ recipeId: 1, name: "Soupe à l'oignon", portions: 4 },
{ recipeId: 2, name: "Tarte à l'oignon", portions: 4 },
],
phases: [
{
index: 0,
kind: "mise-en-place",
background: [],
tasks: [
{
id: "prep:chop:onion",
kind: "merged-prep",
technique: { id: 1, key: "chop" },
description: null,
ingredients: [
{
ingredient: ingredient("onion"),
quantity: 5,
unit: { id: 2, key: "piece", type: "COUNT", toBaseFactor: 1 },
},
],
utensils: [{ id: 1, key: "knife" }],
sourceRecipes: [
{ recipeId: 1, name: "Soupe à l'oignon", portions: 4 },
{ recipeId: 2, name: "Tarte à l'oignon", portions: 4 },
],
originalSteps: [],
},
],
},
{
index: 1,
kind: "cooking",
background: [],
tasks: [
{
id: "step:0:1",
kind: "step",
technique: { id: 5, key: "simmer" },
description: "Faire mijoter le bouillon",
ingredients: [],
utensils: [],
sourceRecipes: [{ recipeId: 1, name: "Soupe à l'oignon", portions: 4 }],
originalSteps: [],
},
],
},
{
index: 2,
kind: "cooking",
background: [
{
id: "bg:step:0:1",
technique: { id: 5, key: "simmer" },
description: "Faire mijoter le bouillon",
recipeId: 1,
recipeName: "Soupe à l'oignon",
},
],
tasks: [
{
id: "step:1:3",
kind: "step",
technique: { id: 4, key: "bake" },
description: "Enfourner la tarte",
ingredients: [],
utensils: [],
sourceRecipes: [{ recipeId: 2, name: "Tarte à l'oignon", portions: 4 }],
originalSteps: [],
},
],
},
],
};
}
describe("Cooking session page", () => {
beforeEach(() => {
cy.viewport(1400, 900);
cy.clock(TODAY, ["Date"]);
cy.intercept("GET", "**/auth/me", { statusCode: 200, body: authenticatedProfile });
});
it("shows the empty message when nothing is planned that week", () => {
cy.intercept("GET", /\/cooking-session\?/, {
statusCode: 200,
body: {
startDate: "2026-08-17T00:00:00.000Z",
finishDate: "2026-08-23T00:00:00.000Z",
recipes: [],
phases: [],
},
});
cy.visit("/cuisiner");
cy.contains("h1", "Cuisiner cette semaine").should("be.visible");
cy.contains("Rien de planifié cette semaine à cuisiner").should("be.visible");
});
it("renders each phase, the pooled prep task, and the 'meanwhile' band", () => {
cy.intercept("GET", /\/cooking-session\?/, { statusCode: 200, body: planFixture() }).as(
"getPlan",
);
cy.visit("/cuisiner?date=2026-08-17");
cy.wait("@getPlan").its("request.url").should("include", "date=2026-08-17");
// Mise en place: one pooled prep task, flagged shared, naming both recipes.
cy.contains(".cooking-phase", "Mise en place").should("be.visible");
cy.get(".cooking-task--merged-prep")
.should("contain.text", "Hacher")
.and("contain.text", "Oignon")
.and("contain.text", "Mutualisé");
cy.contains(".cooking-task--merged-prep", "Soupe à l'oignon").should("exist");
// A later phase shows the simmering soup as still running in the background.
cy.contains(".cooking-phase__background", "Pendant ce temps")
.should("contain.text", "Faire mijoter le bouillon")
.and("contain.text", "Soupe à l'oignon");
// Recipe legend is present.
cy.contains(".cooking-session__legend-item", "Tarte à l'oignon").should("be.visible");
});
it("shows an error state when the request fails", () => {
cy.intercept("GET", /\/cooking-session\?/, {
statusCode: 500,
body: { code: 5000, message: "boom" },
});
cy.visit("/cuisiner");
cy.contains("Impossible de charger").should("be.visible");
});
});

View file

@ -1,24 +0,0 @@
Feature: Start cooking an optimized plan
As a member of a household with a planned week
I want to open an optimized cooking plan from my planning
So that shared preparation is pooled and I cook the week efficiently
Background:
Given I am signed in as "Alice" "Martin"
And my household id is 1
And today is frozen at "2026-08-17T09:00:00.000Z"
Scenario: The "Commencer à cuisiner" button is disabled while the week is empty
Given the planning request returns nothing
When I visit "/"
Then the "Commencer à cuisiner" button should be disabled
Scenario: Opening the plan from the planning shows the pooled prep in mise en place
Given the planning for this week has recipes "Soupe à l'oignon" and "Tarte à l'oignon"
And the cooking plan for "2026-08-17" pools "Hacher" of "Oignon" across both recipes
When I visit "/"
And I click the button "Commencer à cuisiner"
Then the URL should include "/cuisiner"
And I should see "Mise en place"
And the pooled prep task should mention "Hacher" and "Oignon"
And the pooled prep task should be flagged as shared

View file

@ -1,101 +0,0 @@
import { Given, Then } from "@badeball/cypress-cucumber-preprocessor";
/** Bare `IngredientView` — only `key` drives the page's `catalog.ingredients.*` lookup. */
function ingredient(key: string) {
return {
id: 1,
key,
icon: "VEGETABLE",
category: "freshProduce",
subcategory: "vegetables",
reproducible: false,
allergens: [],
diets: [],
};
}
Given(
"the planning for this week has recipes {string} and {string}",
(first: string, second: string) => {
cy.intercept("GET", /\/planning\?/, {
statusCode: 200,
body: {
id: 1,
startDate: "2026-08-17T00:00:00.000Z",
finishDate: "2026-08-23T00:00:00.000Z",
items: [
{
id: 1,
weekDay: "lundi",
meal: "dejeuner",
portions: 4,
recipe: { id: 1, name: first },
},
{ id: 2, weekDay: "mardi", meal: "diner", portions: 4, recipe: { id: 2, name: second } },
],
},
});
},
);
Given(
"the cooking plan for {string} pools {string} of {string} across both recipes",
(date: string, _techniqueLabel: string, _ingredientLabel: string) => {
// The page composes the headline itself from the technique/ingredient
// *keys* via i18n — `chop`→"Hacher", `onion`→"Oignon" — so the fixture
// carries keys; the `Then` step checks the rendered French labels the
// feature line names.
cy.intercept("GET", `**/cooking-session?date=${date}`, {
statusCode: 200,
body: {
startDate: `${date}T00:00:00.000Z`,
finishDate: "2026-08-23T00:00:00.000Z",
recipes: [
{ recipeId: 1, name: "Soupe à l'oignon", portions: 4 },
{ recipeId: 2, name: "Tarte à l'oignon", portions: 4 },
],
phases: [
{
index: 0,
kind: "mise-en-place",
background: [],
tasks: [
{
id: "prep:chop:onion",
kind: "merged-prep",
technique: { id: 1, key: "chop" },
description: null,
ingredients: [
{
ingredient: ingredient("onion"),
quantity: 5,
unit: { id: 2, key: "piece", type: "COUNT", toBaseFactor: 1 },
},
],
utensils: [],
sourceRecipes: [
{ recipeId: 1, name: "Soupe à l'oignon", portions: 4 },
{ recipeId: 2, name: "Tarte à l'oignon", portions: 4 },
],
originalSteps: [],
},
],
},
],
},
});
},
);
Then(
"the pooled prep task should mention {string} and {string}",
(techniqueLabel: string, ingredientLabel: string) => {
cy.get(".cooking-task--merged-prep")
.should("contain.text", techniqueLabel)
.and("contain.text", ingredientLabel);
},
);
Then("the pooled prep task should be flagged as shared", () => {
cy.get(".cooking-task--merged-prep").contains("Mutualisé").should("be.visible");
});

View file

@ -111,43 +111,6 @@ describe("Planning grid", () => {
cy.contains("th.today .day-date", "17").should("be.visible");
});
it("disables 'Commencer à cuisiner' on an empty week and enables + navigates it once a recipe is planned", () => {
cy.intercept("GET", /\/planning\?/, { statusCode: 200, body: null });
cy.visit("/");
cy.contains("button", "Commencer à cuisiner").should("be.disabled");
cy.intercept("GET", /\/planning\?/, {
statusCode: 200,
body: {
id: 1,
startDate: "2026-08-17T00:00:00.000Z",
finishDate: "2026-08-23T00:00:00.000Z",
items: [
{
id: 1,
weekDay: "mardi",
meal: "diner",
portions: 4,
recipe: { id: 1, name: "Ratatouille" },
},
],
},
});
cy.intercept("GET", /\/cooking-session\?/, {
statusCode: 200,
body: {
startDate: "2026-08-17T00:00:00.000Z",
finishDate: "2026-08-23T00:00:00.000Z",
recipes: [],
phases: [],
},
});
cy.visit("/");
cy.contains("button", "Commencer à cuisiner").should("not.be.disabled").click();
cy.url().should("include", "/cuisiner");
cy.url().should("include", "date=2026-08-17");
});
it("shows a loading state, then an error state when the request fails", () => {
cy.intercept("GET", /\/planning\?/, {
statusCode: 500,

View file

@ -273,10 +273,6 @@ Then("the {string} button should not be disabled", (text: string) => {
cy.contains("button", text).should("not.be.disabled");
});
Then("the {string} button should be disabled", (text: string) => {
cy.contains("button", text).should("be.disabled");
});
When("I open the account menu", () => {
cy.get(".app-sidebar__account-toggle").click();
});

View file

@ -4,7 +4,6 @@ import { RequireAuth } from "./features/auth/RequireAuth";
import { AppLayout } from "./layouts/AppLayout";
import { LoginPage } from "./pages/auth/LoginPage";
import { SignupPage } from "./pages/auth/SignupPage";
import { CookingSessionPage } from "./pages/cooking-session/CookingSessionPage";
import { OnboardingAllergensPage } from "./pages/onboarding/OnboardingAllergensPage";
import { OnboardingDietPage } from "./pages/onboarding/OnboardingDietPage";
import { OnboardingHouseholdPage } from "./pages/onboarding/OnboardingHouseholdPage";
@ -66,7 +65,6 @@ export function App() {
<Route path="/recettes/:id" element={<RecipesPage />} />
<Route path="/recettes/:id/modifier" element={<RecipeFormPage />} />
<Route path="/liste-de-courses" element={<ShoppingListPage />} />
<Route path="/cuisiner" element={<CookingSessionPage />} />
<Route path="/parametres/compte" element={<AccountSettingsPage />} />
<Route path="/parametres/preferences" element={<PreferencesPage />} />
<Route path="/parametres/foyer" element={<HouseholdSettingsPage />} />

View file

@ -9,7 +9,6 @@ import {
type HouseView,
type IngredientView,
type LoginInput,
type OptimizedCookingPlanView,
type PlanningItemView,
type PlanningView,
type PreferencesView,
@ -178,19 +177,6 @@ export class ApiClient {
return this._request(`/shopping-list?date=${date}`);
}
/**
* Fetches the current user's household's optimized cooking plan for the
* week covering `date` (`YYYY-MM-DD`) every recipe planned that week
* reorganized into ordered phases (shared prep pooled, passive cooks
* floated into the background). Like {@link getShoppingListForWeek} and
* unlike {@link getPlanningForWeek}, never resolves to `null`: no
* household or nothing planned both come back as a normal plan with
* empty `recipes`/`phases`.
*/
public getCookingPlanForWeek(date: string): Promise<OptimizedCookingPlanView> {
return this._request(`/cooking-session?date=${date}`);
}
/** Reference list of dietary regimes to pick from (signup wizard, `/foyer`). Public — no session required. */
public getDiets(): Promise<DietView[]> {
return this._request("/reference/diets");

View file

@ -121,7 +121,6 @@
},
"planning": {
"title": "Planning de la semaine",
"startCooking": "Commencer à cuisiner",
"loading": "Chargement du planning…",
"meals": {
"petit-dejeuner": "Petit-déjeuner",
@ -311,28 +310,6 @@
"loading": "Chargement de la liste de courses…",
"empty": "Aucun ingrédient à acheter pour cette semaine — ajoutez des recettes à votre planning."
},
"cookingSession": {
"title": "Cuisiner cette semaine",
"subtitle": "Toutes les étapes de la semaine, regroupées et réordonnées pour cuisiner efficacement.",
"loading": "Optimisation du plan de cuisine…",
"empty": "Rien de planifié cette semaine à cuisiner — ajoutez des recettes à votre planning.",
"recipesLegend": "Recettes de la semaine",
"phase": {
"label": "Étape {{index}}",
"mise-en-place": "Mise en place",
"cooking": "Cuisson",
"finishing": "Dressage"
},
"background": {
"title": "Pendant ce temps"
},
"task": {
"mergedPrepLabel": "{{technique}} : {{items}}",
"forRecipes": "pour {{recipes}}",
"utensils": "Ustensiles",
"sharedBadge": "Mutualisé"
}
},
"account": {
"title": "Compte",
"identity": {

View file

@ -1,188 +0,0 @@
import { DateTime, formatDateOnly, getWeekStart, parseDateOnly } from "@batch-cooking/date-tools";
import type {
CookingBackgroundTaskView,
CookingPhaseView,
CookingTaskView,
OptimizedCookingPlanView,
} from "@batch-cooking/shared";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useSearchParams } from "react-router-dom";
import { apiClient } from "../../api/client";
import { WeekNavigator } from "../../features/planning/WeekNavigator";
import { taskHeadline, taskRecipeNames } from "./cooking-session";
import "./cooking-session-page.scss";
/** Load state for the `GET /cooking-session` call — same discriminated-union shape as `ShoppingListPage`'s own state. */
type CookingSessionState =
| { status: "loading" }
| { status: "loaded"; plan: OptimizedCookingPlanView }
| { status: "error" };
/**
* "Cuisiner cette semaine" routed at `/cuisiner`, reached from the
* planning page's "Commencer à cuisiner" button. Shows the household's week
* of planned recipes reorganized by the backend optimizer
* (`GET /cooking-session`, see the API's `cooking-optimizer.ts`) into
* ordered phases: a mise-en-place that pools shared prep, then cooking
* phases that interleave the recipes with passive cooks shown as running
* in the background.
*
* The week comes from a `?date=` query param (set by the planning button so
* the two pages stay on the same week); absent/invalid falls back to the
* current week. Read-only and recomputed on every visit no progress
* state to keep in sync, same design stance as `ShoppingListPage`.
*/
export function CookingSessionPage() {
const { t } = useTranslation();
const [searchParams] = useSearchParams();
const [weekStart, setWeekStart] = useState<DateTime>(() => {
const fromQuery = parseDateOnly(searchParams.get("date") ?? "");
return getWeekStart(fromQuery ?? DateTime.utc());
});
const [state, setState] = useState<CookingSessionState>({ status: "loading" });
useEffect(() => {
let cancelled = false;
setState({ status: "loading" });
apiClient
.getCookingPlanForWeek(formatDateOnly(weekStart))
.then((plan) => {
if (!cancelled) setState({ status: "loaded", plan });
})
.catch(() => {
if (!cancelled) setState({ status: "error" });
});
return () => {
cancelled = true;
};
}, [weekStart]);
return (
<div className="cooking-session-page">
<div className="cooking-session-page__header">
<h1>{t("cookingSession.title")}</h1>
<WeekNavigator weekStart={weekStart} onChangeWeek={setWeekStart} />
</div>
<p className="cooking-session-page__subtitle">{t("cookingSession.subtitle")}</p>
{state.status === "loading" && (
<p className="cooking-session-page__status">{t("cookingSession.loading")}</p>
)}
{state.status === "error" && (
<p className="cooking-session-page__status cooking-session-page__status--error">
{t("common.loadError")}
</p>
)}
{state.status === "loaded" && <CookingPlan plan={state.plan} />}
</div>
);
}
/** The plan body — the recipe legend then every phase, or the empty-week message. */
function CookingPlan({ plan }: { plan: OptimizedCookingPlanView }) {
const { t } = useTranslation();
if (plan.phases.length === 0) {
return <p className="cooking-session-page__status">{t("cookingSession.empty")}</p>;
}
return (
<div className="cooking-session">
<section className="cooking-session__legend" aria-label={t("cookingSession.recipesLegend")}>
{plan.recipes.map((recipe) => (
<span
key={`${recipe.recipeId}-${recipe.portions}`}
className="cooking-session__legend-item"
>
{recipe.name} · ×{recipe.portions}
</span>
))}
</section>
{plan.phases.map((phase) => (
<PhaseSection key={phase.index} phase={phase} />
))}
</div>
);
}
/** One phase: its background band (if any) then its task cards. */
function PhaseSection({ phase }: { phase: CookingPhaseView }) {
const { t } = useTranslation();
return (
<section className={`cooking-phase cooking-phase--${phase.kind}`}>
<h2 className="cooking-phase__title">
<span className="cooking-phase__index">
{t("cookingSession.phase.label", { index: phase.index + 1 })}
</span>
<span className="cooking-phase__kind">{t(`cookingSession.phase.${phase.kind}`)}</span>
</h2>
{phase.background.length > 0 && (
<div className="cooking-phase__background">
<span className="cooking-phase__background-title">
{t("cookingSession.background.title")}
</span>
<ul>
{phase.background.map((task) => (
<li key={task.id}>
<BackgroundLine task={task} />
</li>
))}
</ul>
</div>
)}
<ul className="cooking-phase__tasks">
{phase.tasks.map((task) => (
<li key={task.id}>
<TaskCard task={task} />
</li>
))}
</ul>
</section>
);
}
/** A single actionable task — a merged-prep pool or a plain recipe step. */
function TaskCard({ task }: { task: CookingTaskView }) {
const { t } = useTranslation();
return (
<article className={`cooking-task cooking-task--${task.kind}`}>
<p className="cooking-task__headline">
{taskHeadline(task, t)}
{task.kind === "merged-prep" && (
<span className="cooking-task__badge">{t("cookingSession.task.sharedBadge")}</span>
)}
</p>
<p className="cooking-task__recipes">
{t("cookingSession.task.forRecipes", { recipes: taskRecipeNames(task) })}
</p>
{task.utensils.length > 0 && (
<p className="cooking-task__utensils">
{t("cookingSession.task.utensils")} :{" "}
{task.utensils.map((utensil) => t(`catalog.utensils.${utensil.key}`)).join(", ")}
</p>
)}
</article>
);
}
/** One "meanwhile, X is cooking" line inside a phase's background band. */
function BackgroundLine({ task }: { task: CookingBackgroundTaskView }) {
const { t } = useTranslation();
const technique = task.technique ? `${t(`catalog.techSteps.${task.technique.key}`)}` : "";
return (
<span>
{technique}
{task.description} <em>({task.recipeName})</em>
</span>
);
}

View file

@ -1,164 +0,0 @@
// =============================================================================
// Styles specific to CookingSessionPage colocated next to
// CookingSessionPage.tsx since nothing else uses these classes. Same page
// shell/status conventions as shopping-list-page.scss
// (`__header`/`__status`); below it, a vertical stack of phase sections
// each holding a "meanwhile" band and a list of task cards.
// =============================================================================
.cooking-session-page {
height: 100%;
display: flex;
flex-direction: column;
&__header {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: var(--space-md);
margin-bottom: var(--space-sm);
}
&__subtitle {
flex-shrink: 0;
margin: 0 0 var(--space-lg);
color: var(--color-text-muted);
font-size: var(--font-size-md);
}
&__status {
color: var(--color-text-muted);
font-size: var(--font-size-md);
}
&__status--error {
color: var(--color-error);
}
}
// --- Scrollable plan body --------------------------------------------------
.cooking-session {
flex: 1;
min-height: 0;
overflow: auto;
display: flex;
flex-direction: column;
gap: var(--space-lg);
// Recipe legend one chip per planned recipe/portion pairing.
&__legend {
display: flex;
flex-wrap: wrap;
gap: var(--space-xs);
}
&__legend-item {
padding: 0.15rem var(--space-sm);
border-radius: var(--radius-pill);
background: var(--color-surface);
box-shadow: var(--shadow-sm);
font-size: var(--font-size-sm);
color: var(--color-text);
font-variant-numeric: tabular-nums;
}
}
// --- One phase ----------------------------------------------------------------
.cooking-phase {
display: flex;
flex-direction: column;
gap: var(--space-sm);
&__title {
display: flex;
align-items: baseline;
gap: var(--space-sm);
margin: 0;
font-size: var(--font-size-md);
}
&__index {
font-weight: 700;
color: var(--color-text);
}
&__kind {
font-size: var(--font-size-sm);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-text-muted);
}
// "Pendant ce temps" passive cooks still running from earlier phases.
&__background {
border-left: 3px solid var(--color-border);
padding: var(--space-xs) var(--space-md);
color: var(--color-text-muted);
font-size: var(--font-size-sm);
&-title {
display: block;
font-weight: 600;
margin-bottom: 0.15rem;
}
ul {
margin: 0;
padding-left: var(--space-md);
}
}
&__tasks {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
}
// --- One task card ----------------------------------------------------------
.cooking-task {
background: var(--color-surface);
border-radius: var(--radius-md);
box-shadow: var(--shadow-sm);
padding: var(--space-sm) var(--space-md);
// A pooled prep task is the headline feature of this page give it a
// subtle accent border so it stands out from plain recipe steps.
&--merged-prep {
border-left: 3px solid var(--color-accent);
}
&__headline {
margin: 0;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--space-xs);
font-weight: 600;
color: var(--color-text);
}
&__badge {
padding: 0.05rem var(--space-xs);
border-radius: var(--radius-pill);
background: var(--color-accent);
color: var(--color-surface);
font-size: var(--font-size-xs);
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.03em;
}
&__recipes,
&__utensils {
margin: 0.2rem 0 0;
font-size: var(--font-size-sm);
color: var(--color-text-muted);
}
}

View file

@ -1,56 +0,0 @@
import type { CookingTaskIngredientView, CookingTaskView } from "@batch-cooking/shared";
/**
* Minimal shape of `react-i18next`'s `t` just what this module needs.
* Passed in rather than importing `useTranslation` here so these helpers
* stay pure functions the page (and a unit test) can call without mounting
* i18next, the same "logic extracted from the .tsx" split as
* `shopping-list.ts`'s `groupShoppingListItems`.
*/
export type TranslateFn = (key: string, options?: Record<string, unknown>) => string;
/**
* Formats a task quantity for display French conventions, at most 2
* decimals so a scaled/pooled float never shows a trailing-digit artifact
* (`"149.99999999999997"`). Same rule as `shopping-list.ts`'s
* `formatShoppingListQuantity`.
*/
export function formatCookingQuantity(quantity: number): string {
return quantity.toLocaleString("fr-FR", { maximumFractionDigits: 2 });
}
/**
* One ingredient line as a human string `"3 oignon"`, `"200 g farine"`,
* or just `"sel"` when the source clause carried no measurable amount
* (`quantity`/`unit` both `null`, see {@link CookingTaskIngredientView}).
* Labels are resolved through the same `catalog.*` i18n keys as everywhere
* else.
*/
export function formatIngredientLine(line: CookingTaskIngredientView, t: TranslateFn): string {
const name = t(`catalog.ingredients.${line.ingredient.key}`);
if (line.quantity === null) return name;
const amount = formatCookingQuantity(line.quantity);
const unit = line.unit === null ? "" : `${t(`catalog.units.${line.unit.key}`)} `;
return `${amount} ${unit}${name}`.trim();
}
/**
* The headline shown on a task card:
* - `merged-prep` `"Émincer : 3 oignon, 200 g carotte"` (technique label +
* its pooled ingredient lines), built from the
* `cookingSession.task.mergedPrepLabel` template.
* - `step` the original recipe step text, verbatim.
*/
export function taskHeadline(task: CookingTaskView, t: TranslateFn): string {
if (task.kind === "step") return task.description ?? "";
const technique = task.technique
? t(`catalog.techSteps.${task.technique.key}`)
: t("cookingSession.phase.mise-en-place");
const items = task.ingredients.map((line) => formatIngredientLine(line, t)).join(", ");
return t("cookingSession.task.mergedPrepLabel", { technique, items });
}
/** Comma-joined names of the recipes a task belongs to — one for a `step`, several for a pooled `merged-prep`. */
export function taskRecipeNames(task: CookingTaskView): string {
return task.sourceRecipes.map((recipe) => recipe.name).join(", ");
}

View file

@ -8,7 +8,6 @@ import {
} from "@batch-cooking/shared";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { apiClient } from "../../api/client";
import { type PlanningSlot, RecipePickerDialog } from "../../features/planning/RecipePickerDialog";
import { WeekNavigator } from "../../features/planning/WeekNavigator";
@ -38,7 +37,6 @@ const BAND_END_MEALS: ReadonlySet<Meal> = new Set(["collation", "dejeuner", "gou
*/
export function PlanningPage() {
const { t } = useTranslation();
const navigate = useNavigate();
const [weekStart, setWeekStart] = useState<DateTime>(() => getWeekStart(DateTime.utc()));
const [state, setState] = useState<PlanningState>({ status: "loading" });
// The slot a `RecipePickerDialog` is currently open for — `null` means
@ -103,23 +101,11 @@ export function PlanningPage() {
}
}
// Enabled only once we know the week has at least one planned recipe —
// "cuisiner" an empty week would just land on the page's own empty state.
const hasPlannedRecipes = state.status === "loaded" && (state.planning?.items.length ?? 0) > 0;
return (
<div className="planning-page">
<div className="planning-page__header">
<h1>{t("planning.title")}</h1>
<WeekNavigator weekStart={weekStart} onChangeWeek={setWeekStart} />
<button
type="button"
className="planning-page__cook-btn"
disabled={!hasPlannedRecipes}
onClick={() => navigate(`/cuisiner?date=${formatDateOnly(weekStart)}`)}
>
{t("planning.startCooking")}
</button>
</div>
{state.status === "loading" && (

View file

@ -36,29 +36,6 @@
&__status--error {
color: var(--color-error);
}
// "Commencer à cuisiner" same solid-primary treatment as the recipe
// picker's confirm button (features/planning/recipe-picker-dialog.scss).
&__cook-btn {
background: var(--color-primary);
color: var(--color-surface);
border: none;
border-radius: var(--radius-base);
padding: var(--space-sm) var(--space-md);
font-family: var(--font-body);
font-size: var(--font-size-sm);
font-weight: 600;
cursor: pointer;
&:hover {
background: var(--color-primary-hover);
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
}
}
// --- The grid itself --------------------------------------------------------

View file

@ -296,57 +296,6 @@ telles quelles par typage structurel.
---
## Cooking session — optimisation des étapes planifiées
Router `/cooking-session` (`cooking-session.routes.ts`/`.service.ts`),
`requireAuth` — un seul endpoint : `GET /cooking-session?date=YYYY-MM-DD`
`getCookingPlanForDate``OptimizedCookingPlanView`. Même contrat `?date=`
que `GET /shopping-list` (schéma shape-only + `parseDateOnly`), même requête
"plage couvrante" que `getPlanningForDate`, et **jamais `null`** de la même
façon que la liste de courses : pas de foyer / aucun `Planning` couvrant la
semaine ⇒ `recipes: []`, `phases: []`.
C'est la première brique du module « Calcul batch-cooking »
([batch-cooking-architecture.md](./batch-cooking-architecture.md)),
jusqu'ici `TODO`. Le service ne fait que **charger + façonner** : sa requête
Prisma (`cookingSessionPlanningInclude`) reprend le sous-arbre `steps` de
`recipe.service.ts`'s `recipeInclude` (steps ordonnés → `StepTechStep`
ordonnés → `techStep` + `ingredients` résolus + `utensils`), puis
`toOptimizerRecipe` mappe chaque `PlanningItem` vers l'entrée pure de
l'optimiseur (ingrédients/unités/techniques/ustensiles déjà en vues de
référence via `toIngredientView`/`toUnitView` réutilisées — même raison que
`shopping-list.service.ts`). Un `PlanningItem` = une entrée d'optimiseur,
même si deux créneaux pointent la même recette à des portions différentes
(deux vraies préparations ; la mutualisation de la découpe les regroupe
quand même).
**`optimizeCookingPlan`** (`lib/recipe-matching/cooking-optimizer.ts`,
pure/synchrone — testable sans base, même split que `ingredient-matcher.ts`
/ `tech-step-matcher.ts`) réorganise les recettes en **phases ordonnées** :
- **Mutualisation de la mise en place** : une technique de découpe
(`PREP_TECHNIQUES` : `chop`/`peel`/`mince`/`julienne`/…) appliquée au même
ingrédient dans une étape *purement prep* (toutes ses techniques sont des
`PREP_TECHNIQUES`) de **≥ 2 recettes** est regroupée en une tâche
`merged-prep` ; les étapes d'origine sont *absorbées* (ne produisent plus
de tâche). Une découpe présente dans une seule recette reste inline (juste
classée en mise en place). Les découpes à l'intérieur d'une étape de
cuisson ne sont pas mutualisées en v1.
- **Parallélisme** : `TECHNIQUE_ATTENTION` classe chaque étape en `SETUP`
(préchauffage, eau à ébullition — poussé en mise en place), `PASSIVE`
(mijoter, braiser, cuire au four, mariner… — non surveillé une fois
lancé) ou `ACTIVE` (défaut). Les phases de cuisson interclassent les
recettes (une « prochaine étape de chaque recette » par phase) ; une
étape `PASSIVE` fait patienter sa recette une phase et s'affiche en
`background` des phases suivantes tant que sa consommatrice n'est pas
remontée.
Quantités mises à l'échelle par `PlanningItem.portions / Recipe.portions`
comme la liste de courses. Sommes d'ingrédients uniquement à unité
identique (aucune conversion — même posture que `ShoppingListItemView`).
---
## `reference` — catalogues publics (pas de session requise)
Router `/reference` (`reference.routes.ts`/`.service.ts`) — **toutes les

View file

@ -16,7 +16,7 @@ L'application repose sur une architecture **client-serveur** classique :
flowchart TB
subgraph SERVER["Server"]
API["API (REST)"]
CALC["Calcul batch-cooking<br/><i>v1 implémentée (GET /cooking-session)</i>"]
CALC["Calcul batch-cooking<br/><i>(TODO)</i>"]
IMPORT["Import d'une recette<br/><i>implémenté</i>"]
IMP1["Import depuis source<br/>(RecipeSourceAdapter)"]
IMP2["Traduction en étapes<br/>(ingrédients + techniques)"]
@ -38,8 +38,8 @@ flowchart TB
```
*(Le canal websocket envisagé dans la conception d'origine pour le calcul
batch-cooking temps réel n'existe pas encore — le module v1 est un `GET`
recalculé à chaque visite, pas de temps réel ; voir la note plus bas.)*
batch-cooking temps réel n'existe pas encore — rien à documenter tant que ce
module reste TODO ; voir la note plus bas.)*
---
@ -49,7 +49,7 @@ recalculé à chaque visite, pas de temps réel ; voir la note plus bas.)*
Point d'entrée principal pour les échanges entre les clients et le serveur — REST classique, `requireAuth` (cookie JWT httpOnly) sur toute route qui n'est pas une donnée de référence publique. Détail complet des modules : [backend-architecture.md](./backend-architecture.md).
### Module « Calcul batch-cooking »
Logique de calcul du batch-cooking (optimisation des recettes entre elles selon le planning). **Statut : v1 implémentée**`GET /cooking-session?date=``optimizeCookingPlan` (`apps/api/src/lib/recipe-matching/cooking-optimizer.ts`, pur) réorganise les recettes d'une semaine planifiée en **phases ordonnées** : une « mise en place » qui mutualise la découpe commune (même technique de découpe + même ingrédient dans une étape purement prep de ≥ 2 recettes = une seule tâche), puis des phases de cuisson qui interclassent les recettes en poussant les cuissons passives (mijotage, four…) en tâche de fond. v1 hors périmètre : fusion de cuissons, durées estimées, persistance/progression, canal websocket. Détail : [backend-architecture.md](./backend-architecture.md#cooking-session--optimisation-des-étapes-planifiées).
Logique de calcul du batch-cooking (optimisation du planning/des recettes selon le planning). **Statut : TODO — reste à développer**, avec `packages/shared`'s `assertIsNever` déjà en place comme outil prêt à l'emploi pour ce futur module (voir [backend-architecture.md](./backend-architecture.md#packagesshared--assertisnever)).
### Module « Import d'une recette »
**Statut : implémenté.** Pipeline en trois étapes, comme prévu à la conception :
@ -91,15 +91,15 @@ détectées, favoris, visibilité des recettes).
[backend-architecture.md](./backend-architecture.md#liste-de-courses--agrégation-des-ingrédients-planifiés))
— une simple **agrégation** des ingrédients déjà planifiés (somme par
ingrédient/unité, mise à l'échelle par les portions de chaque créneau),
pas une optimisation. Le module « Calcul batch-cooking », lui, optimise les
recettes **entre elles** (mutualiser une préparation commune, paralléliser
les cuissons passives) — **v1 implémentée** via `GET /cooking-session`
(voir [backend-architecture.md](./backend-architecture.md#cooking-session--optimisation-des-étapes-planifiées)).
pas une optimisation. Le module « Calcul batch-cooking » lui-même reste
`TODO` : il désigne quelque chose de plus ambitieux qu'une somme
d'ingrédients — optimiser le planning/les recettes entre elles (ex.
mutualiser une préparation entre plusieurs recettes de la semaine), pas
encore défini plus précisément. C'est le principal chantier restant côté
serveur.
- Le canal websocket envisagé pour la communication temps réel n'a pas encore
été construit — rien ne le remplace aujourd'hui (pas de polling). Le calcul
batch-cooking v1 est un simple `GET` recalculé à chaque visite (comme la
liste de courses), pas de temps réel ; à reconsidérer si une session de
cuisine partagée/synchronisée est ajoutée.
été construit — rien ne le remplace aujourd'hui (pas de polling), à
reconsidérer au moment d'attaquer le calcul batch-cooking.
---