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", "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,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -357,7 +357,6 @@
|
|||
"task": {
|
||||
"mergedPrepLabel": "{{technique}} : {{items}}",
|
||||
"mergedSetupLabel": "{{technique}} · {{temperature}}",
|
||||
"forRecipes": "pour {{recipes}}",
|
||||
"utensils": "Ustensiles",
|
||||
"sharedBadge": "Mutualisé"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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,8 +304,11 @@ function PhaseSection({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{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">
|
||||
{phase.tasks.map((task) => (
|
||||
{group.tasks.map((task) => (
|
||||
<li key={task.id}>
|
||||
<TaskCard
|
||||
task={task}
|
||||
|
|
@ -307,11 +318,18 @@ function PhaseSection({
|
|||
</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")} :{" "}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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()];
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Reference in a new issue