feat(cooking-session): onglets Recettes/Plan + stepper d'étapes cochables #23

Merged
kyuno merged 4 commits from feat/cooking-session-stepper into main 2026-09-03 22:39:55 +02:00
15 changed files with 1725 additions and 174 deletions

View file

@ -1,10 +1,12 @@
import type { import type {
CookingBackgroundTaskView, CookingBackgroundTaskView,
CookingPhaseKind, CookingPhaseKind,
CookingPhaseNoteView,
CookingPhaseView, CookingPhaseView,
CookingSessionRecipeRef, CookingSessionRecipeRef,
CookingTaskIngredientView, CookingTaskIngredientView,
CookingTaskView, CookingTaskView,
CookingTemperatureView,
TechStepView, TechStepView,
UtensilView, UtensilView,
} from "@batch-cooking/shared"; } from "@batch-cooking/shared";
@ -23,12 +25,22 @@ import type {
* `aggregateShoppingList`, so the whole optimization is unit-testable * `aggregateShoppingList`, so the whole optimization is unit-testable
* without a Postgres round-trip. * without a Postgres round-trip.
* *
* v1 scope (see the plan / spec): preparation is the only thing *merged* * Two families of work are *merged* across recipes:
* across recipes — a `chop`/`peel`/… technique applied to the same * - **Preparation** — a `chop`/`peel`/… technique applied to the same
* ingredient by two or more recipes, in a step that does nothing but prep, * ingredient by two or more recipes, in a step that does nothing but
* collapses into a single {@link CookingTaskView} of `kind: "merged-prep"`. * prep, collapses into one {@link CookingTaskView} of `kind:
* Cooking steps themselves are never merged (no "same oven, same * "merged-prep"`.
* temperature" reasoning yet); they're only *reordered* for parallelism. * - **Setup** — the same `preheat`/`boil`/`bainMarie` action shared across
* recipes collapses into one `kind: "merged-setup"` task, but *only when
* the action metadata is compatible*: two recipes that both preheat the
* oven merge only when they preheat it to the **same temperature**.
* Two preheats at different temperatures stay separate (you cannot heat
* one oven to two temperatures at once), are ordered coolest-first, and
* the mise-en-place phase carries a {@link CookingPhaseNoteView} telling
* the cook to run them in sequence.
*
* Cooking steps themselves are still never merged (no shared-oven bake
* batching yet); they're only *reordered* for parallelism.
*/ */
/** /**
@ -66,7 +78,8 @@ const PREP_TECHNIQUES: ReadonlySet<string> = new Set([
* *
* - `"SETUP"` — a short active trigger, then it looks after itself: preheat * - `"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 * the oven, bring a pot of water to the boil. Pooled into the first
* ("mise en place") phase so it's running before it's needed. * ("mise en place") phase so it's running before it's needed, and merged
* across recipes when compatible (see {@link extractMergedSetup}).
* - `"PASSIVE"` — unattended once started (simmer, braise, bake, marinate, * - `"PASSIVE"` — unattended once started (simmer, braise, bake, marinate,
* rest…). Scheduled, then floated into every following phase's * rest…). Scheduled, then floated into every following phase's
* `background` until the step that consumes it comes up. * `background` until the step that consumes it comes up.
@ -106,6 +119,13 @@ interface OptimizerTechStepInput {
order: number; order: number;
ingredients: CookingTaskIngredientView[]; ingredients: CookingTaskIngredientView[];
utensils: UtensilView[]; utensils: UtensilView[];
/**
* Target temperature(s) named in this same clause (`preheat` → "à 180 °C").
* The structured subset only — see {@link CookingTemperatureView}. Not
* scaled (a temperature doesn't grow with the portion count). `[]` when
* the clause named no temperature.
*/
temperatures: CookingTemperatureView[];
} }
/** One recipe step, as handed to {@link optimizeCookingPlan}. */ /** One recipe step, as handed to {@link optimizeCookingPlan}. */
@ -152,7 +172,7 @@ function scaleOf(recipe: OptimizerRecipeInput): number {
return recipe.portions / recipe.recipePortions; return recipe.portions / recipe.recipePortions;
} }
/** A step normalized for scheduling — techniques scaled, attention resolved, ingredients/utensils unioned across its technique clauses. */ /** A step normalized for scheduling — techniques scaled, attention resolved, ingredients/utensils/temperatures unioned across its technique clauses. */
interface NormalizedStep { interface NormalizedStep {
/** Stable within one response: `step:<recipeIndex>:<stepId>` (the index disambiguates the same recipe planned twice). */ /** Stable within one response: `step:<recipeIndex>:<stepId>` (the index disambiguates the same recipe planned twice). */
taskId: string; taskId: string;
@ -164,10 +184,14 @@ interface NormalizedStep {
techSteps: OptimizerTechStepInput[]; techSteps: OptimizerTechStepInput[];
attention: Attention; attention: Attention;
isPurePrep: boolean; isPurePrep: boolean;
/** Every technique in the step is a {@link SETUP_TECHNIQUES} one — eligible to pool via {@link extractMergedSetup}. */
isPureSetup: boolean;
dominantTechnique: TechStepView | null; dominantTechnique: TechStepView | null;
ingredients: CookingTaskIngredientView[]; ingredients: CookingTaskIngredientView[];
utensils: UtensilView[]; utensils: UtensilView[];
/** Set once merged-prep extraction absorbs this step wholesale (all its prep pooled elsewhere) — it then produces no standalone task. */ /** Distinct target temperatures across the step's clauses (see {@link unionTemperatures}) — drives setup pooling and the coolest-first ordering. */
temperatures: CookingTemperatureView[];
/** Set once merged-prep/merged-setup extraction absorbs this step wholesale — it then produces no standalone task. */
absorbed: boolean; absorbed: boolean;
} }
@ -215,11 +239,62 @@ function unionUtensils(utensils: UtensilView[]): UtensilView[] {
return [...byId.values()].sort((a, b) => a.key.localeCompare(b.key)); return [...byId.values()].sort((a, b) => a.key.localeCompare(b.key));
} }
/**
* A stable canonical string for one temperature — `c<°C>` / `g<gasMark>` /
* `q<level>`, or `""` when every field is null (shouldn't happen — the
* upstream extractor guarantees one non-null — but stays total). Two
* temperatures are "the same" iff their keys are equal: **no conversion**
* between °C, gas marks and qualitative levels is guessed for a merge
* decision (same "don't invent a conversion" stance as `ShoppingListItemView`
* on units). °C is rounded to the nearest degree so `180` and `180.00`
* (a `Decimal` artefact) collapse.
*/
function temperatureKey(temperature: CookingTemperatureView): string {
if (temperature.celsius !== null) return `c${Math.round(temperature.celsius)}`;
if (temperature.gasMark !== null) return `g${temperature.gasMark}`;
if (temperature.qualitative !== null) return `q${temperature.qualitative}`;
return "";
}
/** Unions temperatures by {@link temperatureKey}, keeping a stable order by that key. */
function unionTemperatures(temperatures: CookingTemperatureView[]): CookingTemperatureView[] {
const byKey = new Map<string, CookingTemperatureView>();
for (const temperature of temperatures) byKey.set(temperatureKey(temperature), temperature);
return [...byKey.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([, value]) => value);
}
/**
* A single number to *order* temperatures by (coolest first) — **never** used
* to decide a merge (that's {@link temperatureKey}, exact-match only). Gas
* marks map via the French `th × 30 ≈ °C` convention and qualitative levels
* via rough midpoints, purely so a mixed list still sorts sensibly; a
* temperature-less task sorts first.
*/
function comparableTemp(temperature: CookingTemperatureView): number {
if (temperature.celsius !== null) return temperature.celsius;
if (temperature.gasMark !== null) return temperature.gasMark * 30;
if (temperature.qualitative !== null) {
return { low: 120, medium: 180, high: 240 }[temperature.qualitative];
}
return Number.NEGATIVE_INFINITY;
}
/** A setup task's sort position (coolest first) — its lowest target, or "before everything" when it has no detected temperature. */
function setupTaskTemp(task: CookingTaskView): number {
if (task.temperatures.length === 0) return Number.NEGATIVE_INFINITY;
return Math.min(...task.temperatures.map(comparableTemp));
}
/** A step is pure prep only if it has techniques and every one of them is in {@link PREP_TECHNIQUES}. */ /** 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 { function isPurePrepStep(techSteps: OptimizerTechStepInput[]): boolean {
return techSteps.length > 0 && techSteps.every((ts) => PREP_TECHNIQUES.has(ts.techStep.key)); return techSteps.length > 0 && techSteps.every((ts) => PREP_TECHNIQUES.has(ts.techStep.key));
} }
/** A step is pure setup only if it has techniques and every one is in {@link SETUP_TECHNIQUES} — mirror of {@link isPurePrepStep}. */
function isPureSetupStep(techSteps: OptimizerTechStepInput[]): boolean {
return techSteps.length > 0 && techSteps.every((ts) => SETUP_TECHNIQUES.has(ts.techStep.key));
}
/** Resolves a step's {@link Attention} — SETUP wins, then a *trailing* passive technique, else ACTIVE (see {@link SETUP_TECHNIQUES}). */ /** Resolves a step's {@link Attention} — SETUP wins, then a *trailing* passive technique, else ACTIVE (see {@link SETUP_TECHNIQUES}). */
function attentionOf(techSteps: OptimizerTechStepInput[]): Attention { function attentionOf(techSteps: OptimizerTechStepInput[]): Attention {
if (techSteps.some((ts) => SETUP_TECHNIQUES.has(ts.techStep.key))) return "SETUP"; if (techSteps.some((ts) => SETUP_TECHNIQUES.has(ts.techStep.key))) return "SETUP";
@ -251,6 +326,9 @@ function normalizeRecipe(recipe: OptimizerRecipeInput, recipeIndex: number): Nor
unit: line.unit, unit: line.unit,
})), })),
utensils: ts.utensils, utensils: ts.utensils,
// Temperatures are not scaled — an oven set to 180 °C for 4
// portions is still 180 °C for 8.
temperatures: ts.temperatures,
})); }));
const lastTech = techSteps[techSteps.length - 1]; const lastTech = techSteps[techSteps.length - 1];
@ -264,9 +342,11 @@ function normalizeRecipe(recipe: OptimizerRecipeInput, recipeIndex: number): Nor
techSteps, techSteps,
attention: attentionOf(techSteps), attention: attentionOf(techSteps),
isPurePrep: isPurePrepStep(techSteps), isPurePrep: isPurePrepStep(techSteps),
isPureSetup: isPureSetupStep(techSteps),
dominantTechnique: lastTech ? lastTech.techStep : null, dominantTechnique: lastTech ? lastTech.techStep : null,
ingredients: unionIngredients(techSteps.flatMap((ts) => ts.ingredients)), ingredients: unionIngredients(techSteps.flatMap((ts) => ts.ingredients)),
utensils: unionUtensils(techSteps.flatMap((ts) => ts.utensils)), utensils: unionUtensils(techSteps.flatMap((ts) => ts.utensils)),
temperatures: unionTemperatures(techSteps.flatMap((ts) => ts.temperatures)),
absorbed: false, absorbed: false,
} satisfies NormalizedStep; } satisfies NormalizedStep;
}); });
@ -283,6 +363,26 @@ function prepSignature(step: NormalizedStep): string {
return [...new Set(pairs)].sort().join("+"); return [...new Set(pairs)].sort().join("+");
} }
/**
* The setup signature of a pure-setup step — `<sorted technique keys>|<sorted
* temperature keys>`. Two steps with the same signature start the *same*
* appliance in the *same* state and can be pooled. The temperature part is
* `""` when no temperature was detected, which is a valid signature: two
* bare "préchauffer le four" steps then share it and merge (the optimistic
* default — an unknown temperature is assumed compatible). The appliance
* itself is deliberately not in the key (a `preheat` is the oven either
* way); its utensils are still unioned onto the pooled task.
*/
function setupSignature(step: NormalizedStep): string {
const techKeys = [...new Set(step.techSteps.map((ts) => ts.techStep.key))].sort().join("+");
const tempKeys = step.temperatures
.map(temperatureKey)
.filter((key) => key !== "")
.sort()
.join("+");
return `${techKeys}|${tempKeys}`;
}
/** Builds one {@link CookingTaskView} from a normalized step run as written. */ /** Builds one {@link CookingTaskView} from a normalized step run as written. */
function stepToTask(step: NormalizedStep): CookingTaskView { function stepToTask(step: NormalizedStep): CookingTaskView {
return { return {
@ -292,6 +392,10 @@ function stepToTask(step: NormalizedStep): CookingTaskView {
description: step.description, description: step.description,
ingredients: step.ingredients, ingredients: step.ingredients,
utensils: step.utensils, utensils: step.utensils,
// Only a setup step (preheat/boil/…) carries a target temperature the
// plan should reason about; every other step leaves it `[]` even if its
// text happens to mention degrees.
temperatures: step.attention === "SETUP" ? step.temperatures : [],
sourceRecipes: [step.recipe], sourceRecipes: [step.recipe],
originalSteps: [ originalSteps: [
{ {
@ -376,6 +480,7 @@ function extractMergedPrep(steps: NormalizedStep[]): CookingTaskView[] {
description: null, description: null,
ingredients, ingredients,
utensils, utensils,
temperatures: [],
sourceRecipes, sourceRecipes,
originalSteps, originalSteps,
}); });
@ -384,6 +489,99 @@ function extractMergedPrep(steps: NormalizedStep[]): CookingTaskView[] {
return merged.sort((a, b) => a.id.localeCompare(b.id)); return merged.sort((a, b) => a.id.localeCompare(b.id));
} }
/**
* Pools pure-setup steps with the same {@link setupSignature} (same action,
* same detected temperature) across two or more distinct recipes into one
* `merged-setup` {@link CookingTaskView}, marking each contributing step
* `absorbed`. Two `preheat` steps at *different* temperatures have different
* signatures and are therefore never pooled — {@link detectApplianceTemperatureConflicts}
* turns that into a phase note. A signature only one recipe needs is left
* untouched (still lands in mise-en-place as its own step task).
*
* Returns the merged tasks sorted by id.
*/
function extractMergedSetup(steps: NormalizedStep[]): CookingTaskView[] {
const bySignature = new Map<string, NormalizedStep[]>();
for (const step of steps) {
if (!step.isPureSetup) continue;
const signature = setupSignature(step);
const group = bySignature.get(signature);
if (group) group.push(step);
else bySignature.set(signature, [step]);
}
const merged: CookingTaskView[] = [];
for (const group of bySignature.values()) {
const recipeIndexes = new Set(group.map((s) => s.recipeIndex));
if (recipeIndexes.size < 2) continue;
for (const step of group) step.absorbed = true;
const firstTech = group[0]?.techSteps[0]?.techStep ?? null;
const ingredients = unionIngredients(
group.flatMap((s) => s.techSteps.flatMap((ts) => ts.ingredients)),
);
const utensils = unionUtensils(group.flatMap((s) => s.utensils));
const temperatures = unionTemperatures(group.flatMap((s) => s.temperatures)).sort(
(a, b) => comparableTemp(a) - comparableTemp(b),
);
// Distinct source recipes / original step texts, in input order.
const sourceRecipes: CookingSessionRecipeRef[] = [];
const seenRecipe = new Set<number>();
const originalSteps: CookingTaskView["originalSteps"] = [];
for (const step of [...group].sort((a, b) => a.recipeIndex - b.recipeIndex)) {
if (!seenRecipe.has(step.recipeIndex)) {
seenRecipe.add(step.recipeIndex);
sourceRecipes.push(step.recipe);
}
originalSteps.push({
recipeId: step.recipe.recipeId,
recipeName: step.recipe.name,
description: step.description,
});
}
const tempKey = temperatures.map(temperatureKey).join("+");
merged.push({
id: `setup:${firstTech ? firstTech.key : "setup"}:${tempKey || "any"}`,
kind: "merged-setup",
technique: firstTech,
description: null,
ingredients,
utensils,
temperatures,
sourceRecipes,
originalSteps,
});
}
return merged.sort((a, b) => a.id.localeCompare(b.id));
}
/**
* The kitchen has one oven: if the mise-en-place ends up with two or more
* `preheat` tasks at *distinct known* temperatures, they cannot run at once.
* Emits a single {@link CookingPhaseNoteView} listing those temperatures
* coolest-first so the web layer can say "enchaîner 180 °C puis 210 °C".
* A `preheat` with no detected temperature contributes nothing to the note
* (it merged optimistically with the other unknowns and we've no number to
* show).
*/
function detectApplianceTemperatureConflicts(
setupTasks: CookingTaskView[],
): CookingPhaseNoteView[] {
const preheats = setupTasks.filter((task) => task.technique?.key === "preheat");
const distinct = unionTemperatures(preheats.flatMap((task) => task.temperatures))
.filter((temperature) => temperatureKey(temperature) !== "")
.sort((a, b) => comparableTemp(a) - comparableTemp(b));
const technique = preheats.find((task) => task.technique !== null)?.technique;
if (distinct.length < 2 || !technique) return [];
return [{ kind: "appliance-temperature-conflict", technique, temperatures: distinct }];
}
/** Maps the input recipe list to its display legend, de-duplicating an exact `(recipeId, portions)` repeat. */ /** Maps the input recipe list to its display legend, de-duplicating an exact `(recipeId, portions)` repeat. */
function toRecipeLegend(recipes: OptimizerRecipeInput[]): CookingSessionRecipeRef[] { function toRecipeLegend(recipes: OptimizerRecipeInput[]): CookingSessionRecipeRef[] {
const seen = new Set<string>(); const seen = new Set<string>();
@ -406,9 +604,11 @@ function cookingPhaseKind(tasks: CookingTaskView[]): CookingPhaseKind {
* See the file header. Given the week's planned recipes (already resolved * See the file header. Given the week's planned recipes (already resolved
* to reference views), returns the display legend plus the ordered phases: * to reference views), returns the display legend plus the ordered phases:
* *
* 1. **Mise en place** (`"mise-en-place"`) — every `merged-prep` task, then * 1. **Mise en place** (`"mise-en-place"`) — all prep first (`merged-prep`
* every leftover pure-prep step, then every `SETUP` step. Omitted * tasks, then leftover pure-prep steps), then all setup (`merged-setup`
* entirely if it would be empty. * tasks and leftover `SETUP` steps, ordered coolest-first). Carries an
* `appliance-temperature-conflict` note when the week needs the oven at
* two or more temperatures. Omitted entirely if it would be empty.
* 2. **Cooking** (`"cooking"` / `"finishing"`) — the recipes interleaved: * 2. **Cooking** (`"cooking"` / `"finishing"`) — the recipes interleaved:
* each phase pops the next remaining step of every recipe that still has * 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 * one (passive-cook steps first, so long cooks start early). A passive
@ -420,21 +620,41 @@ export function optimizeCookingPlan(recipes: OptimizerRecipeInput[]): OptimizeCo
const normalized = recipes.map((recipe, index) => normalizeRecipe(recipe, index)); const normalized = recipes.map((recipe, index) => normalizeRecipe(recipe, index));
const allSteps = normalized.flat(); const allSteps = normalized.flat();
// Merged-prep first, then merged-setup — each marks its contributing
// steps `absorbed`, so the leftover loop below only sees the solo ones.
const mergedPrep = extractMergedPrep(allSteps); const mergedPrep = extractMergedPrep(allSteps);
const mergedSetup = extractMergedSetup(allSteps);
const phases: CookingPhaseView[] = []; const phases: CookingPhaseView[] = [];
// Phase 0 — mise en place. // Phase 0 — mise en place: all prep, then all setup. Keeping the two
const miseTasks: CookingTaskView[] = [...mergedPrep]; // groups apart (rather than input order) lets the setup group be ordered
// coolest-first without disturbing the prep order.
const prepTasks: CookingTaskView[] = [...mergedPrep];
const setupTasks: CookingTaskView[] = [...mergedSetup];
for (const step of allSteps) { for (const step of allSteps) {
if (step.absorbed) continue; if (step.absorbed) continue;
if (step.isPurePrep || step.attention === "SETUP") { if (step.isPurePrep) {
miseTasks.push(stepToTask(step)); prepTasks.push(stepToTask(step));
step.absorbed = true; // consumed here, not again in the cooking loop step.absorbed = true; // consumed here, not again in the cooking loop
} else if (step.attention === "SETUP") {
setupTasks.push(stepToTask(step));
step.absorbed = true;
} }
} }
// Coolest oven first, so a temperature conflict reads "180 °C puis 210 °C";
// `id` as a stable tiebreaker keeps the plan deterministic.
setupTasks.sort((a, b) => setupTaskTemp(a) - setupTaskTemp(b) || a.id.localeCompare(b.id));
const miseTasks = [...prepTasks, ...setupTasks];
if (miseTasks.length > 0) { if (miseTasks.length > 0) {
phases.push({ index: 0, kind: "mise-en-place", tasks: miseTasks, background: [] }); phases.push({
index: 0,
kind: "mise-en-place",
tasks: miseTasks,
background: [],
notes: detectApplianceTemperatureConflicts(setupTasks),
});
} }
// Cooking phases — one "next step of each recipe" per phase. `hold` keeps // Cooking phases — one "next step of each recipe" per phase. `hold` keeps
@ -490,6 +710,9 @@ export function optimizeCookingPlan(recipes: OptimizerRecipeInput[]): OptimizeCo
kind: cookingPhaseKind(tasks), kind: cookingPhaseKind(tasks),
tasks, tasks,
background, background,
// Temperature conflicts only matter in mise-en-place, where all the
// preheats live; a cooking phase never carries a note today.
notes: [],
}); });
for (const step of phaseSteps) { for (const step of phaseSteps) {

View file

@ -47,6 +47,7 @@ function cookingSessionPlanningInclude() {
}, },
}, },
utensils: { include: { utensil: true } }, utensils: { include: { utensil: true } },
temperatures: true,
}, },
}, },
}, },
@ -87,6 +88,19 @@ function toOptimizerRecipe(item: PlanningItemWithSteps): OptimizerRecipeInput {
order: techStep.order, order: techStep.order,
ingredients, ingredients,
utensils: techStep.utensils.map(({ utensil }) => ({ id: utensil.id, key: utensil.key })), utensils: techStep.utensils.map(({ utensil }) => ({ id: utensil.id, key: utensil.key })),
// Structured value only — `Decimal` °C → plain number, `qualitative`
// re-narrowed to the literal union (same posture as
// `recipe.service.ts`'s `toStepTechStepViews`).
temperatures: techStep.temperatures.map((temperature) => ({
celsius: temperature.celsius === null ? null : Number(temperature.celsius),
gasMark: temperature.gasMark,
qualitative:
temperature.qualitative === "low" ||
temperature.qualitative === "medium" ||
temperature.qualitative === "high"
? temperature.qualitative
: null,
})),
}; };
}), }),
})); }));

