perf(tech-steps): corrige la largeur du textcat — démarrage ~5× plus rapide #25
15 changed files with 1725 additions and 174 deletions
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
})),
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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)", () => {
|
||||||
|
|
|
||||||
|
|
@ -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", () => {
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
40
apps/web/src/features/recipes/steps/temperature-label.ts
Normal file
40
apps/web/src/features/recipes/steps/temperature-label.ts
Normal 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 ?? "";
|
||||||
|
}
|
||||||
|
|
@ -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": {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
67
apps/web/src/pages/cooking-session/cooking-progress.ts
Normal file
67
apps/web/src/pages/cooking-session/cooking-progress.ts
Normal 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();
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue