feat(cooking): page /cuisiner + bouton "Commencer a cuisiner"
Some checks failed
CI / lint (push) Successful in 3m1s
CI / test (push) Failing after 3s
CI / build (push) Successful in 4m24s
CI / intent-service-test (push) Successful in 18m40s
CI / e2e (push) Successful in 13m15s

- 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:
Nicolas 2026-08-28 09:09:32 +02:00
parent 6b60c11408
commit e8ac2d1629
13 changed files with 824 additions and 0 deletions

View 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");
});
});

View 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

View 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");
});

View file

@ -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,

View file

@ -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();
});

View file

@ -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 />} />

View file

@ -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");

View file

@ -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": {

View 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>
);
}

View 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);
}
}

View 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(", ");
}

View file

@ -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" && (

View file

@ -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 --------------------------------------------------------