feat(cooking-session): regroupe les tâches du stepper en mini-sections par recette #27

Merged
kyuno merged 1 commit from feat/cooking-stepper-recipe-groups into main 2026-09-04 19:34:42 +02:00
7 changed files with 164 additions and 22 deletions

View file

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

View file

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

View file

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

View file

@ -357,7 +357,6 @@
"task": {
"mergedPrepLabel": "{{technique}} : {{items}}",
"mergedSetupLabel": "{{technique}} · {{temperature}}",
"forRecipes": "pour {{recipes}}",
"utensils": "Ustensiles",
"sharedBadge": "Mutualisé"
},

View file

@ -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 (
<section className={`cooking-phase cooking-phase--${phase.kind}`}>
@ -296,22 +304,32 @@ function PhaseSection({
</div>
)}
<ul className="cooking-phase__tasks">
{phase.tasks.map((task) => (
<li key={task.id}>
<TaskCard
task={task}
checked={checked.has(task.id)}
onToggle={(isChecked) => onToggle(task.id, isChecked)}
/>
</li>
))}
</ul>
{groups.map((group) => (
<div key={group.key} className="cooking-phase__recipe-group">
<h3 className="cooking-phase__recipe-group-title">{recipeNames(group.recipes)}</h3>
<ul className="cooking-phase__tasks">
{group.tasks.map((task) => (
<li key={task.id}>
<TaskCard
task={task}
checked={checked.has(task.id)}
onToggle={(isChecked) => onToggle(task.id, isChecked)}
/>
</li>
))}
</ul>
</div>
))}
</section>
);
}
/** 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({
<span className="cooking-task__badge">{t("cookingSession.task.sharedBadge")}</span>
)}
</span>
<span className="cooking-task__recipes">
{t("cookingSession.task.forRecipes", { recipes: taskRecipeNames(task) })}
</span>
{task.utensils.length > 0 && (
<span className="cooking-task__utensils">
{t("cookingSession.task.utensils")} :{" "}

View file

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

View file

@ -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<string, CookingTaskGroup>();
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()];
}
/**