View file

@ -210,5 +210,120 @@ describe("Cooking session", () => {
const backgrounds = later.flatMap((p: { background: unknown[] }) => p.background); const backgrounds = later.flatMap((p: { background: unknown[] }) => p.background);
expect(backgrounds.length).to.be.greaterThan(0); expect(backgrounds.length).to.be.greaterThan(0);
}); });
it("pools two preheats at the same temperature, and flags a conflict at different temperatures", async () => {
const agent = request.agent(app);
await agent.post("/auth/signup").send(buildSignupPayload());
const houseRes = await agent.post("/house").send({ name: "Chez moi" });
const houseId: number = houseRes.body.id;
const authorId: number = houseRes.body.adminId;
const preheatId = await techStepId("preheat");
const bakeId = await techStepId("bake");
/** A recipe whose first step is "préchauffer le four" carrying a °C temperature, then a bake. */
async function makeOvenRecipe(name: string, description: string, celsius: number) {
return prisma.recipe.create({
data: {
name,
authorId,
portions: 4,
steps: {
create: [
{
order: 0,
description,
techSteps: {
create: [
{
techStepId: preheatId,
order: 0,
temperatures: {
create: [
{
start: 0,
end: description.length,
celsius,
gasMark: null,
qualitative: null,
raw: `${celsius}°C`,
},
],
},
},
],
},
},
{
order: 1,
description: "Enfourner",
techSteps: { create: [{ techStepId: bakeId, order: 0 }] },
},
],
},
},
});
}
async function planWeek(recipeIds: number[]) {
const planning = await prisma.planning.create({
data: {
houseId,
startDate: TEST_REFERENCE_DATE.minus({ days: 2 }).toJSDate(),
finishDate: TEST_REFERENCE_DATE.plus({ days: 2 }).toJSDate(),
},
});
await prisma.planningItem.createMany({
data: recipeIds.map((recipeId, i) => ({
planningId: planning.id,
weekDay: i === 0 ? "lundi" : "mardi",
meal: i === 0 ? "dejeuner" : "diner",
recipeId,
portions: 4,
})),
});
}
// Same temperature → one pooled merged-setup task, no conflict note.
const gratin = await makeOvenRecipe("Gratin", "Préchauffer le four à 180°C", 180);
const tarte = await makeOvenRecipe("Tarte", "Préchauffer à 180 degrés", 180);
await planWeek([gratin.id, tarte.id]);
let res = await agent.get("/cooking-session").query({ date: today() });
expect(res.status).to.equal(200);
let mise = res.body.phases[0];
const setupMerges = mise.tasks.filter((t: { kind: string }) => t.kind === "merged-setup");
expect(setupMerges).to.have.length(1);
expect(setupMerges[0].technique.key).to.equal("preheat");
expect(setupMerges[0].temperatures).to.deep.equal([
{ celsius: 180, gasMark: null, qualitative: null },
]);
expect(mise.notes).to.deep.equal([]);
// Re-plan the same week with a second temperature → not merged, conflict note.
await prisma.planning.deleteMany({ where: { houseId } });
const roti = await makeOvenRecipe("Rôti", "Préchauffer le four à 210°C", 210);
await planWeek([gratin.id, roti.id]);
res = await agent.get("/cooking-session").query({ date: today() });
mise = res.body.phases[0];
expect(mise.tasks.filter((t: { kind: string }) => t.kind === "merged-setup")).to.have.length(
0,
);
const preheats = mise.tasks.filter(
(t: { technique: { key: string } | null }) => t.technique?.key === "preheat",
);
expect(preheats.map((t: { description: string }) => t.description)).to.deep.equal([
"Préchauffer le four à 180°C",
"Préchauffer le four à 210°C",
]);
const note = mise.notes.find(
(n: { kind: string }) => n.kind === "appliance-temperature-conflict",
);
expect(note, "a conflict note").to.not.equal(undefined);
expect(note.temperatures.map((tp: { celsius: number }) => tp.celsius)).to.deep.equal([
180, 210,
]);
});
}); });
}); });

