feat(cooking-session): regroupement d'étapes sensible aux métadonnées d'action
All checks were successful
CI / lint (push) Successful in 1m57s
CI / build (push) Successful in 3m2s
CI / e2e (push) Successful in 7m59s
CI / intent-service-test (push) Successful in 18m44s
CI / test (push) Successful in 28m2s

L'optimiseur ne mutualisait que le pré-découpage. Il regroupe maintenant
aussi les actions SETUP (préchauffage, ébullition) entre recettes, mais
seulement quand elles sont compatibles : deux préchauffages ne fusionnent
que s'ils visent la même température détectée. Deux préchauffages à des
températures différentes restent séparés, ordonnés du plus froid au plus
chaud, et la mise en place porte une note « Four requis à N températures —
enchaîner … ». Défaut optimiste : deux préchauffages sans température
détectée fusionnent quand même (supposés compatibles).

- cooking-optimizer.ts : temperatureKey (comparaison exacte, aucune
  conversion inter-représentations), isPureSetupStep / setupSignature /
  extractMergedSetup en miroir du chemin merged-prep ; comparableTemp pour
  l'ordre seul ; detectApplianceTemperatureConflicts → CookingPhaseView.notes
- cooking-session.service.ts : `temperatures` dans l'include + mapping
- packages/shared : CookingTemperatureView, kind « merged-setup »,
  CookingTaskView.temperatures, CookingPhaseNoteView, CookingPhaseView.notes
- web : temperature-label.ts extrait de StepDescription et réutilisé ;
  taskHeadline gère merged-setup ; phaseNoteText ; bandeau .cooking-phase__note
- i18n : cookingSession.task.mergedSetupLabel, cookingSession.note.*
- tests : 7 cas Mocha « setup pooling » + test DB cooking-session,
  cas Cypress mode layout + scénario Gherkin

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Nicolas 2026-09-03 12:05:44 +02:00
parent 3d5998a535
commit 85268729e6
14 changed files with 901 additions and 64 deletions

View file

