Compare commits
1 commit
main
...
feat/cooki
| Author | SHA1 | Date | |
|---|---|---|---|
| 33b8a85204 |
20 changed files with 14 additions and 1302 deletions
6
.github/workflows/ci.yml
vendored
6
.github/workflows/ci.yml
vendored
|
|
@ -54,16 +54,14 @@ jobs:
|
||||||
POSTGRES_USER: ci
|
POSTGRES_USER: ci
|
||||||
POSTGRES_PASSWORD: ci
|
POSTGRES_PASSWORD: ci
|
||||||
POSTGRES_DB: batchcooking_ci
|
POSTGRES_DB: batchcooking_ci
|
||||||
|
ports:
|
||||||
|
- 5433:5432
|
||||||
options: >-
|
options: >-
|
||||||
--health-cmd pg_isready
|
--health-cmd pg_isready
|
||||||
--health-interval 5s
|
--health-interval 5s
|
||||||
--health-timeout 5s
|
--health-timeout 5s
|
||||||
--health-retries 10
|
--health-retries 10
|
||||||
steps:
|
steps:
|
||||||
- name: Install curl and dependencies
|
|
||||||
run: |
|
|
||||||
apt-get update && apt-get install -y curl
|
|
||||||
|
|
||||||
- uses: https://github.com/actions/checkout@v4
|
- uses: https://github.com/actions/checkout@v4
|
||||||
|
|
||||||
- uses: pnpm/action-setup@v4
|
- uses: pnpm/action-setup@v4
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@ import { env } from "./config/env.js";
|
||||||
import { errorLogger } from "./middlewares/error-logger.js";
|
import { errorLogger } from "./middlewares/error-logger.js";
|
||||||
import { requestLogger } from "./middlewares/request-logger.js";
|
import { requestLogger } from "./middlewares/request-logger.js";
|
||||||
import { authRouter } from "./modules/auth/auth.routes.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 { houseRouter } from "./modules/house/house.routes.js";
|
||||||
import { techStepWorkerRouter } from "./modules/internal/tech-step-worker.routes.js";
|
import { techStepWorkerRouter } from "./modules/internal/tech-step-worker.routes.js";
|
||||||
import { planningRouter } from "./modules/planning/planning.routes.js";
|
import { planningRouter } from "./modules/planning/planning.routes.js";
|
||||||
|
|
@ -53,7 +52,6 @@ export function createServer(): ExpressServer {
|
||||||
server.mountRouter("/recipes", recipeRouter);
|
server.mountRouter("/recipes", recipeRouter);
|
||||||
server.mountRouter("/reference", referenceRouter);
|
server.mountRouter("/reference", referenceRouter);
|
||||||
server.mountRouter("/shopping-list", shoppingListRouter);
|
server.mountRouter("/shopping-list", shoppingListRouter);
|
||||||
server.mountRouter("/cooking-session", cookingSessionRouter);
|
|
||||||
server.mountRouter("/sources", sourcesRouter);
|
server.mountRouter("/sources", sourcesRouter);
|
||||||
|
|
||||||
// Serves the built frontend (production Docker image only — see
|
// Serves the built frontend (production Docker image only — see
|
||||||
|
|
|
||||||
|
|
@ -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);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -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");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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");
|
|
||||||
});
|
|
||||||
|
|
@ -111,43 +111,6 @@ describe("Planning grid", () => {
|
||||||
cy.contains("th.today .day-date", "17").should("be.visible");
|
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", () => {
|
it("shows a loading state, then an error state when the request fails", () => {
|
||||||
cy.intercept("GET", /\/planning\?/, {
|
cy.intercept("GET", /\/planning\?/, {
|
||||||
statusCode: 500,
|
statusCode: 500,
|
||||||
|
|
|
||||||
|
|
@ -273,10 +273,6 @@ Then("the {string} button should not be disabled", (text: string) => {
|
||||||
cy.contains("button", text).should("not.be.disabled");
|
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", () => {
|
When("I open the account menu", () => {
|
||||||
cy.get(".app-sidebar__account-toggle").click();
|
cy.get(".app-sidebar__account-toggle").click();
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ import { RequireAuth } from "./features/auth/RequireAuth";
|
||||||
import { AppLayout } from "./layouts/AppLayout";
|
import { AppLayout } from "./layouts/AppLayout";
|
||||||
import { LoginPage } from "./pages/auth/LoginPage";
|
import { LoginPage } from "./pages/auth/LoginPage";
|
||||||
import { SignupPage } from "./pages/auth/SignupPage";
|
import { SignupPage } from "./pages/auth/SignupPage";
|
||||||
import { CookingSessionPage } from "./pages/cooking-session/CookingSessionPage";
|
|
||||||
import { OnboardingAllergensPage } from "./pages/onboarding/OnboardingAllergensPage";
|
import { OnboardingAllergensPage } from "./pages/onboarding/OnboardingAllergensPage";
|
||||||
import { OnboardingDietPage } from "./pages/onboarding/OnboardingDietPage";
|
import { OnboardingDietPage } from "./pages/onboarding/OnboardingDietPage";
|
||||||
import { OnboardingHouseholdPage } from "./pages/onboarding/OnboardingHouseholdPage";
|
import { OnboardingHouseholdPage } from "./pages/onboarding/OnboardingHouseholdPage";
|
||||||
|
|
@ -66,7 +65,6 @@ export function App() {
|
||||||
<Route path="/recettes/:id" element={<RecipesPage />} />
|
<Route path="/recettes/:id" element={<RecipesPage />} />
|
||||||
<Route path="/recettes/:id/modifier" element={<RecipeFormPage />} />
|
<Route path="/recettes/:id/modifier" element={<RecipeFormPage />} />
|
||||||
<Route path="/liste-de-courses" element={<ShoppingListPage />} />
|
<Route path="/liste-de-courses" element={<ShoppingListPage />} />
|
||||||
<Route path="/cuisiner" element={<CookingSessionPage />} />
|
|
||||||
<Route path="/parametres/compte" element={<AccountSettingsPage />} />
|
<Route path="/parametres/compte" element={<AccountSettingsPage />} />
|
||||||
<Route path="/parametres/preferences" element={<PreferencesPage />} />
|
<Route path="/parametres/preferences" element={<PreferencesPage />} />
|
||||||
<Route path="/parametres/foyer" element={<HouseholdSettingsPage />} />
|
<Route path="/parametres/foyer" element={<HouseholdSettingsPage />} />
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,6 @@ import {
|
||||||
type HouseView,
|
type HouseView,
|
||||||
type IngredientView,
|
type IngredientView,
|
||||||
type LoginInput,
|
type LoginInput,
|
||||||
type OptimizedCookingPlanView,
|
|
||||||
type PlanningItemView,
|
type PlanningItemView,
|
||||||
type PlanningView,
|
type PlanningView,
|
||||||
type PreferencesView,
|
type PreferencesView,
|
||||||
|
|
@ -178,19 +177,6 @@ export class ApiClient {
|
||||||
return this._request(`/shopping-list?date=${date}`);
|
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. */
|
/** Reference list of dietary regimes to pick from (signup wizard, `/foyer`). Public — no session required. */
|
||||||
public getDiets(): Promise<DietView[]> {
|
public getDiets(): Promise<DietView[]> {
|
||||||
return this._request("/reference/diets");
|
return this._request("/reference/diets");
|
||||||
|
|
|
||||||
|
|
@ -121,7 +121,6 @@
|
||||||
},
|
},
|
||||||
"planning": {
|
"planning": {
|
||||||
"title": "Planning de la semaine",
|
"title": "Planning de la semaine",
|
||||||
"startCooking": "Commencer à cuisiner",
|
|
||||||
"loading": "Chargement du planning…",
|
"loading": "Chargement du planning…",
|
||||||
"meals": {
|
"meals": {
|
||||||
"petit-dejeuner": "Petit-déjeuner",
|
"petit-dejeuner": "Petit-déjeuner",
|
||||||
|
|
@ -311,28 +310,6 @@
|
||||||
"loading": "Chargement de la liste de courses…",
|
"loading": "Chargement de la liste de courses…",
|
||||||
"empty": "Aucun ingrédient à acheter pour cette semaine — ajoutez des recettes à votre planning."
|
"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": {
|
"account": {
|
||||||
"title": "Compte",
|
"title": "Compte",
|
||||||
"identity": {
|
"identity": {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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(", ");
|
|
||||||
}
|
|
||||||
|
|
@ -8,7 +8,6 @@ import {
|
||||||
} from "@batch-cooking/shared";
|
} from "@batch-cooking/shared";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useNavigate } from "react-router-dom";
|
|
||||||
import { apiClient } from "../../api/client";
|
import { apiClient } from "../../api/client";
|
||||||
import { type PlanningSlot, RecipePickerDialog } from "../../features/planning/RecipePickerDialog";
|
import { type PlanningSlot, RecipePickerDialog } from "../../features/planning/RecipePickerDialog";
|
||||||
import { WeekNavigator } from "../../features/planning/WeekNavigator";
|
import { WeekNavigator } from "../../features/planning/WeekNavigator";
|
||||||
|
|
@ -38,7 +37,6 @@ const BAND_END_MEALS: ReadonlySet<Meal> = new Set(["collation", "dejeuner", "gou
|
||||||
*/
|
*/
|
||||||
export function PlanningPage() {
|
export function PlanningPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const navigate = useNavigate();
|
|
||||||
const [weekStart, setWeekStart] = useState<DateTime>(() => getWeekStart(DateTime.utc()));
|
const [weekStart, setWeekStart] = useState<DateTime>(() => getWeekStart(DateTime.utc()));
|
||||||
const [state, setState] = useState<PlanningState>({ status: "loading" });
|
const [state, setState] = useState<PlanningState>({ status: "loading" });
|
||||||
// The slot a `RecipePickerDialog` is currently open for — `null` means
|
// 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 (
|
return (
|
||||||
<div className="planning-page">
|
<div className="planning-page">
|
||||||
<div className="planning-page__header">
|
<div className="planning-page__header">
|
||||||
<h1>{t("planning.title")}</h1>
|
<h1>{t("planning.title")}</h1>
|
||||||
<WeekNavigator weekStart={weekStart} onChangeWeek={setWeekStart} />
|
<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>
|
</div>
|
||||||
|
|
||||||
{state.status === "loading" && (
|
{state.status === "loading" && (
|
||||||
|
|
|
||||||
|
|
@ -36,29 +36,6 @@
|
||||||
&__status--error {
|
&__status--error {
|
||||||
color: var(--color-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 --------------------------------------------------------
|
// --- The grid itself --------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -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)
|
## `reference` — catalogues publics (pas de session requise)
|
||||||
|
|
||||||
Router `/reference` (`reference.routes.ts`/`.service.ts`) — **toutes les
|
Router `/reference` (`reference.routes.ts`/`.service.ts`) — **toutes les
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ L'application repose sur une architecture **client-serveur** classique :
|
||||||
flowchart TB
|
flowchart TB
|
||||||
subgraph SERVER["Server"]
|
subgraph SERVER["Server"]
|
||||||
API["API (REST)"]
|
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>"]
|
IMPORT["Import d'une recette<br/><i>implémenté</i>"]
|
||||||
IMP1["Import depuis source<br/>(RecipeSourceAdapter)"]
|
IMP1["Import depuis source<br/>(RecipeSourceAdapter)"]
|
||||||
IMP2["Traduction en étapes<br/>(ingrédients + techniques)"]
|
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
|
*(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`
|
batch-cooking temps réel n'existe pas encore — rien à documenter tant que ce
|
||||||
recalculé à chaque visite, pas de temps réel ; voir la note plus bas.)*
|
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).
|
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 »
|
### 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 »
|
### Module « Import d'une recette »
|
||||||
**Statut : implémenté.** Pipeline en trois étapes, comme prévu à la conception :
|
**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))
|
[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
|
— 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),
|
ingrédient/unité, mise à l'échelle par les portions de chaque créneau),
|
||||||
pas une optimisation. Le module « Calcul batch-cooking », lui, optimise les
|
pas une optimisation. Le module « Calcul batch-cooking » lui-même reste
|
||||||
recettes **entre elles** (mutualiser une préparation commune, paralléliser
|
`TODO` : il désigne quelque chose de plus ambitieux qu'une somme
|
||||||
les cuissons passives) — **v1 implémentée** via `GET /cooking-session`
|
d'ingrédients — optimiser le planning/les recettes entre elles (ex.
|
||||||
(voir [backend-architecture.md](./backend-architecture.md#cooking-session--optimisation-des-étapes-planifiées)).
|
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
|
- 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
|
été construit — rien ne le remplace aujourd'hui (pas de polling), à
|
||||||
batch-cooking v1 est un simple `GET` recalculé à chaque visite (comme la
|
reconsidérer au moment d'attaquer le calcul batch-cooking.
|
||||||
liste de courses), pas de temps réel ; à reconsidérer si une session de
|
|
||||||
cuisine partagée/synchronisée est ajoutée.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue