diff --git a/apps/api/src/lib/recipe-matching/cooking-optimizer.ts b/apps/api/src/lib/recipe-matching/cooking-optimizer.ts new file mode 100644 index 0000000..b3d4ef9 --- /dev/null +++ b/apps/api/src/lib/recipe-matching/cooking-optimizer.ts @@ -0,0 +1,511 @@ +import type { + CookingBackgroundTaskView, + CookingPhaseKind, + CookingPhaseView, + CookingSessionRecipeRef, + CookingTaskIngredientView, + CookingTaskView, + TechStepView, + UtensilView, +} from "@batch-cooking/shared"; + +/** + * The pure core of the "Calcul batch-cooking" module (`specs/batch-cooking-architecture.md`): + * takes the week's planned recipes — already resolved to reference views by + * `cooking-session.service.ts` — and reorganizes their steps into an ordered + * sequence of {@link CookingPhaseView}s that pools shared preparation and + * interleaves the recipes so passive cooks (simmer, braise, bake…) run in + * the background while the cook does active work from another recipe. + * + * Pure and synchronous, no database access — same `matchXxx()` pure / + * `loadXxx()` DB-backed split as `ingredient-matcher.ts` / + * `tech-step-matcher.ts` / `shopping-list.service.ts`'s + * `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. + */ + +/** + * Technique keys (`TechStep.key`, see `reference-seed-data.ts`'s + * `TECH_STEPS`) that are pure knife/prep work on an ingredient — the only + * techniques v1 pools across recipes. A *step* counts as prep only when + * **every** technique it mentions is in here (see {@link isPurePrepStep}): + * "émincer les oignons" merges, "faire revenir les oignons émincés" does + * not (its `panFry` keeps it a cooking step). + */ +const PREP_TECHNIQUES: ReadonlySet = new Set([ + "chop", + "peel", + "mince", + "julienne", + "brunoise", + "concasse", + "paysanne", + "mirepoix", + "zest", + "score", + "pod", + "shellEgg", + "hollowOut", + "filet", + "disgorge", + "sift", + "dustWithFlour", + "peelBlanch", +]); + +/** + * How much of the cook's attention a technique needs once it's under way — + * the axis that makes parallelism possible. + * + * - `"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. + * - `"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. + * - anything not listed here, or a step with no detected technique at all, + * is treated as `"ACTIVE"` — hands-on, occupies the cook. + */ +const SETUP_TECHNIQUES: ReadonlySet = new Set(["preheat", "boil", "bainMarie"]); + +/** See {@link SETUP_TECHNIQUES}. */ +const PASSIVE_TECHNIQUES: ReadonlySet = new Set([ + "simmer", + "bake", + "roast", + "braise", + "marinate", + "rest", + "proof", + "confit", + "reduce", + "blindBake", + "compote", + "smother", + "setGel", + "pasteurize", + "appertize", + "poach", + "sweat", + "glaze", +]); + +/** Attention class of a single step — see {@link SETUP_TECHNIQUES}. */ +type Attention = "SETUP" | "PASSIVE" | "ACTIVE"; + +/** One technique occurrence within a step, already scaled to the planned portions. */ +interface OptimizerTechStepInput { + techStep: TechStepView; + order: number; + ingredients: CookingTaskIngredientView[]; + utensils: UtensilView[]; +} + +/** One recipe step, as handed to {@link optimizeCookingPlan}. */ +interface OptimizerStepInput { + stepId: number; + order: number; + description: string; + techSteps: OptimizerTechStepInput[]; +} + +/** + * One planned recipe, as handed to {@link optimizeCookingPlan}. `portions` + * is the planning slot's own count and `recipePortions` the recipe's + * as-written yield — quantities are scaled by `portions / recipePortions` + * (see {@link scaleOf}). The same recipe planned twice at different portion + * counts arrives as two entries with the same `recipeId`; that's + * intentional (two real cooking jobs), and merged-prep still pools their + * knife work back together. + */ +interface OptimizerRecipeInput { + recipeId: number; + name: string; + portions: number; + recipePortions: number; + steps: OptimizerStepInput[]; +} + +/** {@link optimizeCookingPlan}'s result — the date-range/legend wrapper is added by the service. */ +interface OptimizeCookingPlanResult { + recipes: CookingSessionRecipeRef[]; + phases: CookingPhaseView[]; +} + +export type { + OptimizeCookingPlanResult, + OptimizerRecipeInput, + OptimizerStepInput, + OptimizerTechStepInput, +}; + +/** Portion scale factor for a recipe — guards a missing/zero as-written yield (bad data) by falling back to 1× rather than dividing by zero. */ +function scaleOf(recipe: OptimizerRecipeInput): number { + if (!recipe.recipePortions || recipe.recipePortions <= 0) return 1; + return recipe.portions / recipe.recipePortions; +} + +/** A step normalized for scheduling — techniques scaled, attention resolved, ingredients/utensils unioned across its technique clauses. */ +interface NormalizedStep { + /** Stable within one response: `step::` (the index disambiguates the same recipe planned twice). */ + taskId: string; + recipeIndex: number; + recipe: CookingSessionRecipeRef; + stepId: number; + order: number; + description: string; + techSteps: OptimizerTechStepInput[]; + attention: Attention; + isPurePrep: 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. */ + absorbed: boolean; +} + +/** Sums two ingredient lines only when it's unambiguous — same unit id and both quantities known; otherwise the pooled line carries no number (see `ShoppingListItemView`'s "don't guess a conversion" rule). */ +function poolIngredient(lines: CookingTaskIngredientView[]): { + quantity: number | null; + unit: CookingTaskIngredientView["unit"]; +} { + const first = lines[0]; + if (!first) return { quantity: null, unit: null }; + const unitId = first.unit?.id ?? null; + let total = 0; + for (const line of lines) { + if (line.quantity === null || (line.unit?.id ?? null) !== unitId) { + return { quantity: null, unit: null }; + } + total += line.quantity; + } + return { quantity: total, unit: first.unit }; +} + +/** Unions ingredient lines by `(ingredientId, unitId)`, summing quantities within a group the same careful way as {@link poolIngredient}. */ +function unionIngredients(lines: CookingTaskIngredientView[]): CookingTaskIngredientView[] { + const groups = new Map(); + for (const line of lines) { + const key = `${line.ingredient.id}:${line.unit?.id ?? "x"}`; + const group = groups.get(key); + if (group) group.push(line); + else groups.set(key, [line]); + } + const out: CookingTaskIngredientView[] = []; + for (const group of groups.values()) { + const head = group[0]; + if (!head) continue; + const pooled = poolIngredient(group); + out.push({ ingredient: head.ingredient, quantity: pooled.quantity, unit: pooled.unit }); + } + return out.sort((a, b) => a.ingredient.key.localeCompare(b.ingredient.key)); +} + +/** Unions utensils by id, keeping a stable order by key. */ +function unionUtensils(utensils: UtensilView[]): UtensilView[] { + const byId = new Map(); + for (const utensil of utensils) byId.set(utensil.id, utensil); + return [...byId.values()].sort((a, b) => a.key.localeCompare(b.key)); +} + +/** 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)); +} + +/** 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"; + const last = techSteps[techSteps.length - 1]; + if (last && PASSIVE_TECHNIQUES.has(last.techStep.key)) return "PASSIVE"; + return "ACTIVE"; +} + +/** Turns one recipe's raw steps into {@link NormalizedStep}s — scales quantities, resolves attention, unions per-clause ingredients/utensils up to the step. */ +function normalizeRecipe(recipe: OptimizerRecipeInput, recipeIndex: number): NormalizedStep[] { + const scale = scaleOf(recipe); + const recipeRef: CookingSessionRecipeRef = { + recipeId: recipe.recipeId, + name: recipe.name, + portions: recipe.portions, + }; + + return [...recipe.steps] + .sort((a, b) => a.order - b.order) + .map((step) => { + const techSteps: OptimizerTechStepInput[] = [...step.techSteps] + .sort((a, b) => a.order - b.order) + .map((ts) => ({ + techStep: ts.techStep, + order: ts.order, + ingredients: ts.ingredients.map((line) => ({ + ingredient: line.ingredient, + quantity: line.quantity === null ? null : line.quantity * scale, + unit: line.unit, + })), + utensils: ts.utensils, + })); + + const lastTech = techSteps[techSteps.length - 1]; + return { + taskId: `step:${recipeIndex}:${step.stepId}`, + recipeIndex, + recipe: recipeRef, + stepId: step.stepId, + order: step.order, + description: step.description, + techSteps, + attention: attentionOf(techSteps), + isPurePrep: isPurePrepStep(techSteps), + dominantTechnique: lastTech ? lastTech.techStep : null, + ingredients: unionIngredients(techSteps.flatMap((ts) => ts.ingredients)), + utensils: unionUtensils(techSteps.flatMap((ts) => ts.utensils)), + absorbed: false, + } satisfies NormalizedStep; + }); +} + +/** The prep signature of a pure-prep step — sorted `:` pairs; two steps with the same signature do identical knife work and can be pooled. */ +function prepSignature(step: NormalizedStep): string { + const pairs: string[] = []; + for (const ts of step.techSteps) { + for (const line of ts.ingredients) { + pairs.push(`${ts.techStep.key}:${line.ingredient.id}`); + } + } + return [...new Set(pairs)].sort().join("+"); +} + +/** Builds one {@link CookingTaskView} from a normalized step run as written. */ +function stepToTask(step: NormalizedStep): CookingTaskView { + return { + id: step.taskId, + kind: "step", + technique: step.dominantTechnique, + description: step.description, + ingredients: step.ingredients, + utensils: step.utensils, + sourceRecipes: [step.recipe], + originalSteps: [ + { + recipeId: step.recipe.recipeId, + recipeName: step.recipe.name, + description: step.description, + }, + ], + }; +} + +/** Builds the running-in-the-background status line for a passive step already scheduled in an earlier phase. */ +function stepToBackground(step: NormalizedStep): CookingBackgroundTaskView { + return { + id: `bg:${step.taskId}`, + technique: step.dominantTechnique, + description: step.description, + recipeId: step.recipe.recipeId, + recipeName: step.recipe.name, + }; +} + +/** + * Pools pure-prep steps that do the *exact same* knife work (same + * {@link prepSignature}) in two or more distinct recipes into one + * `merged-prep` {@link CookingTaskView}, and marks every contributing step + * `absorbed` so it produces no standalone task. A pure-prep step whose + * signature is unique (only one recipe needs it) is left untouched — it + * still lands in the mise-en-place phase, just as its own step task. + * + * Returns the merged tasks in a stable order (by id). + */ +function extractMergedPrep(steps: NormalizedStep[]): CookingTaskView[] { + const bySignature = new Map(); + for (const step of steps) { + if (!step.isPurePrep) continue; + const signature = prepSignature(step); + if (signature === "") continue; + const group = bySignature.get(signature); + if (group) group.push(step); + else bySignature.set(signature, [step]); + } + + const merged: CookingTaskView[] = []; + for (const [signature, group] of bySignature) { + const recipeIndexes = new Set(group.map((s) => s.recipeIndex)); + if (recipeIndexes.size < 2) continue; + + for (const step of group) step.absorbed = true; + + // Every contributing clause's ingredient lines, pooled per ingredient. + const allLines = group.flatMap((s) => s.techSteps.flatMap((ts) => ts.ingredients)); + const ingredients = unionIngredients(allLines); + const utensils = unionUtensils(group.flatMap((s) => s.utensils)); + + // Dominant technique of the pool = the first pair's technique (v1 + // signatures are almost always a single `:` + // pair; a multi-pair signature just takes the earliest). + const firstTech = group[0]?.techSteps[0]?.techStep ?? null; + const firstIngredientKey = ingredients[0]?.ingredient.key ?? signature; + + // 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, + }); + } + + merged.push({ + id: `prep:${firstTech ? firstTech.key : "prep"}:${firstIngredientKey}`, + kind: "merged-prep", + technique: firstTech, + description: null, + ingredients, + utensils, + sourceRecipes, + originalSteps, + }); + } + + return merged.sort((a, b) => a.id.localeCompare(b.id)); +} + +/** 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(); + const out: CookingSessionRecipeRef[] = []; + for (const recipe of recipes) { + const key = `${recipe.recipeId}:${recipe.portions}`; + if (seen.has(key)) continue; + seen.add(key); + out.push({ recipeId: recipe.recipeId, name: recipe.name, portions: recipe.portions }); + } + return out; +} + +/** A phase is `"finishing"` when everything left in it is plating; otherwise it's a normal `"cooking"` phase. */ +function cookingPhaseKind(tasks: CookingTaskView[]): CookingPhaseKind { + return tasks.every((task) => task.technique?.key === "plate") ? "finishing" : "cooking"; +} + +/** + * 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. + * 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 + * step scheduled in one phase is echoed in every later phase's + * `background` until that recipe's next step is popped. + */ +export function optimizeCookingPlan(recipes: OptimizerRecipeInput[]): OptimizeCookingPlanResult { + const legend = toRecipeLegend(recipes); + const normalized = recipes.map((recipe, index) => normalizeRecipe(recipe, index)); + const allSteps = normalized.flat(); + + const mergedPrep = extractMergedPrep(allSteps); + + const phases: CookingPhaseView[] = []; + + // Phase 0 — mise en place. + const miseTasks: CookingTaskView[] = [...mergedPrep]; + for (const step of allSteps) { + if (step.absorbed) continue; + if (step.isPurePrep || step.attention === "SETUP") { + miseTasks.push(stepToTask(step)); + step.absorbed = true; // consumed here, not again in the cooking loop + } + } + if (miseTasks.length > 0) { + phases.push({ index: 0, kind: "mise-en-place", tasks: miseTasks, background: [] }); + } + + // Cooking phases — one "next step of each recipe" per phase. `hold` keeps + // a recipe out of the *next* phase right after it starts a passive cook, + // so another recipe's active work fills that phase and the passive cook + // shows up as `background` there instead of being immediately followed by + // its own next step. + const queues = normalized.map((steps) => ({ + remaining: steps.filter((s) => !s.absorbed), + cursor: 0, + hold: 0, + })); + /** Passive steps started in an earlier phase, keyed by recipe index, still "cooking". */ + const runningPassive = new Map(); + + while (queues.some((queue) => queue.cursor < queue.remaining.length)) { + const phaseSteps: NormalizedStep[] = []; + queues.forEach((queue, recipeIndex) => { + const next = queue.remaining[queue.cursor]; + if (!next) return; + if (queue.hold > 0) { + // Still tending its passive cook this phase — leave it in + // `runningPassive` so it renders as background, don't advance. + queue.hold--; + return; + } + // This recipe is advancing — whatever passive cook it had going is + // now being tended to, so it stops showing as background. + runningPassive.delete(recipeIndex); + phaseSteps.push(next); + queue.cursor++; + }); + + // Every recipe with steps left is holding on a passive cook — break the + // stall by releasing all holds and letting the next iteration advance. + if (phaseSteps.length === 0) { + for (const queue of queues) queue.hold = 0; + continue; + } + + // Background = passive cooks from earlier phases not yet resolved above. + const background = [...runningPassive.values()].map(stepToBackground); + + // Start the long cooks first within the phase. + phaseSteps.sort((a, b) => { + const rank = (s: NormalizedStep) => (s.attention === "PASSIVE" ? 0 : 1); + return rank(a) - rank(b) || a.recipeIndex - b.recipeIndex; + }); + + const tasks = phaseSteps.map(stepToTask); + phases.push({ + index: phases.length, + kind: cookingPhaseKind(tasks), + tasks, + background, + }); + + for (const step of phaseSteps) { + if (step.attention === "PASSIVE") { + runningPassive.set(step.recipeIndex, step); + const queue = queues[step.recipeIndex]; + if (queue) queue.hold = 1; + } + } + } + + // `index` was set from `phases.length` as we went; re-stamp so it always + // matches the final array position even if phase 0 was skipped. + phases.forEach((phase, index) => { + phase.index = index; + }); + + return { recipes: legend, phases }; +} diff --git a/apps/api/test/recipe-matching/cooking-optimizer.test.ts b/apps/api/test/recipe-matching/cooking-optimizer.test.ts new file mode 100644 index 0000000..57175b2 --- /dev/null +++ b/apps/api/test/recipe-matching/cooking-optimizer.test.ts @@ -0,0 +1,1024 @@ +import type { + CookingPhaseView, + CookingTaskIngredientView, + CookingTaskView, + IngredientView, + TechStepView, + UnitView, +} from "@batch-cooking/shared"; +import { expect } from "chai"; +import { + type OptimizerRecipeInput, + type OptimizerStepInput, + type OptimizerTechStepInput, + optimizeCookingPlan, +} from "../../src/lib/recipe-matching/cooking-optimizer.js"; + +/** + * Pure unit tests for the batch-cooking optimizer — no database, fixtures + * hand-built (they're technique/ingredient reference shapes, not the + * personal data `@faker-js/faker` covers). + * + * The suite is black-box: everything goes through `optimizeCookingPlan` + * (the only public entry point), never the private helpers, so the tests + * stay valid across any internal refactor that keeps the contract. + */ +describe("cooking-optimizer", () => { + // --- Fixture builders ---------------------------------------------------- + + /** Minimal `TechStepView` — only `key` drives the optimizer, `id` is passed through. */ + function tech(key: string): TechStepView { + // Stable pseudo-id per key so two fixtures naming the same technique + // get the same `id` without a lookup table. + let hash = 0; + for (const char of key) hash = (hash * 31 + char.charCodeAt(0)) % 100000; + return { id: hash, key }; + } + + /** Minimal `IngredientView` — the optimizer only reads `id`/`key`. */ + function ingredient(id: number, key: string): IngredientView { + return { + id, + key, + icon: "VEGETABLE", + category: "freshProduce", + subcategory: "vegetables", + reproducible: false, + allergens: [], + diets: [], + }; + } + + /** Reference ingredient catalog shared by the fixtures below. */ + const ING = { + onion: ingredient(1, "onion"), + garlic: ingredient(2, "garlic"), + carrot: ingredient(3, "carrot"), + potato: ingredient(4, "potato"), + tomato: ingredient(5, "tomato"), + beef: ingredient(6, "beef"), + chicken: ingredient(7, "chicken"), + parsley: ingredient(8, "parsley"), + cream: ingredient(9, "cream"), + flour: ingredient(10, "flour"), + butter: ingredient(11, "butter"), + rice: ingredient(12, "rice"), + lentils: ingredient(13, "lentils"), + egg: ingredient(14, "egg"), + cheese: ingredient(15, "cheese"), + } as const; + + const GRAM: UnitView = { id: 1, key: "gram", type: "MASS", toBaseFactor: 1 }; + const PIECE: UnitView = { id: 2, key: "piece", type: "COUNT", toBaseFactor: 1 }; + const KILOGRAM: UnitView = { id: 3, key: "kilogram", type: "MASS", toBaseFactor: 1000 }; + + function line( + ing: IngredientView, + quantity: number | null = null, + unit: UnitView | null = null, + ): CookingTaskIngredientView { + return { ingredient: ing, quantity, unit }; + } + + function utensil(id: number, key: string) { + return { id, key }; + } + + /** One technique clause of a step. */ + function clause( + techKey: string, + ingredients: CookingTaskIngredientView[] = [], + utensils: { id: number; key: string }[] = [], + ): OptimizerTechStepInput { + return { techStep: tech(techKey), order: 0, ingredients, utensils }; + } + + /** A step described by an ordered list of technique clauses. */ + function step( + stepId: number, + order: number, + description: string, + clauses: OptimizerTechStepInput[], + ): OptimizerStepInput { + return { + stepId, + order, + description, + techSteps: clauses.map((c, i) => ({ ...c, order: i })), + }; + } + + /** Shorthand for a single-technique step. */ + function simpleStep( + stepId: number, + order: number, + description: string, + techKey: string, + ingredients: CookingTaskIngredientView[] = [], + ): OptimizerStepInput { + return step(stepId, order, description, [clause(techKey, ingredients)]); + } + + function recipe( + recipeId: number, + name: string, + steps: OptimizerStepInput[], + portions = 4, + recipePortions = 4, + ): OptimizerRecipeInput { + return { recipeId, name, portions, recipePortions, steps }; + } + + // --- Assertion helpers ------------------------------------------------- + + const allTasks = (plan: { phases: CookingPhaseView[] }): CookingTaskView[] => + plan.phases.flatMap((phase) => phase.tasks); + + const mergedTasks = (plan: { phases: CookingPhaseView[] }): CookingTaskView[] => + allTasks(plan).filter((task) => task.kind === "merged-prep"); + + const stepTasks = (plan: { phases: CookingPhaseView[] }): CookingTaskView[] => + allTasks(plan).filter((task) => task.kind === "step"); + + /** + * Structural invariant: every input step is accounted for **exactly + * once** — either it survived as its own `step` task, or it was absorbed + * into a `merged-prep` pool (its text shows in that pool's + * `originalSteps`), never both and never neither. Also: no `step` task id + * is emitted twice, and every `step` task maps back to a real input step. + */ + function assertEveryStepAccountedForOnce( + recipes: OptimizerRecipeInput[], + plan: { phases: CookingPhaseView[] }, + ) { + const stepTaskIds = new Set(); + for (const task of stepTasks(plan)) { + expect(stepTaskIds.has(task.id), `duplicate step task ${task.id}`).to.equal(false); + stepTaskIds.add(task.id); + } + + const absorbedTexts = new Set( + mergedTasks(plan).flatMap((task) => task.originalSteps.map((s) => s.description)), + ); + + recipes.forEach((r, recipeIndex) => { + for (const s of r.steps) { + const asStepTask = stepTaskIds.has(`step:${recipeIndex}:${s.stepId}`); + const asAbsorbed = absorbedTexts.has(s.description); + expect( + asStepTask !== asAbsorbed, + `step "${s.description}" (recipe ${recipeIndex}) should appear exactly once (stepTask=${asStepTask}, absorbed=${asAbsorbed})`, + ).to.equal(true); + } + }); + + // Every emitted step task points at a real input step. + const validIds = new Set( + recipes.flatMap((r, i) => r.steps.map((s) => `step:${i}:${s.stepId}`)), + ); + for (const id of stepTaskIds) { + expect(validIds.has(id), `step task ${id} has no matching input step`).to.equal(true); + } + } + + /** + * Task ids are globally unique across the plan; within any single phase, + * no id (task or background) collides. A background line legitimately + * repeats across consecutive phases (a long cook shown as still running), + * so background ids are only checked for uniqueness *within* a phase. + */ + function assertUniqueIds(plan: { phases: CookingPhaseView[] }) { + const taskIds: string[] = []; + for (const phase of plan.phases) { + const perPhase = new Set(); + for (const task of phase.tasks) { + taskIds.push(task.id); + expect( + perPhase.has(task.id), + `duplicate id ${task.id} within phase ${phase.index}`, + ).to.equal(false); + perPhase.add(task.id); + } + for (const bg of phase.background) { + expect(perPhase.has(bg.id), `duplicate id ${bg.id} within phase ${phase.index}`).to.equal( + false, + ); + perPhase.add(bg.id); + } + } + expect(taskIds.length, "task ids should be globally unique").to.equal(new Set(taskIds).size); + } + + /** Phase indices are 0..n-1 and match array position. */ + function assertPhaseIndices(plan: { phases: CookingPhaseView[] }) { + plan.phases.forEach((phase, i) => { + expect(phase.index).to.equal(i); + }); + } + + // ===================================================================== + // Basic contract + // ===================================================================== + describe("basic contract", () => { + it("returns an empty plan for no recipes", () => { + expect(optimizeCookingPlan([])).to.deep.equal({ recipes: [], phases: [] }); + }); + + it("returns an empty plan for recipes that have no steps", () => { + const plan = optimizeCookingPlan([recipe(1, "Vide", []), recipe(2, "Vide aussi", [])]); + expect(plan.phases).to.deep.equal([]); + expect(plan.recipes.map((r) => r.name)).to.deep.equal(["Vide", "Vide aussi"]); + }); + + it("keeps a single recipe's steps in order across phases", () => { + const plan = optimizeCookingPlan([ + recipe(1, "Omelette", [ + simpleStep(1, 0, "Battre les œufs", "whisk", [line(ING.egg, 3, PIECE)]), + simpleStep(2, 1, "Cuire", "cook"), + simpleStep(3, 2, "Servir", "plate"), + ]), + ]); + const descriptions = stepTasks(plan).map((t) => t.description); + expect(descriptions).to.deep.equal(["Battre les œufs", "Cuire", "Servir"]); + assertEveryStepAccountedForOnce( + [ + recipe(1, "Omelette", [ + simpleStep(1, 0, "Battre les œufs", "whisk", [line(ING.egg, 3, PIECE)]), + simpleStep(2, 1, "Cuire", "cook"), + simpleStep(3, 2, "Servir", "plate"), + ]), + ], + plan, + ); + }); + + it("exposes each planned recipe in the legend, de-duplicating an exact repeat", () => { + const plan = optimizeCookingPlan([ + recipe(1, "A", [simpleStep(1, 0, "Cuire", "cook")], 4, 4), + recipe(1, "A", [simpleStep(1, 0, "Cuire", "cook")], 4, 4), + recipe(1, "A", [simpleStep(1, 0, "Cuire", "cook")], 8, 4), + ]); + expect(plan.recipes).to.deep.equal([ + { recipeId: 1, name: "A", portions: 4 }, + { recipeId: 1, name: "A", portions: 8 }, + ]); + }); + + it("is deterministic — identical input yields byte-identical output", () => { + const build = () => [ + recipe(1, "Soupe", [ + simpleStep(1, 0, "Émincer l'oignon", "chop", [line(ING.onion, 2, PIECE)]), + simpleStep(2, 1, "Mijoter", "simmer"), + ]), + recipe(2, "Tarte", [ + simpleStep(3, 0, "Émincer l'oignon", "chop", [line(ING.onion, 3, PIECE)]), + simpleStep(4, 1, "Enfourner", "bake"), + ]), + ]; + const a = optimizeCookingPlan(build()); + const b = optimizeCookingPlan(build()); + expect(JSON.stringify(a)).to.equal(JSON.stringify(b)); + }); + }); + + // ===================================================================== + // Technique classification (attention + prep) + // ===================================================================== + describe("technique classification", () => { + it("pulls a SETUP step (preheat / boil) into the mise-en-place phase, out of recipe order", () => { + const plan = optimizeCookingPlan([ + recipe(1, "Gratin", [ + simpleStep(1, 0, "Éplucher les pommes de terre", "peel", [line(ING.potato, 800, GRAM)]), + simpleStep(2, 1, "Préchauffer le four à 200°C", "preheat"), + simpleStep(3, 2, "Assembler", "coat"), + simpleStep(4, 3, "Enfourner", "bake"), + ]), + ]); + + const mise = plan.phases[0]; + expect(mise?.kind).to.equal("mise-en-place"); + const miseDescriptions = mise?.tasks.map((t) => t.description) ?? []; + expect(miseDescriptions).to.include("Préchauffer le four à 200°C"); + expect(miseDescriptions).to.include("Éplucher les pommes de terre"); + // ...and it does not reappear later. + const laterDescriptions = plan.phases + .slice(1) + .flatMap((p) => p.tasks.map((t) => t.description)); + expect(laterDescriptions).to.not.include("Préchauffer le four à 200°C"); + }); + + it("treats a step whose LAST technique is passive as a background cook", () => { + const plan = optimizeCookingPlan([ + recipe(1, "Bœuf braisé", [ + simpleStep(1, 0, "Saisir la viande", "brown", [line(ING.beef, 1, KILOGRAM)]), + step(2, 1, "Ajouter le bouillon et laisser braiser 2h", [ + clause("deglaze"), + clause("braise"), + ]), + simpleStep(3, 2, "Dresser", "plate"), + ]), + recipe(2, "Salade", [ + simpleStep(4, 0, "Laver", "mix"), + simpleStep(5, 1, "Assaisonner", "season"), + simpleStep(6, 2, "Servir", "plate"), + ]), + ]); + + const backgrounds = plan.phases.flatMap((p) => p.background.map((b) => b.description)); + expect(backgrounds).to.include("Ajouter le bouillon et laisser braiser 2h"); + }); + + it("does NOT treat a step as prep when it mixes a cut with a cooking technique", () => { + const recipes = [ + recipe(1, "Poêlée A", [ + step(1, 0, "Émincer puis faire revenir l'oignon", [ + clause("chop", [line(ING.onion, 2, PIECE)]), + clause("panFry", [line(ING.onion, 2, PIECE)]), + ]), + ]), + recipe(2, "Poêlée B", [ + step(2, 0, "Émincer puis faire revenir l'oignon", [ + clause("chop", [line(ING.onion, 2, PIECE)]), + clause("panFry", [line(ING.onion, 2, PIECE)]), + ]), + ]), + ]; + const plan = optimizeCookingPlan(recipes); + + // Identical text in two recipes, but the panFry keeps each a cooking + // step — no pooling, both survive as their own step task. + expect(mergedTasks(plan)).to.have.lengthOf(0); + expect(stepTasks(plan)).to.have.lengthOf(2); + assertEveryStepAccountedForOnce(recipes, plan); + }); + + it("classifies a step with no detected technique as ordinary active work", () => { + const plan = optimizeCookingPlan([ + recipe(1, "Libre", [ + { stepId: 1, order: 0, description: "Faire quelque chose", techSteps: [] }, + simpleStep(2, 1, "Puis autre chose", "mix"), + ]), + ]); + // No technique → not prep, not passive, not setup: it flows through the + // cooking phases like any active step, and carries a null technique. + const task = stepTasks(plan).find((t) => t.description === "Faire quelque chose"); + expect(task, "the no-technique step should still be scheduled").to.not.equal(undefined); + expect(task?.technique).to.equal(null); + expect(plan.phases.some((p) => p.kind === "mise-en-place")).to.equal(false); + }); + }); + + // ===================================================================== + // Prep pooling + // ===================================================================== + describe("prep pooling", () => { + it("pools identical prep from two recipes into one merged-prep task with summed quantity", () => { + const recipes = [ + recipe(1, "Soupe", [ + simpleStep(1, 0, "Émincer les oignons", "chop", [line(ING.onion, 2, PIECE)]), + simpleStep(2, 1, "Faire mijoter", "simmer"), + ]), + recipe(2, "Tarte", [ + simpleStep(3, 0, "Émincer les oignons", "chop", [line(ING.onion, 3, PIECE)]), + simpleStep(4, 1, "Enfourner", "bake"), + ]), + ]; + const plan = optimizeCookingPlan(recipes); + + const merged = mergedTasks(plan); + expect(merged).to.have.lengthOf(1); + expect(merged[0]?.technique?.key).to.equal("chop"); + expect(merged[0]?.description).to.equal(null); + expect(merged[0]?.sourceRecipes.map((r) => r.recipeId)).to.have.members([1, 2]); + expect(merged[0]?.ingredients[0]?.quantity).to.equal(5); + expect(merged[0]?.ingredients[0]?.unit?.key).to.equal("piece"); + expect(merged[0]?.id).to.equal("prep:chop:onion"); + assertEveryStepAccountedForOnce(recipes, plan); + }); + + it("pools across THREE recipes and lists every source recipe once", () => { + const recipes = [1, 2, 3].map((id) => + recipe(id, `Recette ${id}`, [ + simpleStep(id * 10, 0, "Presser l'ail", "mince", [line(ING.garlic, 2, PIECE)]), + simpleStep(id * 10 + 1, 1, "Cuire", "cook"), + ]), + ); + const plan = optimizeCookingPlan(recipes); + + const merged = mergedTasks(plan); + expect(merged).to.have.lengthOf(1); + expect(merged[0]?.sourceRecipes.map((r) => r.recipeId)).to.deep.equal([1, 2, 3]); + expect(merged[0]?.ingredients[0]?.quantity).to.equal(6); + assertEveryStepAccountedForOnce(recipes, plan); + }); + + it("keeps prep that only one recipe needs as its own step task (no merge)", () => { + const recipes = [ + recipe(1, "Curry", [ + simpleStep(1, 0, "Râper les carottes", "chop", [line(ING.carrot, 200, GRAM)]), + simpleStep(2, 1, "Cuire", "cook"), + ]), + recipe(2, "Gratin", [simpleStep(3, 0, "Cuire au four", "bake")]), + ]; + const plan = optimizeCookingPlan(recipes); + + expect(mergedTasks(plan)).to.have.lengthOf(0); + const prepTask = allTasks(plan).find((t) => t.description === "Râper les carottes"); + expect(prepTask?.kind).to.equal("step"); + expect(plan.phases[0]?.kind).to.equal("mise-en-place"); + expect(plan.phases[0]?.tasks).to.include(prepTask); + assertEveryStepAccountedForOnce(recipes, plan); + }); + + it("does not pool the same ingredient cut with two DIFFERENT techniques", () => { + const recipes = [ + recipe(1, "A", [simpleStep(1, 0, "Émincer l'oignon", "chop", [line(ING.onion, 1, PIECE)])]), + recipe(2, "B", [ + simpleStep(2, 0, "Tailler l'oignon en brunoise", "brunoise", [line(ING.onion, 1, PIECE)]), + ]), + ]; + const plan = optimizeCookingPlan(recipes); + expect(mergedTasks(plan)).to.have.lengthOf(0); + assertEveryStepAccountedForOnce(recipes, plan); + }); + + it("pools a multi-ingredient prep step only when the whole cut signature matches", () => { + const matching = [ + recipe(1, "Mirepoix A", [ + step(1, 0, "Tailler oignon et carotte", [ + clause("chop", [line(ING.onion, 1, PIECE)]), + clause("chop", [line(ING.carrot, 1, PIECE)]), + ]), + ]), + recipe(2, "Mirepoix B", [ + step(2, 0, "Tailler oignon et carotte", [ + clause("chop", [line(ING.onion, 2, PIECE)]), + clause("chop", [line(ING.carrot, 2, PIECE)]), + ]), + ]), + ]; + const plan = optimizeCookingPlan(matching); + const merged = mergedTasks(plan); + expect(merged).to.have.lengthOf(1); + // Two ingredient lines, each summed within its own (ingredient, unit) group. + const byKey = Object.fromEntries( + (merged[0]?.ingredients ?? []).map((i) => [i.ingredient.key, i.quantity]), + ); + expect(byKey).to.deep.equal({ carrot: 3, onion: 3 }); + assertEveryStepAccountedForOnce(matching, plan); + }); + + it("does NOT pool multi-ingredient prep steps whose signatures differ", () => { + const recipes = [ + recipe(1, "A", [ + step(1, 0, "Tailler oignon et carotte", [ + clause("chop", [line(ING.onion, 1, PIECE)]), + clause("chop", [line(ING.carrot, 1, PIECE)]), + ]), + ]), + recipe(2, "B", [ + step(2, 0, "Tailler oignon et pomme de terre", [ + clause("chop", [line(ING.onion, 1, PIECE)]), + clause("chop", [line(ING.potato, 1, PIECE)]), + ]), + ]), + ]; + const plan = optimizeCookingPlan(recipes); + expect(mergedTasks(plan)).to.have.lengthOf(0); + assertEveryStepAccountedForOnce(recipes, plan); + }); + + it("drops the quantity when one recipe names the ingredient without a measurable amount", () => { + const recipes = [ + recipe(1, "A", [ + simpleStep(1, 0, "Émincer les oignons", "chop", [line(ING.onion, 2, PIECE)]), + ]), + recipe(2, "B", [ + simpleStep(2, 0, "Émincer les oignons", "chop", [line(ING.onion, null, PIECE)]), + ]), + ]; + const plan = optimizeCookingPlan(recipes); + const merged = mergedTasks(plan); + expect(merged).to.have.lengthOf(1); + expect(merged[0]?.ingredients[0]?.quantity).to.equal(null); + expect(merged[0]?.ingredients[0]?.unit).to.equal(null); + }); + + it("unions the utensils of every pooled step, de-duplicated", () => { + const recipes = [ + recipe(1, "A", [ + step(1, 0, "Émincer les oignons", [ + clause( + "chop", + [line(ING.onion, 1, PIECE)], + [utensil(1, "knife"), utensil(2, "cuttingBoard")], + ), + ]), + ]), + recipe(2, "B", [ + step(2, 0, "Émincer les oignons", [ + clause( + "chop", + [line(ING.onion, 1, PIECE)], + [utensil(1, "knife"), utensil(3, "mandoline")], + ), + ]), + ]), + ]; + const plan = optimizeCookingPlan(recipes); + const merged = mergedTasks(plan); + expect(merged[0]?.utensils.map((u) => u.key)).to.deep.equal([ + "cuttingBoard", + "knife", + "mandoline", + ]); + }); + + it("still pools when the SAME recipe is planned twice at different portions", () => { + const recipes = [ + recipe( + 1, + "Bolo", + [simpleStep(1, 0, "Émincer l'oignon", "chop", [line(ING.onion, 1, PIECE)])], + 4, + 4, + ), + recipe( + 1, + "Bolo", + [simpleStep(1, 0, "Émincer l'oignon", "chop", [line(ING.onion, 1, PIECE)])], + 8, + 4, + ), + ]; + const plan = optimizeCookingPlan(recipes); + const merged = mergedTasks(plan); + expect(merged).to.have.lengthOf(1); + // 1 × (4/4) + 1 × (8/4) = 3 + expect(merged[0]?.ingredients[0]?.quantity).to.equal(3); + expect(merged[0]?.sourceRecipes).to.have.lengthOf(2); + }); + + it("does not pool a prep clause that resolved no ingredient — it stays a standalone mise-en-place task", () => { + const recipes = [ + recipe(1, "A", [simpleStep(1, 0, "Émincer finement", "chop", [])]), + recipe(2, "B", [simpleStep(2, 0, "Émincer finement", "chop", [])]), + ]; + const plan = optimizeCookingPlan(recipes); + expect(mergedTasks(plan)).to.have.lengthOf(0); + // Both are pure-prep with no ingredient → each lands in mise-en-place as its own task. + expect(plan.phases[0]?.kind).to.equal("mise-en-place"); + expect( + plan.phases[0]?.tasks.filter((t) => t.description === "Émincer finement"), + ).to.have.lengthOf(2); + assertEveryStepAccountedForOnce(recipes, plan); + }); + }); + + // ===================================================================== + // Quantity scaling + // ===================================================================== + describe("quantity scaling", () => { + it("scales technique-clause quantities by portions / recipePortions", () => { + const plan = optimizeCookingPlan([ + recipe(1, "Base", [simpleStep(1, 0, "Émincer", "chop", [line(ING.onion, 2, PIECE)])], 8, 4), + recipe( + 2, + "Autre", + [simpleStep(2, 0, "Émincer", "chop", [line(ING.onion, 1, PIECE)])], + 4, + 4, + ), + ]); + const merged = mergedTasks(plan)[0]; + // (2 × 8/4) + (1 × 4/4) = 5 + expect(merged?.ingredients[0]?.quantity).to.equal(5); + }); + + it("falls back to 1× when recipePortions is zero or missing (bad data guard)", () => { + const plan = optimizeCookingPlan([ + recipe(1, "A", [simpleStep(1, 0, "Émincer", "chop", [line(ING.onion, 3, PIECE)])], 10, 0), + recipe(2, "B", [simpleStep(2, 0, "Émincer", "chop", [line(ING.onion, 1, PIECE)])], 4, 4), + ]); + const merged = mergedTasks(plan)[0]; + // recipe 1 scaled 1× (guard) → 3 ; recipe 2 → 1 ; total 4 + expect(merged?.ingredients[0]?.quantity).to.equal(4); + }); + + it("leaves a null-quantity clause null after scaling", () => { + const plan = optimizeCookingPlan([ + recipe(1, "A", [simpleStep(1, 0, "Saler", "season", [line(ING.onion, null, null)])], 8, 4), + ]); + const task = stepTasks(plan).find((t) => t.description === "Saler"); + expect(task?.ingredients[0]?.quantity).to.equal(null); + }); + }); + + // ===================================================================== + // Phase scheduling & parallelism + // ===================================================================== + describe("phase scheduling", () => { + it("floats a passive cook into later phases' background while another recipe works, then clears it", () => { + const plan = optimizeCookingPlan([ + recipe(1, "Ragoût", [ + simpleStep(1, 0, "Faire mijoter la viande", "simmer"), + simpleStep(2, 1, "Rectifier l'assaisonnement", "season"), + simpleStep(3, 2, "Dresser", "plate"), + ]), + recipe(2, "Salade", [ + simpleStep(4, 0, "Mélanger", "mix"), + simpleStep(5, 1, "Assaisonner", "season"), + simpleStep(6, 2, "Servir", "plate"), + ]), + ]); + + const cooking = plan.phases.filter((p) => p.kind !== "mise-en-place"); + const withSimmerBg = cooking.filter((p) => + p.background.some((b) => b.description === "Faire mijoter la viande"), + ); + expect(withSimmerBg.length).to.be.greaterThan(0); + expect(withSimmerBg[0]?.background[0]?.recipeId).to.equal(1); + + // Once recipe 1 pops its next step, the simmer stops being background. + const lastPhase = plan.phases[plan.phases.length - 1]; + expect( + lastPhase?.background.some((b) => b.description === "Faire mijoter la viande"), + ).to.equal(false); + }); + + it("does not surface a single recipe's own passive cook as its own background (nothing else to do)", () => { + const plan = optimizeCookingPlan([ + recipe(1, "Soupe", [ + simpleStep(1, 0, "Mijoter", "simmer"), + simpleStep(2, 1, "Mixer", "mix"), + ]), + ]); + const backgrounds = plan.phases.flatMap((p) => p.background); + expect(backgrounds).to.deep.equal([]); + }); + + it("marks the last phase 'finishing' when it holds only plating", () => { + const plan = optimizeCookingPlan([ + recipe(1, "A", [simpleStep(1, 0, "Cuire", "cook"), simpleStep(2, 1, "Dresser", "plate")]), + recipe(2, "B", [simpleStep(3, 0, "Cuire", "cook"), simpleStep(4, 1, "Dresser", "plate")]), + ]); + const last = plan.phases[plan.phases.length - 1]; + expect(last?.kind).to.equal("finishing"); + expect(last?.tasks.every((t) => t.technique?.key === "plate")).to.equal(true); + }); + + it("keeps a mixed final phase as 'cooking', not 'finishing'", () => { + const plan = optimizeCookingPlan([ + recipe(1, "A", [simpleStep(1, 0, "Dresser", "plate")]), + recipe(2, "B", [simpleStep(2, 0, "Étape 1", "mix"), simpleStep(3, 1, "Étape 2", "cook")]), + ]); + expect(plan.phases.every((p) => p.kind !== "finishing")).to.equal(true); + }); + + it("breaks the stall when every remaining recipe is holding on a passive cook", () => { + // Both recipes: a passive step then a final step. After each starts its + // passive cook they both 'hold' the next phase — the optimizer must + // release the holds rather than loop or emit an empty phase. + const plan = optimizeCookingPlan([ + recipe(1, "A", [ + simpleStep(1, 0, "Mijoter A", "simmer"), + simpleStep(2, 1, "Finir A", "plate"), + ]), + recipe(2, "B", [ + simpleStep(3, 0, "Rôtir B", "roast"), + simpleStep(4, 1, "Finir B", "plate"), + ]), + ]); + expect(plan.phases.every((p) => p.tasks.length > 0)).to.equal(true); + assertEveryStepAccountedForOnce( + [ + recipe(1, "A", [ + simpleStep(1, 0, "Mijoter A", "simmer"), + simpleStep(2, 1, "Finir A", "plate"), + ]), + recipe(2, "B", [ + simpleStep(3, 0, "Rôtir B", "roast"), + simpleStep(4, 1, "Finir B", "plate"), + ]), + ], + plan, + ); + }); + + it("omits the mise-en-place phase entirely when there is no prep and no setup, and re-indexes from 0", () => { + const plan = optimizeCookingPlan([ + recipe(1, "A", [simpleStep(1, 0, "Cuire", "cook"), simpleStep(2, 1, "Dresser", "plate")]), + ]); + expect(plan.phases[0]?.kind).to.not.equal("mise-en-place"); + assertPhaseIndices(plan); + }); + + it("starts the long cook first within a phase (passive tasks sorted ahead of active ones)", () => { + const plan = optimizeCookingPlan([ + recipe(1, "Active", [simpleStep(1, 0, "Touiller", "mix")]), + recipe(2, "Passive", [simpleStep(2, 0, "Mettre à mijoter", "simmer")]), + ]); + const firstCooking = plan.phases.find((p) => p.kind !== "mise-en-place"); + expect(firstCooking?.tasks[0]?.description).to.equal("Mettre à mijoter"); + }); + }); + + // ===================================================================== + // Production-shaped datasets: a household's full week + // ===================================================================== + describe("full-week planning (production-shaped)", () => { + /** + * A realistic household week — seven dinners, each a genuine + * multi-step recipe. Onion / garlic / carrot / parsley recur across + * several recipes with a real prep step, so the optimizer has plenty + * to pool; several recipes have a long passive cook (simmer / bake / + * roast / braise) to exercise the background scheduler. + */ + function householdWeek(): OptimizerRecipeInput[] { + return [ + recipe(101, "Soupe à l'oignon", [ + simpleStep(1, 0, "Émincer les oignons", "chop", [line(ING.onion, 6, PIECE)]), + simpleStep(2, 1, "Faire suer les oignons au beurre", "sweat", [ + line(ING.butter, 40, GRAM), + ]), + simpleStep(3, 2, "Mouiller au bouillon et laisser mijoter 30 min", "simmer"), + simpleStep(4, 3, "Gratiner au four", "bake", [line(ING.cheese, 150, GRAM)]), + simpleStep(5, 4, "Servir bien chaud", "plate"), + ]), + recipe(102, "Bœuf bourguignon", [ + simpleStep(10, 0, "Tailler les carottes", "chop", [line(ING.carrot, 400, GRAM)]), + simpleStep(11, 1, "Émincer les oignons", "chop", [line(ING.onion, 3, PIECE)]), + simpleStep(12, 2, "Colorer la viande", "brown", [line(ING.beef, 1200, GRAM)]), + simpleStep(13, 3, "Déglacer au vin rouge", "deglaze"), + simpleStep(14, 4, "Braiser 3 h à couvert", "braise"), + simpleStep(15, 5, "Dresser", "plate"), + ]), + recipe(103, "Curry de lentilles", [ + simpleStep(20, 0, "Émincer les oignons", "chop", [line(ING.onion, 2, PIECE)]), + simpleStep(21, 1, "Presser l'ail", "mince", [line(ING.garlic, 3, PIECE)]), + simpleStep(22, 2, "Faire revenir les épices", "panFry"), + simpleStep(23, 3, "Ajouter lentilles et tomates, mijoter 25 min", "simmer", [ + line(ING.lentils, 300, GRAM), + line(ING.tomato, 400, GRAM), + ]), + 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"), + simpleStep(31, 1, "Éplucher les pommes de terre", "peel", [ + line(ING.potato, 1, KILOGRAM), + ]), + simpleStep(32, 2, "Presser l'ail", "mince", [line(ING.garlic, 4, PIECE)]), + simpleStep(33, 3, "Enfourner le poulet 1 h 15", "roast", [line(ING.chicken, 1600, GRAM)]), + simpleStep(34, 4, "Laisser reposer 10 min", "rest"), + simpleStep(35, 5, "Découper et dresser", "plate"), + ]), + recipe(105, "Gratin dauphinois", [ + simpleStep(40, 0, "Préchauffer le four à 180°C", "preheat"), + simpleStep(41, 1, "Éplucher les pommes de terre", "peel", [ + line(ING.potato, 1, KILOGRAM), + ]), + simpleStep(42, 2, "Émincer finement à la mandoline", "chop", [ + line(ING.potato, 1, KILOGRAM), + ]), + simpleStep(43, 3, "Monter le gratin avec la crème", "coat", [line(ING.cream, 500, GRAM)]), + simpleStep(44, 4, "Cuire 1 h au four", "bake"), + ]), + recipe(106, "Risotto aux champignons", [ + simpleStep(50, 0, "Émincer les oignons", "chop", [line(ING.onion, 1, PIECE)]), + simpleStep(51, 1, "Nacrer le riz", "panFry", [line(ING.rice, 320, GRAM)]), + simpleStep(52, 2, "Mouiller louche à louche 18 min", "simmer"), + simpleStep(53, 3, "Lier au beurre et parmesan", "mix", [line(ING.cheese, 80, GRAM)]), + simpleStep(54, 4, "Servir aussitôt", "plate"), + ]), + recipe(107, "Salade & omelette", [ + simpleStep(60, 0, "Battre les œufs", "whisk", [line(ING.egg, 6, PIECE)]), + simpleStep(61, 1, "Hacher le persil", "chop", [line(ING.parsley, 20, GRAM)]), + simpleStep(62, 2, "Cuire l'omelette", "cook"), + simpleStep(63, 3, "Assaisonner la salade", "season"), + simpleStep(64, 4, "Servir", "plate"), + ]), + ]; + } + + it("produces a coherent plan: mise-en-place first, every phase non-empty, indices 0..n-1", () => { + const week = householdWeek(); + const plan = optimizeCookingPlan(week); + + expect(plan.recipes).to.have.lengthOf(week.length); + expect(plan.phases.length).to.be.greaterThan(1); + expect(plan.phases[0]?.kind).to.equal("mise-en-place"); + expect(plan.phases.every((p) => p.tasks.length > 0)).to.equal(true); + assertPhaseIndices(plan); + assertUniqueIds(plan); + }); + + it("accounts for every one of the ~40 planned steps exactly once", () => { + const week = householdWeek(); + const plan = optimizeCookingPlan(week); + assertEveryStepAccountedForOnce(week, plan); + }); + + it("pools the shared knife work — onion, garlic and potato each become one merged-prep task", () => { + const week = householdWeek(); + const plan = optimizeCookingPlan(week); + const merged = mergedTasks(plan); + const byIngredient = merged.map((t) => t.ingredients[0]?.ingredient.key).sort(); + + // onion: recipes 101,102,103,106 — garlic: 103,104 — potato peel: 104,105 + expect(byIngredient).to.include("onion"); + expect(byIngredient).to.include("garlic"); + expect(byIngredient).to.include("potato"); + + const onionPool = merged.find((t) => t.ingredients[0]?.ingredient.key === "onion"); + expect(onionPool?.sourceRecipes.map((r) => r.recipeId)).to.have.members([101, 102, 103, 106]); + // 6 + 3 + 2 + 1 pieces + expect(onionPool?.ingredients[0]?.quantity).to.equal(12); + expect(onionPool?.technique?.key).to.equal("chop"); + }); + + it("pulls every 'préchauffer le four' into the mise-en-place phase", () => { + const week = householdWeek(); + const plan = optimizeCookingPlan(week); + const startsWithPreheat = (d: string | null) => (d ?? "").startsWith("Préchauffer le four"); + const miseDescr = plan.phases[0]?.tasks.map((t) => t.description) ?? []; + expect(miseDescr.filter(startsWithPreheat)).to.have.lengthOf(2); + const laterDescr = plan.phases.slice(1).flatMap((p) => p.tasks.map((t) => t.description)); + expect(laterDescr.some(startsWithPreheat)).to.equal(false); + }); + + it("runs the long cooks in the background of later phases (bourguignon braise, poulet rôti, gratins)", () => { + const week = householdWeek(); + const plan = optimizeCookingPlan(week); + const bgDescr = plan.phases.flatMap((p) => p.background.map((b) => b.description)); + expect(bgDescr.some((d) => d.includes("Braiser 3 h"))).to.equal(true); + expect(bgDescr.some((d) => d.includes("Enfourner le poulet"))).to.equal(true); + // Every background line is the echo of a real passive step scheduled earlier. + const passiveStepIds = new Set(plan.phases.flatMap((p) => p.tasks).map((t) => t.id)); + for (const phase of plan.phases) { + for (const bg of phase.background) { + expect(passiveStepIds.has(bg.id.replace(/^bg:/, ""))).to.equal(true); + } + } + }); + + it("never emits a merged-prep for a cut that lives inside a cooking step (mandoline slice in the gratin)", () => { + // Recipe 105 step 42 "Émincer finement à la mandoline" IS pure prep + // (chop only) on potato — it should be eligible to pool with 105's own + // peel? No: different technique. It pools with nothing else here + // because no other recipe chops potato. So it stays a solo mise task. + const week = householdWeek(); + const plan = optimizeCookingPlan(week); + const mandoline = allTasks(plan).find( + (t) => t.description === "Émincer finement à la mandoline", + ); + expect(mandoline?.kind).to.equal("step"); + expect(plan.phases[0]?.tasks).to.include(mandoline); + }); + + it("is deterministic on the full week", () => { + const a = optimizeCookingPlan(householdWeek()); + const b = optimizeCookingPlan(householdWeek()); + expect(JSON.stringify(a)).to.equal(JSON.stringify(b)); + }); + }); + + // ===================================================================== + // Two people / two households combined into one big cook + // ===================================================================== + describe("multiple people — combined week", () => { + /** Alice's 3 dinners + Bob's 3 dinners, all pooled into one session. */ + function combinedWeek(): OptimizerRecipeInput[] { + const alice: OptimizerRecipeInput[] = [ + recipe( + 201, + "Alice — Chili", + [ + simpleStep(1, 0, "Émincer les oignons", "chop", [line(ING.onion, 2, PIECE)]), + simpleStep(2, 1, "Presser l'ail", "mince", [line(ING.garlic, 2, PIECE)]), + simpleStep(3, 2, "Mijoter 40 min", "simmer"), + simpleStep(4, 3, "Servir", "plate"), + ], + 2, + 4, + ), + recipe( + 202, + "Alice — Ratatouille", + [ + simpleStep(10, 0, "Tailler les tomates", "concasse", [line(ING.tomato, 500, GRAM)]), + simpleStep(11, 1, "Émincer les oignons", "chop", [line(ING.onion, 1, PIECE)]), + simpleStep(12, 2, "Compoter à feu doux", "compote"), + simpleStep(13, 3, "Dresser", "plate"), + ], + 2, + 4, + ), + recipe( + 203, + "Alice — Salade de lentilles", + [ + simpleStep(20, 0, "Cuire les lentilles", "boil", [line(ING.lentils, 200, GRAM)]), + simpleStep(21, 1, "Hacher le persil", "chop", [line(ING.parsley, 15, GRAM)]), + simpleStep(22, 2, "Assaisonner", "season"), + ], + 2, + 4, + ), + ]; + const bob: OptimizerRecipeInput[] = [ + recipe( + 301, + "Bob — Bolognaise", + [ + simpleStep(30, 0, "Émincer les oignons", "chop", [line(ING.onion, 2, PIECE)]), + simpleStep(31, 1, "Tailler les carottes", "chop", [line(ING.carrot, 200, GRAM)]), + simpleStep(32, 2, "Colorer la viande", "brown", [line(ING.beef, 500, GRAM)]), + simpleStep(33, 3, "Mijoter 1 h", "simmer"), + simpleStep(34, 4, "Servir", "plate"), + ], + 3, + 4, + ), + recipe( + 302, + "Bob — Poulet basquaise", + [ + simpleStep(40, 0, "Émincer les oignons", "chop", [line(ING.onion, 2, PIECE)]), + simpleStep(41, 1, "Presser l'ail", "mince", [line(ING.garlic, 2, PIECE)]), + simpleStep(42, 2, "Saisir le poulet", "brown", [line(ING.chicken, 800, GRAM)]), + simpleStep(43, 3, "Mijoter 35 min", "simmer"), + simpleStep(44, 4, "Dresser", "plate"), + ], + 3, + 4, + ), + recipe( + 303, + "Bob — Riz pilaf", + [ + simpleStep(50, 0, "Nacrer le riz", "panFry", [line(ING.rice, 250, GRAM)]), + simpleStep(51, 1, "Cuire couvert 17 min", "simmer"), + simpleStep(52, 2, "Égrainer et servir", "plate"), + ], + 3, + 4, + ), + ]; + return [...alice, ...bob]; + } + + it("pools knife work across both people's recipes", () => { + const week = combinedWeek(); + const plan = optimizeCookingPlan(week); + const merged = mergedTasks(plan); + + const onionPool = merged.find((t) => t.ingredients[0]?.ingredient.key === "onion"); + // onion chopped in 201, 202, 301, 302 + expect(onionPool?.sourceRecipes.map((r) => r.recipeId)).to.have.members([201, 202, 301, 302]); + // 2×(2/4) + 1×(2/4) + 2×(3/4) + 2×(3/4) = 1 + 0.5 + 1.5 + 1.5 = 4.5 + expect(onionPool?.ingredients[0]?.quantity).to.equal(4.5); + + const garlicPool = merged.find((t) => t.ingredients[0]?.ingredient.key === "garlic"); + expect(garlicPool?.sourceRecipes.map((r) => r.recipeId)).to.have.members([201, 302]); + }); + + it("accounts for every step and keeps every id unique across the combined plan", () => { + const week = combinedWeek(); + const plan = optimizeCookingPlan(week); + assertEveryStepAccountedForOnce(week, plan); + assertUniqueIds(plan); + assertPhaseIndices(plan); + }); + + it("still schedules six simultaneous simmers without an empty or infinite phase", () => { + const week = combinedWeek(); + const plan = optimizeCookingPlan(week); + expect(plan.phases.every((p) => p.tasks.length > 0)).to.equal(true); + + // Every recipe's very last step is scheduled somewhere. + const stepTaskIds = new Set(stepTasks(plan).map((t) => t.id)); + week.forEach((r, recipeIndex) => { + const lastStep = [...r.steps].sort((a, b) => a.order - b.order).at(-1); + expect( + stepTaskIds.has(`step:${recipeIndex}:${lastStep?.stepId}`), + `${r.name}'s last step should be scheduled`, + ).to.equal(true); + }); + }); + + it("orders the plan so all pooled prep is done before any recipe's cooking step", () => { + const week = combinedWeek(); + const plan = optimizeCookingPlan(week); + const firstCookingPhaseIndex = plan.phases.findIndex((p) => p.kind !== "mise-en-place"); + const misePhases = plan.phases.slice(0, firstCookingPhaseIndex); + // All merged-prep tasks live in the mise-en-place phase(s). + const mergedOutsideMise = plan.phases + .slice(firstCookingPhaseIndex) + .flatMap((p) => p.tasks) + .filter((t) => t.kind === "merged-prep"); + expect(mergedOutsideMise).to.deep.equal([]); + expect(misePhases.length).to.equal(1); + }); + }); +}); diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 73fb904..1034999 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -8,6 +8,7 @@ export * from "./data/catalog-labels-fr.js"; export * from "./errors/error-codes.js"; export * from "./schemas/account.js"; export * from "./schemas/auth.js"; +export * from "./schemas/cooking-session.js"; export * from "./schemas/household.js"; export * from "./schemas/planning.js"; export * from "./schemas/preferences.js"; @@ -17,6 +18,7 @@ export * from "./schemas/shopping-list.js"; export * from "./schemas/sources.js"; export * from "./schemas/tech-step-worker.js"; export * from "./tools/assert-is-never.js"; +export * from "./types/cooking-session.js"; export * from "./types/household.js"; export * from "./types/planning.js"; export * from "./types/preferences.js"; diff --git a/packages/shared/src/schemas/cooking-session.ts b/packages/shared/src/schemas/cooking-session.ts new file mode 100644 index 0000000..7b630d9 --- /dev/null +++ b/packages/shared/src/schemas/cooking-session.ts @@ -0,0 +1,18 @@ +import { z } from "zod"; + +// See schemas/auth.ts for the shared client/server validation rationale. + +/** + * Payload accepted by `GET /cooking-session`'s `?date=` query param — same + * shape/rationale as `schemas/shopping-list.ts`'s `getShoppingListSchema` + * (only the `YYYY-MM-DD` shape is checked here; real-calendar-date + * validation is service-side via `@batch-cooking/date-tools`'s + * `parseDateOnly`). Kept as its own schema rather than importing another + * module's near-identical one — each router owns its own request contract + * in this repo, even when two happen to share a shape. + */ +export const getCookingSessionSchema = z.object({ + date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Date invalide"), +}); +/** Inferred TS type for {@link getCookingSessionSchema}'s validated output. */ +export type GetCookingSessionInput = z.infer; diff --git a/packages/shared/src/types/cooking-session.ts b/packages/shared/src/types/cooking-session.ts new file mode 100644 index 0000000..4c7da94 --- /dev/null +++ b/packages/shared/src/types/cooking-session.ts @@ -0,0 +1,120 @@ +import type { IngredientView, TechStepView, UnitView, UtensilView } from "./reference.js"; + +/** + * A recipe that contributes to an optimized cooking plan, resolved to just + * enough for a display legend / provenance badge — same "resolve to + * `{id, name}` and nothing more" treatment as `PlanningItemView.recipe`. + * `portions` is this contribution's own portion count (the `PlanningItem`'s, + * not `Recipe.portions`), since the plan scales quantities to it. + */ +export interface CookingSessionRecipeRef { + recipeId: number; + name: string; + portions: number; +} + +/** + * Which stage of the session a {@link CookingPhaseView} belongs to. Not a + * free-form title — the label is resolved client-side via + * `t(\`cookingSession.phase.${kind}\`)`, same "API sends a key, web owns the + * wording" split as every reference catalog: + * + * - `"mise-en-place"` — the first phase: all shared prep pooled together + * (`chop`/`peel`/… the same ingredient across recipes = one task) plus + * `SETUP` tasks (preheat the oven, bring water to a boil). + * - `"cooking"` — the interleaved middle phases, one "next ready step of + * each recipe" per phase, with passive cooks floated into `background`. + * - `"finishing"` — the last phase when it only holds plating/`plate` work. + */ +export type CookingPhaseKind = "mise-en-place" | "cooking" | "finishing"; + +/** + * One ingredient line attached to a {@link CookingTaskView} — the same + * `(ingredient, quantity, unit)` shape as `StepTechStepIngredientView`, + * carried through so the cook sees "3 oignons" next to "Émincer". Both + * `quantity` and `unit` are `null` when the source clause named the + * ingredient with no measurable amount ("ajouter le sel"), or when a + * merge pooled two incompatible units and no single total could be given + * (see {@link CookingTaskView.kind}). + */ +export interface CookingTaskIngredientView { + ingredient: IngredientView; + quantity: number | null; + unit: UnitView | null; +} + +/** + * One actionable unit of work in a phase's `tasks` list. + * + * - `kind: "step"` — a single recipe step, run as written. `technique` is + * its dominant detected technique (or `null` if it mentions none), + * `description` is the original step text, `sourceRecipes` has exactly one + * entry. + * - `kind: "merged-prep"` — shared preparation pooled across recipes: the + * same prep technique applied to the same ingredient by two or more + * recipes, collapsed into one task (the "mutualise the onions" case). + * `description` is `null` (the web layer composes a label from + * `technique` + `ingredients`), `sourceRecipes` lists every recipe it + * covers, and `ingredients` holds the pooled quantity. + * + * `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". + */ +export interface CookingTaskView { + id: string; + kind: "merged-prep" | "step"; + technique: TechStepView | null; + description: string | null; + ingredients: CookingTaskIngredientView[]; + utensils: UtensilView[]; + sourceRecipes: CookingSessionRecipeRef[]; + originalSteps: { recipeId: number; recipeName: string; description: string }[]; +} + +/** + * A passive cook (simmer, braise, bake, marinate…) started in an earlier + * phase and still running — surfaced in every later phase's `background` + * until the step that consumes it comes up, so the cook is reminded "the + * beef is still braising" while doing active work from another recipe. Not + * something to act on now, just a status line, hence a thinner shape than + * {@link CookingTaskView}. + */ +export interface CookingBackgroundTaskView { + id: string; + technique: TechStepView | null; + description: string; + recipeId: number; + recipeName: string; +} + +/** + * 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. + */ +export interface CookingPhaseView { + index: number; + kind: CookingPhaseKind; + tasks: CookingTaskView[]; + background: CookingBackgroundTaskView[]; +} + +/** + * A household's week of planned recipes, reorganized into an ordered + * sequence of cooking phases (see `apps/api`'s `cooking-optimizer.ts`). + * + * Like `ShoppingListView` and unlike `PlanningView`, this is **never** + * `null` — no household, or a household with nothing planned that week, + * both degrade to an empty `phases` array on an otherwise normal object + * (the week's date range is always computable), not a separate "nothing to + * show" state the frontend has to branch on. + */ +export interface OptimizedCookingPlanView { + startDate: string; + finishDate: string; + recipes: CookingSessionRecipeRef[]; + phases: CookingPhaseView[]; +}