@ -1,10 +1,12 @@
import type { import type {
CookingBackgroundTaskView, CookingBackgroundTaskView,
CookingPhaseKind, CookingPhaseKind,
CookingPhaseNoteView,
CookingPhaseView, CookingPhaseView,
CookingSessionRecipeRef, CookingSessionRecipeRef,
CookingTaskIngredientView, CookingTaskIngredientView,
CookingTaskView, CookingTaskView,
CookingTemperatureView,
TechStepView, TechStepView,
UtensilView, UtensilView,
} from "@batch-cooking/shared"; } from "@batch-cooking/shared";
@ -23,12 +25,22 @@ import type {
* `aggregateShoppingList`, so the whole optimization is unit-testable * `aggregateShoppingList`, so the whole optimization is unit-testable
* without a Postgres round-trip. * without a Postgres round-trip.
* *
* v1 scope (see the plan / spec): preparation is the only thing *merged* * Two families of work are *merged* across recipes:
* across recipes — a `chop`/`peel`/… technique applied to the same * - **Preparation** — a `chop`/`peel`/… technique applied to the same
* ingredient by two or more recipes, in a step that does nothing but prep, * ingredient by two or more recipes, in a step that does nothing but
* collapses into a single {@link CookingTaskView} of `kind: "merged-prep"`. * prep, collapses into one {@link CookingTaskView} of `kind:
* Cooking steps themselves are never merged (no "same oven, same * "merged-prep"`.
* temperature" reasoning yet); they're only *reordered* for parallelism. * - **Setup** — the same `preheat`/`boil`/`bainMarie` action shared across
* recipes collapses into one `kind: "merged-setup"` task, but *only when
* the action metadata is compatible*: two recipes that both preheat the
* oven merge only when they preheat it to the **same temperature**.
* Two preheats at different temperatures stay separate (you cannot heat
* one oven to two temperatures at once), are ordered coolest-first, and
* the mise-en-place phase carries a {@link CookingPhaseNoteView} telling
* the cook to run them in sequence.
*
* Cooking steps themselves are still never merged (no shared-oven bake
* batching yet); they're only *reordered* for parallelism.
*/ */
/** /**
@ -66,7 +78,8 @@ const PREP_TECHNIQUES: ReadonlySet<string> = new Set([
* *
* - `"SETUP"` — a short active trigger, then it looks after itself: preheat * - `"SETUP"` — a short active trigger, then it looks after itself: preheat
* the oven, bring a pot of water to the boil. Pooled into the first * the oven, bring a pot of water to the boil. Pooled into the first
* ("mise en place") phase so it's running before it's needed. * ("mise en place") phase so it's running before it's needed, and merged
* across recipes when compatible (see {@link extractMergedSetup}).
* - `"PASSIVE"` — unattended once started (simmer, braise, bake, marinate, * - `"PASSIVE"` — unattended once started (simmer, braise, bake, marinate,
* rest…). Scheduled, then floated into every following phase's * rest…). Scheduled, then floated into every following phase's
* `background` until the step that consumes it comes up. * `background` until the step that consumes it comes up.
@ -106,6 +119,13 @@ interface OptimizerTechStepInput {
order: number; order: number;
ingredients: CookingTaskIngredientView[]; ingredients: CookingTaskIngredientView[];
utensils: UtensilView[]; utensils: UtensilView[];
/**
* Target temperature(s) named in this same clause (`preheat` → "à 180 °C").
* The structured subset only — see {@link CookingTemperatureView}. Not
* scaled (a temperature doesn't grow with the portion count). `[]` when
* the clause named no temperature.
*/
temperatures: CookingTemperatureView[];
} }
/** One recipe step, as handed to {@link optimizeCookingPlan}. */ /** One recipe step, as handed to {@link optimizeCookingPlan}. */
@ -152,7 +172,7 @@ function scaleOf(recipe: OptimizerRecipeInput): number {
return recipe.portions / recipe.recipePortions; return recipe.portions / recipe.recipePortions;
} }
/** A step normalized for scheduling — techniques scaled, attention resolved, ingredients/utensils unioned across its technique clauses. */ /** A step normalized for scheduling — techniques scaled, attention resolved, ingredients/utensils/temperatures unioned across its technique clauses. */
interface NormalizedStep { interface NormalizedStep {
/** Stable within one response: `step:<recipeIndex>:<stepId>` (the index disambiguates the same recipe planned twice). */ /** Stable within one response: `step:<recipeIndex>:<stepId>` (the index disambiguates the same recipe planned twice). */
taskId: string; taskId: string;
@ -164,10 +184,14 @@ interface NormalizedStep {
techSteps: OptimizerTechStepInput[]; techSteps: OptimizerTechStepInput[];
attention: Attention; attention: Attention;
isPurePrep: boolean; isPurePrep: boolean;
/** Every technique in the step is a {@link SETUP_TECHNIQUES} one — eligible to pool via {@link extractMergedSetup}. */
isPureSetup: boolean;
dominantTechnique: TechStepView | null; dominantTechnique: TechStepView | null;
ingredients: CookingTaskIngredientView[]; ingredients: CookingTaskIngredientView[];
utensils: UtensilView[]; utensils: UtensilView[];
/** Set once merged-prep extraction absorbs this step wholesale (all its prep pooled elsewhere) — it then produces no standalone task. */ /** Distinct target temperatures across the step's clauses (see {@link unionTemperatures}) — drives setup pooling and the coolest-first ordering. */
temperatures: CookingTemperatureView[];
/** Set once merged-prep/merged-setup extraction absorbs this step wholesale — it then produces no standalone task. */
absorbed: boolean; absorbed: boolean;
} }
@ -215,11 +239,62 @@ function unionUtensils(utensils: UtensilView[]): UtensilView[] {
return [...byId.values()].sort((a, b) => a.key.localeCompare(b.key)); return [...byId.values()].sort((a, b) => a.key.localeCompare(b.key));
} }
/**
* A stable canonical string for one temperature — `c<°C>` / `g<gasMark>` /
* `q<level>`, or `""` when every field is null (shouldn't happen — the
* upstream extractor guarantees one non-null — but stays total). Two
* temperatures are "the same" iff their keys are equal: **no conversion**
* between °C, gas marks and qualitative levels is guessed for a merge
* decision (same "don't invent a conversion" stance as `ShoppingListItemView`
* on units). °C is rounded to the nearest degree so `180` and `180.00`
* (a `Decimal` artefact) collapse.
*/
function temperatureKey(temperature: CookingTemperatureView): string {
if (temperature.celsius !== null) return `c${Math.round(temperature.celsius)}`;
if (temperature.gasMark !== null) return `g${temperature.gasMark}`;
if (temperature.qualitative !== null) return `q${temperature.qualitative}`;
return "";
}
/** Unions temperatures by {@link temperatureKey}, keeping a stable order by that key. */
function unionTemperatures(temperatures: CookingTemperatureView[]): CookingTemperatureView[] {
const byKey = new Map<string, CookingTemperatureView>();
for (const temperature of temperatures) byKey.set(temperatureKey(temperature), temperature);
return [...byKey.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([, value]) => value);
}
/**
* A single number to *order* temperatures by (coolest first) — **never** used
* to decide a merge (that's {@link temperatureKey}, exact-match only). Gas
* marks map via the French `th × 30 ≈ °C` convention and qualitative levels
* via rough midpoints, purely so a mixed list still sorts sensibly; a
* temperature-less task sorts first.
*/
function comparableTemp(temperature: CookingTemperatureView): number {
if (temperature.celsius !== null) return temperature.celsius;
if (temperature.gasMark !== null) return temperature.gasMark * 30;
if (temperature.qualitative !== null) {
return { low: 120, medium: 180, high: 240 }[temperature.qualitative];
}
return Number.NEGATIVE_INFINITY;
}
/** A setup task's sort position (coolest first) — its lowest target, or "before everything" when it has no detected temperature. */
function setupTaskTemp(task: CookingTaskView): number {
if (task.temperatures.length === 0) return Number.NEGATIVE_INFINITY;
return Math.min(...task.temperatures.map(comparableTemp));
}
/** A step is pure prep only if it has techniques and every one of them is in {@link PREP_TECHNIQUES}. */ /** A step is pure prep only if it has techniques and every one of them is in {@link PREP_TECHNIQUES}. */
function isPurePrepStep(techSteps: OptimizerTechStepInput[]): boolean { function isPurePrepStep(techSteps: OptimizerTechStepInput[]): boolean {
return techSteps.length > 0 && techSteps.every((ts) => PREP_TECHNIQUES.has(ts.techStep.key)); return techSteps.length > 0 && techSteps.every((ts) => PREP_TECHNIQUES.has(ts.techStep.key));
} }
/** A step is pure setup only if it has techniques and every one is in {@link SETUP_TECHNIQUES} — mirror of {@link isPurePrepStep}. */
function isPureSetupStep(techSteps: OptimizerTechStepInput[]): boolean {
return techSteps.length > 0 && techSteps.every((ts) => SETUP_TECHNIQUES.has(ts.techStep.key));
}
/** Resolves a step's {@link Attention} — SETUP wins, then a *trailing* passive technique, else ACTIVE (see {@link SETUP_TECHNIQUES}). */ /** Resolves a step's {@link Attention} — SETUP wins, then a *trailing* passive technique, else ACTIVE (see {@link SETUP_TECHNIQUES}). */
function attentionOf(techSteps: OptimizerTechStepInput[]): Attention { function attentionOf(techSteps: OptimizerTechStepInput[]): Attention {
if (techSteps.some((ts) => SETUP_TECHNIQUES.has(ts.techStep.key))) return "SETUP"; if (techSteps.some((ts) => SETUP_TECHNIQUES.has(ts.techStep.key))) return "SETUP";
@ -251,6 +326,9 @@ function normalizeRecipe(recipe: OptimizerRecipeInput, recipeIndex: number): Nor
unit: line.unit, unit: line.unit,
})), })),
utensils: ts.utensils, utensils: ts.utensils,
// Temperatures are not scaled — an oven set to 180 °C for 4
// portions is still 180 °C for 8.
temperatures: ts.temperatures,
})); }));
const lastTech = techSteps[techSteps.length - 1]; const lastTech = techSteps[techSteps.length - 1];
@ -264,9 +342,11 @@ function normalizeRecipe(recipe: OptimizerRecipeInput, recipeIndex: number): Nor
techSteps, techSteps,
attention: attentionOf(techSteps), attention: attentionOf(techSteps),
isPurePrep: isPurePrepStep(techSteps), isPurePrep: isPurePrepStep(techSteps),
isPureSetup: isPureSetupStep(techSteps),
dominantTechnique: lastTech ? lastTech.techStep : null, dominantTechnique: lastTech ? lastTech.techStep : null,
ingredients: unionIngredients(techSteps.flatMap((ts) => ts.ingredients)), ingredients: unionIngredients(techSteps.flatMap((ts) => ts.ingredients)),
utensils: unionUtensils(techSteps.flatMap((ts) => ts.utensils)), utensils: unionUtensils(techSteps.flatMap((ts) => ts.utensils)),
temperatures: unionTemperatures(techSteps.flatMap((ts) => ts.temperatures)),
absorbed: false, absorbed: false,
} satisfies NormalizedStep; } satisfies NormalizedStep;
}); });
@ -283,6 +363,26 @@ function prepSignature(step: NormalizedStep): string {
return [...new Set(pairs)].sort().join("+"); return [...new Set(pairs)].sort().join("+");
} }
/**
* The setup signature of a pure-setup step — `<sorted technique keys>|<sorted
* temperature keys>`. Two steps with the same signature start the *same*
* appliance in the *same* state and can be pooled. The temperature part is
* `""` when no temperature was detected, which is a valid signature: two
* bare "préchauffer le four" steps then share it and merge (the optimistic
* default — an unknown temperature is assumed compatible). The appliance
* itself is deliberately not in the key (a `preheat` is the oven either
* way); its utensils are still unioned onto the pooled task.
*/
function setupSignature(step: NormalizedStep): string {
const techKeys = [...new Set(step.techSteps.map((ts) => ts.techStep.key))].sort().join("+");
const tempKeys = step.temperatures
.map(temperatureKey)
.filter((key) => key !== "")
.sort()
.join("+");
return `${techKeys}|${tempKeys}`;
}
/** Builds one {@link CookingTaskView} from a normalized step run as written. */ /** Builds one {@link CookingTaskView} from a normalized step run as written. */
function stepToTask(step: NormalizedStep): CookingTaskView { function stepToTask(step: NormalizedStep): CookingTaskView {
return { return {
@ -292,6 +392,10 @@ function stepToTask(step: NormalizedStep): CookingTaskView {
description: step.description, description: step.description,
ingredients: step.ingredients, ingredients: step.ingredients,
utensils: step.utensils, utensils: step.utensils,
// Only a setup step (preheat/boil/…) carries a target temperature the
// plan should reason about; every other step leaves it `[]` even if its
// text happens to mention degrees.
temperatures: step.attention === "SETUP" ? step.temperatures : [],
sourceRecipes: [step.recipe], sourceRecipes: [step.recipe],
originalSteps: [ originalSteps: [
{ {
@ -376,6 +480,7 @@ function extractMergedPrep(steps: NormalizedStep[]): CookingTaskView[] {
description: null, description: null,
ingredients, ingredients,
utensils, utensils,
temperatures: [],
sourceRecipes, sourceRecipes,
originalSteps, originalSteps,
}); });
@ -384,6 +489,99 @@ function extractMergedPrep(steps: NormalizedStep[]): CookingTaskView[] {
return merged.sort((a, b) => a.id.localeCompare(b.id)); return merged.sort((a, b) => a.id.localeCompare(b.id));
} }
/**
* Pools pure-setup steps with the same {@link setupSignature} (same action,
* same detected temperature) across two or more distinct recipes into one
* `merged-setup` {@link CookingTaskView}, marking each contributing step
* `absorbed`. Two `preheat` steps at *different* temperatures have different
* signatures and are therefore never pooled — {@link detectApplianceTemperatureConflicts}
* turns that into a phase note. A signature only one recipe needs is left
* untouched (still lands in mise-en-place as its own step task).
*
* Returns the merged tasks sorted by id.
*/
function extractMergedSetup(steps: NormalizedStep[]): CookingTaskView[] {
const bySignature = new Map<string, NormalizedStep[]>();
for (const step of steps) {
if (!step.isPureSetup) continue;
const signature = setupSignature(step);
const group = bySignature.get(signature);
if (group) group.push(step);
else bySignature.set(signature, [step]);
}
const merged: CookingTaskView[] = [];
for (const group of bySignature.values()) {
const recipeIndexes = new Set(group.map((s) => s.recipeIndex));
if (recipeIndexes.size < 2) continue;
for (const step of group) step.absorbed = true;
const firstTech = group[0]?.techSteps[0]?.techStep ?? null;
const ingredients = unionIngredients(
group.flatMap((s) => s.techSteps.flatMap((ts) => ts.ingredients)),
);
const utensils = unionUtensils(group.flatMap((s) => s.utensils));
const temperatures = unionTemperatures(group.flatMap((s) => s.temperatures)).sort(
(a, b) => comparableTemp(a) - comparableTemp(b),
);
// Distinct source recipes / original step texts, in input order.
const sourceRecipes: CookingSessionRecipeRef[] = [];
const seenRecipe = new Set<number>();
const originalSteps: CookingTaskView["originalSteps"] = [];
for (const step of [...group].sort((a, b) => a.recipeIndex - b.recipeIndex)) {
if (!seenRecipe.has(step.recipeIndex)) {
seenRecipe.add(step.recipeIndex);
sourceRecipes.push(step.recipe);
}
originalSteps.push({
recipeId: step.recipe.recipeId,
recipeName: step.recipe.name,
description: step.description,
});
}
const tempKey = temperatures.map(temperatureKey).join("+");
merged.push({
id: `setup:${firstTech ? firstTech.key : "setup"}:${tempKey || "any"}`,
kind: "merged-setup",
technique: firstTech,
description: null,
ingredients,
utensils,
temperatures,
sourceRecipes,
originalSteps,
});
}
return merged.sort((a, b) => a.id.localeCompare(b.id));
}
/**
* The kitchen has one oven: if the mise-en-place ends up with two or more
* `preheat` tasks at *distinct known* temperatures, they cannot run at once.
* Emits a single {@link CookingPhaseNoteView} listing those temperatures
* coolest-first so the web layer can say "enchaîner 180 °C puis 210 °C".
* A `preheat` with no detected temperature contributes nothing to the note
* (it merged optimistically with the other unknowns and we've no number to
* show).
*/
function detectApplianceTemperatureConflicts(
setupTasks: CookingTaskView[],
): CookingPhaseNoteView[] {
const preheats = setupTasks.filter((task) => task.technique?.key === "preheat");
const distinct = unionTemperatures(preheats.flatMap((task) => task.temperatures))
.filter((temperature) => temperatureKey(temperature) !== "")
.sort((a, b) => comparableTemp(a) - comparableTemp(b));
const technique = preheats.find((task) => task.technique !== null)?.technique;
if (distinct.length < 2 || !technique) return [];
return [{ kind: "appliance-temperature-conflict", technique, temperatures: distinct }];
}
/** Maps the input recipe list to its display legend, de-duplicating an exact `(recipeId, portions)` repeat. */ /** Maps the input recipe list to its display legend, de-duplicating an exact `(recipeId, portions)` repeat. */
function toRecipeLegend(recipes: OptimizerRecipeInput[]): CookingSessionRecipeRef[] { function toRecipeLegend(recipes: OptimizerRecipeInput[]): CookingSessionRecipeRef[] {
const seen = new Set<string>(); const seen = new Set<string>();
@ -406,9 +604,11 @@ function cookingPhaseKind(tasks: CookingTaskView[]): CookingPhaseKind {
* See the file header. Given the week's planned recipes (already resolved * See the file header. Given the week's planned recipes (already resolved
* to reference views), returns the display legend plus the ordered phases: * to reference views), returns the display legend plus the ordered phases:
* *
* 1. **Mise en place** (`"mise-en-place"`) — every `merged-prep` task, then * 1. **Mise en place** (`"mise-en-place"`) — all prep first (`merged-prep`
* every leftover pure-prep step, then every `SETUP` step. Omitted * tasks, then leftover pure-prep steps), then all setup (`merged-setup`
* entirely if it would be empty. * tasks and leftover `SETUP` steps, ordered coolest-first). Carries an
* `appliance-temperature-conflict` note when the week needs the oven at
* two or more temperatures. Omitted entirely if it would be empty.
* 2. **Cooking** (`"cooking"` / `"finishing"`) — the recipes interleaved: * 2. **Cooking** (`"cooking"` / `"finishing"`) — the recipes interleaved:
* each phase pops the next remaining step of every recipe that still has * each phase pops the next remaining step of every recipe that still has
* one (passive-cook steps first, so long cooks start early). A passive * one (passive-cook steps first, so long cooks start early). A passive
@ -420,21 +620,41 @@ export function optimizeCookingPlan(recipes: OptimizerRecipeInput[]): OptimizeCo
const normalized = recipes.map((recipe, index) => normalizeRecipe(recipe, index)); const normalized = recipes.map((recipe, index) => normalizeRecipe(recipe, index));
const allSteps = normalized.flat(); const allSteps = normalized.flat();
// Merged-prep first, then merged-setup — each marks its contributing
// steps `absorbed`, so the leftover loop below only sees the solo ones.
const mergedPrep = extractMergedPrep(allSteps); const mergedPrep = extractMergedPrep(allSteps);
const mergedSetup = extractMergedSetup(allSteps);
const phases: CookingPhaseView[] = []; const phases: CookingPhaseView[] = [];
// Phase 0 — mise en place. // Phase 0 — mise en place: all prep, then all setup. Keeping the two
const miseTasks: CookingTaskView[] = [...mergedPrep]; // groups apart (rather than input order) lets the setup group be ordered
// coolest-first without disturbing the prep order.
const prepTasks: CookingTaskView[] = [...mergedPrep];
const setupTasks: CookingTaskView[] = [...mergedSetup];
for (const step of allSteps) { for (const step of allSteps) {
if (step.absorbed) continue; if (step.absorbed) continue;
if (step.isPurePrep || step.attention === "SETUP") { if (step.isPurePrep) {
miseTasks.push(stepToTask(step)); prepTasks.push(stepToTask(step));
step.absorbed = true; // consumed here, not again in the cooking loop step.absorbed = true; // consumed here, not again in the cooking loop
} else if (step.attention === "SETUP") {
setupTasks.push(stepToTask(step));
step.absorbed = true;
} }
} }
// Coolest oven first, so a temperature conflict reads "180 °C puis 210 °C";
// `id` as a stable tiebreaker keeps the plan deterministic.
setupTasks.sort((a, b) => setupTaskTemp(a) - setupTaskTemp(b) || a.id.localeCompare(b.id));
const miseTasks = [...prepTasks, ...setupTasks];
if (miseTasks.length > 0) { if (miseTasks.length > 0) {
phases.push({ index: 0, kind: "mise-en-place", tasks: miseTasks, background: [] }); phases.push({
index: 0,
kind: "mise-en-place",
tasks: miseTasks,
background: [],
notes: detectApplianceTemperatureConflicts(setupTasks),
});
} }
// Cooking phases — one "next step of each recipe" per phase. `hold` keeps // Cooking phases — one "next step of each recipe" per phase. `hold` keeps
@ -490,6 +710,9 @@ export function optimizeCookingPlan(recipes: OptimizerRecipeInput[]): OptimizeCo
kind: cookingPhaseKind(tasks), kind: cookingPhaseKind(tasks),
tasks, tasks,
background, background,
// Temperature conflicts only matter in mise-en-place, where all the
// preheats live; a cooking phase never carries a note today.
notes: [],
}); });
for (const step of phaseSteps) { for (const step of phaseSteps) {

View file

@ -47,6 +47,7 @@ function cookingSessionPlanningInclude() {
}, },
}, },
utensils: { include: { utensil: true } }, utensils: { include: { utensil: true } },
temperatures: true,
}, },
}, },
}, },
@ -87,6 +88,19 @@ function toOptimizerRecipe(item: PlanningItemWithSteps): OptimizerRecipeInput {
order: techStep.order, order: techStep.order,
ingredients, ingredients,
utensils: techStep.utensils.map(({ utensil }) => ({ id: utensil.id, key: utensil.key })), utensils: techStep.utensils.map(({ utensil }) => ({ id: utensil.id, key: utensil.key })),
// Structured value only — `Decimal` °C → plain number, `qualitative`
// re-narrowed to the literal union (same posture as
// `recipe.service.ts`'s `toStepTechStepViews`).
temperatures: techStep.temperatures.map((temperature) => ({
celsius: temperature.celsius === null ? null : Number(temperature.celsius),
gasMark: temperature.gasMark,
qualitative:
temperature.qualitative === "low" ||
temperature.qualitative === "medium" ||
temperature.qualitative === "high"
? temperature.qualitative
: null,
})),
}; };
}), }),
})); }));

