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({
)}
-
- {phase.tasks.map((task) => (
- -
- onToggle(task.id, isChecked)}
- />
-
- ))}
-
+ {groups.map((group) => (
+
+
{recipeNames(group.recipes)}
+
+ {group.tasks.map((task) => (
+ -
+ onToggle(task.id, isChecked)}
+ />
+
+ ))}
+
+
+ ))}
);
}
-/** 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()];
}
/**