From 85268729e60a4ef429e75fe6803e84ce3854f493 Mon Sep 17 00:00:00 2001 From: Nicolas Date: Thu, 3 Sep 2026 12:05:44 +0200 Subject: [PATCH] =?UTF-8?q?feat(cooking-session):=20regroupement=20d'?= =?UTF-8?q?=C3=A9tapes=20sensible=20aux=20m=C3=A9tadonn=C3=A9es=20d'action?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../lib/recipe-matching/cooking-optimizer.ts | 257 ++++++++++++++++-- .../cooking-session.service.ts | 14 + apps/api/test/cooking-session.test.ts | 115 ++++++++ .../recipe-matching/cooking-optimizer.test.ts | 190 ++++++++++++- .../cypress/e2e/cooking-session-page.cy.ts | 95 +++++++ apps/web/cypress/e2e/cooking-session.feature | 8 + apps/web/cypress/e2e/cooking-session.ts | 51 ++++ .../recipes/steps/StepDescription.tsx | 30 +- .../recipes/steps/temperature-label.ts | 40 +++ apps/web/src/locales/fr/translation.json | 5 + .../cooking-session/CookingSessionPage.tsx | 19 +- .../cooking-session/cooking-session-page.scss | 17 +- .../pages/cooking-session/cooking-session.ts | 44 ++- packages/shared/src/types/cooking-session.ts | 80 +++++- 14 files changed, 901 insertions(+), 64 deletions(-) create mode 100644 apps/web/src/features/recipes/steps/temperature-label.ts diff --git a/apps/api/src/lib/recipe-matching/cooking-optimizer.ts b/apps/api/src/lib/recipe-matching/cooking-optimizer.ts index b3d4ef9..5fbc493 100644 --- a/apps/api/src/lib/recipe-matching/cooking-optimizer.ts +++ b/apps/api/src/lib/recipe-matching/cooking-optimizer.ts @@ -1,10 +1,12 @@ import type { CookingBackgroundTaskView, CookingPhaseKind, + CookingPhaseNoteView, CookingPhaseView, CookingSessionRecipeRef, CookingTaskIngredientView, CookingTaskView, + CookingTemperatureView, TechStepView, UtensilView, } from "@batch-cooking/shared"; @@ -23,12 +25,22 @@ import type { * `aggregateShoppingList`, so the whole optimization is unit-testable * without a Postgres round-trip. * - * v1 scope (see the plan / spec): preparation is the only thing *merged* - * across recipes — a `chop`/`peel`/… technique applied to the same - * ingredient by two or more recipes, in a step that does nothing but prep, - * collapses into a single {@link CookingTaskView} of `kind: "merged-prep"`. - * Cooking steps themselves are never merged (no "same oven, same - * temperature" reasoning yet); they're only *reordered* for parallelism. + * Two families of work are *merged* across recipes: + * - **Preparation** — a `chop`/`peel`/… technique applied to the same + * ingredient by two or more recipes, in a step that does nothing but + * prep, collapses into one {@link CookingTaskView} of `kind: + * "merged-prep"`. + * - **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 = new Set([ * * - `"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 - * ("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, * rest…). Scheduled, then floated into every following phase's * `background` until the step that consumes it comes up. @@ -106,6 +119,13 @@ interface OptimizerTechStepInput { order: number; ingredients: CookingTaskIngredientView[]; 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}. */ @@ -152,7 +172,7 @@ function scaleOf(recipe: OptimizerRecipeInput): number { 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 { /** Stable within one response: `step::` (the index disambiguates the same recipe planned twice). */ taskId: string; @@ -164,10 +184,14 @@ interface NormalizedStep { techSteps: OptimizerTechStepInput[]; attention: Attention; isPurePrep: boolean; + /** Every technique in the step is a {@link SETUP_TECHNIQUES} one — eligible to pool via {@link extractMergedSetup}. */ + isPureSetup: boolean; dominantTechnique: TechStepView | null; ingredients: CookingTaskIngredientView[]; 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; } @@ -215,11 +239,62 @@ function unionUtensils(utensils: UtensilView[]): UtensilView[] { return [...byId.values()].sort((a, b) => a.key.localeCompare(b.key)); } +/** + * A stable canonical string for one temperature — `c<°C>` / `g` / + * `q`, 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(); + 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}. */ function isPurePrepStep(techSteps: OptimizerTechStepInput[]): boolean { 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}). */ function attentionOf(techSteps: OptimizerTechStepInput[]): Attention { 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, })), 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]; @@ -264,9 +342,11 @@ function normalizeRecipe(recipe: OptimizerRecipeInput, recipeIndex: number): Nor techSteps, attention: attentionOf(techSteps), isPurePrep: isPurePrepStep(techSteps), + isPureSetup: isPureSetupStep(techSteps), dominantTechnique: lastTech ? lastTech.techStep : null, ingredients: unionIngredients(techSteps.flatMap((ts) => ts.ingredients)), utensils: unionUtensils(techSteps.flatMap((ts) => ts.utensils)), + temperatures: unionTemperatures(techSteps.flatMap((ts) => ts.temperatures)), absorbed: false, } satisfies NormalizedStep; }); @@ -283,6 +363,26 @@ function prepSignature(step: NormalizedStep): string { return [...new Set(pairs)].sort().join("+"); } +/** + * The setup signature of a pure-setup step — `|`. 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. */ function stepToTask(step: NormalizedStep): CookingTaskView { return { @@ -292,6 +392,10 @@ function stepToTask(step: NormalizedStep): CookingTaskView { description: step.description, ingredients: step.ingredients, 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], originalSteps: [ { @@ -376,6 +480,7 @@ function extractMergedPrep(steps: NormalizedStep[]): CookingTaskView[] { description: null, ingredients, utensils, + temperatures: [], sourceRecipes, originalSteps, }); @@ -384,6 +489,99 @@ function extractMergedPrep(steps: NormalizedStep[]): CookingTaskView[] { 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(); + 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(); + 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. */ function toRecipeLegend(recipes: OptimizerRecipeInput[]): CookingSessionRecipeRef[] { const seen = new Set(); @@ -406,9 +604,11 @@ function cookingPhaseKind(tasks: CookingTaskView[]): CookingPhaseKind { * See the file header. Given the week's planned recipes (already resolved * to reference views), returns the display legend plus the ordered phases: * - * 1. **Mise en place** (`"mise-en-place"`) — every `merged-prep` task, then - * every leftover pure-prep step, then every `SETUP` step. Omitted - * entirely if it would be empty. + * 1. **Mise en place** (`"mise-en-place"`) — all prep first (`merged-prep` + * tasks, then leftover pure-prep steps), then all setup (`merged-setup` + * 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: * 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 @@ -420,21 +620,41 @@ export function optimizeCookingPlan(recipes: OptimizerRecipeInput[]): OptimizeCo const normalized = recipes.map((recipe, index) => normalizeRecipe(recipe, index)); 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 mergedSetup = extractMergedSetup(allSteps); const phases: CookingPhaseView[] = []; - // Phase 0 — mise en place. - const miseTasks: CookingTaskView[] = [...mergedPrep]; + // Phase 0 — mise en place: all prep, then all setup. Keeping the two + // 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) { if (step.absorbed) continue; - if (step.isPurePrep || step.attention === "SETUP") { - miseTasks.push(stepToTask(step)); + if (step.isPurePrep) { + prepTasks.push(stepToTask(step)); 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) { - 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 @@ -490,6 +710,9 @@ export function optimizeCookingPlan(recipes: OptimizerRecipeInput[]): OptimizeCo kind: cookingPhaseKind(tasks), tasks, 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) { diff --git a/apps/api/src/modules/cooking-session/cooking-session.service.ts b/apps/api/src/modules/cooking-session/cooking-session.service.ts index ee5d9de..b87cde0 100644 --- a/apps/api/src/modules/cooking-session/cooking-session.service.ts +++ b/apps/api/src/modules/cooking-session/cooking-session.service.ts @@ -47,6 +47,7 @@ function cookingSessionPlanningInclude() { }, }, utensils: { include: { utensil: true } }, + temperatures: true, }, }, }, @@ -87,6 +88,19 @@ function toOptimizerRecipe(item: PlanningItemWithSteps): OptimizerRecipeInput { order: techStep.order, ingredients, 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, + })), }; }), })); diff --git a/apps/api/test/cooking-session.test.ts b/apps/api/test/cooking-session.test.ts index c23d127..3dda1b3 100644 --- a/apps/api/test/cooking-session.test.ts +++ b/apps/api/test/cooking-session.test.ts @@ -210,5 +210,120 @@ describe("Cooking session", () => { const backgrounds = later.flatMap((p: { background: unknown[] }) => p.background); 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, + ]); + }); }); }); diff --git a/apps/api/test/recipe-matching/cooking-optimizer.test.ts b/apps/api/test/recipe-matching/cooking-optimizer.test.ts index 57175b2..6bb8587 100644 --- a/apps/api/test/recipe-matching/cooking-optimizer.test.ts +++ b/apps/api/test/recipe-matching/cooking-optimizer.test.ts @@ -2,6 +2,7 @@ import type { CookingPhaseView, CookingTaskIngredientView, CookingTaskView, + CookingTemperatureView, IngredientView, TechStepView, UnitView, @@ -84,13 +85,19 @@ describe("cooking-optimizer", () => { 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. */ function clause( techKey: string, ingredients: CookingTaskIngredientView[] = [], utensils: { id: number; key: string }[] = [], + temperatures: CookingTemperatureView[] = [], ): 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. */ @@ -134,8 +141,9 @@ describe("cooking-optimizer", () => { const allTasks = (plan: { phases: CookingPhaseView[] }): CookingTaskView[] => 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[] => - allTasks(plan).filter((task) => task.kind === "merged-prep"); + allTasks(plan).filter((task) => task.kind !== "step"); const stepTasks = (plan: { phases: CookingPhaseView[] }): CookingTaskView[] => 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 // ===================================================================== @@ -764,7 +931,7 @@ describe("cooking-optimizer", () => { simpleStep(24, 4, "Parsemer de persil", "plate", [line(ING.parsley, null, null)]), ]), 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", [ line(ING.potato, 1, KILOGRAM), ]), @@ -774,7 +941,7 @@ describe("cooking-optimizer", () => { simpleStep(35, 5, "Découper et dresser", "plate"), ]), 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", [ line(ING.potato, 1, KILOGRAM), ]), @@ -837,14 +1004,25 @@ describe("cooking-optimizer", () => { 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 plan = optimizeCookingPlan(week); 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) ?? []; - 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)); 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)", () => { diff --git a/apps/web/cypress/e2e/cooking-session-page.cy.ts b/apps/web/cypress/e2e/cooking-session-page.cy.ts index aeff9c7..2f7fd8b 100644 --- a/apps/web/cypress/e2e/cooking-session-page.cy.ts +++ b/apps/web/cypress/e2e/cooking-session-page.cy.ts @@ -44,6 +44,7 @@ function planFixture() { index: 0, kind: "mise-en-place", background: [], + notes: [], tasks: [ { id: "prep:chop:onion", @@ -58,6 +59,7 @@ function planFixture() { }, ], utensils: [{ id: 1, key: "knife" }], + temperatures: [], sourceRecipes: [ { recipeId: 1, name: "Soupe à l'oignon", portions: 4 }, { recipeId: 2, name: "Tarte à l'oignon", portions: 6 }, @@ -70,6 +72,7 @@ function planFixture() { index: 1, kind: "cooking", background: [], + notes: [], tasks: [ { id: "step:0:1", @@ -78,6 +81,7 @@ function planFixture() { description: "Faire mijoter le bouillon", ingredients: [], utensils: [], + temperatures: [], sourceRecipes: [{ recipeId: 1, name: "Soupe à l'oignon", portions: 4 }], originalSteps: [], }, @@ -95,6 +99,7 @@ function planFixture() { recipeName: "Soupe à l'oignon", }, ], + notes: [], tasks: [ { id: "step:1:3", @@ -103,6 +108,7 @@ function planFixture() { description: "Enfourner la tarte", ingredients: [], utensils: [], + temperatures: [], sourceRecipes: [{ recipeId: 2, name: "Tarte à l'oignon", portions: 6 }], 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", () => { beforeEach(() => { cy.viewport(1400, 900); @@ -226,6 +300,27 @@ describe("Cooking session page", () => { 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", () => { cy.intercept("GET", /\/cooking-session\?/, { statusCode: 500, diff --git a/apps/web/cypress/e2e/cooking-session.feature b/apps/web/cypress/e2e/cooking-session.feature index 754c51e..fde4212 100644 --- a/apps/web/cypress/e2e/cooking-session.feature +++ b/apps/web/cypress/e2e/cooking-session.feature @@ -53,3 +53,11 @@ Feature: Start cooking an optimized plan Then I should see "Soupe à l'oignon" And the recipe "Soupe à l'oignon" should link to "/recettes/1" 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" diff --git a/apps/web/cypress/e2e/cooking-session.ts b/apps/web/cypress/e2e/cooking-session.ts index 9c36ae2..b8f2d9f 100644 --- a/apps/web/cypress/e2e/cooking-session.ts +++ b/apps/web/cypress/e2e/cooking-session.ts @@ -59,6 +59,7 @@ Given( index: 0, kind: "mise-en-place", background: [], + notes: [], tasks: [ { id: "prep:chop:onion", @@ -73,6 +74,7 @@ Given( }, ], utensils: [], + temperatures: [], sourceRecipes: [ { recipeId: 1, name: "Soupe à 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, kind: "mise-en-place", background: [], + notes: [], tasks: [ { id: "prep:chop:onion", @@ -131,6 +134,7 @@ Given("the cooking plan for {string} has a mise en place then a cooking step", ( }, ], utensils: [], + temperatures: [], sourceRecipes: [ { recipeId: 1, name: "Soupe à l'oignon", portions: 4 }, { 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, kind: "cooking", background: [], + notes: [], tasks: [ { 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", ingredients: [], utensils: [], + temperatures: [], sourceRecipes: [{ recipeId: 1, name: "Soupe à l'oignon", portions: 4 }], 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) => { cy.contains(".cooking-session-page__tab", label).click(); }); diff --git a/apps/web/src/features/recipes/steps/StepDescription.tsx b/apps/web/src/features/recipes/steps/StepDescription.tsx index e24421f..703c77f 100644 --- a/apps/web/src/features/recipes/steps/StepDescription.tsx +++ b/apps/web/src/features/recipes/steps/StepDescription.tsx @@ -1,38 +1,12 @@ -import type { - StepTechStepTemperatureView, - StepTechStepView, - SubmitTechStepCorrectionResult, -} from "@batch-cooking/shared"; +import type { StepTechStepView, SubmitTechStepCorrectionResult } from "@batch-cooking/shared"; import { Fragment, useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { Tooltip } from "../../../components/ui/Tooltip"; import { splitDescriptionSegments } from "./highlight-tech-steps"; import { TechStepCorrectionPopover } from "./TechStepCorrectionPopover"; +import { temperatureLabel } from "./temperature-label"; 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, -): 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 * matched words highlighted and given a {@link Tooltip} naming the diff --git a/apps/web/src/features/recipes/steps/temperature-label.ts b/apps/web/src/features/recipes/steps/temperature-label.ts new file mode 100644 index 0000000..67f7975 --- /dev/null +++ b/apps/web/src/features/recipes/steps/temperature-label.ts @@ -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, +): 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 ?? ""; +} diff --git a/apps/web/src/locales/fr/translation.json b/apps/web/src/locales/fr/translation.json index 6c05381..960cce1 100644 --- a/apps/web/src/locales/fr/translation.json +++ b/apps/web/src/locales/fr/translation.json @@ -350,8 +350,13 @@ "background": { "title": "Pendant ce temps" }, + "note": { + "applianceTemperatureConflict": "Four requis à {{count}} températures — enchaîner {{sequence}}", + "sequenceSeparator": "puis" + }, "task": { "mergedPrepLabel": "{{technique}} : {{items}}", + "mergedSetupLabel": "{{technique}} · {{temperature}}", "forRecipes": "pour {{recipes}}", "utensils": "Ustensiles", "sharedBadge": "Mutualisé" diff --git a/apps/web/src/pages/cooking-session/CookingSessionPage.tsx b/apps/web/src/pages/cooking-session/CookingSessionPage.tsx index ae7eb7c..0f126de 100644 --- a/apps/web/src/pages/cooking-session/CookingSessionPage.tsx +++ b/apps/web/src/pages/cooking-session/CookingSessionPage.tsx @@ -13,7 +13,13 @@ import { apiClient } from "../../api/client"; import { CheckboxOption } from "../../components/ui/Checkbox"; import { WeekNavigator } from "../../features/planning/WeekNavigator"; 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"; /** Load state for the `GET /cooking-session` call — same discriminated-union shape as `ShoppingListPage`'s own state. */ @@ -269,6 +275,12 @@ function PhaseSection({ return (
+ {(phase.notes ?? []).map((note) => ( +

+ {phaseNoteText(note, t)} +

+ ))} + {phase.background.length > 0 && (
@@ -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({ task, checked, @@ -310,6 +322,7 @@ function TaskCard({ onToggle: (isChecked: boolean) => void; }) { const { t } = useTranslation(); + const isMerged = task.kind === "merged-prep" || task.kind === "merged-setup"; return ( {taskHeadline(task, t)} - {task.kind === "merged-prep" && ( + {isMerged && ( {t("cookingSession.task.sharedBadge")} )} diff --git a/apps/web/src/pages/cooking-session/cooking-session-page.scss b/apps/web/src/pages/cooking-session/cooking-session-page.scss index 2e1b274..d455298 100644 --- a/apps/web/src/pages/cooking-session/cooking-session-page.scss +++ b/apps/web/src/pages/cooking-session/cooking-session-page.scss @@ -303,6 +303,20 @@ flex-direction: column; 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. &__background { border-left: 3px solid var(--color-border); @@ -346,7 +360,8 @@ align-items: flex-start; } - .cooking-task--merged-prep { + .cooking-task--merged-prep, + .cooking-task--merged-setup { border-left: 3px solid var(--color-accent); } diff --git a/apps/web/src/pages/cooking-session/cooking-session.ts b/apps/web/src/pages/cooking-session/cooking-session.ts index 8abed73..7161f33 100644 --- a/apps/web/src/pages/cooking-session/cooking-session.ts +++ b/apps/web/src/pages/cooking-session/cooking-session.ts @@ -1,9 +1,12 @@ import type { + CookingPhaseNoteView, CookingPhaseView, CookingTaskIngredientView, CookingTaskView, + CookingTemperatureView, OptimizedCookingPlanView, } from "@batch-cooking/shared"; +import { temperatureLabel } from "../../features/recipes/steps/temperature-label"; /** * 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(); } +/** + * 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: * - `merged-prep` — `"Émincer : 3 oignon, 200 g carotte"` (technique label + * its pooled ingredient lines), built from the * `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. */ export function taskHeadline(task: CookingTaskView, t: TranslateFn): string { if (task.kind === "step") return task.description ?? ""; + const technique = task.technique ? t(`catalog.techSteps.${task.technique.key}`) : 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(", "); 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 { 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. * `CookingTaskView.id` is only stable *within one plan response* (see its diff --git a/packages/shared/src/types/cooking-session.ts b/packages/shared/src/types/cooking-session.ts index 4c7da94..77d6d78 100644 --- a/packages/shared/src/types/cooking-session.ts +++ b/packages/shared/src/types/cooking-session.ts @@ -43,6 +43,27 @@ export interface CookingTaskIngredientView { 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. * @@ -56,19 +77,34 @@ export interface CookingTaskIngredientView { * `description` is `null` (the web layer composes a label from * `technique` + `ingredients`), `sourceRecipes` lists every recipe it * 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::"` - * or `"step:"`) 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". + * `temperatures` is non-empty only for a `merged-setup` task, or a + * `kind: "step"` task whose step is itself a setup action (so the plan can + * still order and conflict-check a preheat that only one recipe needs); + * `[]` otherwise. + * + * `id` is stable within a single response + * (`"prep::"`, `"setup::"` + * or `"step::"`) 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 { id: string; - kind: "merged-prep" | "step"; + kind: "merged-prep" | "merged-setup" | "step"; technique: TechStepView | null; description: string | null; ingredients: CookingTaskIngredientView[]; utensils: UtensilView[]; + temperatures: CookingTemperatureView[]; sourceRecipes: CookingSessionRecipeRef[]; originalSteps: { recipeId: number; recipeName: string; description: string }[]; } @@ -89,17 +125,45 @@ export interface CookingBackgroundTaskView { 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 - * (`tasks`), plus any passive cooks carried over from before (`background`). - * `index` is 0-based and matches the array position — carried explicitly so - * a caller rendering a single phase still knows where it sits. + * (`tasks`), plus any passive cooks carried over from before (`background`) + * and any phase-level caveats (`notes`, usually `[]` — see + * {@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 { index: number; kind: CookingPhaseKind; tasks: CookingTaskView[]; background: CookingBackgroundTaskView[]; + notes: CookingPhaseNoteView[]; } /** -- 2.45.2