View file

@ -210,5 +210,120 @@ describe("Cooking session", () => {
const backgrounds = later.flatMap((p: { background: unknown[] }) => p.background); const backgrounds = later.flatMap((p: { background: unknown[] }) => p.background);
expect(backgrounds.length).to.be.greaterThan(0); expect(backgrounds.length).to.be.greaterThan(0);
}); });
it("pools two preheats at the same temperature, and flags a conflict at different temperatures", async () => {
const agent = request.agent(app);
await agent.post("/auth/signup").send(buildSignupPayload());
const houseRes = await agent.post("/house").send({ name: "Chez moi" });
const houseId: number = houseRes.body.id;
const authorId: number = houseRes.body.adminId;
const preheatId = await techStepId("preheat");
const bakeId = await techStepId("bake");
/** A recipe whose first step is "préchauffer le four" carrying a °C temperature, then a bake. */
async function makeOvenRecipe(name: string, description: string, celsius: number) {
return prisma.recipe.create({
data: {
name,
authorId,
portions: 4,
steps: {
create: [
{
order: 0,
description,
techSteps: {
create: [
{
techStepId: preheatId,
order: 0,
temperatures: {
create: [
{
start: 0,
end: description.length,
celsius,
gasMark: null,
qualitative: null,
raw: `${celsius}°C`,
},
],
},
},
],
},
},
{
order: 1,
description: "Enfourner",
techSteps: { create: [{ techStepId: bakeId, order: 0 }] },
},
],
},
},
});
}
async function planWeek(recipeIds: number[]) {
const planning = await prisma.planning.create({
data: {
houseId,
startDate: TEST_REFERENCE_DATE.minus({ days: 2 }).toJSDate(),
finishDate: TEST_REFERENCE_DATE.plus({ days: 2 }).toJSDate(),
},
});
await prisma.planningItem.createMany({
data: recipeIds.map((recipeId, i) => ({
planningId: planning.id,
weekDay: i === 0 ? "lundi" : "mardi",
meal: i === 0 ? "dejeuner" : "diner",
recipeId,
portions: 4,
})),
});
}
// Same temperature → one pooled merged-setup task, no conflict note.
const gratin = await makeOvenRecipe("Gratin", "Préchauffer le four à 180°C", 180);
const tarte = await makeOvenRecipe("Tarte", "Préchauffer à 180 degrés", 180);
await planWeek([gratin.id, tarte.id]);
let res = await agent.get("/cooking-session").query({ date: today() });
expect(res.status).to.equal(200);
let mise = res.body.phases[0];
const setupMerges = mise.tasks.filter((t: { kind: string }) => t.kind === "merged-setup");
expect(setupMerges).to.have.length(1);
expect(setupMerges[0].technique.key).to.equal("preheat");
expect(setupMerges[0].temperatures).to.deep.equal([
{ celsius: 180, gasMark: null, qualitative: null },
]);
expect(mise.notes).to.deep.equal([]);
// Re-plan the same week with a second temperature → not merged, conflict note.
await prisma.planning.deleteMany({ where: { houseId } });
const roti = await makeOvenRecipe("Rôti", "Préchauffer le four à 210°C", 210);
await planWeek([gratin.id, roti.id]);
res = await agent.get("/cooking-session").query({ date: today() });
mise = res.body.phases[0];
expect(mise.tasks.filter((t: { kind: string }) => t.kind === "merged-setup")).to.have.length(
0,
);
const preheats = mise.tasks.filter(
(t: { technique: { key: string } | null }) => t.technique?.key === "preheat",
);
expect(preheats.map((t: { description: string }) => t.description)).to.deep.equal([
"Préchauffer le four à 180°C",
"Préchauffer le four à 210°C",
]);
const note = mise.notes.find(
(n: { kind: string }) => n.kind === "appliance-temperature-conflict",
);
expect(note, "a conflict note").to.not.equal(undefined);
expect(note.temperatures.map((tp: { celsius: number }) => tp.celsius)).to.deep.equal([
180, 210,
]);
});
}); });
}); });

