feat(cooking): moteur d'optimisation des etapes de batch-cooking

Coeur pur du module "Calcul batch-cooking" (specs/batch-cooking-architecture.md,
jusqu'ici TODO) : optimizeCookingPlan() prend les recettes planifiees d'une
semaine et les reorganise en phases ordonnees.

- Mutualisation de la mise en place : une meme technique de decoupe (chop, peel,
  mince...) appliquee au meme ingredient par >= 2 recettes est regroupee en une
  seule tache "merged-prep" (les oignons de plusieurs recettes = une decoupe).
- Parallelisme : les cuissons passives (simmer, braise, bake, marinate...) sont
  poussees en tache de fond des phases suivantes pendant qu'une autre recette
  avance en actif.
- Fonction pure sans base (meme split matchXxx pur / loadXxx DB-backed que
  ingredient-matcher / tech-step-matcher), testable en isolation.

Types partages : OptimizedCookingPlanView + schema getCookingSessionSchema.
Tests Mocha purs (6 cas) : merge, non-merge d'une decoupe solo, tache de fond,
mise a l'echelle par portions, plan vide, legende.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Nicolas 2026-08-28 08:52:52 +02:00
parent 9eadf3b974
commit 83a12d474d
5 changed files with 1675 additions and 0 deletions

View file

@ -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<string> = 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<string> = new Set(["preheat", "boil", "bainMarie"]);
/** See {@link SETUP_TECHNIQUES}. */
const PASSIVE_TECHNIQUES: ReadonlySet<string> = 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:<recipeIndex>:<stepId>` (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<string, CookingTaskIngredientView[]>();
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<number, UtensilView>();
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 `<techniqueKey>:<ingredientId>` 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<string, NormalizedStep[]>();
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 `<technique>:<ingredient>`
// 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<number>();
const originalSteps: CookingTaskView["originalSteps"] = [];
for (const step of [...group].sort((a, b) => a.recipeIndex - b.recipeIndex)) {
if (!seenRecipe.has(step.recipeIndex)) {
seenRecipe.add(step.recipeIndex);
sourceRecipes.push(step.recipe);
}
originalSteps.push({
recipeId: step.recipe.recipeId,
recipeName: step.recipe.name,
description: step.description,
});
}
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<string>();
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<number, NormalizedStep>();
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 };
}

File diff suppressed because it is too large Load diff

View file

@ -8,6 +8,7 @@ export * from "./data/catalog-labels-fr.js";
export * from "./errors/error-codes.js"; export * from "./errors/error-codes.js";
export * from "./schemas/account.js"; export * from "./schemas/account.js";
export * from "./schemas/auth.js"; export * from "./schemas/auth.js";
export * from "./schemas/cooking-session.js";
export * from "./schemas/household.js"; export * from "./schemas/household.js";
export * from "./schemas/planning.js"; export * from "./schemas/planning.js";
export * from "./schemas/preferences.js"; export * from "./schemas/preferences.js";
@ -17,6 +18,7 @@ export * from "./schemas/shopping-list.js";
export * from "./schemas/sources.js"; export * from "./schemas/sources.js";
export * from "./schemas/tech-step-worker.js"; export * from "./schemas/tech-step-worker.js";
export * from "./tools/assert-is-never.js"; export * from "./tools/assert-is-never.js";
export * from "./types/cooking-session.js";
export * from "./types/household.js"; export * from "./types/household.js";
export * from "./types/planning.js"; export * from "./types/planning.js";
export * from "./types/preferences.js"; export * from "./types/preferences.js";

View file

@ -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<typeof getCookingSessionSchema>;

View file

@ -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:<techniqueKey>:<ingredientKey>"`
* or `"step:<stepId>"`) so the frontend can key a checklist off it.
* `originalSteps` is the provenance trail the exact step text(s) this
* task stands in for, so the UI can link back to "voir la recette".
*/
export interface CookingTaskView {
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[];
}