diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 1d62303..028fc4b 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -6,6 +6,7 @@ import { env } from "./config/env.js"; import { errorLogger } from "./middlewares/error-logger.js"; import { requestLogger } from "./middlewares/request-logger.js"; import { authRouter } from "./modules/auth/auth.routes.js"; +import { cookingSessionRouter } from "./modules/cooking-session/cooking-session.routes.js"; import { houseRouter } from "./modules/house/house.routes.js"; import { techStepWorkerRouter } from "./modules/internal/tech-step-worker.routes.js"; import { planningRouter } from "./modules/planning/planning.routes.js"; @@ -52,6 +53,7 @@ export function createServer(): ExpressServer { server.mountRouter("/recipes", recipeRouter); server.mountRouter("/reference", referenceRouter); server.mountRouter("/shopping-list", shoppingListRouter); + server.mountRouter("/cooking-session", cookingSessionRouter); server.mountRouter("/sources", sourcesRouter); // Serves the built frontend (production Docker image only — see 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/src/modules/cooking-session/cooking-session.routes.ts b/apps/api/src/modules/cooking-session/cooking-session.routes.ts new file mode 100644 index 0000000..3744f7f --- /dev/null +++ b/apps/api/src/modules/cooking-session/cooking-session.routes.ts @@ -0,0 +1,37 @@ +import { parseDateOnly } from "@batch-cooking/date-tools"; +import { HttpError } from "@batch-cooking/error-tools"; +import { wrapAsyncHandler } from "@batch-cooking/express-tools"; +import { ErrorCode, getCookingSessionSchema } from "@batch-cooking/shared"; +import { Router } from "express"; +import { type AuthLocals, requireAuth } from "../../middlewares/require-auth.js"; +import { getCookingPlanForDate } from "./cooking-session.service.js"; + +/** Router mounted at `/cooking-session` in app.ts. */ +export const cookingSessionRouter = Router(); + +/** + * Returns the authenticated user's household's optimized cooking plan for + * the week covering `?date=` (`YYYY-MM-DD`) — every recipe planned that + * week reorganized into ordered phases (see {@link getCookingPlanForDate}). + * Always `200`, never `null` — no household or nothing planned that week + * both come back as a normal `OptimizedCookingPlanView` with empty + * `recipes`/`phases`. Same request contract as `GET /shopping-list`. + */ +cookingSessionRouter.get( + "/", + requireAuth, + wrapAsyncHandler(async (req, res) => { + const input = getCookingSessionSchema.parse(req.query); + const date = parseDateOnly(input.date); + if (date === null) { + throw new HttpError( + 400, + ErrorCode.VALIDATION_ERROR, + `Not a real calendar date: ${input.date}`, + ); + } + + const plan = await getCookingPlanForDate(res.locals.userProfile.houseId, date); + res.status(200).json(plan); + }), +); diff --git a/apps/api/src/modules/cooking-session/cooking-session.service.ts b/apps/api/src/modules/cooking-session/cooking-session.service.ts new file mode 100644 index 0000000..ee5d9de --- /dev/null +++ b/apps/api/src/modules/cooking-session/cooking-session.service.ts @@ -0,0 +1,168 @@ +import { type DateTime, getWeekStart, toDateOnly } from "@batch-cooking/date-tools"; +import type { CookingTaskIngredientView, OptimizedCookingPlanView } from "@batch-cooking/shared"; +import type { Prisma } from "@prisma/client"; +import { prisma } from "../../db/prisma.js"; +import { + type OptimizerRecipeInput, + type OptimizerStepInput, + optimizeCookingPlan, +} from "../../lib/recipe-matching/cooking-optimizer.js"; +import { toIngredientView, toUnitView } from "../recipe/recipe.service.js"; + +/** + * Prisma `include` for a `Planning` query that needs, for every item, its + * recipe's ordered steps with the full detected-technique tree — the raw + * material the optimizer works on (see `cooking-optimizer.ts`). It's the + * `steps` sub-tree of `recipe.service.ts`'s own `recipeInclude`, resolved + * the same way so {@link toIngredientView}/{@link toUnitView} can be reused + * as-is; deliberately narrower than a full `RecipeView` fetch (no + * diets/favorites/recipe-level ingredient list — the optimizer reads + * quantities off the technique clauses, not the recipe header). + */ +function cookingSessionPlanningInclude() { + return { + items: { + include: { + recipe: { + select: { + id: true, + name: true, + portions: true, + steps: { + orderBy: { order: "asc" }, + include: { + techSteps: { + orderBy: { order: "asc" }, + include: { + techStep: true, + ingredients: { + include: { + ingredient: { + include: { + allergies: { include: { allergy: { include: { category: true } } } }, + diets: { include: { diet: true } }, + }, + }, + unit: true, + }, + }, + utensils: { include: { utensil: true } }, + }, + }, + }, + }, + }, + }, + }, + }, + } satisfies Prisma.PlanningInclude; +} + +type PlanningWithSteps = Prisma.PlanningGetPayload<{ + include: ReturnType; +}>; +type PlanningItemWithSteps = PlanningWithSteps["items"][number]; + +/** + * Maps one planning item's recipe (with {@link cookingSessionPlanningInclude}) + * to the optimizer's pure input shape — ingredient/unit/technique/utensil + * rows resolved to their reference views here so the optimizer itself never + * touches Prisma. `Decimal` quantities become plain numbers (same + * `Number(...)` conversion as `recipe.service.ts`'s own view mappers); an + * unresolved-unit line keeps `unit: null`. + */ +function toOptimizerRecipe(item: PlanningItemWithSteps): OptimizerRecipeInput { + const steps: OptimizerStepInput[] = item.recipe.steps.map((step) => ({ + stepId: step.id, + order: step.order, + description: step.description, + techSteps: step.techSteps.map((techStep) => { + const ingredients: CookingTaskIngredientView[] = techStep.ingredients.map((line) => ({ + ingredient: toIngredientView(line.ingredient), + quantity: line.quantity === null ? null : Number(line.quantity), + unit: line.unit === null ? null : toUnitView(line.unit), + })); + return { + techStep: { id: techStep.techStep.id, key: techStep.techStep.key }, + order: techStep.order, + ingredients, + utensils: techStep.utensils.map(({ utensil }) => ({ id: utensil.id, key: utensil.key })), + }; + }), + })); + + return { + recipeId: item.recipe.id, + name: item.recipe.name, + // The slot's own portion count vs. the recipe's as-written yield — the + // optimizer scales technique-clause quantities by the ratio, same + // reasoning as `shopping-list.service.ts`'s `aggregateShoppingList`. + portions: item.portions, + recipePortions: item.recipe.portions, + steps, + }; +} + +/** + * Builds the household's optimized cooking plan for the week covering + * `date` — every recipe planned that week, reorganized into ordered phases + * that pool shared prep and float passive cooks into the background (see + * `cooking-optimizer.ts`). `date` follows the same convention as + * `planning.service.ts`'s `getPlanningForDate` (a caller-parsed `?date=`, + * not necessarily a Monday). + * + * Like `getShoppingListForDate` and unlike `getPlanningForDate`, this + * **never** returns `null` — no household and "no planning covers this week + * yet" both degrade to an empty `phases`/`recipes` on an otherwise normal + * {@link OptimizedCookingPlanView} (the week's date range is always + * computable from `date` alone). + */ +export async function getCookingPlanForDate( + houseId: number | null, + date: DateTime, +): Promise { + try { + const weekStart = getWeekStart(toDateOnly(date)); + const weekFinish = weekStart.plus({ days: 6 }); + const emptyPlan: OptimizedCookingPlanView = { + startDate: weekStart.toJSDate().toISOString(), + finishDate: weekFinish.toJSDate().toISOString(), + recipes: [], + phases: [], + }; + + if (houseId === null) { + return emptyPlan; + } + + // Same "covering range" lookup as getShoppingListForDate — see + // getPlanningForDate's doc comment for the UTC-midnight `Date` rationale. + const dateOnly = toDateOnly(date).toJSDate(); + const planning = await prisma.planning.findFirst({ + where: { + houseId, + startDate: { lte: dateOnly }, + finishDate: { gte: dateOnly }, + }, + orderBy: { startDate: "desc" }, + include: cookingSessionPlanningInclude(), + }); + + if (!planning) { + return emptyPlan; + } + + const { recipes, phases } = optimizeCookingPlan(planning.items.map(toOptimizerRecipe)); + return { + startDate: planning.startDate.toISOString(), + finishDate: planning.finishDate.toISOString(), + recipes, + phases, + }; + } catch (err) { + // Rethrown as-is — `wrapAsyncHandler`/the error middleware handles it, + // this service layer just isn't allowed a bare `await` per the repo's + // async/try-catch convention. + throw err; + } +} diff --git a/apps/api/test/cooking-session.test.ts b/apps/api/test/cooking-session.test.ts new file mode 100644 index 0000000..b218aff --- /dev/null +++ b/apps/api/test/cooking-session.test.ts @@ -0,0 +1,204 @@ +import type { DateTime } from "@batch-cooking/date-tools"; +import { ErrorCode, type SignupInput } from "@batch-cooking/shared"; +import { faker } from "@faker-js/faker"; +import { expect } from "chai"; +import request from "supertest"; +import { createApp } from "../src/app.js"; +import { prisma } from "../src/db/prisma.js"; +import { TEST_REFERENCE_DATE } from "../test-support/reference-date.js"; +import { resetDatabase } from "../test-support/reset-db.js"; + +/** See `auth.test.ts` — same rationale for generating rather than hardcoding. */ +function buildSignupPayload(): SignupInput { + const firstName = faker.person.firstName(); + const lastName = faker.person.lastName(); + return { + firstName, + lastName, + email: faker.internet.email({ firstName, lastName }).toLowerCase(), + password: faker.internet.password({ length: 16 }), + }; +} + +/** `toISODate()` only returns `null` for an invalid `DateTime` — never the always-valid values here. */ +function isoDate(date: DateTime): string { + const iso = date.toISODate(); + if (iso === null) throw new Error("Unexpectedly invalid DateTime in a test helper"); + return iso; +} + +/** The fixed test "today", as the `YYYY-MM-DD` string the `?date=` query expects. */ +function today(): string { + return isoDate(TEST_REFERENCE_DATE); +} + +/** Resolves a reference row's id by its `reference-seed-data.ts` uid (also its DB `key`) — same helpers as `recipe.test.ts`. */ +async function ingredientId(key: string): Promise { + return (await prisma.ingredient.findFirstOrThrow({ where: { key } })).id; +} +async function unitId(key: string): Promise { + return (await prisma.unit.findFirstOrThrow({ where: { key } })).id; +} +async function techStepId(key: string): Promise { + return (await prisma.techStep.findFirstOrThrow({ where: { key } })).id; +} + +describe("Cooking session", () => { + const app = createApp(); + + beforeEach(async () => { + await resetDatabase(); + }); + + after(async () => { + await prisma.$disconnect(); + }); + + describe("GET /cooking-session", () => { + it("rejects requests without a session cookie with 401 NOT_AUTHENTICATED", async () => { + const res = await request(app).get("/cooking-session").query({ date: today() }); + + expect(res.status).to.equal(401); + expect(res.body.code).to.equal(ErrorCode.NOT_AUTHENTICATED); + }); + + it("rejects a malformed date with 400 VALIDATION_ERROR", async () => { + const agent = request.agent(app); + await agent.post("/auth/signup").send(buildSignupPayload()); + + const res = await agent.get("/cooking-session").query({ date: "not-a-date" }); + + expect(res.status).to.equal(400); + expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR); + }); + + it("returns an empty plan when the profile has no household", async () => { + const agent = request.agent(app); + await agent.post("/auth/signup").send(buildSignupPayload()); + + const res = await agent.get("/cooking-session").query({ date: today() }); + + expect(res.status).to.equal(200); + expect(res.body.recipes).to.deep.equal([]); + expect(res.body.phases).to.deep.equal([]); + }); + + it("returns an empty plan when no planning covers that week", async () => { + const agent = request.agent(app); + await agent.post("/auth/signup").send(buildSignupPayload()); + await agent.post("/house").send({ name: "Chez moi" }); + + const res = await agent.get("/cooking-session").query({ date: today() }); + + expect(res.status).to.equal(200); + expect(res.body.phases).to.deep.equal([]); + }); + + it("pools an identical prep step from two planned recipes into one merged-prep task", async () => { + const agent = request.agent(app); + await agent.post("/auth/signup").send(buildSignupPayload()); + const houseRes = await agent.post("/house").send({ name: "Chez moi" }); + const houseId: number = houseRes.body.id; + const authorId: number = houseRes.body.adminId; + + const onionId = await ingredientId("onion"); + const pieceId = await unitId("piece"); + const chopId = await techStepId("chop"); + const simmerId = await techStepId("simmer"); + + /** A recipe: one pure-prep "chop onion" step, then one simmer step. */ + async function makeRecipe(name: string, onionQty: number) { + return prisma.recipe.create({ + data: { + name, + authorId, + portions: 4, + steps: { + create: [ + { + order: 0, + description: "Émincer les oignons", + techSteps: { + create: [ + { + techStepId: chopId, + order: 0, + ingredients: { + create: [ + { + ingredientId: onionId, + quantity: onionQty, + unitId: pieceId, + start: 0, + end: 1, + }, + ], + }, + }, + ], + }, + }, + { + order: 1, + description: "Faire mijoter", + techSteps: { create: [{ techStepId: simmerId, order: 0 }] }, + }, + ], + }, + }, + }); + } + + const soupe = await makeRecipe("Soupe", 2); + const tarte = await makeRecipe("Tarte", 3); + + const planning = await prisma.planning.create({ + data: { + houseId, + startDate: TEST_REFERENCE_DATE.minus({ days: 2 }).toJSDate(), + finishDate: TEST_REFERENCE_DATE.plus({ days: 2 }).toJSDate(), + }, + }); + await prisma.planningItem.createMany({ + data: [ + { + planningId: planning.id, + weekDay: "lundi", + meal: "dejeuner", + recipeId: soupe.id, + portions: 4, + }, + { + planningId: planning.id, + weekDay: "mardi", + meal: "diner", + recipeId: tarte.id, + portions: 4, + }, + ], + }); + + const res = await agent.get("/cooking-session").query({ date: today() }); + + expect(res.status).to.equal(200); + expect(res.body.recipes.map((r: { name: string }) => r.name)).to.have.members([ + "Soupe", + "Tarte", + ]); + + const mise = res.body.phases[0]; + expect(mise.kind).to.equal("mise-en-place"); + const merged = mise.tasks.filter((t: { kind: string }) => t.kind === "merged-prep"); + expect(merged).to.have.length(1); + expect(merged[0].technique.key).to.equal("chop"); + expect(merged[0].ingredients[0].ingredient.key).to.equal("onion"); + expect(merged[0].ingredients[0].quantity).to.equal(5); + expect(merged[0].sourceRecipes).to.have.length(2); + + // The simmer steps land in a later phase, and one shows as background. + const later = res.body.phases.slice(1); + const backgrounds = later.flatMap((p: { background: unknown[] }) => p.background); + expect(backgrounds.length).to.be.greaterThan(0); + }); + }); +}); 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/apps/web/cypress/e2e/cooking-session-page.cy.ts b/apps/web/cypress/e2e/cooking-session-page.cy.ts new file mode 100644 index 0000000..0c2580f --- /dev/null +++ b/apps/web/cypress/e2e/cooking-session-page.cy.ts @@ -0,0 +1,174 @@ +// Mocks the API via cy.intercept — this job doesn't run a live backend (see +// .github/workflows/ci.yml); apps/api's own Mocha suite covers real +// `GET /cooking-session` behavior (including the optimizer) against a real +// database. + +const authenticatedProfile = { + id: 1, + firstName: "Alice", + lastName: "Martin", + email: "alice@example.com", + tokenVersion: 0, + houseId: 1, + dietId: null, +}; + +// 2026-08-17 is a Monday — frozen so "this week" is deterministic. +const TODAY = new Date("2026-08-17T09:00:00Z"); + +/** Bare `IngredientView` — only `key` drives the page's label lookup. */ +function ingredient(key: string) { + return { + id: 1, + key, + icon: "VEGETABLE", + category: "freshProduce", + subcategory: "vegetables", + reproducible: false, + allergens: [], + diets: [], + }; +} + +/** A plan with a pooled prep task in mise-en-place and a passive cook floated into a later phase. */ +function planFixture() { + return { + startDate: "2026-08-17T00:00:00.000Z", + finishDate: "2026-08-23T00:00:00.000Z", + recipes: [ + { recipeId: 1, name: "Soupe à l'oignon", portions: 4 }, + { recipeId: 2, name: "Tarte à l'oignon", portions: 4 }, + ], + phases: [ + { + index: 0, + kind: "mise-en-place", + background: [], + tasks: [ + { + id: "prep:chop:onion", + kind: "merged-prep", + technique: { id: 1, key: "chop" }, + description: null, + ingredients: [ + { + ingredient: ingredient("onion"), + quantity: 5, + unit: { id: 2, key: "piece", type: "COUNT", toBaseFactor: 1 }, + }, + ], + utensils: [{ id: 1, key: "knife" }], + sourceRecipes: [ + { recipeId: 1, name: "Soupe à l'oignon", portions: 4 }, + { recipeId: 2, name: "Tarte à l'oignon", portions: 4 }, + ], + originalSteps: [], + }, + ], + }, + { + index: 1, + kind: "cooking", + background: [], + tasks: [ + { + id: "step:0:1", + kind: "step", + technique: { id: 5, key: "simmer" }, + description: "Faire mijoter le bouillon", + ingredients: [], + utensils: [], + sourceRecipes: [{ recipeId: 1, name: "Soupe à l'oignon", portions: 4 }], + originalSteps: [], + }, + ], + }, + { + index: 2, + kind: "cooking", + background: [ + { + id: "bg:step:0:1", + technique: { id: 5, key: "simmer" }, + description: "Faire mijoter le bouillon", + recipeId: 1, + recipeName: "Soupe à l'oignon", + }, + ], + tasks: [ + { + id: "step:1:3", + kind: "step", + technique: { id: 4, key: "bake" }, + description: "Enfourner la tarte", + ingredients: [], + utensils: [], + sourceRecipes: [{ recipeId: 2, name: "Tarte à l'oignon", portions: 4 }], + originalSteps: [], + }, + ], + }, + ], + }; +} + +describe("Cooking session page", () => { + beforeEach(() => { + cy.viewport(1400, 900); + cy.clock(TODAY, ["Date"]); + cy.intercept("GET", "**/auth/me", { statusCode: 200, body: authenticatedProfile }); + }); + + it("shows the empty message when nothing is planned that week", () => { + cy.intercept("GET", /\/cooking-session\?/, { + statusCode: 200, + body: { + startDate: "2026-08-17T00:00:00.000Z", + finishDate: "2026-08-23T00:00:00.000Z", + recipes: [], + phases: [], + }, + }); + + cy.visit("/cuisiner"); + + cy.contains("h1", "Cuisiner cette semaine").should("be.visible"); + cy.contains("Rien de planifié cette semaine à cuisiner").should("be.visible"); + }); + + it("renders each phase, the pooled prep task, and the 'meanwhile' band", () => { + cy.intercept("GET", /\/cooking-session\?/, { statusCode: 200, body: planFixture() }).as( + "getPlan", + ); + + cy.visit("/cuisiner?date=2026-08-17"); + cy.wait("@getPlan").its("request.url").should("include", "date=2026-08-17"); + + // Mise en place: one pooled prep task, flagged shared, naming both recipes. + cy.contains(".cooking-phase", "Mise en place").should("be.visible"); + cy.get(".cooking-task--merged-prep") + .should("contain.text", "Hacher") + .and("contain.text", "Oignon") + .and("contain.text", "Mutualisé"); + cy.contains(".cooking-task--merged-prep", "Soupe à l'oignon").should("exist"); + + // A later phase shows the simmering soup as still running in the background. + cy.contains(".cooking-phase__background", "Pendant ce temps") + .should("contain.text", "Faire mijoter le bouillon") + .and("contain.text", "Soupe à l'oignon"); + + // Recipe legend is present. + cy.contains(".cooking-session__legend-item", "Tarte à l'oignon").should("be.visible"); + }); + + it("shows an error state when the request fails", () => { + cy.intercept("GET", /\/cooking-session\?/, { + statusCode: 500, + body: { code: 5000, message: "boom" }, + }); + + cy.visit("/cuisiner"); + + cy.contains("Impossible de charger").should("be.visible"); + }); +}); diff --git a/apps/web/cypress/e2e/cooking-session.feature b/apps/web/cypress/e2e/cooking-session.feature new file mode 100644 index 0000000..e7f3b67 --- /dev/null +++ b/apps/web/cypress/e2e/cooking-session.feature @@ -0,0 +1,24 @@ +Feature: Start cooking an optimized plan + As a member of a household with a planned week + I want to open an optimized cooking plan from my planning + So that shared preparation is pooled and I cook the week efficiently + + Background: + Given I am signed in as "Alice" "Martin" + And my household id is 1 + And today is frozen at "2026-08-17T09:00:00.000Z" + + Scenario: The "Commencer à cuisiner" button is disabled while the week is empty + Given the planning request returns nothing + When I visit "/" + Then the "Commencer à cuisiner" button should be disabled + + Scenario: Opening the plan from the planning shows the pooled prep in mise en place + Given the planning for this week has recipes "Soupe à l'oignon" and "Tarte à l'oignon" + And the cooking plan for "2026-08-17" pools "Hacher" of "Oignon" across both recipes + When I visit "/" + And I click the button "Commencer à cuisiner" + Then the URL should include "/cuisiner" + And I should see "Mise en place" + And the pooled prep task should mention "Hacher" and "Oignon" + And the pooled prep task should be flagged as shared diff --git a/apps/web/cypress/e2e/cooking-session.ts b/apps/web/cypress/e2e/cooking-session.ts new file mode 100644 index 0000000..c249fab --- /dev/null +++ b/apps/web/cypress/e2e/cooking-session.ts @@ -0,0 +1,101 @@ +import { Given, Then } from "@badeball/cypress-cucumber-preprocessor"; + +/** Bare `IngredientView` — only `key` drives the page's `catalog.ingredients.*` lookup. */ +function ingredient(key: string) { + return { + id: 1, + key, + icon: "VEGETABLE", + category: "freshProduce", + subcategory: "vegetables", + reproducible: false, + allergens: [], + diets: [], + }; +} + +Given( + "the planning for this week has recipes {string} and {string}", + (first: string, second: string) => { + cy.intercept("GET", /\/planning\?/, { + statusCode: 200, + body: { + id: 1, + startDate: "2026-08-17T00:00:00.000Z", + finishDate: "2026-08-23T00:00:00.000Z", + items: [ + { + id: 1, + weekDay: "lundi", + meal: "dejeuner", + portions: 4, + recipe: { id: 1, name: first }, + }, + { id: 2, weekDay: "mardi", meal: "diner", portions: 4, recipe: { id: 2, name: second } }, + ], + }, + }); + }, +); + +Given( + "the cooking plan for {string} pools {string} of {string} across both recipes", + (date: string, _techniqueLabel: string, _ingredientLabel: string) => { + // The page composes the headline itself from the technique/ingredient + // *keys* via i18n — `chop`→"Hacher", `onion`→"Oignon" — so the fixture + // carries keys; the `Then` step checks the rendered French labels the + // feature line names. + cy.intercept("GET", `**/cooking-session?date=${date}`, { + statusCode: 200, + body: { + startDate: `${date}T00:00:00.000Z`, + finishDate: "2026-08-23T00:00:00.000Z", + recipes: [ + { recipeId: 1, name: "Soupe à l'oignon", portions: 4 }, + { recipeId: 2, name: "Tarte à l'oignon", portions: 4 }, + ], + phases: [ + { + index: 0, + kind: "mise-en-place", + background: [], + tasks: [ + { + id: "prep:chop:onion", + kind: "merged-prep", + technique: { id: 1, key: "chop" }, + description: null, + ingredients: [ + { + ingredient: ingredient("onion"), + quantity: 5, + unit: { id: 2, key: "piece", type: "COUNT", toBaseFactor: 1 }, + }, + ], + utensils: [], + sourceRecipes: [ + { recipeId: 1, name: "Soupe à l'oignon", portions: 4 }, + { recipeId: 2, name: "Tarte à l'oignon", portions: 4 }, + ], + originalSteps: [], + }, + ], + }, + ], + }, + }); + }, +); + +Then( + "the pooled prep task should mention {string} and {string}", + (techniqueLabel: string, ingredientLabel: string) => { + cy.get(".cooking-task--merged-prep") + .should("contain.text", techniqueLabel) + .and("contain.text", ingredientLabel); + }, +); + +Then("the pooled prep task should be flagged as shared", () => { + cy.get(".cooking-task--merged-prep").contains("Mutualisé").should("be.visible"); +}); diff --git a/apps/web/cypress/e2e/planning-page.cy.ts b/apps/web/cypress/e2e/planning-page.cy.ts index 3b4744d..7770ea9 100644 --- a/apps/web/cypress/e2e/planning-page.cy.ts +++ b/apps/web/cypress/e2e/planning-page.cy.ts @@ -111,6 +111,43 @@ describe("Planning grid", () => { cy.contains("th.today .day-date", "17").should("be.visible"); }); + it("disables 'Commencer à cuisiner' on an empty week and enables + navigates it once a recipe is planned", () => { + cy.intercept("GET", /\/planning\?/, { statusCode: 200, body: null }); + cy.visit("/"); + cy.contains("button", "Commencer à cuisiner").should("be.disabled"); + + cy.intercept("GET", /\/planning\?/, { + statusCode: 200, + body: { + id: 1, + startDate: "2026-08-17T00:00:00.000Z", + finishDate: "2026-08-23T00:00:00.000Z", + items: [ + { + id: 1, + weekDay: "mardi", + meal: "diner", + portions: 4, + recipe: { id: 1, name: "Ratatouille" }, + }, + ], + }, + }); + cy.intercept("GET", /\/cooking-session\?/, { + statusCode: 200, + body: { + startDate: "2026-08-17T00:00:00.000Z", + finishDate: "2026-08-23T00:00:00.000Z", + recipes: [], + phases: [], + }, + }); + cy.visit("/"); + cy.contains("button", "Commencer à cuisiner").should("not.be.disabled").click(); + cy.url().should("include", "/cuisiner"); + cy.url().should("include", "date=2026-08-17"); + }); + it("shows a loading state, then an error state when the request fails", () => { cy.intercept("GET", /\/planning\?/, { statusCode: 500, diff --git a/apps/web/cypress/support/step_definitions/common.steps.ts b/apps/web/cypress/support/step_definitions/common.steps.ts index 8084ff2..c74aac9 100644 --- a/apps/web/cypress/support/step_definitions/common.steps.ts +++ b/apps/web/cypress/support/step_definitions/common.steps.ts @@ -273,6 +273,10 @@ Then("the {string} button should not be disabled", (text: string) => { cy.contains("button", text).should("not.be.disabled"); }); +Then("the {string} button should be disabled", (text: string) => { + cy.contains("button", text).should("be.disabled"); +}); + When("I open the account menu", () => { cy.get(".app-sidebar__account-toggle").click(); }); diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index d3c44ed..d318330 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -4,6 +4,7 @@ import { RequireAuth } from "./features/auth/RequireAuth"; import { AppLayout } from "./layouts/AppLayout"; import { LoginPage } from "./pages/auth/LoginPage"; import { SignupPage } from "./pages/auth/SignupPage"; +import { CookingSessionPage } from "./pages/cooking-session/CookingSessionPage"; import { OnboardingAllergensPage } from "./pages/onboarding/OnboardingAllergensPage"; import { OnboardingDietPage } from "./pages/onboarding/OnboardingDietPage"; import { OnboardingHouseholdPage } from "./pages/onboarding/OnboardingHouseholdPage"; @@ -65,6 +66,7 @@ export function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts index ac97e1b..4ababb9 100644 --- a/apps/web/src/api/client.ts +++ b/apps/web/src/api/client.ts @@ -9,6 +9,7 @@ import { type HouseView, type IngredientView, type LoginInput, + type OptimizedCookingPlanView, type PlanningItemView, type PlanningView, type PreferencesView, @@ -177,6 +178,19 @@ export class ApiClient { return this._request(`/shopping-list?date=${date}`); } + /** + * Fetches the current user's household's optimized cooking plan for the + * week covering `date` (`YYYY-MM-DD`) — every recipe planned that week + * reorganized into ordered phases (shared prep pooled, passive cooks + * floated into the background). Like {@link getShoppingListForWeek} and + * unlike {@link getPlanningForWeek}, never resolves to `null`: no + * household or nothing planned both come back as a normal plan with + * empty `recipes`/`phases`. + */ + public getCookingPlanForWeek(date: string): Promise { + return this._request(`/cooking-session?date=${date}`); + } + /** Reference list of dietary regimes to pick from (signup wizard, `/foyer`). Public — no session required. */ public getDiets(): Promise { return this._request("/reference/diets"); diff --git a/apps/web/src/locales/fr/translation.json b/apps/web/src/locales/fr/translation.json index f19bbbf..4de04be 100644 --- a/apps/web/src/locales/fr/translation.json +++ b/apps/web/src/locales/fr/translation.json @@ -121,6 +121,7 @@ }, "planning": { "title": "Planning de la semaine", + "startCooking": "Commencer à cuisiner", "loading": "Chargement du planning…", "meals": { "petit-dejeuner": "Petit-déjeuner", @@ -310,6 +311,28 @@ "loading": "Chargement de la liste de courses…", "empty": "Aucun ingrédient à acheter pour cette semaine — ajoutez des recettes à votre planning." }, + "cookingSession": { + "title": "Cuisiner cette semaine", + "subtitle": "Toutes les étapes de la semaine, regroupées et réordonnées pour cuisiner efficacement.", + "loading": "Optimisation du plan de cuisine…", + "empty": "Rien de planifié cette semaine à cuisiner — ajoutez des recettes à votre planning.", + "recipesLegend": "Recettes de la semaine", + "phase": { + "label": "Étape {{index}}", + "mise-en-place": "Mise en place", + "cooking": "Cuisson", + "finishing": "Dressage" + }, + "background": { + "title": "Pendant ce temps" + }, + "task": { + "mergedPrepLabel": "{{technique}} : {{items}}", + "forRecipes": "pour {{recipes}}", + "utensils": "Ustensiles", + "sharedBadge": "Mutualisé" + } + }, "account": { "title": "Compte", "identity": { diff --git a/apps/web/src/pages/cooking-session/CookingSessionPage.tsx b/apps/web/src/pages/cooking-session/CookingSessionPage.tsx new file mode 100644 index 0000000..ef63ee9 --- /dev/null +++ b/apps/web/src/pages/cooking-session/CookingSessionPage.tsx @@ -0,0 +1,188 @@ +import { DateTime, formatDateOnly, getWeekStart, parseDateOnly } from "@batch-cooking/date-tools"; +import type { + CookingBackgroundTaskView, + CookingPhaseView, + CookingTaskView, + OptimizedCookingPlanView, +} from "@batch-cooking/shared"; +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useSearchParams } from "react-router-dom"; +import { apiClient } from "../../api/client"; +import { WeekNavigator } from "../../features/planning/WeekNavigator"; +import { taskHeadline, taskRecipeNames } from "./cooking-session"; +import "./cooking-session-page.scss"; + +/** Load state for the `GET /cooking-session` call — same discriminated-union shape as `ShoppingListPage`'s own state. */ +type CookingSessionState = + | { status: "loading" } + | { status: "loaded"; plan: OptimizedCookingPlanView } + | { status: "error" }; + +/** + * "Cuisiner cette semaine" — routed at `/cuisiner`, reached from the + * planning page's "Commencer à cuisiner" button. Shows the household's week + * of planned recipes reorganized by the backend optimizer + * (`GET /cooking-session`, see the API's `cooking-optimizer.ts`) into + * ordered phases: a mise-en-place that pools shared prep, then cooking + * phases that interleave the recipes with passive cooks shown as running + * in the background. + * + * The week comes from a `?date=` query param (set by the planning button so + * the two pages stay on the same week); absent/invalid falls back to the + * current week. Read-only and recomputed on every visit — no progress + * state to keep in sync, same design stance as `ShoppingListPage`. + */ +export function CookingSessionPage() { + const { t } = useTranslation(); + const [searchParams] = useSearchParams(); + const [weekStart, setWeekStart] = useState(() => { + const fromQuery = parseDateOnly(searchParams.get("date") ?? ""); + return getWeekStart(fromQuery ?? DateTime.utc()); + }); + const [state, setState] = useState({ status: "loading" }); + + useEffect(() => { + let cancelled = false; + setState({ status: "loading" }); + + apiClient + .getCookingPlanForWeek(formatDateOnly(weekStart)) + .then((plan) => { + if (!cancelled) setState({ status: "loaded", plan }); + }) + .catch(() => { + if (!cancelled) setState({ status: "error" }); + }); + + return () => { + cancelled = true; + }; + }, [weekStart]); + + return ( +
+
+

{t("cookingSession.title")}

+ +
+

{t("cookingSession.subtitle")}

+ + {state.status === "loading" && ( +

{t("cookingSession.loading")}

+ )} + + {state.status === "error" && ( +

+ {t("common.loadError")} +

+ )} + + {state.status === "loaded" && } +
+ ); +} + +/** The plan body — the recipe legend then every phase, or the empty-week message. */ +function CookingPlan({ plan }: { plan: OptimizedCookingPlanView }) { + const { t } = useTranslation(); + + if (plan.phases.length === 0) { + return

{t("cookingSession.empty")}

; + } + + return ( +
+
+ {plan.recipes.map((recipe) => ( + + {recipe.name} · ×{recipe.portions} + + ))} +
+ + {plan.phases.map((phase) => ( + + ))} +
+ ); +} + +/** One phase: its background band (if any) then its task cards. */ +function PhaseSection({ phase }: { phase: CookingPhaseView }) { + const { t } = useTranslation(); + + return ( +
+

+ + {t("cookingSession.phase.label", { index: phase.index + 1 })} + + {t(`cookingSession.phase.${phase.kind}`)} +

+ + {phase.background.length > 0 && ( +
+ + {t("cookingSession.background.title")} + +
    + {phase.background.map((task) => ( +
  • + +
  • + ))} +
+
+ )} + +
    + {phase.tasks.map((task) => ( +
  • + +
  • + ))} +
+
+ ); +} + +/** A single actionable task — a merged-prep pool or a plain recipe step. */ +function TaskCard({ task }: { task: CookingTaskView }) { + const { t } = useTranslation(); + + return ( +
+

+ {taskHeadline(task, t)} + {task.kind === "merged-prep" && ( + {t("cookingSession.task.sharedBadge")} + )} +

+

+ {t("cookingSession.task.forRecipes", { recipes: taskRecipeNames(task) })} +

+ {task.utensils.length > 0 && ( +

+ {t("cookingSession.task.utensils")} :{" "} + {task.utensils.map((utensil) => t(`catalog.utensils.${utensil.key}`)).join(", ")} +

+ )} +
+ ); +} + +/** One "meanwhile, X is cooking" line inside a phase's background band. */ +function BackgroundLine({ task }: { task: CookingBackgroundTaskView }) { + const { t } = useTranslation(); + const technique = task.technique ? `${t(`catalog.techSteps.${task.technique.key}`)} — ` : ""; + return ( + + {technique} + {task.description} ({task.recipeName}) + + ); +} diff --git a/apps/web/src/pages/cooking-session/cooking-session-page.scss b/apps/web/src/pages/cooking-session/cooking-session-page.scss new file mode 100644 index 0000000..6513e40 --- /dev/null +++ b/apps/web/src/pages/cooking-session/cooking-session-page.scss @@ -0,0 +1,164 @@ +// ============================================================================= +// Styles specific to CookingSessionPage — colocated next to +// CookingSessionPage.tsx since nothing else uses these classes. Same page +// shell/status conventions as shopping-list-page.scss +// (`__header`/`__status`); below it, a vertical stack of phase sections +// each holding a "meanwhile" band and a list of task cards. +// ============================================================================= + +.cooking-session-page { + height: 100%; + display: flex; + flex-direction: column; + + &__header { + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: var(--space-md); + margin-bottom: var(--space-sm); + } + + &__subtitle { + flex-shrink: 0; + margin: 0 0 var(--space-lg); + color: var(--color-text-muted); + font-size: var(--font-size-md); + } + + &__status { + color: var(--color-text-muted); + font-size: var(--font-size-md); + } + + &__status--error { + color: var(--color-error); + } +} + +// --- Scrollable plan body -------------------------------------------------- +.cooking-session { + flex: 1; + min-height: 0; + overflow: auto; + display: flex; + flex-direction: column; + gap: var(--space-lg); + + // Recipe legend — one chip per planned recipe/portion pairing. + &__legend { + display: flex; + flex-wrap: wrap; + gap: var(--space-xs); + } + + &__legend-item { + padding: 0.15rem var(--space-sm); + border-radius: var(--radius-pill); + background: var(--color-surface); + box-shadow: var(--shadow-sm); + font-size: var(--font-size-sm); + color: var(--color-text); + font-variant-numeric: tabular-nums; + } +} + +// --- One phase ---------------------------------------------------------------- +.cooking-phase { + display: flex; + flex-direction: column; + gap: var(--space-sm); + + &__title { + display: flex; + align-items: baseline; + gap: var(--space-sm); + margin: 0; + font-size: var(--font-size-md); + } + + &__index { + font-weight: 700; + color: var(--color-text); + } + + &__kind { + font-size: var(--font-size-sm); + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--color-text-muted); + } + + // "Pendant ce temps" — passive cooks still running from earlier phases. + &__background { + border-left: 3px solid var(--color-border); + padding: var(--space-xs) var(--space-md); + color: var(--color-text-muted); + font-size: var(--font-size-sm); + + &-title { + display: block; + font-weight: 600; + margin-bottom: 0.15rem; + } + + ul { + margin: 0; + padding-left: var(--space-md); + } + } + + &__tasks { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: var(--space-sm); + } +} + +// --- One task card ---------------------------------------------------------- +.cooking-task { + background: var(--color-surface); + border-radius: var(--radius-md); + box-shadow: var(--shadow-sm); + padding: var(--space-sm) var(--space-md); + + // A pooled prep task is the headline feature of this page — give it a + // subtle accent border so it stands out from plain recipe steps. + &--merged-prep { + border-left: 3px solid var(--color-accent); + } + + &__headline { + margin: 0; + display: flex; + align-items: center; + flex-wrap: wrap; + gap: var(--space-xs); + font-weight: 600; + color: var(--color-text); + } + + &__badge { + padding: 0.05rem var(--space-xs); + border-radius: var(--radius-pill); + background: var(--color-accent); + color: var(--color-surface); + font-size: var(--font-size-xs); + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.03em; + } + + &__recipes, + &__utensils { + margin: 0.2rem 0 0; + font-size: var(--font-size-sm); + color: var(--color-text-muted); + } +} diff --git a/apps/web/src/pages/cooking-session/cooking-session.ts b/apps/web/src/pages/cooking-session/cooking-session.ts new file mode 100644 index 0000000..9c5b9b8 --- /dev/null +++ b/apps/web/src/pages/cooking-session/cooking-session.ts @@ -0,0 +1,56 @@ +import type { CookingTaskIngredientView, CookingTaskView } from "@batch-cooking/shared"; + +/** + * Minimal shape of `react-i18next`'s `t` — just what this module needs. + * Passed in rather than importing `useTranslation` here so these helpers + * stay pure functions the page (and a unit test) can call without mounting + * i18next, the same "logic extracted from the .tsx" split as + * `shopping-list.ts`'s `groupShoppingListItems`. + */ +export type TranslateFn = (key: string, options?: Record) => string; + +/** + * Formats a task quantity for display — French conventions, at most 2 + * decimals so a scaled/pooled float never shows a trailing-digit artifact + * (`"149.99999999999997"`). Same rule as `shopping-list.ts`'s + * `formatShoppingListQuantity`. + */ +export function formatCookingQuantity(quantity: number): string { + return quantity.toLocaleString("fr-FR", { maximumFractionDigits: 2 }); +} + +/** + * One ingredient line as a human string — `"3 oignon"`, `"200 g farine"`, + * or just `"sel"` when the source clause carried no measurable amount + * (`quantity`/`unit` both `null`, see {@link CookingTaskIngredientView}). + * Labels are resolved through the same `catalog.*` i18n keys as everywhere + * else. + */ +export function formatIngredientLine(line: CookingTaskIngredientView, t: TranslateFn): string { + const name = t(`catalog.ingredients.${line.ingredient.key}`); + if (line.quantity === null) return name; + const amount = formatCookingQuantity(line.quantity); + const unit = line.unit === null ? "" : `${t(`catalog.units.${line.unit.key}`)} `; + return `${amount} ${unit}${name}`.trim(); +} + +/** + * The headline shown on a task card: + * - `merged-prep` — `"Émincer : 3 oignon, 200 g carotte"` (technique label + + * its pooled ingredient lines), built from the + * `cookingSession.task.mergedPrepLabel` template. + * - `step` — the original recipe step text, verbatim. + */ +export function taskHeadline(task: CookingTaskView, t: TranslateFn): string { + if (task.kind === "step") return task.description ?? ""; + const technique = task.technique + ? t(`catalog.techSteps.${task.technique.key}`) + : t("cookingSession.phase.mise-en-place"); + const items = task.ingredients.map((line) => formatIngredientLine(line, t)).join(", "); + return t("cookingSession.task.mergedPrepLabel", { technique, items }); +} + +/** Comma-joined names of the recipes a task belongs to — one for a `step`, several for a pooled `merged-prep`. */ +export function taskRecipeNames(task: CookingTaskView): string { + return task.sourceRecipes.map((recipe) => recipe.name).join(", "); +} diff --git a/apps/web/src/pages/planning/PlanningPage.tsx b/apps/web/src/pages/planning/PlanningPage.tsx index 3c920f1..80d4ebf 100644 --- a/apps/web/src/pages/planning/PlanningPage.tsx +++ b/apps/web/src/pages/planning/PlanningPage.tsx @@ -8,6 +8,7 @@ import { } from "@batch-cooking/shared"; import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; +import { useNavigate } from "react-router-dom"; import { apiClient } from "../../api/client"; import { type PlanningSlot, RecipePickerDialog } from "../../features/planning/RecipePickerDialog"; import { WeekNavigator } from "../../features/planning/WeekNavigator"; @@ -37,6 +38,7 @@ const BAND_END_MEALS: ReadonlySet = new Set(["collation", "dejeuner", "gou */ export function PlanningPage() { const { t } = useTranslation(); + const navigate = useNavigate(); const [weekStart, setWeekStart] = useState(() => getWeekStart(DateTime.utc())); const [state, setState] = useState({ status: "loading" }); // The slot a `RecipePickerDialog` is currently open for — `null` means @@ -101,11 +103,23 @@ export function PlanningPage() { } } + // Enabled only once we know the week has at least one planned recipe — + // "cuisiner" an empty week would just land on the page's own empty state. + const hasPlannedRecipes = state.status === "loaded" && (state.planning?.items.length ?? 0) > 0; + return (