View file

@ -2,6 +2,7 @@ import type {
CookingPhaseView, CookingPhaseView,
CookingTaskIngredientView, CookingTaskIngredientView,
CookingTaskView, CookingTaskView,
CookingTemperatureView,
IngredientView, IngredientView,
TechStepView, TechStepView,
UnitView, UnitView,
@ -84,13 +85,19 @@ describe("cooking-optimizer", () => {
return { id, key }; return { id, key };
} }
/** Minimal `CookingTemperatureView` — a plain °C target, the common `preheat` case. */
function celsius(value: number): CookingTemperatureView {
return { celsius: value, gasMark: null, qualitative: null };
}
/** One technique clause of a step. */ /** One technique clause of a step. */
function clause( function clause(
techKey: string, techKey: string,
ingredients: CookingTaskIngredientView[] = [], ingredients: CookingTaskIngredientView[] = [],
utensils: { id: number; key: string }[] = [], utensils: { id: number; key: string }[] = [],
temperatures: CookingTemperatureView[] = [],
): OptimizerTechStepInput { ): OptimizerTechStepInput {
return { techStep: tech(techKey), order: 0, ingredients, utensils }; return { techStep: tech(techKey), order: 0, ingredients, utensils, temperatures };
} }
/** A step described by an ordered list of technique clauses. */ /** A step described by an ordered list of technique clauses. */
@ -134,8 +141,9 @@ describe("cooking-optimizer", () => {
const allTasks = (plan: { phases: CookingPhaseView[] }): CookingTaskView[] => const allTasks = (plan: { phases: CookingPhaseView[] }): CookingTaskView[] =>
plan.phases.flatMap((phase) => phase.tasks); plan.phases.flatMap((phase) => phase.tasks);
/** Every pooled task — `merged-prep` and `merged-setup` alike (i.e. anything that isn't a plain recipe `step`). */
const mergedTasks = (plan: { phases: CookingPhaseView[] }): CookingTaskView[] => const mergedTasks = (plan: { phases: CookingPhaseView[] }): CookingTaskView[] =>
allTasks(plan).filter((task) => task.kind === "merged-prep"); allTasks(plan).filter((task) => task.kind !== "step");
const stepTasks = (plan: { phases: CookingPhaseView[] }): CookingTaskView[] => const stepTasks = (plan: { phases: CookingPhaseView[] }): CookingTaskView[] =>
allTasks(plan).filter((task) => task.kind === "step"); allTasks(plan).filter((task) => task.kind === "step");
@ -575,6 +583,165 @@ describe("cooking-optimizer", () => {
}); });
}); });
// =====================================================================
// Setup pooling (preheat / boil), gated on action metadata
// =====================================================================
describe("setup pooling", () => {
const setupMerges = (plan: { phases: CookingPhaseView[] }) =>
mergedTasks(plan).filter((t) => t.kind === "merged-setup");
const conflictNote = (plan: { phases: CookingPhaseView[] }) =>
plan.phases[0]?.notes.find((n) => n.kind === "appliance-temperature-conflict");
it("merges two preheats at the same temperature into one merged-setup task", () => {
const recipes = [
recipe(1, "Gratin", [
step(1, 0, "Préchauffer le four à 180°C", [clause("preheat", [], [], [celsius(180)])]),
simpleStep(2, 1, "Enfourner", "bake"),
]),
recipe(2, "Tarte", [
step(3, 0, "Préchauffer à 180 degrés", [clause("preheat", [], [], [celsius(180)])]),
simpleStep(4, 1, "Cuire", "bake"),
]),
];
const plan = optimizeCookingPlan(recipes);
const merged = setupMerges(plan);
expect(merged).to.have.lengthOf(1);
expect(merged[0]?.technique?.key).to.equal("preheat");
expect(merged[0]?.description).to.equal(null);
expect(merged[0]?.temperatures).to.deep.equal([celsius(180)]);
expect(merged[0]?.sourceRecipes.map((r) => r.recipeId)).to.have.members([1, 2]);
expect(merged[0]?.id).to.equal("setup:preheat:c180");
expect(plan.phases[0]?.notes).to.deep.equal([]);
assertEveryStepAccountedForOnce(recipes, plan);
});
it("keeps two preheats at different temperatures separate, coolest-first, with a conflict note", () => {
const recipes = [
recipe(1, "Gratin", [
step(1, 0, "Préchauffer le four à 210°C", [clause("preheat", [], [], [celsius(210)])]),
simpleStep(2, 1, "Enfourner", "bake"),
]),
recipe(2, "Tarte", [
step(3, 0, "Préchauffer le four à 180°C", [clause("preheat", [], [], [celsius(180)])]),
simpleStep(4, 1, "Cuire", "bake"),
]),
];
const plan = optimizeCookingPlan(recipes);
expect(setupMerges(plan)).to.have.lengthOf(0);
const preheats = (plan.phases[0]?.tasks ?? []).filter((t) => t.technique?.key === "preheat");
expect(preheats.map((t) => t.description)).to.deep.equal([
"Préchauffer le four à 180°C",
"Préchauffer le four à 210°C",
]);
const note = conflictNote(plan);
expect(note, "a conflict note on the mise-en-place phase").to.not.equal(undefined);
expect(note?.technique.key).to.equal("preheat");
expect(note?.temperatures.map((tp) => tp.celsius)).to.deep.equal([180, 210]);
assertEveryStepAccountedForOnce(recipes, plan);
});
it("merges two preheats with no detected temperature (optimistic default), no note", () => {
const recipes = [
recipe(1, "A", [
simpleStep(1, 0, "Préchauffer le four", "preheat"),
simpleStep(2, 1, "Enfourner", "bake"),
]),
recipe(2, "B", [
simpleStep(3, 0, "Préchauffer le four", "preheat"),
simpleStep(4, 1, "Cuire", "bake"),
]),
];
const plan = optimizeCookingPlan(recipes);
const merged = setupMerges(plan);
expect(merged).to.have.lengthOf(1);
expect(merged[0]?.temperatures).to.deep.equal([]);
expect(merged[0]?.id).to.equal("setup:preheat:any");
expect(plan.phases[0]?.notes).to.deep.equal([]);
assertEveryStepAccountedForOnce(recipes, plan);
});
it("does not fire a conflict note when a known-temperature preheat sits next to an unknown one", () => {
const recipes = [
recipe(1, "A", [
step(1, 0, "Préchauffer le four à 200°C", [clause("preheat", [], [], [celsius(200)])]),
simpleStep(2, 1, "Enfourner", "bake"),
]),
recipe(2, "B", [
simpleStep(3, 0, "Préchauffer le four", "preheat"),
simpleStep(4, 1, "Cuire", "bake"),
]),
];
const plan = optimizeCookingPlan(recipes);
// Two distinct signatures (`c200` vs `""`) → no merge, but only one
// *known* temperature → nothing to sequence, so no note.
expect(setupMerges(plan)).to.have.lengthOf(0);
expect(plan.phases[0]?.notes).to.deep.equal([]);
assertEveryStepAccountedForOnce(recipes, plan);
});
it("pools 'bring water to a boil' across recipes regardless of temperature", () => {
const recipes = [
recipe(1, "Pâtes", [
simpleStep(1, 0, "Porter une casserole d'eau à ébullition", "boil"),
simpleStep(2, 1, "Cuire les pâtes", "cook"),
]),
recipe(2, "Riz", [
simpleStep(3, 0, "Porter de l'eau à ébullition", "boil"),
simpleStep(4, 1, "Cuire le riz", "cook"),
]),
];
const plan = optimizeCookingPlan(recipes);
const merged = setupMerges(plan);
expect(merged).to.have.lengthOf(1);
expect(merged[0]?.technique?.key).to.equal("boil");
expect(merged[0]?.sourceRecipes.map((r) => r.recipeId)).to.have.members([1, 2]);
assertEveryStepAccountedForOnce(recipes, plan);
});
it("keeps a preheat only one recipe needs as its own step task, no note", () => {
const recipes = [
recipe(1, "Gratin", [
step(1, 0, "Préchauffer le four à 200°C", [clause("preheat", [], [], [celsius(200)])]),
simpleStep(2, 1, "Enfourner", "bake"),
]),
recipe(2, "Salade", [simpleStep(3, 0, "Mélanger", "mix")]),
];
const plan = optimizeCookingPlan(recipes);
expect(setupMerges(plan)).to.have.lengthOf(0);
const preheat = allTasks(plan).find((t) => t.technique?.key === "preheat");
expect(preheat?.kind).to.equal("step");
expect(plan.phases[0]?.tasks).to.include(preheat);
expect(plan.phases[0]?.notes).to.deep.equal([]);
assertEveryStepAccountedForOnce(recipes, plan);
});
it("is deterministic with temperature-gated setup merges", () => {
const build = () => [
recipe(1, "A", [
step(1, 0, "Préchauffer à 180°C", [clause("preheat", [], [], [celsius(180)])]),
simpleStep(2, 1, "Cuire", "bake"),
]),
recipe(2, "B", [
step(3, 0, "Préchauffer à 220°C", [clause("preheat", [], [], [celsius(220)])]),
simpleStep(4, 1, "Cuire", "bake"),
]),
recipe(3, "C", [
step(5, 0, "Préchauffer à 180°C", [clause("preheat", [], [], [celsius(180)])]),
simpleStep(6, 1, "Cuire", "bake"),
]),
];
expect(JSON.stringify(optimizeCookingPlan(build()))).to.equal(
JSON.stringify(optimizeCookingPlan(build())),
);
});
});
// ===================================================================== // =====================================================================
// Quantity scaling // Quantity scaling
// ===================================================================== // =====================================================================
@ -764,7 +931,7 @@ describe("cooking-optimizer", () => {
simpleStep(24, 4, "Parsemer de persil", "plate", [line(ING.parsley, null, null)]), simpleStep(24, 4, "Parsemer de persil", "plate", [line(ING.parsley, null, null)]),
]), ]),
recipe(104, "Poulet rôti & pommes de terre", [ recipe(104, "Poulet rôti & pommes de terre", [
simpleStep(30, 0, "Préchauffer le four à 210°C", "preheat"), step(30, 0, "Préchauffer le four à 210°C", [clause("preheat", [], [], [celsius(210)])]),
simpleStep(31, 1, "Éplucher les pommes de terre", "peel", [ simpleStep(31, 1, "Éplucher les pommes de terre", "peel", [
line(ING.potato, 1, KILOGRAM), line(ING.potato, 1, KILOGRAM),
]), ]),
@ -774,7 +941,7 @@ describe("cooking-optimizer", () => {
simpleStep(35, 5, "Découper et dresser", "plate"), simpleStep(35, 5, "Découper et dresser", "plate"),
]), ]),
recipe(105, "Gratin dauphinois", [ recipe(105, "Gratin dauphinois", [
simpleStep(40, 0, "Préchauffer le four à 180°C", "preheat"), step(40, 0, "Préchauffer le four à 180°C", [clause("preheat", [], [], [celsius(180)])]),
simpleStep(41, 1, "Éplucher les pommes de terre", "peel", [ simpleStep(41, 1, "Éplucher les pommes de terre", "peel", [
line(ING.potato, 1, KILOGRAM), line(ING.potato, 1, KILOGRAM),
]), ]),
@ -837,14 +1004,25 @@ describe("cooking-optimizer", () => {
expect(onionPool?.technique?.key).to.equal("chop"); expect(onionPool?.technique?.key).to.equal("chop");
}); });
it("pulls every 'préchauffer le four' into the mise-en-place phase", () => { it("keeps the two 'préchauffer le four' steps separate (different °C), coolest-first, with a conflict note", () => {
const week = householdWeek(); const week = householdWeek();
const plan = optimizeCookingPlan(week); const plan = optimizeCookingPlan(week);
const startsWithPreheat = (d: string | null) => (d ?? "").startsWith("Préchauffer le four"); const startsWithPreheat = (d: string | null) => (d ?? "").startsWith("Préchauffer le four");
// Recipes 104 (210 °C) and 105 (180 °C) preheat the same oven to
// different temperatures — never merged, both in mise-en-place, and
// ordered so the cook heats to 180 first then 210.
const miseDescr = plan.phases[0]?.tasks.map((t) => t.description) ?? []; const miseDescr = plan.phases[0]?.tasks.map((t) => t.description) ?? [];
expect(miseDescr.filter(startsWithPreheat)).to.have.lengthOf(2); expect(miseDescr.filter(startsWithPreheat)).to.deep.equal([
"Préchauffer le four à 180°C",
"Préchauffer le four à 210°C",
]);
const laterDescr = plan.phases.slice(1).flatMap((p) => p.tasks.map((t) => t.description)); const laterDescr = plan.phases.slice(1).flatMap((p) => p.tasks.map((t) => t.description));
expect(laterDescr.some(startsWithPreheat)).to.equal(false); expect(laterDescr.some(startsWithPreheat)).to.equal(false);
const note = plan.phases[0]?.notes.find((n) => n.kind === "appliance-temperature-conflict");
expect(note?.technique.key).to.equal("preheat");
expect(note?.temperatures.map((tp) => tp.celsius)).to.deep.equal([180, 210]);
}); });
it("runs the long cooks in the background of later phases (bourguignon braise, poulet rôti, gratins)", () => { it("runs the long cooks in the background of later phases (bourguignon braise, poulet rôti, gratins)", () => {

View file

@ -44,6 +44,7 @@ function planFixture() {
index: 0, index: 0,
kind: "mise-en-place", kind: "mise-en-place",
background: [], background: [],
notes: [],
tasks: [ tasks: [
{ {
id: "prep:chop:onion", id: "prep:chop:onion",
@ -58,6 +59,7 @@ function planFixture() {
}, },
], ],
utensils: [{ id: 1, key: "knife" }], utensils: [{ id: 1, key: "knife" }],
temperatures: [],
sourceRecipes: [ sourceRecipes: [
{ recipeId: 1, name: "Soupe à l'oignon", portions: 4 }, { recipeId: 1, name: "Soupe à l'oignon", portions: 4 },
{ recipeId: 2, name: "Tarte à l'oignon", portions: 6 }, { recipeId: 2, name: "Tarte à l'oignon", portions: 6 },
@ -70,6 +72,7 @@ function planFixture() {
index: 1, index: 1,
kind: "cooking", kind: "cooking",
background: [], background: [],
notes: [],
tasks: [ tasks: [
{ {
id: "step:0:1", id: "step:0:1",
@ -78,6 +81,7 @@ function planFixture() {
description: "Faire mijoter le bouillon", description: "Faire mijoter le bouillon",
ingredients: [], ingredients: [],
utensils: [], utensils: [],
temperatures: [],
sourceRecipes: [{ recipeId: 1, name: "Soupe à l'oignon", portions: 4 }], sourceRecipes: [{ recipeId: 1, name: "Soupe à l'oignon", portions: 4 }],
originalSteps: [], originalSteps: [],
}, },
@ -95,6 +99,7 @@ function planFixture() {
recipeName: "Soupe à l'oignon", recipeName: "Soupe à l'oignon",
}, },
], ],
notes: [],
tasks: [ tasks: [
{ {
id: "step:1:3", id: "step:1:3",
@ -103,6 +108,7 @@ function planFixture() {
description: "Enfourner la tarte", description: "Enfourner la tarte",
ingredients: [], ingredients: [],
utensils: [], utensils: [],
temperatures: [],
sourceRecipes: [{ recipeId: 2, name: "Tarte à l'oignon", portions: 6 }], sourceRecipes: [{ recipeId: 2, name: "Tarte à l'oignon", portions: 6 }],
originalSteps: [], originalSteps: [],
}, },
@ -112,6 +118,74 @@ function planFixture() {
}; };
} }
/**
* A one-phase plan whose mise-en-place has a pooled `merged-setup` preheat
* (both recipes, same 180 °C) plus two un-merged preheats at 200 °C / 220 °C
* that trigger an `appliance-temperature-conflict` note.
*/
function setupPlanFixture() {
const soupe = { recipeId: 1, name: "Soupe", portions: 4 };
const gratin = { recipeId: 2, name: "Gratin", portions: 4 };
const roti = { recipeId: 3, name: "Rôti", portions: 4 };
return {
startDate: "2026-08-17T00:00:00.000Z",
finishDate: "2026-08-23T00:00:00.000Z",
recipes: [soupe, gratin, roti],
phases: [
{
index: 0,
kind: "mise-en-place",
background: [],
notes: [
{
kind: "appliance-temperature-conflict",
technique: { id: 9, key: "preheat" },
temperatures: [
{ celsius: 200, gasMark: null, qualitative: null },
{ celsius: 220, gasMark: null, qualitative: null },
],
},
],
tasks: [
{
id: "setup:preheat:c180",
kind: "merged-setup",
technique: { id: 9, key: "preheat" },
description: null,
ingredients: [],
utensils: [],
temperatures: [{ celsius: 180, gasMark: null, qualitative: null }],
sourceRecipes: [soupe, gratin],
originalSteps: [],
},
{
id: "step:1:10",
kind: "step",
technique: { id: 9, key: "preheat" },
description: "Préchauffer le four à 200°C",
ingredients: [],
utensils: [],
temperatures: [{ celsius: 200, gasMark: null, qualitative: null }],
sourceRecipes: [gratin],
originalSteps: [],
},
{
id: "step:2:20",
kind: "step",
technique: { id: 9, key: "preheat" },
description: "Préchauffer le four à 220°C",
ingredients: [],
utensils: [],
temperatures: [{ celsius: 220, gasMark: null, qualitative: null }],
sourceRecipes: [roti],
originalSteps: [],
},
],
},
],
};
}
describe("Cooking session page", () => { describe("Cooking session page", () => {
beforeEach(() => { beforeEach(() => {
cy.viewport(1400, 900); cy.viewport(1400, 900);
@ -226,6 +300,27 @@ describe("Cooking session page", () => {
cy.get(".cooking-stepper").should("not.exist"); cy.get(".cooking-stepper").should("not.exist");
}); });
it("renders a merged-setup preheat with its temperature and the oven-conflict note", () => {
cy.intercept("GET", /\/cooking-session\?/, { statusCode: 200, body: setupPlanFixture() });
cy.visit("/cuisiner?date=2026-08-17");
// The pooled preheat shows the technique label + its pooled temperature,
// flagged shared like a merged-prep task.
cy.get(".cooking-task--merged-setup")
.should("contain.text", "180 °C")
.and("contain.text", "Mutualisé");
// The phase warns the oven is needed at two more temperatures, in order.
cy.get(".cooking-phase__note")
.should("contain.text", "2 températures")
.and("contain.text", "200 °C puis 220 °C");
// The two un-merged preheats are still listed as their own tasks.
cy.contains(".cooking-task", "Préchauffer le four à 200°C").should("be.visible");
cy.contains(".cooking-task", "Préchauffer le four à 220°C").should("be.visible");
cy.contains(".cooking-plan__count", "0 / 3 tâches").should("be.visible");
});
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,

View file

@ -53,3 +53,11 @@ Feature: Start cooking an optimized plan
Then I should see "Soupe à l'oignon" Then I should see "Soupe à l'oignon"
And the recipe "Soupe à l'oignon" should link to "/recettes/1" And the recipe "Soupe à l'oignon" should link to "/recettes/1"
And the recipe "Tarte à l'oignon" should link to "/recettes/2" And the recipe "Tarte à l'oignon" should link to "/recettes/2"
Scenario: Two recipes preheat the oven to different temperatures
Given the planning for this week has recipes "Gratin" and "Rôti"
And the cooking plan for "2026-08-17" needs the oven at 180 °C and 210 °C
When I visit "/cuisiner?date=2026-08-17"
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"

View file

@ -59,6 +59,7 @@ Given(
index: 0, index: 0,
kind: "mise-en-place", kind: "mise-en-place",
background: [], background: [],
notes: [],
tasks: [ tasks: [
{ {
id: "prep:chop:onion", id: "prep:chop:onion",
@ -73,6 +74,7 @@ Given(
}, },
], ],
utensils: [], utensils: [],
temperatures: [],
sourceRecipes: [ sourceRecipes: [
{ recipeId: 1, name: "Soupe à l'oignon", portions: 4 }, { recipeId: 1, name: "Soupe à l'oignon", portions: 4 },
{ recipeId: 2, name: "Tarte à l'oignon", portions: 4 }, { recipeId: 2, name: "Tarte à l'oignon", portions: 4 },
@ -117,6 +119,7 @@ Given("the cooking plan for {string} has a mise en place then a cooking step", (
index: 0, index: 0,
kind: "mise-en-place", kind: "mise-en-place",
background: [], background: [],
notes: [],
tasks: [ tasks: [
{ {
id: "prep:chop:onion", id: "prep:chop:onion",
@ -131,6 +134,7 @@ Given("the cooking plan for {string} has a mise en place then a cooking step", (
}, },
], ],
utensils: [], utensils: [],
temperatures: [],
sourceRecipes: [ sourceRecipes: [
{ recipeId: 1, name: "Soupe à l'oignon", portions: 4 }, { recipeId: 1, name: "Soupe à l'oignon", portions: 4 },
{ recipeId: 2, name: "Tarte à l'oignon", portions: 6 }, { recipeId: 2, name: "Tarte à l'oignon", portions: 6 },
@ -143,6 +147,7 @@ Given("the cooking plan for {string} has a mise en place then a cooking step", (
index: 1, index: 1,
kind: "cooking", kind: "cooking",
background: [], background: [],
notes: [],
tasks: [ tasks: [
{ {
id: "step:0:1", id: "step:0:1",
@ -151,6 +156,7 @@ Given("the cooking plan for {string} has a mise en place then a cooking step", (
description: "Faire mijoter le bouillon", description: "Faire mijoter le bouillon",
ingredients: [], ingredients: [],
utensils: [], utensils: [],
temperatures: [],
sourceRecipes: [{ recipeId: 1, name: "Soupe à l'oignon", portions: 4 }], sourceRecipes: [{ recipeId: 1, name: "Soupe à l'oignon", portions: 4 }],
originalSteps: [], originalSteps: [],
}, },
@ -161,6 +167,51 @@ Given("the cooking plan for {string} has a mise en place then a cooking step", (
}); });
}); });
Given("the cooking plan for {string} needs the oven at 180 °C and 210 °C", (date: string) => {
const celsius = (value: number) => ({ celsius: value, gasMark: null, qualitative: null });
const preheatStep = (id: string, recipe: { recipeId: number; name: string }, value: number) => ({
id,
kind: "step",
technique: { id: 9, key: "preheat" },
description: `Préchauffer le four à ${value}°C`,
ingredients: [],
utensils: [],
temperatures: [celsius(value)],
sourceRecipes: [recipe],
originalSteps: [],
});
const gratin = { recipeId: 1, name: "Gratin", portions: 4 };
const roti = { recipeId: 2, name: "Rôti", portions: 4 };
cy.intercept("GET", `**/cooking-session?date=${date}`, {
statusCode: 200,
body: {
startDate: `${date}T00:00:00.000Z`,
finishDate: "2026-08-23T00:00:00.000Z",
recipes: [gratin, roti],
phases: [
{
index: 0,
kind: "mise-en-place",
background: [],
notes: [
{
kind: "appliance-temperature-conflict",
technique: { id: 9, key: "preheat" },
temperatures: [celsius(180), celsius(210)],
},
],
// Coolest first, as the optimizer emits them.
tasks: [preheatStep("step:0:1", gratin, 180), preheatStep("step:1:2", roti, 210)],
},
],
},
});
});
Then("I should see the oven conflict note {string}", (text: string) => {
cy.get(".cooking-phase__note").should("contain.text", text);
});
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();
}); });

View file

@ -1,38 +1,12 @@
import type { import type { StepTechStepView, SubmitTechStepCorrectionResult } from "@batch-cooking/shared";
StepTechStepTemperatureView,
StepTechStepView,
SubmitTechStepCorrectionResult,
} from "@batch-cooking/shared";
import { Fragment, useEffect, useRef, useState } from "react"; import { Fragment, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Tooltip } from "../../../components/ui/Tooltip"; import { Tooltip } from "../../../components/ui/Tooltip";
import { splitDescriptionSegments } from "./highlight-tech-steps"; import { splitDescriptionSegments } from "./highlight-tech-steps";
import { TechStepCorrectionPopover } from "./TechStepCorrectionPopover"; import { TechStepCorrectionPopover } from "./TechStepCorrectionPopover";
import { temperatureLabel } from "./temperature-label";
import { type TextSelectionRange, useTextSelection } from "./use-text-selection"; import { type TextSelectionRange, useTextSelection } from "./use-text-selection";
/**
* Human label for a detected temperature — the °C value, else the gas-mark
* number, else the qualitative level, each via i18n. Shown in the
* highlight's Tooltip (`raw` is what stays inline as the highlighted text).
* `t` is passed in (see `ingredient-label.ts` for the same "plain function,
* unit-testable" split).
*/
function temperatureLabel(
temperature: StepTechStepTemperatureView,
t: (key: string, options?: Record<string, unknown>) => string,
): string {
if (temperature.celsius !== null) {
return t("recipes.temperature.celsius", { value: temperature.celsius });
}
if (temperature.gasMark !== null) {
return t("recipes.temperature.gasMark", { value: temperature.gasMark });
}
if (temperature.qualitative !== null) {
return t(`recipes.temperature.qualitative.${temperature.qualitative}`);
}
return temperature.raw;
}
/** /**
* A recipe step's description, with every detected technique's exact * A recipe step's description, with every detected technique's exact
* matched words highlighted and given a {@link Tooltip} naming the * matched words highlighted and given a {@link Tooltip} naming the

View file

@ -0,0 +1,40 @@
/**
* The three structured fields a temperature label is built from. Both
* `StepTechStepTemperatureView` (recipe-detail highlight — also carries a
* `raw` fragment) and `CookingTemperatureView` (the cooking session's
* pooled setup targets — no `raw`) satisfy this, so {@link temperatureLabel}
* serves both without either type depending on the other.
*/
export interface TemperatureLabelInput {
celsius: number | null;
gasMark: number | null;
qualitative: "low" | "medium" | "high" | null;
/** The exact recognized fragment, present only on a recipe-detail temperature — used only as a last-resort fallback (unreachable when one of the three above is set, which the contract guarantees). */
raw?: string;
}
/**
* Human label for a detected temperature — the °C value, else the gas-mark
* number, else the qualitative level, each via i18n (`recipes.temperature.*`).
* `t` is passed in rather than calling `useTranslation` here so this stays a
* plain function usable from non-component code and unit-testable without
* mounting i18next — same split as `ingredient-label.ts` / `shopping-list.ts`.
*
* Extracted from `StepDescription.tsx` (its original home) once the cooking
* session page needed the same label for a `merged-setup` task.
*/
export function temperatureLabel(
temperature: TemperatureLabelInput,
t: (key: string, options?: Record<string, unknown>) => string,
): string {
if (temperature.celsius !== null) {
return t("recipes.temperature.celsius", { value: temperature.celsius });
}
if (temperature.gasMark !== null) {
return t("recipes.temperature.gasMark", { value: temperature.gasMark });
}
if (temperature.qualitative !== null) {
return t(`recipes.temperature.qualitative.${temperature.qualitative}`);
}
return temperature.raw ?? "";
}

View file

@ -350,8 +350,13 @@
"background": { "background": {
"title": "Pendant ce temps" "title": "Pendant ce temps"
}, },
"note": {
"applianceTemperatureConflict": "Four requis à {{count}} températures — enchaîner {{sequence}}",
"sequenceSeparator": "puis"
},
"task": { "task": {
"mergedPrepLabel": "{{technique}} : {{items}}", "mergedPrepLabel": "{{technique}} : {{items}}",
"mergedSetupLabel": "{{technique}} · {{temperature}}",
"forRecipes": "pour {{recipes}}", "forRecipes": "pour {{recipes}}",
"utensils": "Ustensiles", "utensils": "Ustensiles",
"sharedBadge": "Mutualisé" "sharedBadge": "Mutualisé"

View file

@ -13,7 +13,13 @@ import { apiClient } from "../../api/client";
import { CheckboxOption } from "../../components/ui/Checkbox"; 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 { phaseProgress, reconcileChecked, taskHeadline, taskRecipeNames } from "./cooking-session"; import {
phaseNoteText,
phaseProgress,
reconcileChecked,
taskHeadline,
taskRecipeNames,
} from "./cooking-session";
import "./cooking-session-page.scss"; import "./cooking-session-page.scss";
/** Load state for the `GET /cooking-session` call — same discriminated-union shape as `ShoppingListPage`'s own state. */ /** Load state for the `GET /cooking-session` call — same discriminated-union shape as `ShoppingListPage`'s own state. */
@ -269,6 +275,12 @@ function PhaseSection({
return ( return (
<section className={`cooking-phase cooking-phase--${phase.kind}`}> <section className={`cooking-phase cooking-phase--${phase.kind}`}>
{(phase.notes ?? []).map((note) => (
<p key={note.kind} className="cooking-phase__note">
{phaseNoteText(note, t)}
</p>
))}
{phase.background.length > 0 && ( {phase.background.length > 0 && (
<div className="cooking-phase__background"> <div className="cooking-phase__background">
<span className="cooking-phase__background-title"> <span className="cooking-phase__background-title">
@ -299,7 +311,7 @@ function PhaseSection({
); );
} }
/** A single actionable task — a merged-prep pool 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. */
function TaskCard({ function TaskCard({
task, task,
checked, checked,
@ -310,6 +322,7 @@ function TaskCard({
onToggle: (isChecked: boolean) => void; onToggle: (isChecked: boolean) => void;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const isMerged = task.kind === "merged-prep" || task.kind === "merged-setup";
return ( return (
<CheckboxOption <CheckboxOption
@ -320,7 +333,7 @@ function TaskCard({
<span className="cooking-task__body"> <span className="cooking-task__body">
<span className="cooking-task__headline"> <span className="cooking-task__headline">
{taskHeadline(task, t)} {taskHeadline(task, t)}
{task.kind === "merged-prep" && ( {isMerged && (
<span className="cooking-task__badge">{t("cookingSession.task.sharedBadge")}</span> <span className="cooking-task__badge">{t("cookingSession.task.sharedBadge")}</span>
)} )}
</span> </span>

View file

@ -303,6 +303,20 @@
flex-direction: column; flex-direction: column;
gap: var(--space-sm); gap: var(--space-sm);
// A phase-level caveat — today only the "four requis à 2 températures"
// conflict. A tinted callout in the accent colour so it reads as a
// heads-up, not a plain paragraph.
&__note {
margin: 0;
padding: var(--space-sm) var(--space-md);
border-left: 3px solid var(--color-accent);
border-radius: 0 var(--radius-md) var(--radius-md) 0;
background: color-mix(in srgb, var(--color-accent) 10%, var(--color-surface));
color: var(--color-text);
font-size: var(--font-size-sm);
font-weight: 600;
}
// "Pendant ce temps" — passive cooks still running from earlier phases. // "Pendant ce temps" — passive cooks still running from earlier phases.
&__background { &__background {
border-left: 3px solid var(--color-border); border-left: 3px solid var(--color-border);
@ -346,7 +360,8 @@
align-items: flex-start; align-items: flex-start;
} }
.cooking-task--merged-prep { .cooking-task--merged-prep,
.cooking-task--merged-setup {
border-left: 3px solid var(--color-accent); border-left: 3px solid var(--color-accent);
} }

View file

@ -1,9 +1,12 @@
import type { import type {
CookingPhaseNoteView,
CookingPhaseView, CookingPhaseView,
CookingTaskIngredientView, CookingTaskIngredientView,
CookingTaskView, CookingTaskView,
CookingTemperatureView,
OptimizedCookingPlanView, OptimizedCookingPlanView,
} from "@batch-cooking/shared"; } from "@batch-cooking/shared";
import { temperatureLabel } from "../../features/recipes/steps/temperature-label";
/** /**
* Minimal shape of `react-i18next`'s `t` — just what this module needs. * Minimal shape of `react-i18next`'s `t` — just what this module needs.
@ -39,27 +42,66 @@ export function formatIngredientLine(line: CookingTaskIngredientView, t: Transla
return `${amount} ${unit}${name}`.trim(); return `${amount} ${unit}${name}`.trim();
} }
/**
* The pooled target temperature(s) of a `merged-setup` task as one string —
* `"180 °C"`, `"180 °C / Thermostat 6"` in the rare mixed case, or `""` when
* no temperature was detected. Labels via the shared {@link temperatureLabel}
* (same `recipes.temperature.*` keys as the recipe-step highlight).
*/
export function formatTemperatures(temperatures: CookingTemperatureView[], t: TranslateFn): string {
return temperatures.map((temperature) => temperatureLabel(temperature, t)).join(" / ");
}
/** /**
* The headline shown on a task card: * The headline shown on a task card:
* - `merged-prep` — `"Émincer : 3 oignon, 200 g carotte"` (technique label + * - `merged-prep` — `"Émincer : 3 oignon, 200 g carotte"` (technique label +
* its pooled ingredient lines), built from the * its pooled ingredient lines), built from the
* `cookingSession.task.mergedPrepLabel` template. * `cookingSession.task.mergedPrepLabel` template.
* - `merged-setup` — `"Préchauffer · 180 °C"` (technique label + pooled
* temperature), via `cookingSession.task.mergedSetupLabel`; just the
* technique label when no temperature was detected.
* - `step` — the original recipe step text, verbatim. * - `step` — the original recipe step text, verbatim.
*/ */
export function taskHeadline(task: CookingTaskView, t: TranslateFn): string { export function taskHeadline(task: CookingTaskView, t: TranslateFn): string {
if (task.kind === "step") return task.description ?? ""; if (task.kind === "step") return task.description ?? "";
const technique = task.technique const technique = task.technique
? t(`catalog.techSteps.${task.technique.key}`) ? t(`catalog.techSteps.${task.technique.key}`)
: t("cookingSession.phase.mise-en-place"); : t("cookingSession.phase.mise-en-place");
if (task.kind === "merged-setup") {
const temperature = formatTemperatures(task.temperatures, t);
return temperature
? t("cookingSession.task.mergedSetupLabel", { technique, temperature })
: technique;
}
const items = task.ingredients.map((line) => formatIngredientLine(line, t)).join(", "); const items = task.ingredients.map((line) => formatIngredientLine(line, t)).join(", ");
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`. */ /** 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 { export function taskRecipeNames(task: CookingTaskView): string {
return task.sourceRecipes.map((recipe) => recipe.name).join(", "); return task.sourceRecipes.map((recipe) => recipe.name).join(", ");
} }
/**
* The sentence for a phase-level note (see {@link CookingPhaseNoteView}).
* Today the only kind is `appliance-temperature-conflict`: the oven is
* needed at several temperatures at once, so the cook runs the preheats one
* after another — "Four requis à 2 températures — enchaîner 180 °C puis
* 210 °C" (temperatures already coolest-first from the optimizer).
*/
export function phaseNoteText(note: CookingPhaseNoteView, t: TranslateFn): string {
const sequence = note.temperatures
.map((temperature) => temperatureLabel(temperature, t))
.join(` ${t("cookingSession.note.sequenceSeparator")} `);
return t("cookingSession.note.applianceTemperatureConflict", {
count: note.temperatures.length,
sequence,
});
}
/** /**
* Drops any ticked id that no longer names a task in the current plan. * Drops any ticked id that no longer names a task in the current plan.
* `CookingTaskView.id` is only stable *within one plan response* (see its * `CookingTaskView.id` is only stable *within one plan response* (see its

View file

@ -43,6 +43,27 @@ export interface CookingTaskIngredientView {
unit: UnitView | null; unit: UnitView | null;
} }
/**
* A target temperature carried by a {@link CookingTaskView} or a
* {@link CookingPhaseNoteView} — the *structured* subset of
* `StepTechStepTemperatureView` (no `raw` text / spans / `source`), which is
* all the optimizer needs to decide whether two `preheat` actions can be
* merged and to order them coolest-first. Exactly as on the recipe-detail
* view, at least one of the three is non-null:
* - `celsius` — °C (a °F mention was converted upstream).
* - `gasMark` — a gas-mark / thermostat number.
* - `qualitative` — `"low" | "medium" | "high"` ("feu doux/moyen/vif").
*
* Two temperatures are treated as the *same* only when they match in the
* same representation — no °C↔gas-mark↔qualitative conversion is guessed
* for a merge decision (same stance as `ShoppingListItemView` on units).
*/
export interface CookingTemperatureView {
celsius: number | null;
gasMark: number | null;
qualitative: "low" | "medium" | "high" | null;
}
/** /**
* One actionable unit of work in a phase's `tasks` list. * One actionable unit of work in a phase's `tasks` list.
* *
@ -56,19 +77,34 @@ export interface CookingTaskIngredientView {
* `description` is `null` (the web layer composes a label from * `description` is `null` (the web layer composes a label from
* `technique` + `ingredients`), `sourceRecipes` lists every recipe it * `technique` + `ingredients`), `sourceRecipes` lists every recipe it
* covers, and `ingredients` holds the pooled quantity. * covers, and `ingredients` holds the pooled quantity.
* - `kind: "merged-setup"` — the same setup action (`preheat` / `boil` /
* `bainMarie`) shared across recipes: two recipes that both preheat the
* oven **to the same temperature** become one task. `description` is
* `null` (the web layer composes a label from `technique` +
* `temperatures`), `temperatures` holds the pooled target(s). Two
* `preheat` actions at *different* temperatures are never merged — they
* stay separate tasks and the phase carries a
* {@link CookingPhaseNoteView} telling the cook to run them in sequence.
* *
* `id` is stable within a single response (`"prep:<techniqueKey>:<ingredientKey>"` * `temperatures` is non-empty only for a `merged-setup` task, or a
* or `"step:<stepId>"`) so the frontend can key a checklist off it. * `kind: "step"` task whose step is itself a setup action (so the plan can
* `originalSteps` is the provenance trail — the exact step text(s) this * still order and conflict-check a preheat that only one recipe needs);
* task stands in for, so the UI can link back to "voir la recette". * `[]` otherwise.
*
* `id` is stable within a single response
* (`"prep:<techniqueKey>:<ingredientKey>"`, `"setup:<techniqueKey>:<tempKey>"`
* or `"step:<recipeIndex>:<stepId>"`) so the frontend can key a checklist
* off it. `originalSteps` is the provenance trail — the exact step text(s)
* this task stands in for, so the UI can link back to "voir la recette".
*/ */
export interface CookingTaskView { export interface CookingTaskView {
id: string; id: string;
kind: "merged-prep" | "step"; kind: "merged-prep" | "merged-setup" | "step";
technique: TechStepView | null; technique: TechStepView | null;
description: string | null; description: string | null;
ingredients: CookingTaskIngredientView[]; ingredients: CookingTaskIngredientView[];
utensils: UtensilView[]; utensils: UtensilView[];
temperatures: CookingTemperatureView[];
sourceRecipes: CookingSessionRecipeRef[]; sourceRecipes: CookingSessionRecipeRef[];
originalSteps: { recipeId: number; recipeName: string; description: string }[]; originalSteps: { recipeId: number; recipeName: string; description: string }[];
} }
@ -89,17 +125,45 @@ export interface CookingBackgroundTaskView {
recipeName: string; recipeName: string;
} }
/**
* Kind of a {@link CookingPhaseNoteView}. Only one today; kept as a named
* union so a future note type is an additive change and the web layer's
* `switch` stays exhaustive.
*
* - `"appliance-temperature-conflict"` — the week needs the same appliance
* (the oven, via `preheat`) at two or more incompatible temperatures.
* The conflicting tasks are left un-merged and ordered coolest-first; the
* note tells the cook to run them one after the other.
*/
export type CookingPhaseNoteKind = "appliance-temperature-conflict";
/**
* A caveat attached to a whole phase rather than a single task — see
* {@link CookingPhaseNoteKind}. `technique` is the action the conflict is
* about (`preheat`), `temperatures` the distinct incompatible targets in
* ascending order (coolest first), so the web layer can compose e.g.
* "Four requis à 2 températures — enchaîner 180 °C puis 210 °C".
*/
export interface CookingPhaseNoteView {
kind: CookingPhaseNoteKind;
technique: TechStepView;
temperatures: CookingTemperatureView[];
}
/** /**
* One phase of the optimized plan: a batch of work the cook does now * One phase of the optimized plan: a batch of work the cook does now
* (`tasks`), plus any passive cooks carried over from before (`background`). * (`tasks`), plus any passive cooks carried over from before (`background`)
* `index` is 0-based and matches the array position — carried explicitly so * and any phase-level caveats (`notes`, usually `[]` — see
* a caller rendering a single phase still knows where it sits. * {@link CookingPhaseNoteView}). `index` is 0-based and matches the array
* position — carried explicitly so a caller rendering a single phase still
* knows where it sits.
*/ */
export interface CookingPhaseView { export interface CookingPhaseView {
index: number; index: number;
kind: CookingPhaseKind; kind: CookingPhaseKind;
tasks: CookingTaskView[]; tasks: CookingTaskView[];
background: CookingBackgroundTaskView[]; background: CookingBackgroundTaskView[];
notes: CookingPhaseNoteView[];
} }
/** /**