View file

@ -2,6 +2,7 @@ import type {
CookingPhaseView, CookingPhaseView,
CookingTaskIngredientView, CookingTaskIngredientView,
CookingTaskView, CookingTaskView,
CookingTemperatureView,
IngredientView, IngredientView,
TechStepView, TechStepView,
UnitView, UnitView,
@ -84,13 +85,19 @@ describe("cooking-optimizer", () => {
return { id, key }; return { id, key };
} }
/** Minimal `CookingTemperatureView` — a plain °C target, the common `preheat` case. */
function celsius(value: number): CookingTemperatureView {
return { celsius: value, gasMark: null, qualitative: null };
}
/** One technique clause of a step. */ /** One technique clause of a step. */
function clause( function clause(
techKey: string, techKey: string,
ingredients: CookingTaskIngredientView[] = [], ingredients: CookingTaskIngredientView[] = [],
utensils: { id: number; key: string }[] = [], utensils: { id: number; key: string }[] = [],
temperatures: CookingTemperatureView[] = [],
): OptimizerTechStepInput { ): OptimizerTechStepInput {
return { techStep: tech(techKey), order: 0, ingredients, utensils }; return { techStep: tech(techKey), order: 0, ingredients, utensils, temperatures };
} }
/** A step described by an ordered list of technique clauses. */ /** A step described by an ordered list of technique clauses. */
@ -134,8 +141,9 @@ describe("cooking-optimizer", () => {
const allTasks = (plan: { phases: CookingPhaseView[] }): CookingTaskView[] => const allTasks = (plan: { phases: CookingPhaseView[] }): CookingTaskView[] =>
plan.phases.flatMap((phase) => phase.tasks); plan.phases.flatMap((phase) => phase.tasks);
/** Every pooled task — `merged-prep` and `merged-setup` alike (i.e. anything that isn't a plain recipe `step`). */
const mergedTasks = (plan: { phases: CookingPhaseView[] }): CookingTaskView[] => const mergedTasks = (plan: { phases: CookingPhaseView[] }): CookingTaskView[] =>
allTasks(plan).filter((task) => task.kind === "merged-prep"); allTasks(plan).filter((task) => task.kind !== "step");
const stepTasks = (plan: { phases: CookingPhaseView[] }): CookingTaskView[] => const stepTasks = (plan: { phases: CookingPhaseView[] }): CookingTaskView[] =>
allTasks(plan).filter((task) => task.kind === "step"); allTasks(plan).filter((task) => task.kind === "step");
@ -575,6 +583,165 @@ describe("cooking-optimizer", () => {
}); });
}); });
// =====================================================================
// Setup pooling (preheat / boil), gated on action metadata
// =====================================================================
describe("setup pooling", () => {
const setupMerges = (plan: { phases: CookingPhaseView[] }) =>
mergedTasks(plan).filter((t) => t.kind === "merged-setup");
const conflictNote = (plan: { phases: CookingPhaseView[] }) =>
plan.phases[0]?.notes.find((n) => n.kind === "appliance-temperature-conflict");
it("merges two preheats at the same temperature into one merged-setup task", () => {
const recipes = [
recipe(1, "Gratin", [
step(1, 0, "Préchauffer le four à 180°C", [clause("preheat", [], [], [celsius(180)])]),
simpleStep(2, 1, "Enfourner", "bake"),
]),
recipe(2, "Tarte", [
step(3, 0, "Préchauffer à 180 degrés", [clause("preheat", [], [], [celsius(180)])]),
simpleStep(4, 1, "Cuire", "bake"),
]),
];
const plan = optimizeCookingPlan(recipes);
const merged = setupMerges(plan);
expect(merged).to.have.lengthOf(1);
expect(merged[0]?.technique?.key).to.equal("preheat");
expect(merged[0]?.description).to.equal(null);
expect(merged[0]?.temperatures).to.deep.equal([celsius(180)]);
expect(merged[0]?.sourceRecipes.map((r) => r.recipeId)).to.have.members([1, 2]);
expect(merged[0]?.id).to.equal("setup:preheat:c180");
expect(plan.phases[0]?.notes).to.deep.equal([]);
assertEveryStepAccountedForOnce(recipes, plan);
});
it("keeps two preheats at different temperatures separate, coolest-first, with a conflict note", () => {
const recipes = [
recipe(1, "Gratin", [
step(1, 0, "Préchauffer le four à 210°C", [clause("preheat", [], [], [celsius(210)])]),
simpleStep(2, 1, "Enfourner", "bake"),
]),
recipe(2, "Tarte", [
step(3, 0, "Préchauffer le four à 180°C", [clause("preheat", [], [], [celsius(180)])]),
simpleStep(4, 1, "Cuire", "bake"),
]),
];
const plan = optimizeCookingPlan(recipes);
expect(setupMerges(plan)).to.have.lengthOf(0);
const preheats = (plan.phases[0]?.tasks ?? []).filter((t) => t.technique?.key === "preheat");
expect(preheats.map((t) => t.description)).to.deep.equal([
"Préchauffer le four à 180°C",
"Préchauffer le four à 210°C",
]);
const note = conflictNote(plan);
expect(note, "a conflict note on the mise-en-place phase").to.not.equal(undefined);
expect(note?.technique.key).to.equal("preheat");
expect(note?.temperatures.map((tp) => tp.celsius)).to.deep.equal([180, 210]);
assertEveryStepAccountedForOnce(recipes, plan);
});
it("merges two preheats with no detected temperature (optimistic default), no note", () => {
const recipes = [
recipe(1, "A", [
simpleStep(1, 0, "Préchauffer le four", "preheat"),
simpleStep(2, 1, "Enfourner", "bake"),
]),
recipe(2, "B", [
simpleStep(3, 0, "Préchauffer le four", "preheat"),
simpleStep(4, 1, "Cuire", "bake"),
]),
];
const plan = optimizeCookingPlan(recipes);
const merged = setupMerges(plan);
expect(merged).to.have.lengthOf(1);
expect(merged[0]?.temperatures).to.deep.equal([]);
expect(merged[0]?.id).to.equal("setup:preheat:any");
expect(plan.phases[0]?.notes).to.deep.equal([]);
assertEveryStepAccountedForOnce(recipes, plan);
});
it("does not fire a conflict note when a known-temperature preheat sits next to an unknown one", () => {
const recipes = [
recipe(1, "A", [
step(1, 0, "Préchauffer le four à 200°C", [clause("preheat", [], [], [celsius(200)])]),
simpleStep(2, 1, "Enfourner", "bake"),
]),
recipe(2, "B", [
simpleStep(3, 0, "Préchauffer le four", "preheat"),
simpleStep(4, 1, "Cuire", "bake"),
]),
];
const plan = optimizeCookingPlan(recipes);
// Two distinct signatures (`c200` vs `""`) → no merge, but only one
// *known* temperature → nothing to sequence, so no note.
expect(setupMerges(plan)).to.have.lengthOf(0);
expect(plan.phases[0]?.notes).to.deep.equal([]);
assertEveryStepAccountedForOnce(recipes, plan);
});
it("pools 'bring water to a boil' across recipes regardless of temperature", () => {
const recipes = [
recipe(1, "Pâtes", [
simpleStep(1, 0, "Porter une casserole d'eau à ébullition", "boil"),
simpleStep(2, 1, "Cuire les pâtes", "cook"),
]),
recipe(2, "Riz", [
simpleStep(3, 0, "Porter de l'eau à ébullition", "boil"),
simpleStep(4, 1, "Cuire le riz", "cook"),
]),
];
const plan = optimizeCookingPlan(recipes);
const merged = setupMerges(plan);
expect(merged).to.have.lengthOf(1);
expect(merged[0]?.technique?.key).to.equal("boil");
expect(merged[0]?.sourceRecipes.map((r) => r.recipeId)).to.have.members([1, 2]);
assertEveryStepAccountedForOnce(recipes, plan);
});
it("keeps a preheat only one recipe needs as its own step task, no note", () => {
const recipes = [
recipe(1, "Gratin", [
step(1, 0, "Préchauffer le four à 200°C", [clause("preheat", [], [], [celsius(200)])]),
simpleStep(2, 1, "Enfourner", "bake"),
]),
recipe(2, "Salade", [simpleStep(3, 0, "Mélanger", "mix")]),
];
const plan = optimizeCookingPlan(recipes);
expect(setupMerges(plan)).to.have.lengthOf(0);
const preheat = allTasks(plan).find((t) => t.technique?.key === "preheat");
expect(preheat?.kind).to.equal("step");
expect(plan.phases[0]?.tasks).to.include(preheat);
expect(plan.phases[0]?.notes).to.deep.equal([]);
assertEveryStepAccountedForOnce(recipes, plan);
});
it("is deterministic with temperature-gated setup merges", () => {
const build = () => [
recipe(1, "A", [
step(1, 0, "Préchauffer à 180°C", [clause("preheat", [], [], [celsius(180)])]),
simpleStep(2, 1, "Cuire", "bake"),
]),
recipe(2, "B", [
step(3, 0, "Préchauffer à 220°C", [clause("preheat", [], [], [celsius(220)])]),
simpleStep(4, 1, "Cuire", "bake"),
]),
recipe(3, "C", [
step(5, 0, "Préchauffer à 180°C", [clause("preheat", [], [], [celsius(180)])]),
simpleStep(6, 1, "Cuire", "bake"),
]),
];
expect(JSON.stringify(optimizeCookingPlan(build()))).to.equal(
JSON.stringify(optimizeCookingPlan(build())),
);
});
});
// ===================================================================== // =====================================================================
// Quantity scaling // Quantity scaling
// ===================================================================== // =====================================================================
@ -764,7 +931,7 @@ describe("cooking-optimizer", () => {
simpleStep(24, 4, "Parsemer de persil", "plate", [line(ING.parsley, null, null)]), simpleStep(24, 4, "Parsemer de persil", "plate", [line(ING.parsley, null, null)]),
]), ]),
recipe(104, "Poulet rôti & pommes de terre", [ recipe(104, "Poulet rôti & pommes de terre", [
simpleStep(30, 0, "Préchauffer le four à 210°C", "preheat"), step(30, 0, "Préchauffer le four à 210°C", [clause("preheat", [], [], [celsius(210)])]),
simpleStep(31, 1, "Éplucher les pommes de terre", "peel", [ simpleStep(31, 1, "Éplucher les pommes de terre", "peel", [
line(ING.potato, 1, KILOGRAM), line(ING.potato, 1, KILOGRAM),
]), ]),
@ -774,7 +941,7 @@ describe("cooking-optimizer", () => {
simpleStep(35, 5, "Découper et dresser", "plate"), simpleStep(35, 5, "Découper et dresser", "plate"),
]), ]),
recipe(105, "Gratin dauphinois", [ recipe(105, "Gratin dauphinois", [
simpleStep(40, 0, "Préchauffer le four à 180°C", "preheat"), step(40, 0, "Préchauffer le four à 180°C", [clause("preheat", [], [], [celsius(180)])]),
simpleStep(41, 1, "Éplucher les pommes de terre", "peel", [ simpleStep(41, 1, "Éplucher les pommes de terre", "peel", [
line(ING.potato, 1, KILOGRAM), line(ING.potato, 1, KILOGRAM),
]), ]),
@ -837,14 +1004,25 @@ describe("cooking-optimizer", () => {
expect(onionPool?.technique?.key).to.equal("chop"); expect(onionPool?.technique?.key).to.equal("chop");
}); });
it("pulls every 'préchauffer le four' into the mise-en-place phase", () => { it("keeps the two 'préchauffer le four' steps separate (different °C), coolest-first, with a conflict note", () => {
const week = householdWeek(); const week = householdWeek();
const plan = optimizeCookingPlan(week); const plan = optimizeCookingPlan(week);
const startsWithPreheat = (d: string | null) => (d ?? "").startsWith("Préchauffer le four"); const startsWithPreheat = (d: string | null) => (d ?? "").startsWith("Préchauffer le four");
// Recipes 104 (210 °C) and 105 (180 °C) preheat the same oven to
// different temperatures — never merged, both in mise-en-place, and
// ordered so the cook heats to 180 first then 210.
const miseDescr = plan.phases[0]?.tasks.map((t) => t.description) ?? []; const miseDescr = plan.phases[0]?.tasks.map((t) => t.description) ?? [];
expect(miseDescr.filter(startsWithPreheat)).to.have.lengthOf(2); expect(miseDescr.filter(startsWithPreheat)).to.deep.equal([
"Préchauffer le four à 180°C",
"Préchauffer le four à 210°C",
]);
const laterDescr = plan.phases.slice(1).flatMap((p) => p.tasks.map((t) => t.description)); const laterDescr = plan.phases.slice(1).flatMap((p) => p.tasks.map((t) => t.description));
expect(laterDescr.some(startsWithPreheat)).to.equal(false); expect(laterDescr.some(startsWithPreheat)).to.equal(false);
const note = plan.phases[0]?.notes.find((n) => n.kind === "appliance-temperature-conflict");
expect(note?.technique.key).to.equal("preheat");
expect(note?.temperatures.map((tp) => tp.celsius)).to.deep.equal([180, 210]);
}); });
it("runs the long cooks in the background of later phases (bourguignon braise, poulet rôti, gratins)", () => { it("runs the long cooks in the background of later phases (bourguignon braise, poulet rôti, gratins)", () => {

View file

@ -30,20 +30,21 @@ function ingredient(key: string) {
}; };
} }
/** A plan with a pooled prep task in mise-en-place and a passive cook floated into a later phase. */ /** A 3-phase plan: a pooled prep task in mise-en-place, then two cooking phases, the last with a passive cook floated into its background. */
function planFixture() { function planFixture() {
return { return {
startDate: "2026-08-17T00:00:00.000Z", startDate: "2026-08-17T00:00:00.000Z",
finishDate: "2026-08-23T00:00:00.000Z", finishDate: "2026-08-23T00:00:00.000Z",
recipes: [ recipes: [
{ recipeId: 1, name: "Soupe à l'oignon", portions: 4 }, { recipeId: 1, name: "Soupe à l'oignon", portions: 4 },
{ recipeId: 2, name: "Tarte à l'oignon", portions: 4 }, { recipeId: 2, name: "Tarte à l'oignon", portions: 6 },
], ],
phases: [ phases: [
{ {
index: 0, index: 0,
kind: "mise-en-place", kind: "mise-en-place",
background: [], background: [],
notes: [],
tasks: [ tasks: [
{ {
id: "prep:chop:onion", id: "prep:chop:onion",
@ -58,9 +59,10 @@ function planFixture() {
}, },
], ],
utensils: [{ id: 1, key: "knife" }], utensils: [{ id: 1, key: "knife" }],
temperatures: [],
sourceRecipes: [ sourceRecipes: [
{ recipeId: 1, name: "Soupe à l'oignon", portions: 4 }, { recipeId: 1, name: "Soupe à l'oignon", portions: 4 },
{ recipeId: 2, name: "Tarte à l'oignon", portions: 4 }, { recipeId: 2, name: "Tarte à l'oignon", portions: 6 },
], ],
originalSteps: [], originalSteps: [],
}, },
@ -70,6 +72,7 @@ function planFixture() {
index: 1, index: 1,
kind: "cooking", kind: "cooking",
background: [], background: [],
notes: [],
tasks: [ tasks: [
{ {
id: "step:0:1", id: "step:0:1",
@ -78,6 +81,7 @@ function planFixture() {
description: "Faire mijoter le bouillon", description: "Faire mijoter le bouillon",
ingredients: [], ingredients: [],
utensils: [], utensils: [],
temperatures: [],
sourceRecipes: [{ recipeId: 1, name: "Soupe à l'oignon", portions: 4 }], sourceRecipes: [{ recipeId: 1, name: "Soupe à l'oignon", portions: 4 }],
originalSteps: [], originalSteps: [],
}, },
@ -95,6 +99,7 @@ function planFixture() {
recipeName: "Soupe à l'oignon", recipeName: "Soupe à l'oignon",
}, },
], ],
notes: [],
tasks: [ tasks: [
{ {
id: "step:1:3", id: "step:1:3",
@ -103,7 +108,76 @@ function planFixture() {
description: "Enfourner la tarte", description: "Enfourner la tarte",
ingredients: [], ingredients: [],
utensils: [], utensils: [],
sourceRecipes: [{ recipeId: 2, name: "Tarte à l'oignon", portions: 4 }], temperatures: [],
sourceRecipes: [{ recipeId: 2, name: "Tarte à l'oignon", portions: 6 }],
originalSteps: [],
},
],
},
],
};
}
/**
* A one-phase plan whose mise-en-place has a pooled `merged-setup` preheat
* (both recipes, same 180 °C) plus two un-merged preheats at 200 °C / 220 °C
* that trigger an `appliance-temperature-conflict` note.
*/
function setupPlanFixture() {
const soupe = { recipeId: 1, name: "Soupe", portions: 4 };
const gratin = { recipeId: 2, name: "Gratin", portions: 4 };
const roti = { recipeId: 3, name: "Rôti", portions: 4 };
return {
startDate: "2026-08-17T00:00:00.000Z",
finishDate: "2026-08-23T00:00:00.000Z",
recipes: [soupe, gratin, roti],
phases: [
{
index: 0,
kind: "mise-en-place",
background: [],
notes: [
{
kind: "appliance-temperature-conflict",
technique: { id: 9, key: "preheat" },
temperatures: [
{ celsius: 200, gasMark: null, qualitative: null },
{ celsius: 220, gasMark: null, qualitative: null },
],
},
],
tasks: [
{
id: "setup:preheat:c180",
kind: "merged-setup",
technique: { id: 9, key: "preheat" },
description: null,
ingredients: [],
utensils: [],
temperatures: [{ celsius: 180, gasMark: null, qualitative: null }],
sourceRecipes: [soupe, gratin],
originalSteps: [],
},
{
id: "step:1:10",
kind: "step",
technique: { id: 9, key: "preheat" },
description: "Préchauffer le four à 200°C",
ingredients: [],
utensils: [],
temperatures: [{ celsius: 200, gasMark: null, qualitative: null }],
sourceRecipes: [gratin],
originalSteps: [],
},
{
id: "step:2:20",
kind: "step",
technique: { id: 9, key: "preheat" },
description: "Préchauffer le four à 220°C",
ingredients: [],
utensils: [],
temperatures: [{ celsius: 220, gasMark: null, qualitative: null }],
sourceRecipes: [roti],
originalSteps: [], originalSteps: [],
}, },
], ],
@ -136,7 +210,7 @@ describe("Cooking session page", () => {
cy.contains("Rien de planifié cette semaine à cuisiner").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", () => { it("opens on the Plan tab: a stepper of every phase, only the first phase's tasks shown", () => {
cy.intercept("GET", /\/cooking-session\?/, { statusCode: 200, body: planFixture() }).as( cy.intercept("GET", /\/cooking-session\?/, { statusCode: 200, body: planFixture() }).as(
"getPlan", "getPlan",
); );
@ -144,21 +218,107 @@ describe("Cooking session page", () => {
cy.visit("/cuisiner?date=2026-08-17"); cy.visit("/cuisiner?date=2026-08-17");
cy.wait("@getPlan").its("request.url").should("include", "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. // One stepper node per phase; the first is the current one.
cy.contains(".cooking-phase", "Mise en place").should("be.visible"); cy.get(".cooking-stepper__node").should("have.length", 3);
cy.get(".cooking-stepper__node").first().should("have.class", "cooking-stepper__node--current");
cy.contains(".cooking-plan__position", "Étape 1 / 3").should("be.visible");
cy.contains(".cooking-plan__count", "0 / 1 tâches").should("be.visible");
// Mise en place: the pooled prep task, flagged shared, naming both recipes.
cy.get(".cooking-task--merged-prep") cy.get(".cooking-task--merged-prep")
.should("contain.text", "Hacher") .should("contain.text", "Hacher")
.and("contain.text", "Oignon") .and("contain.text", "Oignon")
.and("contain.text", "Mutualisé"); .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. // The next phase's step is not rendered yet.
cy.contains("Faire mijoter le bouillon").should("not.exist");
// Prev is disabled on the first phase.
cy.contains(".cooking-plan__nav-btn", "Étape précédente").should("be.disabled");
});
it("walks the phases with the next/prev buttons and by clicking a stepper node", () => {
cy.intercept("GET", /\/cooking-session\?/, { statusCode: 200, body: planFixture() });
cy.visit("/cuisiner?date=2026-08-17");
// Forward to phase 2.
cy.contains(".cooking-plan__nav-btn", "Étape suivante").click();
cy.contains(".cooking-plan__position", "Étape 2 / 3").should("be.visible");
cy.contains(".cooking-phase__tasks", "Faire mijoter le bouillon").should("be.visible");
// Forward to phase 3 — its background band shows the soup still simmering.
cy.contains(".cooking-plan__nav-btn", "Étape suivante").click();
cy.contains(".cooking-plan__position", "Étape 3 / 3").should("be.visible");
cy.contains(".cooking-phase__background", "Pendant ce temps") cy.contains(".cooking-phase__background", "Pendant ce temps")
.should("contain.text", "Faire mijoter le bouillon") .should("contain.text", "Faire mijoter le bouillon")
.and("contain.text", "Soupe à l'oignon"); .and("contain.text", "Soupe à l'oignon");
cy.contains(".cooking-phase__tasks", "Enfourner la tarte").should("be.visible");
cy.contains(".cooking-plan__nav-btn", "Étape suivante").should("be.disabled");
// Recipe legend is present. // Back one step.
cy.contains(".cooking-session__legend-item", "Tarte à l'oignon").should("be.visible"); cy.contains(".cooking-plan__nav-btn", "Étape précédente").click();
cy.contains(".cooking-plan__position", "Étape 2 / 3").should("be.visible");
// Jump straight back to the first phase from the stepper.
cy.get(".cooking-stepper__node").first().click();
cy.contains(".cooking-plan__position", "Étape 1 / 3").should("be.visible");
cy.get(".cooking-task--merged-prep").should("be.visible");
});
it("ticks a task, marks its phase done in the stepper, and keeps the tick after a reload", () => {
cy.intercept("GET", /\/cooking-session\?/, { statusCode: 200, body: planFixture() });
cy.visit("/cuisiner?date=2026-08-17");
cy.get(".cooking-task").first().find('input[type="checkbox"]').check({ force: true });
cy.get(".cooking-task").first().should("have.class", "cooking-task--done");
// The whole phase is now done — its stepper node reflects that.
cy.get(".cooking-stepper__node").first().should("have.class", "cooking-stepper__node--done");
cy.contains(".cooking-plan__count", "1 / 1 tâches").should("be.visible");
cy.reload();
cy.get(".cooking-task").first().should("have.class", "cooking-task--done");
cy.get(".cooking-stepper__node").first().should("have.class", "cooking-stepper__node--done");
});
it("lists the week's recipes on the Recettes tab, each linking to its own page", () => {
cy.intercept("GET", /\/cooking-session\?/, { statusCode: 200, body: planFixture() });
cy.visit("/cuisiner?date=2026-08-17");
cy.contains(".cooking-session-page__tab", "Recettes").click();
cy.get(".cooking-recipes__item").should("have.length", 2);
cy.contains(".cooking-recipes__item", "Soupe à l'oignon")
.find("a")
.should("have.attr", "href", "/recettes/1");
cy.contains(".cooking-recipes__item", "Tarte à l'oignon")
.should("contain.text", "6 portions")
.find("a")
.should("have.attr", "href", "/recettes/2");
// The stepper isn't part of this tab.
cy.get(".cooking-stepper").should("not.exist");
});
it("renders a merged-setup preheat with its temperature and the oven-conflict note", () => {
cy.intercept("GET", /\/cooking-session\?/, { statusCode: 200, body: setupPlanFixture() });
cy.visit("/cuisiner?date=2026-08-17");
// The pooled preheat shows the technique label + its pooled temperature,
// flagged shared like a merged-prep task.
cy.get(".cooking-task--merged-setup")
.should("contain.text", "180 °C")
.and("contain.text", "Mutualisé");
// The phase warns the oven is needed at two more temperatures, in order.
cy.get(".cooking-phase__note")
.should("contain.text", "2 températures")
.and("contain.text", "200 °C puis 220 °C");
// The two un-merged preheats are still listed as their own tasks.
cy.contains(".cooking-task", "Préchauffer le four à 200°C").should("be.visible");
cy.contains(".cooking-task", "Préchauffer le four à 220°C").should("be.visible");
cy.contains(".cooking-plan__count", "0 / 3 tâches").should("be.visible");
}); });
it("shows an error state when the request fails", () => { it("shows an error state when the request fails", () => {

View file

@ -22,3 +22,42 @@ Feature: Start cooking an optimized plan
And I should see "Mise en place" And I should see "Mise en place"
And the pooled prep task should mention "Hacher" and "Oignon" And the pooled prep task should mention "Hacher" and "Oignon"
And the pooled prep task should be flagged as shared And the pooled prep task should be flagged as shared
Scenario: The stepper walks the phases one at a time
Given the planning for this week has recipes "Soupe à l'oignon" and "Tarte à l'oignon"
And the cooking plan for "2026-08-17" has a mise en place then a cooking step
When I visit "/cuisiner?date=2026-08-17"
Then the current cooking step should be "Étape 1 / 2"
And I should not see "Faire mijoter le bouillon"
When I go to the next cooking step
Then the current cooking step should be "Étape 2 / 2"
And I should see "Faire mijoter le bouillon"
When I go to the previous cooking step
Then the current cooking step should be "Étape 1 / 2"
Scenario: A ticked task stays ticked while moving through the stepper
Given the planning for this week has recipes "Soupe à l'oignon" and "Tarte à l'oignon"
And the cooking plan for "2026-08-17" has a mise en place then a cooking step
When I visit "/cuisiner?date=2026-08-17"
And I tick the cooking task "Hacher"
Then the cooking task "Hacher" should be done
When I go to the next cooking step
And I go to the previous cooking step
Then the cooking task "Hacher" should be done
Scenario: The Recettes tab lists the week's planned recipes
Given the planning for this week has recipes "Soupe à l'oignon" and "Tarte à l'oignon"
And the cooking plan for "2026-08-17" has a mise en place then a cooking step
When I visit "/cuisiner?date=2026-08-17"
And I open the "Recettes" cooking tab
Then I should see "Soupe à l'oignon"
And the recipe "Soupe à l'oignon" should link to "/recettes/1"
And the recipe "Tarte à l'oignon" should link to "/recettes/2"
Scenario: Two recipes preheat the oven to different temperatures
Given the planning for this week has recipes "Gratin" and "Rôti"
And the cooking plan for "2026-08-17" needs the oven at 180 °C and 210 °C
When I visit "/cuisiner?date=2026-08-17"
Then I should see the oven conflict note "enchaîner 180 °C puis 210 °C"
And I should see "Préchauffer le four à 180°C"
And I should see "Préchauffer le four à 210°C"

View file

@ -1,4 +1,4 @@
import { Given, Then } from "@badeball/cypress-cucumber-preprocessor"; import { Given, Then, When } from "@badeball/cypress-cucumber-preprocessor";
/** Bare `IngredientView` — only `key` drives the page's `catalog.ingredients.*` lookup. */ /** Bare `IngredientView` — only `key` drives the page's `catalog.ingredients.*` lookup. */
function ingredient(key: string) { function ingredient(key: string) {
@ -59,6 +59,7 @@ Given(
index: 0, index: 0,
kind: "mise-en-place", kind: "mise-en-place",
background: [], background: [],
notes: [],
tasks: [ tasks: [
{ {
id: "prep:chop:onion", id: "prep:chop:onion",
@ -73,6 +74,7 @@ Given(
}, },
], ],
utensils: [], utensils: [],
temperatures: [],
sourceRecipes: [ sourceRecipes: [
{ recipeId: 1, name: "Soupe à l'oignon", portions: 4 }, { recipeId: 1, name: "Soupe à l'oignon", portions: 4 },
{ recipeId: 2, name: "Tarte à l'oignon", portions: 4 }, { recipeId: 2, name: "Tarte à l'oignon", portions: 4 },
@ -99,3 +101,143 @@ Then(
Then("the pooled prep task should be flagged as shared", () => { Then("the pooled prep task should be flagged as shared", () => {
cy.get(".cooking-task--merged-prep").contains("Mutualisé").should("be.visible"); cy.get(".cooking-task--merged-prep").contains("Mutualisé").should("be.visible");
}); });
Given("the cooking plan for {string} has a mise en place then a cooking step", (date: string) => {
// Two phases so the stepper actually has somewhere to step to: the
// pooled `chop onion` prep, then a `simmer` cooking step.
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: 6 },
],
phases: [
{
index: 0,
kind: "mise-en-place",
background: [],
notes: [],
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: [],
temperatures: [],
sourceRecipes: [
{ recipeId: 1, name: "Soupe à l'oignon", portions: 4 },
{ recipeId: 2, name: "Tarte à l'oignon", portions: 6 },
],
originalSteps: [],
},
],
},
{
index: 1,
kind: "cooking",
background: [],
notes: [],
tasks: [
{
id: "step:0:1",
kind: "step",
technique: { id: 5, key: "simmer" },
description: "Faire mijoter le bouillon",
ingredients: [],
utensils: [],
temperatures: [],
sourceRecipes: [{ recipeId: 1, name: "Soupe à l'oignon", portions: 4 }],
originalSteps: [],
},
],
},
],
},
});
});
Given("the cooking plan for {string} needs the oven at 180 °C and 210 °C", (date: string) => {
const celsius = (value: number) => ({ celsius: value, gasMark: null, qualitative: null });
const preheatStep = (id: string, recipe: { recipeId: number; name: string }, value: number) => ({
id,
kind: "step",
technique: { id: 9, key: "preheat" },
description: `Préchauffer le four à ${value}°C`,
ingredients: [],
utensils: [],
temperatures: [celsius(value)],
sourceRecipes: [recipe],
originalSteps: [],
});
const gratin = { recipeId: 1, name: "Gratin", portions: 4 };
const roti = { recipeId: 2, name: "Rôti", portions: 4 };
cy.intercept("GET", `**/cooking-session?date=${date}`, {
statusCode: 200,
body: {
startDate: `${date}T00:00:00.000Z`,
finishDate: "2026-08-23T00:00:00.000Z",
recipes: [gratin, roti],
phases: [
{
index: 0,
kind: "mise-en-place",
background: [],
notes: [
{
kind: "appliance-temperature-conflict",
technique: { id: 9, key: "preheat" },
temperatures: [celsius(180), celsius(210)],
},
],
// Coolest first, as the optimizer emits them.
tasks: [preheatStep("step:0:1", gratin, 180), preheatStep("step:1:2", roti, 210)],
},
],
},
});
});
Then("I should see the oven conflict note {string}", (text: string) => {
cy.get(".cooking-phase__note").should("contain.text", text);
});
When("I open the {string} cooking tab", (label: string) => {
cy.contains(".cooking-session-page__tab", label).click();
});
// The task card is a `<label>` wrapping a visually-hidden checkbox — click
// the card, same approach as common.steps.ts's "I click the radio".
When("I tick the cooking task {string}", (headline: string) => {
cy.contains(".cooking-task", headline).click();
});
Then("the cooking task {string} should be done", (headline: string) => {
cy.contains(".cooking-task", headline).should("have.class", "cooking-task--done");
});
When("I go to the next cooking step", () => {
cy.contains(".cooking-plan__nav-btn", "Étape suivante").click();
});
When("I go to the previous cooking step", () => {
cy.contains(".cooking-plan__nav-btn", "Étape précédente").click();
});
Then("the current cooking step should be {string}", (position: string) => {
cy.contains(".cooking-plan__position", position).should("be.visible");
});
Then("the recipe {string} should link to {string}", (name: string, href: string) => {
cy.contains(".cooking-recipes__item", name).find("a").should("have.attr", "href", href);
});

View file

@ -1,38 +1,12 @@
import type { import type { StepTechStepView, SubmitTechStepCorrectionResult } from "@batch-cooking/shared";
StepTechStepTemperatureView,
StepTechStepView,
SubmitTechStepCorrectionResult,
} from "@batch-cooking/shared";
import { Fragment, useEffect, useRef, useState } from "react"; import { Fragment, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Tooltip } from "../../../components/ui/Tooltip"; import { Tooltip } from "../../../components/ui/Tooltip";
import { splitDescriptionSegments } from "./highlight-tech-steps"; import { splitDescriptionSegments } from "./highlight-tech-steps";
import { TechStepCorrectionPopover } from "./TechStepCorrectionPopover"; import { TechStepCorrectionPopover } from "./TechStepCorrectionPopover";
import { temperatureLabel } from "./temperature-label";
import { type TextSelectionRange, useTextSelection } from "./use-text-selection"; import { type TextSelectionRange, useTextSelection } from "./use-text-selection";
/**
* Human label for a detected temperature — the °C value, else the gas-mark
* number, else the qualitative level, each via i18n. Shown in the
* highlight's Tooltip (`raw` is what stays inline as the highlighted text).
* `t` is passed in (see `ingredient-label.ts` for the same "plain function,
* unit-testable" split).
*/
function temperatureLabel(
temperature: StepTechStepTemperatureView,
t: (key: string, options?: Record<string, unknown>) => string,
): string {
if (temperature.celsius !== null) {
return t("recipes.temperature.celsius", { value: temperature.celsius });
}
if (temperature.gasMark !== null) {
return t("recipes.temperature.gasMark", { value: temperature.gasMark });
}
if (temperature.qualitative !== null) {
return t(`recipes.temperature.qualitative.${temperature.qualitative}`);
}
return temperature.raw;
}
/** /**
* A recipe step's description, with every detected technique's exact * A recipe step's description, with every detected technique's exact
* matched words highlighted and given a {@link Tooltip} naming the * matched words highlighted and given a {@link Tooltip} naming the

View file

@ -0,0 +1,40 @@
/**
* The three structured fields a temperature label is built from. Both
* `StepTechStepTemperatureView` (recipe-detail highlight — also carries a
* `raw` fragment) and `CookingTemperatureView` (the cooking session's
* pooled setup targets — no `raw`) satisfy this, so {@link temperatureLabel}
* serves both without either type depending on the other.
*/
export interface TemperatureLabelInput {
celsius: number | null;
gasMark: number | null;
qualitative: "low" | "medium" | "high" | null;
/** The exact recognized fragment, present only on a recipe-detail temperature — used only as a last-resort fallback (unreachable when one of the three above is set, which the contract guarantees). */
raw?: string;
}
/**
* Human label for a detected temperature — the °C value, else the gas-mark
* number, else the qualitative level, each via i18n (`recipes.temperature.*`).
* `t` is passed in rather than calling `useTranslation` here so this stays a
* plain function usable from non-component code and unit-testable without
* mounting i18next — same split as `ingredient-label.ts` / `shopping-list.ts`.
*
* Extracted from `StepDescription.tsx` (its original home) once the cooking
* session page needed the same label for a `merged-setup` task.
*/
export function temperatureLabel(
temperature: TemperatureLabelInput,
t: (key: string, options?: Record<string, unknown>) => string,
): string {
if (temperature.celsius !== null) {
return t("recipes.temperature.celsius", { value: temperature.celsius });
}
if (temperature.gasMark !== null) {
return t("recipes.temperature.gasMark", { value: temperature.gasMark });
}
if (temperature.qualitative !== null) {
return t(`recipes.temperature.qualitative.${temperature.qualitative}`);
}
return temperature.raw ?? "";
}

View file

@ -330,20 +330,40 @@
"loading": "Optimisation du plan de cuisine…", "loading": "Optimisation du plan de cuisine…",
"empty": "Rien de planifié cette semaine à cuisiner — ajoutez des recettes à votre planning.", "empty": "Rien de planifié cette semaine à cuisiner — ajoutez des recettes à votre planning.",
"recipesLegend": "Recettes de la semaine", "recipesLegend": "Recettes de la semaine",
"tabs": {
"plan": "Plan",
"recipes": "Recettes"
},
"phase": { "phase": {
"label": "Étape {{index}}",
"mise-en-place": "Mise en place", "mise-en-place": "Mise en place",
"cooking": "Cuisson", "cooking": "Cuisson",
"finishing": "Dressage" "finishing": "Dressage"
}, },
"stepper": {
"prev": "Étape précédente",
"next": "Étape suivante",
"position": "Étape {{current}} / {{total}}"
},
"progress": {
"tasks": "{{done}} / {{total}} tâches"
},
"background": { "background": {
"title": "Pendant ce temps" "title": "Pendant ce temps"
}, },
"note": {
"applianceTemperatureConflict": "Four requis à {{count}} températures — enchaîner {{sequence}}",
"sequenceSeparator": "puis"
},
"task": { "task": {
"mergedPrepLabel": "{{technique}} : {{items}}", "mergedPrepLabel": "{{technique}} : {{items}}",
"mergedSetupLabel": "{{technique}} · {{temperature}}",
"forRecipes": "pour {{recipes}}", "forRecipes": "pour {{recipes}}",
"utensils": "Ustensiles", "utensils": "Ustensiles",
"sharedBadge": "Mutualisé" "sharedBadge": "Mutualisé"
},
"recipesTab": {
"portions": "{{n}} portions",
"viewRecipe": "Voir la recette"
} }
}, },
"account": { "account": {

View file

@ -2,15 +2,24 @@ import { DateTime, formatDateOnly, getWeekStart, parseDateOnly } from "@batch-co
import type { import type {
CookingBackgroundTaskView, CookingBackgroundTaskView,
CookingPhaseView, CookingPhaseView,
CookingSessionRecipeRef,
CookingTaskView, CookingTaskView,
OptimizedCookingPlanView, OptimizedCookingPlanView,
} from "@batch-cooking/shared"; } from "@batch-cooking/shared";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useSearchParams } from "react-router-dom"; import { Link, useSearchParams } from "react-router-dom";
import { apiClient } from "../../api/client"; import { apiClient } from "../../api/client";
import { CheckboxOption } from "../../components/ui/Checkbox";
import { WeekNavigator } from "../../features/planning/WeekNavigator"; import { WeekNavigator } from "../../features/planning/WeekNavigator";
import { taskHeadline, taskRecipeNames } from "./cooking-session"; import { cookingProgress } from "./cooking-progress";
import {
phaseNoteText,
phaseProgress,
reconcileChecked,
taskHeadline,
taskRecipeNames,
} from "./cooking-session";
import "./cooking-session-page.scss"; import "./cooking-session-page.scss";
/** Load state for the `GET /cooking-session` call — same discriminated-union shape as `ShoppingListPage`'s own state. */ /** Load state for the `GET /cooking-session` call — same discriminated-union shape as `ShoppingListPage`'s own state. */
@ -19,6 +28,14 @@ type CookingSessionState =
| { status: "loaded"; plan: OptimizedCookingPlanView } | { status: "loaded"; plan: OptimizedCookingPlanView }
| { status: "error" }; | { status: "error" };
/**
* Which panel of the cooking view is showing:
* - `"plan"` (default) — the step-by-step cooking plan, one phase at a time.
* - `"recipes"` — the flat list of the week's planned recipes, for a quick
* "what am I even making" glance / a jump to a recipe's own page.
*/
type CookingTab = "plan" | "recipes";
/** /**
* "Cuisiner cette semaine" — routed at `/cuisiner`, reached from the * "Cuisiner cette semaine" — routed at `/cuisiner`, reached from the
* planning page's "Commencer à cuisiner" button. Shows the household's week * planning page's "Commencer à cuisiner" button. Shows the household's week
@ -28,10 +45,13 @@ type CookingSessionState =
* phases that interleave the recipes with passive cooks shown as running * phases that interleave the recipes with passive cooks shown as running
* in the background. * in the background.
* *
* The week comes from a `?date=` query param (set by the planning button so * Two tabs: **Plan** walks the phases one at a time through a stepper, with
* the two pages stay on the same week); absent/invalid falls back to the * every task tickable (progress kept in `localStorage` via
* current week. Read-only and recomputed on every visit — no progress * {@link cookingProgress} — no server state, same recompute-on-every-visit
* state to keep in sync, same design stance as `ShoppingListPage`. * stance as `ShoppingListPage`); **Recettes** is just the week's recipe
* list. 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.
*/ */
export function CookingSessionPage() { export function CookingSessionPage() {
const { t } = useTranslation(); const { t } = useTranslation();
@ -41,6 +61,7 @@ export function CookingSessionPage() {
return getWeekStart(fromQuery ?? DateTime.utc()); return getWeekStart(fromQuery ?? DateTime.utc());
}); });
const [state, setState] = useState<CookingSessionState>({ status: "loading" }); const [state, setState] = useState<CookingSessionState>({ status: "loading" });
const [tab, setTab] = useState<CookingTab>("plan");
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
@ -60,6 +81,8 @@ export function CookingSessionPage() {
}; };
}, [weekStart]); }, [weekStart]);
const weekKey = formatDateOnly(weekStart);
return ( return (
<div className="cooking-session-page"> <div className="cooking-session-page">
<div className="cooking-session-page__header"> <div className="cooking-session-page__header">
@ -68,6 +91,23 @@ export function CookingSessionPage() {
</div> </div>
<p className="cooking-session-page__subtitle">{t("cookingSession.subtitle")}</p> <p className="cooking-session-page__subtitle">{t("cookingSession.subtitle")}</p>
<div className="cooking-session-page__tabs">
<button
type="button"
className={`cooking-session-page__tab${tab === "plan" ? " active" : ""}`}
onClick={() => setTab("plan")}
>
{t("cookingSession.tabs.plan")}
</button>
<button
type="button"
className={`cooking-session-page__tab${tab === "recipes" ? " active" : ""}`}
onClick={() => setTab("recipes")}
>
{t("cookingSession.tabs.recipes")}
</button>
</div>
{state.status === "loading" && ( {state.status === "loading" && (
<p className="cooking-session-page__status">{t("cookingSession.loading")}</p> <p className="cooking-session-page__status">{t("cookingSession.loading")}</p>
)} )}
@ -78,51 +118,168 @@ export function CookingSessionPage() {
</p> </p>
)} )}
{state.status === "loaded" && <CookingPlan plan={state.plan} />} {state.status === "loaded" &&
(tab === "plan" ? (
// Keyed by week so switching weeks re-reads that week's checklist
// from localStorage and resets the stepper to its first phase.
<PlanTab key={weekKey} plan={state.plan} weekKey={weekKey} />
) : (
<RecipesTab recipes={state.plan.recipes} />
))}
</div> </div>
); );
} }
/** The plan body — the recipe legend then every phase, or the empty-week message. */ /** The "Recettes" tab — the week's planned recipes as a flat list, each linking to its own page. */
function CookingPlan({ plan }: { plan: OptimizedCookingPlanView }) { function RecipesTab({ recipes }: { recipes: readonly CookingSessionRecipeRef[] }) {
const { t } = useTranslation(); const { t } = useTranslation();
if (recipes.length === 0) {
return <p className="cooking-session-page__status">{t("cookingSession.empty")}</p>;
}
return (
<ul className="cooking-recipes" aria-label={t("cookingSession.recipesLegend")}>
{recipes.map((recipe) => (
<li key={`${recipe.recipeId}-${recipe.portions}`} className="cooking-recipes__item">
<span className="cooking-recipes__name">{recipe.name}</span>
<span className="cooking-recipes__portions">
{t("cookingSession.recipesTab.portions", { n: recipe.portions })}
</span>
<Link className="cooking-recipes__link" to={`/recettes/${recipe.recipeId}`}>
{t("cookingSession.recipesTab.viewRecipe")}
</Link>
</li>
))}
</ul>
);
}
/**
* The "Plan" tab — the optimized phases walked one at a time. Holds the
* stepper position (local, resets on remount / week change) and the ticked
* task ids (restored from {@link cookingProgress}, reconciled against the
* plan actually on screen, saved back on every toggle).
*/
function PlanTab({ plan, weekKey }: { plan: OptimizedCookingPlanView; weekKey: string }) {
const { t } = useTranslation();
const [checked, setChecked] = useState<Set<string>>(() =>
reconcileChecked(cookingProgress.load(weekKey), plan),
);
const [stepIndex, setStepIndex] = useState(0);
if (plan.phases.length === 0) { if (plan.phases.length === 0) {
return <p className="cooking-session-page__status">{t("cookingSession.empty")}</p>; return <p className="cooking-session-page__status">{t("cookingSession.empty")}</p>;
} }
return ( // `stepIndex` can't actually drift out of range (the phase count is fixed
<div className="cooking-session"> // for a mount and the nav buttons are bounded), but clamp defensively so
<section className="cooking-session__legend" aria-label={t("cookingSession.recipesLegend")}> // a bad value degrades to the last phase rather than a blank panel.
{plan.recipes.map((recipe) => ( const safeIndex = Math.min(Math.max(stepIndex, 0), plan.phases.length - 1);
<span const phase = plan.phases[safeIndex];
key={`${recipe.recipeId}-${recipe.portions}`} if (!phase) return null;
className="cooking-session__legend-item"
>
{recipe.name} · ×{recipe.portions}
</span>
))}
</section>
{plan.phases.map((phase) => ( const { done, total } = phaseProgress(phase, checked);
<PhaseSection key={phase.index} phase={phase} />
))} function toggleTask(taskId: string, isChecked: boolean): void {
setChecked((prev) => {
const next = new Set(prev);
if (isChecked) next.add(taskId);
else next.delete(taskId);
cookingProgress.save(weekKey, next);
return next;
});
}
return (
<div className="cooking-plan">
<ol className="cooking-stepper">
{plan.phases.map((phaseNode, index) => {
const nodeProgress = phaseProgress(phaseNode, checked);
const complete = nodeProgress.total > 0 && nodeProgress.done === nodeProgress.total;
const classes = [
"cooking-stepper__node",
index === safeIndex && "cooking-stepper__node--current",
complete && "cooking-stepper__node--done",
]
.filter(Boolean)
.join(" ");
return (
<li key={phaseNode.index} className="cooking-stepper__step">
<button
type="button"
className={classes}
aria-current={index === safeIndex ? "step" : undefined}
onClick={() => setStepIndex(index)}
>
<span className="cooking-stepper__marker" aria-hidden="true">
{complete ? "✓" : index + 1}
</span>
<span className="cooking-stepper__label">
{t(`cookingSession.phase.${phaseNode.kind}`)}
</span>
</button>
</li>
);
})}
</ol>
<div className="cooking-plan__meta">
<span className="cooking-plan__position">
{t("cookingSession.stepper.position", {
current: safeIndex + 1,
total: plan.phases.length,
})}{" "}
· {t(`cookingSession.phase.${phase.kind}`)}
</span>
<span className="cooking-plan__count">
{t("cookingSession.progress.tasks", { done, total })}
</span>
</div>
<PhaseSection phase={phase} checked={checked} onToggle={toggleTask} />
<div className="cooking-plan__nav">
<button
type="button"
className="cooking-plan__nav-btn"
disabled={safeIndex === 0}
onClick={() => setStepIndex(safeIndex - 1)}
>
{t("cookingSession.stepper.prev")}
</button>
<button
type="button"
className="cooking-plan__nav-btn"
disabled={safeIndex === plan.phases.length - 1}
onClick={() => setStepIndex(safeIndex + 1)}
>
{t("cookingSession.stepper.next")}
</button>
</div>
</div> </div>
); );
} }
/** One phase: its background band (if any) then its task cards. */ /** One phase: its background band (if any) then its tickable task cards. */
function PhaseSection({ phase }: { phase: CookingPhaseView }) { function PhaseSection({
phase,
checked,
onToggle,
}: {
phase: CookingPhaseView;
checked: Set<string>;
onToggle: (taskId: string, isChecked: boolean) => void;
}) {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<section className={`cooking-phase cooking-phase--${phase.kind}`}> <section className={`cooking-phase cooking-phase--${phase.kind}`}>
<h2 className="cooking-phase__title"> {(phase.notes ?? []).map((note) => (
<span className="cooking-phase__index"> <p key={note.kind} className="cooking-phase__note">
{t("cookingSession.phase.label", { index: phase.index + 1 })} {phaseNoteText(note, t)}
</span> </p>
<span className="cooking-phase__kind">{t(`cookingSession.phase.${phase.kind}`)}</span> ))}
</h2>
{phase.background.length > 0 && ( {phase.background.length > 0 && (
<div className="cooking-phase__background"> <div className="cooking-phase__background">
@ -142,7 +299,11 @@ function PhaseSection({ phase }: { phase: CookingPhaseView }) {
<ul className="cooking-phase__tasks"> <ul className="cooking-phase__tasks">
{phase.tasks.map((task) => ( {phase.tasks.map((task) => (
<li key={task.id}> <li key={task.id}>
<TaskCard task={task} /> <TaskCard
task={task}
checked={checked.has(task.id)}
onToggle={(isChecked) => onToggle(task.id, isChecked)}
/>
</li> </li>
))} ))}
</ul> </ul>
@ -150,28 +311,43 @@ function PhaseSection({ phase }: { phase: CookingPhaseView }) {
); );
} }
/** A single actionable task — a merged-prep pool or a plain recipe step. */ /** A single actionable task — a merged pool (prep or setup) or a plain recipe step — with a tick to mark it done. */
function TaskCard({ task }: { task: CookingTaskView }) { function TaskCard({
task,
checked,
onToggle,
}: {
task: CookingTaskView;
checked: boolean;
onToggle: (isChecked: boolean) => void;
}) {
const { t } = useTranslation(); const { t } = useTranslation();
const isMerged = task.kind === "merged-prep" || task.kind === "merged-setup";
return ( return (
<article className={`cooking-task cooking-task--${task.kind}`}> <CheckboxOption
<p className="cooking-task__headline"> checked={checked}
onChange={onToggle}
className={`cooking-task cooking-task--${task.kind}${checked ? " cooking-task--done" : ""}`}
>
<span className="cooking-task__body">
<span className="cooking-task__headline">
{taskHeadline(task, t)} {taskHeadline(task, t)}
{task.kind === "merged-prep" && ( {isMerged && (
<span className="cooking-task__badge">{t("cookingSession.task.sharedBadge")}</span> <span className="cooking-task__badge">{t("cookingSession.task.sharedBadge")}</span>
)} )}
</p> </span>
<p className="cooking-task__recipes"> <span className="cooking-task__recipes">
{t("cookingSession.task.forRecipes", { recipes: taskRecipeNames(task) })} {t("cookingSession.task.forRecipes", { recipes: taskRecipeNames(task) })}
</p> </span>
{task.utensils.length > 0 && ( {task.utensils.length > 0 && (
<p className="cooking-task__utensils"> <span className="cooking-task__utensils">
{t("cookingSession.task.utensils")} :{" "} {t("cookingSession.task.utensils")} :{" "}
{task.utensils.map((utensil) => t(`catalog.utensils.${utensil.key}`)).join(", ")} {task.utensils.map((utensil) => t(`catalog.utensils.${utensil.key}`)).join(", ")}
</p> </span>
)} )}
</article> </span>
</CheckboxOption>
); );
} }

View file

@ -0,0 +1,67 @@
/**
* Per-week "which cooking tasks are ticked off" checklist — persisted in the
* browser's `localStorage` only. This feature has no server-side progress
* state by design: `GET /cooking-session` recomputes the whole optimized
* plan on every visit (see `CookingSessionPage`), so a tick is a purely
* local convenience, not shared household state.
*
* A class with a shared singleton rather than two bare functions because it
* wraps Web Storage IO with named operations — same "behaviour with a verb
* ⇒ service class + exported instance" split as `ApiClient` /
* `ErrorMessageService` (`specs/dev-conventions.md`).
*/
export class CookingProgressStore {
/**
* Key namespace — every value this app stores in `localStorage` is
* `batchcooking:`-prefixed (cf. `batchcooking:sidebarCollapsed` in
* `AppLayout`). The week-start `YYYY-MM-DD` is appended so each week keeps
* an independent checklist and ticks never bleed across weeks.
*/
private readonly _keyPrefix = "batchcooking:cookingProgress:";
/**
* The ticked `CookingTaskView.id`s for the week starting `weekKey`
* (`YYYY-MM-DD`). Returns an empty set when nothing is stored yet, when
* `localStorage` is unavailable (private-mode Safari throws on access),
* or when the stored value is not the expected string array — a lost
* checklist just means the cook re-ticks, never a broken page (same
* non-fatal posture as `AuthContext`'s swallowed `me()`).
*/
public load(weekKey: string): Set<string> {
try {
const raw = localStorage.getItem(this._key(weekKey));
if (raw === null) return new Set();
const parsed: unknown = JSON.parse(raw);
if (!Array.isArray(parsed)) return new Set();
return new Set(parsed.filter((id): id is string => typeof id === "string"));
} catch {
// Unreadable/unparseable storage — degrade to "nothing ticked".
return new Set();
}
}
/**
* Replaces the stored checklist for `weekKey` with `checkedIds` (written
* as a plain string array). A no-op if `localStorage` can't be written —
* see {@link load} for why that's swallowed rather than surfaced.
*/
public save(weekKey: string, checkedIds: Set<string>): void {
try {
localStorage.setItem(this._key(weekKey), JSON.stringify([...checkedIds]));
} catch {
// Storage full or unavailable — the in-memory checklist still works
// for this session, it just won't survive a reload.
}
}
/** Full storage key for a given week. */
private _key(weekKey: string): string {
return `${this._keyPrefix}${weekKey}`;
}
}
/**
* Shared instance — the store holds no state of its own (everything lives in
* `localStorage`), so a single one serves every caller.
*/
export const cookingProgress = new CookingProgressStore();

View file

@ -1,9 +1,16 @@
// ============================================================================= // =============================================================================
// Styles specific to CookingSessionPage — colocated next to // Styles specific to CookingSessionPage — colocated next to
// CookingSessionPage.tsx since nothing else uses these classes. Same page // CookingSessionPage.tsx since nothing else uses these classes.
// shell/status conventions as shopping-list-page.scss //
// (`__header`/`__status`); below it, a vertical stack of phase sections // Layout: a fixed page shell (header + subtitle + tab bar), then one of two
// each holding a "meanwhile" band and a list of task cards. // tab panels. The "Recettes" panel is a plain scrolling list; the "Plan"
// panel (`.cooking-plan`) is itself a flex column — a pinned stepper and
// meta line on top, a pinned prev/next bar at the bottom, and the current
// phase scrolling in between so the nav is always reachable.
//
// Every value goes through a `var(--token)` from _theme.scss; light/dark is
// handled entirely by those tokens being redefined, nothing here branches
// on the theme.
// ============================================================================= // =============================================================================
.cooking-session-page { .cooking-session-page {
@ -23,7 +30,7 @@
&__subtitle { &__subtitle {
flex-shrink: 0; flex-shrink: 0;
margin: 0 0 var(--space-lg); margin: 0 0 var(--space-md);
color: var(--color-text-muted); color: var(--color-text-muted);
font-size: var(--font-size-md); font-size: var(--font-size-md);
} }
@ -36,33 +43,258 @@
&__status--error { &__status--error {
color: var(--color-error); color: var(--color-error);
} }
// --- Tab bar ---------------------------------------------------------------
// Same underline-tab language as the recipe catalog's `.recipe-tabs`
// (features/recipes/recipes.scss): scrolls within itself rather than
// widening the page (global.scss forbids a horizontal body scroll).
&__tabs {
flex-shrink: 0;
display: flex;
gap: var(--space-xs);
overflow-x: auto;
border-bottom: 1px solid var(--color-border);
margin-bottom: var(--space-md);
} }
// --- Scrollable plan body -------------------------------------------------- &__tab {
.cooking-session { appearance: none;
flex-shrink: 0;
padding: var(--space-sm) var(--space-md);
font-family: var(--font-display);
font-size: var(--font-size-sm);
font-weight: 700;
letter-spacing: 0.02em;
color: var(--color-text-muted);
background: none;
border: none;
border-bottom: 2.5px solid transparent;
cursor: pointer;
transition:
color 0.15s ease,
border-color 0.15s ease;
&:hover {
color: var(--color-text);
}
&.active {
color: var(--color-primary);
border-color: var(--color-primary);
}
}
}
// --- "Recettes" tab ---------------------------------------------------------
// The week's planned recipes as a flat list — name, its own portion count,
// and a jump to the recipe's page. Deliberately thin: `plan.recipes` only
// carries `{recipeId, name, portions}`.
.cooking-recipes {
flex: 1; flex: 1;
min-height: 0; min-height: 0;
overflow: auto; overflow-y: auto;
list-style: none;
margin: 0;
padding: 0;
display: flex; display: flex;
flex-direction: column; 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); gap: var(--space-xs);
&__item {
display: flex;
align-items: center;
gap: var(--space-sm);
padding: var(--space-sm) var(--space-md);
background: var(--color-surface);
border-radius: var(--radius-md);
box-shadow: var(--shadow-sm);
} }
&__legend-item { &__name {
padding: 0.15rem var(--space-sm); font-weight: 600;
color: var(--color-text);
}
&__portions {
padding: 0.1rem var(--space-sm);
border-radius: var(--radius-pill);
background: var(--color-surface-alt);
font-size: var(--font-size-sm);
color: var(--color-text-muted);
font-variant-numeric: tabular-nums;
}
// Pushed to the trailing edge — a quiet secondary action, not the point
// of the row.
&__link {
margin-left: auto;
flex-shrink: 0;
font-size: var(--font-size-sm);
font-weight: 600;
color: var(--color-primary);
}
}
// --- "Plan" tab -----------------------------------------------------------
.cooking-plan {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
gap: var(--space-md);
// Meta line under the stepper: "Étape 2 / 4 · Cuisson" on the left, the
// "{done} / {total} tâches" counter on the right.
&__meta {
flex-shrink: 0;
display: flex;
align-items: baseline;
justify-content: space-between;
flex-wrap: wrap;
gap: var(--space-sm);
}
&__position {
font-family: var(--font-display);
font-weight: 700;
font-size: var(--font-size-md);
color: var(--color-text);
}
&__count {
font-size: var(--font-size-sm);
color: var(--color-text-muted);
font-variant-numeric: tabular-nums;
}
// The current phase scrolls; everything around it stays put.
.cooking-phase {
flex: 1;
min-height: 0;
overflow-y: auto;
}
&__nav {
flex-shrink: 0;
display: flex;
justify-content: space-between;
gap: var(--space-sm);
}
&__nav-btn {
appearance: none;
padding: var(--space-sm) var(--space-lg);
border: 1.5px solid var(--color-primary);
border-radius: var(--radius-base);
background: var(--color-surface);
color: var(--color-primary);
font-family: var(--font-display);
font-weight: 700;
font-size: var(--font-size-sm);
cursor: pointer;
transition:
background-color 0.15s ease,
opacity 0.15s ease;
&:hover:not(:disabled) {
background: color-mix(in srgb, var(--color-primary) 10%, var(--color-surface));
}
&:disabled {
opacity: 0.4;
cursor: not-allowed;
}
}
}
// --- Stepper --------------------------------------------------------------
// One clickable node per phase, in a row that scrolls sideways within
// itself when the phases don't all fit. A thin connector sits between
// consecutive nodes.
.cooking-stepper {
flex-shrink: 0;
list-style: none;
margin: 0;
padding: 0 0 var(--space-xs);
display: flex;
gap: var(--space-xs);
overflow-x: auto;
&__step {
flex: 0 0 auto;
display: flex;
align-items: center;
&:not(:last-child)::after {
content: "";
width: var(--space-md);
height: 2px;
background: var(--color-border);
margin: 0 var(--space-xs);
}
}
&__node {
appearance: none;
display: flex;
align-items: center;
gap: var(--space-xs);
padding: var(--space-xs) var(--space-sm);
border: 1.5px solid var(--color-border);
border-radius: var(--radius-pill); border-radius: var(--radius-pill);
background: var(--color-surface); background: var(--color-surface);
box-shadow: var(--shadow-sm); color: var(--color-text-muted);
font-family: var(--font-display);
font-size: var(--font-size-sm); font-size: var(--font-size-sm);
font-weight: 700;
white-space: nowrap;
cursor: pointer;
transition:
color 0.15s ease,
border-color 0.15s ease,
background-color 0.15s ease;
&:hover {
color: var(--color-text); color: var(--color-text);
border-color: var(--color-primary);
}
// The phase currently on screen.
&--current {
color: var(--color-primary);
border-color: var(--color-primary);
background: color-mix(in srgb, var(--color-primary) 10%, var(--color-surface));
}
// Every task in the phase is ticked.
&--done {
color: var(--color-success);
border-color: var(--color-success);
}
}
// The leading circle — a step number, or a check once the phase is done.
&__marker {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.4rem;
height: 1.4rem;
border-radius: var(--radius-pill);
background: var(--color-surface-alt);
font-size: var(--font-size-xs);
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
} }
&__node--current &__marker {
background: var(--color-primary);
color: var(--color-surface);
}
&__node--done &__marker {
background: var(--color-success);
color: var(--color-surface);
}
} }
// --- One phase ---------------------------------------------------------------- // --- One phase ----------------------------------------------------------------
@ -71,25 +303,18 @@
flex-direction: column; flex-direction: column;
gap: var(--space-sm); gap: var(--space-sm);
&__title { // A phase-level caveat — today only the "four requis à 2 températures"
display: flex; // conflict. A tinted callout in the accent colour so it reads as a
align-items: baseline; // heads-up, not a plain paragraph.
gap: var(--space-sm); &__note {
margin: 0; margin: 0;
font-size: var(--font-size-md); padding: var(--space-sm) var(--space-md);
} border-left: 3px solid var(--color-accent);
border-radius: 0 var(--radius-md) var(--radius-md) 0;
&__index { background: color-mix(in srgb, var(--color-accent) 10%, var(--color-surface));
font-weight: 700;
color: var(--color-text); color: var(--color-text);
}
&__kind {
font-size: var(--font-size-sm); font-size: var(--font-size-sm);
font-weight: 600; 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. // "Pendant ce temps" — passive cooks still running from earlier phases.
@ -118,24 +343,55 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--space-sm); gap: var(--space-sm);
}
> li {
display: flex;
} }
// --- One task card ---------------------------------------------------------- // The task card *is* a shared "selectable card" checkbox label (see
// global.scss's `label:has(> input[type="checkbox"])`), so its frame,
// hover, focus ring and hidden native input all come from there. The
// rules below only set what's task-specific — a top-aligned multi-line
// body, the merged-prep accent, and a faded/struck "done" look — and
// are kept at `.cooking-phase__tasks` depth on purpose so they outrank
// that shared rule's own `&.is-selected` treatment.
.cooking-task { .cooking-task {
background: var(--color-surface); flex: 1;
border-radius: var(--radius-md); align-items: flex-start;
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 .cooking-task--merged-prep,
// subtle accent border so it stands out from plain recipe steps. .cooking-task--merged-setup {
&--merged-prep {
border-left: 3px solid var(--color-accent); border-left: 3px solid var(--color-accent);
} }
// Ticked = done: cancel the shared rule's "selected" green tint/border
// (which would clash with the stepper's own green "current" node) and
// just fade the card and strike its headline.
.cooking-task--done {
opacity: 0.7;
background: var(--color-surface);
border-color: var(--color-border);
.cooking-task__headline {
text-decoration: line-through;
}
}
}
}
// --- One task card --------------------------------------------------------
// (frame comes from the shared checkbox-label rule — see above)
.cooking-task {
&__body {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.2rem;
}
&__headline { &__headline {
margin: 0;
display: flex; display: flex;
align-items: center; align-items: center;
flex-wrap: wrap; flex-wrap: wrap;
@ -144,6 +400,7 @@
color: var(--color-text); color: var(--color-text);
} }
// A pooled prep task is the headline feature of this page — flag it.
&__badge { &__badge {
padding: 0.05rem var(--space-xs); padding: 0.05rem var(--space-xs);
border-radius: var(--radius-pill); border-radius: var(--radius-pill);
@ -157,7 +414,7 @@
&__recipes, &__recipes,
&__utensils { &__utensils {
margin: 0.2rem 0 0; display: block;
font-size: var(--font-size-sm); font-size: var(--font-size-sm);
color: var(--color-text-muted); color: var(--color-text-muted);
} }

View file

@ -1,4 +1,12 @@
import type { CookingTaskIngredientView, CookingTaskView } from "@batch-cooking/shared"; import type {
CookingPhaseNoteView,
CookingPhaseView,
CookingTaskIngredientView,
CookingTaskView,
CookingTemperatureView,
OptimizedCookingPlanView,
} from "@batch-cooking/shared";
import { temperatureLabel } from "../../features/recipes/steps/temperature-label";
/** /**
* Minimal shape of `react-i18next`'s `t` — just what this module needs. * Minimal shape of `react-i18next`'s `t` — just what this module needs.
@ -34,23 +42,97 @@ export function formatIngredientLine(line: CookingTaskIngredientView, t: Transla
return `${amount} ${unit}${name}`.trim(); return `${amount} ${unit}${name}`.trim();
} }
/**
* The pooled target temperature(s) of a `merged-setup` task as one string —
* `"180 °C"`, `"180 °C / Thermostat 6"` in the rare mixed case, or `""` when
* no temperature was detected. Labels via the shared {@link temperatureLabel}
* (same `recipes.temperature.*` keys as the recipe-step highlight).
*/
export function formatTemperatures(temperatures: CookingTemperatureView[], t: TranslateFn): string {
return temperatures.map((temperature) => temperatureLabel(temperature, t)).join(" / ");
}
/** /**
* The headline shown on a task card: * The headline shown on a task card:
* - `merged-prep` — `"Émincer : 3 oignon, 200 g carotte"` (technique label + * - `merged-prep` — `"Émincer : 3 oignon, 200 g carotte"` (technique label +
* its pooled ingredient lines), built from the * its pooled ingredient lines), built from the
* `cookingSession.task.mergedPrepLabel` template. * `cookingSession.task.mergedPrepLabel` template.
* - `merged-setup` — `"Préchauffer · 180 °C"` (technique label + pooled
* temperature), via `cookingSession.task.mergedSetupLabel`; just the
* technique label when no temperature was detected.
* - `step` — the original recipe step text, verbatim. * - `step` — the original recipe step text, verbatim.
*/ */
export function taskHeadline(task: CookingTaskView, t: TranslateFn): string { export function taskHeadline(task: CookingTaskView, t: TranslateFn): string {
if (task.kind === "step") return task.description ?? ""; if (task.kind === "step") return task.description ?? "";
const technique = task.technique const technique = task.technique
? t(`catalog.techSteps.${task.technique.key}`) ? t(`catalog.techSteps.${task.technique.key}`)
: t("cookingSession.phase.mise-en-place"); : t("cookingSession.phase.mise-en-place");
if (task.kind === "merged-setup") {
const temperature = formatTemperatures(task.temperatures, t);
return temperature
? t("cookingSession.task.mergedSetupLabel", { technique, temperature })
: technique;
}
const items = task.ingredients.map((line) => formatIngredientLine(line, t)).join(", "); const items = task.ingredients.map((line) => formatIngredientLine(line, t)).join(", ");
return t("cookingSession.task.mergedPrepLabel", { technique, items }); return t("cookingSession.task.mergedPrepLabel", { technique, items });
} }
/** Comma-joined names of the recipes a task belongs to — one for a `step`, several for a pooled `merged-prep`. */ /** Comma-joined names of the recipes a task belongs to — one for a `step`, several for a pooled `merged-prep`/`merged-setup`. */
export function taskRecipeNames(task: CookingTaskView): string { export function taskRecipeNames(task: CookingTaskView): string {
return task.sourceRecipes.map((recipe) => recipe.name).join(", "); return task.sourceRecipes.map((recipe) => recipe.name).join(", ");
} }
/**
* The sentence for a phase-level note (see {@link CookingPhaseNoteView}).
* Today the only kind is `appliance-temperature-conflict`: the oven is
* needed at several temperatures at once, so the cook runs the preheats one
* after another — "Four requis à 2 températures — enchaîner 180 °C puis
* 210 °C" (temperatures already coolest-first from the optimizer).
*/
export function phaseNoteText(note: CookingPhaseNoteView, t: TranslateFn): string {
const sequence = note.temperatures
.map((temperature) => temperatureLabel(temperature, t))
.join(` ${t("cookingSession.note.sequenceSeparator")} `);
return t("cookingSession.note.applianceTemperatureConflict", {
count: note.temperatures.length,
sequence,
});
}
/**
* Drops any ticked id that no longer names a task in the current plan.
* `CookingTaskView.id` is only stable *within one plan response* (see its
* doc comment) — if the household's planning changed since the checklist
* was saved, some stored ids point at tasks that no longer exist, so the
* page filters the restored set against what's actually on screen rather
* than carrying dangling ids around.
*/
export function reconcileChecked(stored: Set<string>, plan: OptimizedCookingPlanView): Set<string> {
const live = new Set<string>();
for (const phase of plan.phases) {
for (const task of phase.tasks) {
if (stored.has(task.id)) live.add(task.id);
}
}
return live;
}
/**
* How many of a phase's tasks are ticked — feeds the stepper node's
* "done" state (`done === total && total > 0`) and the "{{done}} / {{total}}
* tâches" counter shown for the current step. `background` lines don't
* count: they're status reminders for passive cooks, not work to check off.
*/
export function phaseProgress(
phase: CookingPhaseView,
checked: Set<string>,
): { done: number; total: number } {
let done = 0;
for (const task of phase.tasks) {
if (checked.has(task.id)) done++;
}
return { done, total: phase.tasks.length };
}

View file

@ -43,6 +43,27 @@ export interface CookingTaskIngredientView {
unit: UnitView | null; unit: UnitView | null;
} }
/**
* A target temperature carried by a {@link CookingTaskView} or a
* {@link CookingPhaseNoteView} — the *structured* subset of
* `StepTechStepTemperatureView` (no `raw` text / spans / `source`), which is
* all the optimizer needs to decide whether two `preheat` actions can be
* merged and to order them coolest-first. Exactly as on the recipe-detail
* view, at least one of the three is non-null:
* - `celsius` — °C (a °F mention was converted upstream).
* - `gasMark` — a gas-mark / thermostat number.
* - `qualitative` — `"low" | "medium" | "high"` ("feu doux/moyen/vif").
*
* Two temperatures are treated as the *same* only when they match in the
* same representation — no °C↔gas-mark↔qualitative conversion is guessed
* for a merge decision (same stance as `ShoppingListItemView` on units).
*/
export interface CookingTemperatureView {
celsius: number | null;
gasMark: number | null;
qualitative: "low" | "medium" | "high" | null;
}
/** /**
* One actionable unit of work in a phase's `tasks` list. * One actionable unit of work in a phase's `tasks` list.
* *
@ -56,19 +77,34 @@ export interface CookingTaskIngredientView {
* `description` is `null` (the web layer composes a label from * `description` is `null` (the web layer composes a label from
* `technique` + `ingredients`), `sourceRecipes` lists every recipe it * `technique` + `ingredients`), `sourceRecipes` lists every recipe it
* covers, and `ingredients` holds the pooled quantity. * covers, and `ingredients` holds the pooled quantity.
* - `kind: "merged-setup"` — the same setup action (`preheat` / `boil` /
* `bainMarie`) shared across recipes: two recipes that both preheat the
* oven **to the same temperature** become one task. `description` is
* `null` (the web layer composes a label from `technique` +
* `temperatures`), `temperatures` holds the pooled target(s). Two
* `preheat` actions at *different* temperatures are never merged — they
* stay separate tasks and the phase carries a
* {@link CookingPhaseNoteView} telling the cook to run them in sequence.
* *
* `id` is stable within a single response (`"prep:<techniqueKey>:<ingredientKey>"` * `temperatures` is non-empty only for a `merged-setup` task, or a
* or `"step:<stepId>"`) so the frontend can key a checklist off it. * `kind: "step"` task whose step is itself a setup action (so the plan can
* `originalSteps` is the provenance trail — the exact step text(s) this * still order and conflict-check a preheat that only one recipe needs);
* task stands in for, so the UI can link back to "voir la recette". * `[]` otherwise.
*
* `id` is stable within a single response
* (`"prep:<techniqueKey>:<ingredientKey>"`, `"setup:<techniqueKey>:<tempKey>"`
* or `"step:<recipeIndex>:<stepId>"`) so the frontend can key a checklist
* off it. `originalSteps` is the provenance trail — the exact step text(s)
* this task stands in for, so the UI can link back to "voir la recette".
*/ */
export interface CookingTaskView { export interface CookingTaskView {
id: string; id: string;
kind: "merged-prep" | "step"; kind: "merged-prep" | "merged-setup" | "step";
technique: TechStepView | null; technique: TechStepView | null;
description: string | null; description: string | null;
ingredients: CookingTaskIngredientView[]; ingredients: CookingTaskIngredientView[];
utensils: UtensilView[]; utensils: UtensilView[];
temperatures: CookingTemperatureView[];
sourceRecipes: CookingSessionRecipeRef[]; sourceRecipes: CookingSessionRecipeRef[];
originalSteps: { recipeId: number; recipeName: string; description: string }[]; originalSteps: { recipeId: number; recipeName: string; description: string }[];
} }
@ -89,17 +125,45 @@ export interface CookingBackgroundTaskView {
recipeName: string; recipeName: string;
} }
/**
* Kind of a {@link CookingPhaseNoteView}. Only one today; kept as a named
* union so a future note type is an additive change and the web layer's
* `switch` stays exhaustive.
*
* - `"appliance-temperature-conflict"` — the week needs the same appliance
* (the oven, via `preheat`) at two or more incompatible temperatures.
* The conflicting tasks are left un-merged and ordered coolest-first; the
* note tells the cook to run them one after the other.
*/
export type CookingPhaseNoteKind = "appliance-temperature-conflict";
/**
* A caveat attached to a whole phase rather than a single task — see
* {@link CookingPhaseNoteKind}. `technique` is the action the conflict is
* about (`preheat`), `temperatures` the distinct incompatible targets in
* ascending order (coolest first), so the web layer can compose e.g.
* "Four requis à 2 températures — enchaîner 180 °C puis 210 °C".
*/
export interface CookingPhaseNoteView {
kind: CookingPhaseNoteKind;
technique: TechStepView;
temperatures: CookingTemperatureView[];
}
/** /**
* One phase of the optimized plan: a batch of work the cook does now * One phase of the optimized plan: a batch of work the cook does now
* (`tasks`), plus any passive cooks carried over from before (`background`). * (`tasks`), plus any passive cooks carried over from before (`background`)
* `index` is 0-based and matches the array position — carried explicitly so * and any phase-level caveats (`notes`, usually `[]` — see
* a caller rendering a single phase still knows where it sits. * {@link CookingPhaseNoteView}). `index` is 0-based and matches the array
* position — carried explicitly so a caller rendering a single phase still
* knows where it sits.
*/ */
export interface CookingPhaseView { export interface CookingPhaseView {
index: number; index: number;
kind: CookingPhaseKind; kind: CookingPhaseKind;
tasks: CookingTaskView[]; tasks: CookingTaskView[];
background: CookingBackgroundTaskView[]; background: CookingBackgroundTaskView[];
notes: CookingPhaseNoteView[];
} }
/** /**