{t("planning.title")}

+
{state.status === "loading" && ( diff --git a/apps/web/src/pages/planning/planning-page.scss b/apps/web/src/pages/planning/planning-page.scss index 8bf1fdc..bee2e6a 100644 --- a/apps/web/src/pages/planning/planning-page.scss +++ b/apps/web/src/pages/planning/planning-page.scss @@ -36,6 +36,29 @@ &__status--error { color: var(--color-error); } + + // "Commencer à cuisiner" — same solid-primary treatment as the recipe + // picker's confirm button (features/planning/recipe-picker-dialog.scss). + &__cook-btn { + background: var(--color-primary); + color: var(--color-surface); + border: none; + border-radius: var(--radius-base); + padding: var(--space-sm) var(--space-md); + font-family: var(--font-body); + font-size: var(--font-size-sm); + font-weight: 600; + cursor: pointer; + + &:hover { + background: var(--color-primary-hover); + } + + &:disabled { + opacity: 0.6; + cursor: not-allowed; + } + } } // --- The grid itself -------------------------------------------------------- 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[]; +} diff --git a/specs/backend-architecture.md b/specs/backend-architecture.md index 6c5902c..5d2e6ff 100644 --- a/specs/backend-architecture.md +++ b/specs/backend-architecture.md @@ -296,6 +296,57 @@ telles quelles par typage structurel. --- +## Cooking session — optimisation des étapes planifiées + +Router `/cooking-session` (`cooking-session.routes.ts`/`.service.ts`), +`requireAuth` — un seul endpoint : `GET /cooking-session?date=YYYY-MM-DD` → +`getCookingPlanForDate` → `OptimizedCookingPlanView`. Même contrat `?date=` +que `GET /shopping-list` (schéma shape-only + `parseDateOnly`), même requête +"plage couvrante" que `getPlanningForDate`, et **jamais `null`** de la même +façon que la liste de courses : pas de foyer / aucun `Planning` couvrant la +semaine ⇒ `recipes: []`, `phases: []`. + +C'est la première brique du module « Calcul batch-cooking » +([batch-cooking-architecture.md](./batch-cooking-architecture.md)), +jusqu'ici `TODO`. Le service ne fait que **charger + façonner** : sa requête +Prisma (`cookingSessionPlanningInclude`) reprend le sous-arbre `steps` de +`recipe.service.ts`'s `recipeInclude` (steps ordonnés → `StepTechStep` +ordonnés → `techStep` + `ingredients` résolus + `utensils`), puis +`toOptimizerRecipe` mappe chaque `PlanningItem` vers l'entrée pure de +l'optimiseur (ingrédients/unités/techniques/ustensiles déjà en vues de +référence via `toIngredientView`/`toUnitView` réutilisées — même raison que +`shopping-list.service.ts`). Un `PlanningItem` = une entrée d'optimiseur, +même si deux créneaux pointent la même recette à des portions différentes +(deux vraies préparations ; la mutualisation de la découpe les regroupe +quand même). + +**`optimizeCookingPlan`** (`lib/recipe-matching/cooking-optimizer.ts`, +pure/synchrone — testable sans base, même split que `ingredient-matcher.ts` +/ `tech-step-matcher.ts`) réorganise les recettes en **phases ordonnées** : + +- **Mutualisation de la mise en place** : une technique de découpe + (`PREP_TECHNIQUES` : `chop`/`peel`/`mince`/`julienne`/…) appliquée au même + ingrédient dans une étape *purement prep* (toutes ses techniques sont des + `PREP_TECHNIQUES`) de **≥ 2 recettes** est regroupée en une tâche + `merged-prep` ; les étapes d'origine sont *absorbées* (ne produisent plus + de tâche). Une découpe présente dans une seule recette reste inline (juste + classée en mise en place). Les découpes à l'intérieur d'une étape de + cuisson ne sont pas mutualisées en v1. +- **Parallélisme** : `TECHNIQUE_ATTENTION` classe chaque étape en `SETUP` + (préchauffage, eau à ébullition — poussé en mise en place), `PASSIVE` + (mijoter, braiser, cuire au four, mariner… — non surveillé une fois + lancé) ou `ACTIVE` (défaut). Les phases de cuisson interclassent les + recettes (une « prochaine étape de chaque recette » par phase) ; une + étape `PASSIVE` fait patienter sa recette une phase et s'affiche en + `background` des phases suivantes tant que sa consommatrice n'est pas + remontée. + +Quantités mises à l'échelle par `PlanningItem.portions / Recipe.portions` +comme la liste de courses. Sommes d'ingrédients uniquement à unité +identique (aucune conversion — même posture que `ShoppingListItemView`). + +--- + ## `reference` — catalogues publics (pas de session requise) Router `/reference` (`reference.routes.ts`/`.service.ts`) — **toutes les diff --git a/specs/batch-cooking-architecture.md b/specs/batch-cooking-architecture.md index f8d9881..5e67dd4 100644 --- a/specs/batch-cooking-architecture.md +++ b/specs/batch-cooking-architecture.md @@ -16,7 +16,7 @@ L'application repose sur une architecture **client-serveur** classique : flowchart TB subgraph SERVER["Server"] API["API (REST)"] - CALC["Calcul batch-cooking
(TODO)"] + CALC["Calcul batch-cooking
v1 implémentée (GET /cooking-session)"] IMPORT["Import d'une recette
implémenté"] IMP1["Import depuis source
(RecipeSourceAdapter)"] IMP2["Traduction en étapes
(ingrédients + techniques)"] @@ -38,8 +38,8 @@ flowchart TB ``` *(Le canal websocket envisagé dans la conception d'origine pour le calcul -batch-cooking temps réel n'existe pas encore — rien à documenter tant que ce -module reste TODO ; voir la note plus bas.)* +batch-cooking temps réel n'existe pas encore — le module v1 est un `GET` +recalculé à chaque visite, pas de temps réel ; voir la note plus bas.)* --- @@ -49,7 +49,7 @@ module reste TODO ; voir la note plus bas.)* Point d'entrée principal pour les échanges entre les clients et le serveur — REST classique, `requireAuth` (cookie JWT httpOnly) sur toute route qui n'est pas une donnée de référence publique. Détail complet des modules : [backend-architecture.md](./backend-architecture.md). ### Module « Calcul batch-cooking » -Logique de calcul du batch-cooking (optimisation du planning/des recettes selon le planning). **Statut : TODO — reste à développer**, avec `packages/shared`'s `assertIsNever` déjà en place comme outil prêt à l'emploi pour ce futur module (voir [backend-architecture.md](./backend-architecture.md#packagesshared--assertisnever)). +Logique de calcul du batch-cooking (optimisation des recettes entre elles selon le planning). **Statut : v1 implémentée** — `GET /cooking-session?date=` → `optimizeCookingPlan` (`apps/api/src/lib/recipe-matching/cooking-optimizer.ts`, pur) réorganise les recettes d'une semaine planifiée en **phases ordonnées** : une « mise en place » qui mutualise la découpe commune (même technique de découpe + même ingrédient dans une étape purement prep de ≥ 2 recettes = une seule tâche), puis des phases de cuisson qui interclassent les recettes en poussant les cuissons passives (mijotage, four…) en tâche de fond. v1 hors périmètre : fusion de cuissons, durées estimées, persistance/progression, canal websocket. Détail : [backend-architecture.md](./backend-architecture.md#cooking-session--optimisation-des-étapes-planifiées). ### Module « Import d'une recette » **Statut : implémenté.** Pipeline en trois étapes, comme prévu à la conception : @@ -91,15 +91,15 @@ détectées, favoris, visibilité des recettes). [backend-architecture.md](./backend-architecture.md#liste-de-courses--agrégation-des-ingrédients-planifiés)) — une simple **agrégation** des ingrédients déjà planifiés (somme par ingrédient/unité, mise à l'échelle par les portions de chaque créneau), - pas une optimisation. Le module « Calcul batch-cooking » lui-même reste - `TODO` : il désigne quelque chose de plus ambitieux qu'une somme - d'ingrédients — optimiser le planning/les recettes entre elles (ex. - mutualiser une préparation entre plusieurs recettes de la semaine), pas - encore défini plus précisément. C'est le principal chantier restant côté - serveur. + pas une optimisation. Le module « Calcul batch-cooking », lui, optimise les + recettes **entre elles** (mutualiser une préparation commune, paralléliser + les cuissons passives) — **v1 implémentée** via `GET /cooking-session` + (voir [backend-architecture.md](./backend-architecture.md#cooking-session--optimisation-des-étapes-planifiées)). - Le canal websocket envisagé pour la communication temps réel n'a pas encore - été construit — rien ne le remplace aujourd'hui (pas de polling), à - reconsidérer au moment d'attaquer le calcul batch-cooking. + été construit — rien ne le remplace aujourd'hui (pas de polling). Le calcul + batch-cooking v1 est un simple `GET` recalculé à chaque visite (comme la + liste de courses), pas de temps réel ; à reconsidérer si une session de + cuisine partagée/synchronisée est ajoutée. ---