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 }; }