feat(cooking): page /cuisiner + bouton "Commencer a cuisiner"
- Bouton "Commencer a cuisiner" dans l'en-tete du planning, actif seulement quand la semaine affichee contient >= 1 recette ; navigue vers /cuisiner?date=<semaine>. - Page CookingSessionPage (/cuisiner) : consomme GET /cooking-session, rend les phases (mise en place / cuisson / dressage), les taches mutualisees avec badge "Mutualise" + ingredients/ustensiles resolus, et la bande "Pendant ce temps" pour les cuissons de fond. Semaine lue depuis ?date=, WeekNavigator en fallback ; recalcul a chaque visite (comme la liste de courses). - Logique pure extraite dans cooking-session.ts (composition des libelles, formatage des quantites). - i18n : bloc cookingSession.* + planning.startCooking. - apiClient.getCookingPlanForWeek. - Tests Cypress : cooking-session-page.cy.ts (layout, 3 cas), cooking-session .feature (parcours planning -> /cuisiner), assertions bouton dans planning-page.cy.ts ; step generique "button should be disabled" mutualise. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
6b60c11408
commit
e8ac2d1629
13 changed files with 824 additions and 0 deletions
174
apps/web/cypress/e2e/cooking-session-page.cy.ts
Normal file
174
apps/web/cypress/e2e/cooking-session-page.cy.ts
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
// 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");
|
||||
});
|
||||
});
|
||||
24
apps/web/cypress/e2e/cooking-session.feature
Normal file
24
apps/web/cypress/e2e/cooking-session.feature
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
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
|
||||
101
apps/web/cypress/e2e/cooking-session.ts
Normal file
101
apps/web/cypress/e2e/cooking-session.ts
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
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,6 +111,43 @@ 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,
|
||||
|
|
|
|||
|
|
@ -273,6 +273,10 @@ 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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ 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";
|
||||
|
|
@ -65,6 +66,7 @@ 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 />} />
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
type HouseView,
|
||||
type IngredientView,
|
||||
type LoginInput,
|
||||
type OptimizedCookingPlanView,
|
||||
type PlanningItemView,
|
||||
type PlanningView,
|
||||
type PreferencesView,
|
||||
|
|
@ -177,6 +178,19 @@ 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");
|
||||
|
|
|
|||
|
|
@ -121,6 +121,7 @@
|
|||
},
|
||||
"planning": {
|
||||
"title": "Planning de la semaine",
|
||||
"startCooking": "Commencer à cuisiner",
|
||||
"loading": "Chargement du planning…",
|
||||
"meals": {
|
||||
"petit-dejeuner": "Petit-déjeuner",
|
||||
|
|
@ -310,6 +311,28 @@
|
|||
"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": {
|
||||
|
|
|
|||
188
apps/web/src/pages/cooking-session/CookingSessionPage.tsx
Normal file
188
apps/web/src/pages/cooking-session/CookingSessionPage.tsx
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
164
apps/web/src/pages/cooking-session/cooking-session-page.scss
Normal file
164
apps/web/src/pages/cooking-session/cooking-session-page.scss
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
// =============================================================================
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
56
apps/web/src/pages/cooking-session/cooking-session.ts
Normal file
56
apps/web/src/pages/cooking-session/cooking-session.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
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,6 +8,7 @@ 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";
|
||||
|
|
@ -37,6 +38,7 @@ 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
|
||||
|
|
@ -101,11 +103,23 @@ 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" && (
|
||||
|
|
|
|||
|
|
@ -36,6 +36,29 @@
|
|||
&__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 --------------------------------------------------------
|
||||
|
|
|
|||
Loading…
Reference in a new issue