feat(cooking-session): regroupe les tâches du stepper en mini-sections par recette #27
7 changed files with 164 additions and 22 deletions
|
|
@ -230,6 +230,12 @@ describe("Cooking session page", () => {
|
||||||
.and("contain.text", "Oignon")
|
.and("contain.text", "Oignon")
|
||||||
.and("contain.text", "Mutualisé");
|
.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.
|
// The next phase's step is not rendered yet.
|
||||||
cy.contains("Faire mijoter le bouillon").should("not.exist");
|
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");
|
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", () => {
|
it("shows an error state when the request fails", () => {
|
||||||
cy.intercept("GET", /\/cooking-session\?/, {
|
cy.intercept("GET", /\/cooking-session\?/, {
|
||||||
statusCode: 500,
|
statusCode: 500,
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ Feature: Start cooking an optimized plan
|
||||||
And I should see "Mise en place"
|
And I should see "Mise en place"
|
||||||
And the pooled prep task should mention "Hacher" and "Oignon"
|
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 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
|
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"
|
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"
|
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 à 180°C"
|
||||||
And I should see "Préchauffer le four à 210°C"
|
And I should see "Préchauffer le four à 210°C"
|
||||||
|
And I should see the recipe sections "Gratin" and "Rôti"
|
||||||
|
|
|
||||||
|
|
@ -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");
|
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) => {
|
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
|
// Two phases so the stepper actually has somewhere to step to: the
|
||||||
// pooled `chop onion` prep, then a `simmer` cooking step.
|
// 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);
|
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) => {
|
When("I open the {string} cooking tab", (label: string) => {
|
||||||
cy.contains(".cooking-session-page__tab", label).click();
|
cy.contains(".cooking-session-page__tab", label).click();
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -357,7 +357,6 @@
|
||||||
"task": {
|
"task": {
|
||||||
"mergedPrepLabel": "{{technique}} : {{items}}",
|
"mergedPrepLabel": "{{technique}} : {{items}}",
|
||||||
"mergedSetupLabel": "{{technique}} · {{temperature}}",
|
"mergedSetupLabel": "{{technique}} · {{temperature}}",
|
||||||
"forRecipes": "pour {{recipes}}",
|
|
||||||
"utensils": "Ustensiles",
|
"utensils": "Ustensiles",
|
||||||
"sharedBadge": "Mutualisé"
|
"sharedBadge": "Mutualisé"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -14,11 +14,12 @@ import { CheckboxOption } from "../../components/ui/Checkbox";
|
||||||
import { WeekNavigator } from "../../features/planning/WeekNavigator";
|
import { WeekNavigator } from "../../features/planning/WeekNavigator";
|
||||||
import { cookingProgress } from "./cooking-progress";
|
import { cookingProgress } from "./cooking-progress";
|
||||||
import {
|
import {
|
||||||
|
groupTasksByRecipes,
|
||||||
phaseNoteText,
|
phaseNoteText,
|
||||||
phaseProgress,
|
phaseProgress,
|
||||||
|
recipeNames,
|
||||||
reconcileChecked,
|
reconcileChecked,
|
||||||
taskHeadline,
|
taskHeadline,
|
||||||
taskRecipeNames,
|
|
||||||
} from "./cooking-session";
|
} from "./cooking-session";
|
||||||
import "./cooking-session-page.scss";
|
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({
|
function PhaseSection({
|
||||||
phase,
|
phase,
|
||||||
checked,
|
checked,
|
||||||
|
|
@ -272,6 +279,7 @@ function PhaseSection({
|
||||||
onToggle: (taskId: string, isChecked: boolean) => void;
|
onToggle: (taskId: string, isChecked: boolean) => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const groups = groupTasksByRecipes(phase.tasks);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className={`cooking-phase cooking-phase--${phase.kind}`}>
|
<section className={`cooking-phase cooking-phase--${phase.kind}`}>
|
||||||
|
|
@ -296,22 +304,32 @@ function PhaseSection({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<ul className="cooking-phase__tasks">
|
{groups.map((group) => (
|
||||||
{phase.tasks.map((task) => (
|
<div key={group.key} className="cooking-phase__recipe-group">
|
||||||
<li key={task.id}>
|
<h3 className="cooking-phase__recipe-group-title">{recipeNames(group.recipes)}</h3>
|
||||||
<TaskCard
|
<ul className="cooking-phase__tasks">
|
||||||
task={task}
|
{group.tasks.map((task) => (
|
||||||
checked={checked.has(task.id)}
|
<li key={task.id}>
|
||||||
onToggle={(isChecked) => onToggle(task.id, isChecked)}
|
<TaskCard
|
||||||
/>
|
task={task}
|
||||||
</li>
|
checked={checked.has(task.id)}
|
||||||
))}
|
onToggle={(isChecked) => onToggle(task.id, isChecked)}
|
||||||
</ul>
|
/>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</section>
|
</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({
|
function TaskCard({
|
||||||
task,
|
task,
|
||||||
checked,
|
checked,
|
||||||
|
|
@ -337,9 +355,6 @@ function TaskCard({
|
||||||
<span className="cooking-task__badge">{t("cookingSession.task.sharedBadge")}</span>
|
<span className="cooking-task__badge">{t("cookingSession.task.sharedBadge")}</span>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
<span className="cooking-task__recipes">
|
|
||||||
{t("cookingSession.task.forRecipes", { recipes: taskRecipeNames(task) })}
|
|
||||||
</span>
|
|
||||||
{task.utensils.length > 0 && (
|
{task.utensils.length > 0 && (
|
||||||
<span className="cooking-task__utensils">
|
<span className="cooking-task__utensils">
|
||||||
{t("cookingSession.task.utensils")} :{" "}
|
{t("cookingSession.task.utensils")} :{" "}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
&__tasks {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
|
@ -412,7 +436,6 @@
|
||||||
letter-spacing: 0.03em;
|
letter-spacing: 0.03em;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__recipes,
|
|
||||||
&__utensils {
|
&__utensils {
|
||||||
display: block;
|
display: block;
|
||||||
font-size: var(--font-size-sm);
|
font-size: var(--font-size-sm);
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import type {
|
import type {
|
||||||
CookingPhaseNoteView,
|
CookingPhaseNoteView,
|
||||||
CookingPhaseView,
|
CookingPhaseView,
|
||||||
|
CookingSessionRecipeRef,
|
||||||
CookingTaskIngredientView,
|
CookingTaskIngredientView,
|
||||||
CookingTaskView,
|
CookingTaskView,
|
||||||
CookingTemperatureView,
|
CookingTemperatureView,
|
||||||
|
|
@ -80,9 +81,67 @@ export function taskHeadline(task: CookingTaskView, t: TranslateFn): string {
|
||||||
return t("cookingSession.task.mergedPrepLabel", { technique, items });
|
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 {
|
* Comma-joined recipe names — one for a `step` task's solo recipe, several
|
||||||
return task.sourceRecipes.map((recipe) => recipe.name).join(", ");
|
* 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()];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue