From e8ac2d16291228db0cb8b8086cd81acc855c8ef3 Mon Sep 17 00:00:00 2001 From: Nicolas Date: Fri, 28 Aug 2026 09:09:32 +0200 Subject: [PATCH] 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=. - 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 --- .../cypress/e2e/cooking-session-page.cy.ts | 174 ++++++++++++++++ apps/web/cypress/e2e/cooking-session.feature | 24 +++ apps/web/cypress/e2e/cooking-session.ts | 101 ++++++++++ apps/web/cypress/e2e/planning-page.cy.ts | 37 ++++ .../support/step_definitions/common.steps.ts | 4 + apps/web/src/App.tsx | 2 + apps/web/src/api/client.ts | 14 ++ apps/web/src/locales/fr/translation.json | 23 +++ .../cooking-session/CookingSessionPage.tsx | 188 ++++++++++++++++++ .../cooking-session/cooking-session-page.scss | 164 +++++++++++++++ .../pages/cooking-session/cooking-session.ts | 56 ++++++ apps/web/src/pages/planning/PlanningPage.tsx | 14 ++ .../web/src/pages/planning/planning-page.scss | 23 +++ 13 files changed, 824 insertions(+) create mode 100644 apps/web/cypress/e2e/cooking-session-page.cy.ts create mode 100644 apps/web/cypress/e2e/cooking-session.feature create mode 100644 apps/web/cypress/e2e/cooking-session.ts create mode 100644 apps/web/src/pages/cooking-session/CookingSessionPage.tsx create mode 100644 apps/web/src/pages/cooking-session/cooking-session-page.scss create mode 100644 apps/web/src/pages/cooking-session/cooking-session.ts diff --git a/apps/web/cypress/e2e/cooking-session-page.cy.ts b/apps/web/cypress/e2e/cooking-session-page.cy.ts new file mode 100644 index 0000000..0c2580f --- /dev/null +++ b/apps/web/cypress/e2e/cooking-session-page.cy.ts @@ -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"); + }); +}); diff --git a/apps/web/cypress/e2e/cooking-session.feature b/apps/web/cypress/e2e/cooking-session.feature new file mode 100644 index 0000000..e7f3b67 --- /dev/null +++ b/apps/web/cypress/e2e/cooking-session.feature @@ -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 diff --git a/apps/web/cypress/e2e/cooking-session.ts b/apps/web/cypress/e2e/cooking-session.ts new file mode 100644 index 0000000..c249fab --- /dev/null +++ b/apps/web/cypress/e2e/cooking-session.ts @@ -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"); +}); diff --git a/apps/web/cypress/e2e/planning-page.cy.ts b/apps/web/cypress/e2e/planning-page.cy.ts index 3b4744d..7770ea9 100644 --- a/apps/web/cypress/e2e/planning-page.cy.ts +++ b/apps/web/cypress/e2e/planning-page.cy.ts @@ -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, diff --git a/apps/web/cypress/support/step_definitions/common.steps.ts b/apps/web/cypress/support/step_definitions/common.steps.ts index 8084ff2..c74aac9 100644 --- a/apps/web/cypress/support/step_definitions/common.steps.ts +++ b/apps/web/cypress/support/step_definitions/common.steps.ts @@ -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(); }); diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index d3c44ed..d318330 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -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() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts index ac97e1b..4ababb9 100644 --- a/apps/web/src/api/client.ts +++ b/apps/web/src/api/client.ts @@ -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 { + 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 { return this._request("/reference/diets"); diff --git a/apps/web/src/locales/fr/translation.json b/apps/web/src/locales/fr/translation.json index f19bbbf..4de04be 100644 --- a/apps/web/src/locales/fr/translation.json +++ b/apps/web/src/locales/fr/translation.json @@ -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": { diff --git a/apps/web/src/pages/cooking-session/CookingSessionPage.tsx b/apps/web/src/pages/cooking-session/CookingSessionPage.tsx new file mode 100644 index 0000000..ef63ee9 --- /dev/null +++ b/apps/web/src/pages/cooking-session/CookingSessionPage.tsx @@ -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(() => { + const fromQuery = parseDateOnly(searchParams.get("date") ?? ""); + return getWeekStart(fromQuery ?? DateTime.utc()); + }); + const [state, setState] = useState({ 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 ( +
+
+

{t("cookingSession.title")}

+ +
+

{t("cookingSession.subtitle")}

+ + {state.status === "loading" && ( +

{t("cookingSession.loading")}

+ )} + + {state.status === "error" && ( +

+ {t("common.loadError")} +

+ )} + + {state.status === "loaded" && } +
+ ); +} + +/** 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

{t("cookingSession.empty")}

; + } + + return ( +
+
+ {plan.recipes.map((recipe) => ( + + {recipe.name} · ×{recipe.portions} + + ))} +
+ + {plan.phases.map((phase) => ( + + ))} +
+ ); +} + +/** One phase: its background band (if any) then its task cards. */ +function PhaseSection({ phase }: { phase: CookingPhaseView }) { + const { t } = useTranslation(); + + return ( +
+

+ + {t("cookingSession.phase.label", { index: phase.index + 1 })} + + {t(`cookingSession.phase.${phase.kind}`)} +

+ + {phase.background.length > 0 && ( +
+ + {t("cookingSession.background.title")} + +
    + {phase.background.map((task) => ( +
  • + +
  • + ))} +
+
+ )} + +
    + {phase.tasks.map((task) => ( +
  • + +
  • + ))} +
+
+ ); +} + +/** A single actionable task — a merged-prep pool or a plain recipe step. */ +function TaskCard({ task }: { task: CookingTaskView }) { + const { t } = useTranslation(); + + return ( +
+

+ {taskHeadline(task, t)} + {task.kind === "merged-prep" && ( + {t("cookingSession.task.sharedBadge")} + )} +

+

+ {t("cookingSession.task.forRecipes", { recipes: taskRecipeNames(task) })} +

+ {task.utensils.length > 0 && ( +

+ {t("cookingSession.task.utensils")} :{" "} + {task.utensils.map((utensil) => t(`catalog.utensils.${utensil.key}`)).join(", ")} +

+ )} +
+ ); +} + +/** 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 ( + + {technique} + {task.description} ({task.recipeName}) + + ); +} diff --git a/apps/web/src/pages/cooking-session/cooking-session-page.scss b/apps/web/src/pages/cooking-session/cooking-session-page.scss new file mode 100644 index 0000000..6513e40 --- /dev/null +++ b/apps/web/src/pages/cooking-session/cooking-session-page.scss @@ -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); + } +} diff --git a/apps/web/src/pages/cooking-session/cooking-session.ts b/apps/web/src/pages/cooking-session/cooking-session.ts new file mode 100644 index 0000000..9c5b9b8 --- /dev/null +++ b/apps/web/src/pages/cooking-session/cooking-session.ts @@ -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; + +/** + * 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(", "); +} diff --git a/apps/web/src/pages/planning/PlanningPage.tsx b/apps/web/src/pages/planning/PlanningPage.tsx index 3c920f1..80d4ebf 100644 --- a/apps/web/src/pages/planning/PlanningPage.tsx +++ b/apps/web/src/pages/planning/PlanningPage.tsx @@ -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 = new Set(["collation", "dejeuner", "gou */ export function PlanningPage() { const { t } = useTranslation(); + const navigate = useNavigate(); const [weekStart, setWeekStart] = useState(() => getWeekStart(DateTime.utc())); const [state, setState] = useState({ 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 (

{t("planning.title")}

+
{state.status === "loading" && ( diff --git a/apps/web/src/pages/planning/planning-page.scss b/apps/web/src/pages/planning/planning-page.scss index 8bf1fdc..bee2e6a 100644 --- a/apps/web/src/pages/planning/planning-page.scss +++ b/apps/web/src/pages/planning/planning-page.scss @@ -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 --------------------------------------------------------