diff --git a/apps/web/cypress/e2e/cooking-session-page.cy.ts b/apps/web/cypress/e2e/cooking-session-page.cy.ts index 2f7fd8b..22ac292 100644 --- a/apps/web/cypress/e2e/cooking-session-page.cy.ts +++ b/apps/web/cypress/e2e/cooking-session-page.cy.ts @@ -230,6 +230,12 @@ describe("Cooking session page", () => { .and("contain.text", "Oignon") .and("contain.text", "Mutualisé"); + // It sits in one mini section headed with both recipes' names. + cy.get(".cooking-phase__recipe-group-title").should( + "have.text", + "Soupe à l'oignon, Tarte à l'oignon", + ); + // The next phase's step is not rendered yet. cy.contains("Faire mijoter le bouillon").should("not.exist"); @@ -321,6 +327,33 @@ describe("Cooking session page", () => { cy.contains(".cooking-plan__count", "0 / 3 tâches").should("be.visible"); }); + it("splits the phase into mini sections by recipe — pooled task first, then each solo preheat under its own recipe", () => { + cy.intercept("GET", /\/cooking-session\?/, { statusCode: 200, body: setupPlanFixture() }); + cy.visit("/cuisiner?date=2026-08-17"); + + // Three distinct recipe combinations in this phase's tasks: the pooled + // preheat (Soupe + Gratin), then each un-merged preheat solo. + const titles = () => cy.get(".cooking-phase__recipe-group-title"); + titles().should("have.length", 3); + titles().eq(0).should("have.text", "Soupe, Gratin"); + titles().eq(1).should("have.text", "Gratin"); + titles().eq(2).should("have.text", "Rôti"); + + // Each section holds only its own task. + cy.get(".cooking-phase__recipe-group") + .eq(0) + .find(".cooking-task--merged-setup") + .should("exist"); + cy.get(".cooking-phase__recipe-group") + .eq(1) + .should("contain.text", "Préchauffer le four à 200°C") + .and("not.contain.text", "220°C"); + cy.get(".cooking-phase__recipe-group") + .eq(2) + .should("contain.text", "Préchauffer le four à 220°C") + .and("not.contain.text", "200°C"); + }); + it("shows an error state when the request fails", () => { cy.intercept("GET", /\/cooking-session\?/, { statusCode: 500, diff --git a/apps/web/cypress/e2e/cooking-session.feature b/apps/web/cypress/e2e/cooking-session.feature index fde4212..13a09ee 100644 --- a/apps/web/cypress/e2e/cooking-session.feature +++ b/apps/web/cypress/e2e/cooking-session.feature @@ -22,6 +22,7 @@ Feature: Start cooking an optimized plan 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 + And the pooled prep task should sit in the recipe section "Soupe à l'oignon, Tarte à l'oignon" Scenario: The stepper walks the phases one at a time Given the planning for this week has recipes "Soupe à l'oignon" and "Tarte à l'oignon" @@ -61,3 +62,4 @@ Feature: Start cooking an optimized plan Then I should see the oven conflict note "enchaîner 180 °C puis 210 °C" And I should see "Préchauffer le four à 180°C" And I should see "Préchauffer le four à 210°C" + And I should see the recipe sections "Gratin" and "Rôti" diff --git a/apps/web/cypress/e2e/cooking-session.ts b/apps/web/cypress/e2e/cooking-session.ts index b8f2d9f..a6d493f 100644 --- a/apps/web/cypress/e2e/cooking-session.ts +++ b/apps/web/cypress/e2e/cooking-session.ts @@ -102,6 +102,12 @@ Then("the pooled prep task should be flagged as shared", () => { cy.get(".cooking-task--merged-prep").contains("Mutualisé").should("be.visible"); }); +Then("the pooled prep task should sit in the recipe section {string}", (sectionTitle: string) => { + cy.contains(".cooking-phase__recipe-group", sectionTitle) + .find(".cooking-task--merged-prep") + .should("exist"); +}); + Given("the cooking plan for {string} has a mise en place then a cooking step", (date: string) => { // Two phases so the stepper actually has somewhere to step to: the // pooled `chop onion` prep, then a `simmer` cooking step. @@ -212,6 +218,11 @@ Then("I should see the oven conflict note {string}", (text: string) => { cy.get(".cooking-phase__note").should("contain.text", text); }); +Then("I should see the recipe sections {string} and {string}", (first: string, second: string) => { + cy.contains(".cooking-phase__recipe-group-title", first).should("be.visible"); + cy.contains(".cooking-phase__recipe-group-title", second).should("be.visible"); +}); + When("I open the {string} cooking tab", (label: string) => { cy.contains(".cooking-session-page__tab", label).click(); }); diff --git a/apps/web/src/locales/fr/translation.json b/apps/web/src/locales/fr/translation.json index 960cce1..5e72328 100644 --- a/apps/web/src/locales/fr/translation.json +++ b/apps/web/src/locales/fr/translation.json @@ -357,7 +357,6 @@ "task": { "mergedPrepLabel": "{{technique}} : {{items}}", "mergedSetupLabel": "{{technique}} · {{temperature}}", - "forRecipes": "pour {{recipes}}", "utensils": "Ustensiles", "sharedBadge": "Mutualisé" }, diff --git a/apps/web/src/pages/cooking-session/CookingSessionPage.tsx b/apps/web/src/pages/cooking-session/CookingSessionPage.tsx index 0f126de..121ea0a 100644 --- a/apps/web/src/pages/cooking-session/CookingSessionPage.tsx +++ b/apps/web/src/pages/cooking-session/CookingSessionPage.tsx @@ -14,11 +14,12 @@ import { CheckboxOption } from "../../components/ui/Checkbox"; import { WeekNavigator } from "../../features/planning/WeekNavigator"; import { cookingProgress } from "./cooking-progress"; import { + groupTasksByRecipes, phaseNoteText, phaseProgress, + recipeNames, reconcileChecked, taskHeadline, - taskRecipeNames, } from "./cooking-session"; import "./cooking-session-page.scss"; @@ -261,7 +262,13 @@ function PlanTab({ plan, weekKey }: { plan: OptimizedCookingPlanView; weekKey: s ); } -/** One phase: its background band (if any) then its tickable task cards. */ +/** + * One phase: its notes and background band (if any), then its tickable task + * cards split into mini sections — one heading per exact combination of + * recipes a task applies to (see {@link groupTasksByRecipes}), so a cook + * juggling several recipes at once can jump straight to "what do I do for + * Recipe X" instead of scanning one flat list. + */ function PhaseSection({ phase, checked, @@ -272,6 +279,7 @@ function PhaseSection({ onToggle: (taskId: string, isChecked: boolean) => void; }) { const { t } = useTranslation(); + const groups = groupTasksByRecipes(phase.tasks); return (
@@ -296,22 +304,32 @@ function PhaseSection({ )} - + {groups.map((group) => ( +
+

{recipeNames(group.recipes)}

+ +
+ ))}
); } -/** A single actionable task — a merged pool (prep or setup) or a plain recipe step — with a tick to mark it done. */ +/** + * A single actionable task — a merged pool (prep or setup) or a plain recipe + * step — with a tick to mark it done. Which recipe(s) it belongs to is now + * shown by the {@link PhaseSection} group heading above it rather than + * repeated on every card. + */ function TaskCard({ task, checked, @@ -337,9 +355,6 @@ function TaskCard({ {t("cookingSession.task.sharedBadge")} )} - - {t("cookingSession.task.forRecipes", { recipes: taskRecipeNames(task) })} - {task.utensils.length > 0 && ( {t("cookingSession.task.utensils")} :{" "} diff --git a/apps/web/src/pages/cooking-session/cooking-session-page.scss b/apps/web/src/pages/cooking-session/cooking-session-page.scss index d455298..963ec3a 100644 --- a/apps/web/src/pages/cooking-session/cooking-session-page.scss +++ b/apps/web/src/pages/cooking-session/cooking-session-page.scss @@ -336,6 +336,30 @@ } } + // Mini sections — one per exact combination of recipes a task applies to + // (see `groupTasksByRecipes` in cooking-session.ts): a pooled prep/setup + // task pools two-plus recipes and gets its own heading, a plain step's + // section names just the one recipe it's for. Lets a cook juggling + // several recipes at once jump straight to "what do I do for Recipe X" + // instead of scanning one flat list. + &__recipe-group { + display: flex; + flex-direction: column; + gap: var(--space-xs); + } + + &__recipe-group-title { + margin: 0; + padding-bottom: var(--space-xs); + border-bottom: 1px solid var(--color-border); + font-family: var(--font-display); + font-size: var(--font-size-sm); + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.03em; + color: var(--color-text-muted); + } + &__tasks { list-style: none; margin: 0; @@ -412,7 +436,6 @@ letter-spacing: 0.03em; } - &__recipes, &__utensils { display: block; font-size: var(--font-size-sm); diff --git a/apps/web/src/pages/cooking-session/cooking-session.ts b/apps/web/src/pages/cooking-session/cooking-session.ts index 7161f33..46d4915 100644 --- a/apps/web/src/pages/cooking-session/cooking-session.ts +++ b/apps/web/src/pages/cooking-session/cooking-session.ts @@ -1,6 +1,7 @@ import type { CookingPhaseNoteView, CookingPhaseView, + CookingSessionRecipeRef, CookingTaskIngredientView, CookingTaskView, CookingTemperatureView, @@ -80,9 +81,67 @@ export function taskHeadline(task: CookingTaskView, t: TranslateFn): string { 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`/`merged-setup`. */ -export function taskRecipeNames(task: CookingTaskView): string { - return task.sourceRecipes.map((recipe) => recipe.name).join(", "); +/** + * Comma-joined recipe names — one for a `step` task's solo recipe, several + * for a pooled `merged-prep`/`merged-setup`'s {@link CookingSessionRecipeRef} + * list. Takes the recipe list directly (rather than a whole task) so it + * doubles as the heading text for a {@link CookingTaskGroup}, whose + * `recipes` is exactly one task's `sourceRecipes` — see + * {@link groupTasksByRecipes}. + */ +export function recipeNames(recipes: CookingSessionRecipeRef[]): string { + return recipes.map((recipe) => recipe.name).join(", "); +} + +/** + * One "mini section" of a phase's task list: every task that applies to the + * exact same combination of recipes, clustered under one heading (see + * {@link groupTasksByRecipes}). `recipes` is that combination, in the same + * order every task in `tasks` already carries as its own `sourceRecipes` — + * exactly one entry for a `step` task, every recipe it covers for a pooled + * `merged-prep`/`merged-setup`. + */ +export interface CookingTaskGroup { + /** + * Sorted `recipeId` list joined with `,` (e.g. `"1,3"`) — uniquely + * identifies the combination regardless of `sourceRecipes` order, stable + * enough to key a React list. + */ + key: string; + recipes: CookingSessionRecipeRef[]; + tasks: CookingTaskView[]; +} + +/** + * Splits a phase's tasks into {@link CookingTaskGroup}s so the stepper shows + * one mini section per recipe (or per shared pool) instead of one flat list + * — a cook juggling several recipes at once can jump straight to "what do I + * do for Recipe X" instead of picking it out from everything else in the + * phase. + * + * Grouping is by the *exact* recipe combination, not "shares a recipe with": + * a `merged-prep`/`merged-setup` task pools two or more recipes and always + * gets its own section, distinct from either recipe's solo-step section + * even though they overlap — a task is never split across two sections. + * + * A group's position is set by the first task that needs it (so e.g. the + * mise-en-place phase's pooled prep — always first in `phase.tasks`, see + * `cooking-optimizer.ts` — keeps leading as its own section), and a group's + * tasks keep their relative order from `tasks` even when another group's + * tasks were interleaved between them. + */ +export function groupTasksByRecipes(tasks: CookingTaskView[]): CookingTaskGroup[] { + const groups = new Map(); + for (const task of tasks) { + const key = task.sourceRecipes + .map((recipe) => recipe.recipeId) + .sort((a, b) => a - b) + .join(","); + const group = groups.get(key); + if (group) group.tasks.push(task); + else groups.set(key, { key, recipes: task.sourceRecipes, tasks: [task] }); + } + return [...groups.values()]; } /**