batchCooking/apps/web/src/pages/cooking-session/CookingSessionPage.tsx
Nicolas 85268729e6
All checks were successful
CI / lint (push) Successful in 1m57s
CI / build (push) Successful in 3m2s
CI / e2e (push) Successful in 7m59s
CI / intent-service-test (push) Successful in 18m44s
CI / test (push) Successful in 28m2s
feat(cooking-session): regroupement d'étapes sensible aux métadonnées d'action
L'optimiseur ne mutualisait que le pré-découpage. Il regroupe maintenant
aussi les actions SETUP (préchauffage, ébullition) entre recettes, mais
seulement quand elles sont compatibles : deux préchauffages ne fusionnent
que s'ils visent la même température détectée. Deux préchauffages à des
températures différentes restent séparés, ordonnés du plus froid au plus
chaud, et la mise en place porte une note « Four requis à N températures —
enchaîner … ». Défaut optimiste : deux préchauffages sans température
détectée fusionnent quand même (supposés compatibles).

- cooking-optimizer.ts : temperatureKey (comparaison exacte, aucune
  conversion inter-représentations), isPureSetupStep / setupSignature /
  extractMergedSetup en miroir du chemin merged-prep ; comparableTemp pour
  l'ordre seul ; detectApplianceTemperatureConflicts → CookingPhaseView.notes
- cooking-session.service.ts : `temperatures` dans l'include + mapping
- packages/shared : CookingTemperatureView, kind « merged-setup »,
  CookingTaskView.temperatures, CookingPhaseNoteView, CookingPhaseView.notes
- web : temperature-label.ts extrait de StepDescription et réutilisé ;
  taskHeadline gère merged-setup ; phaseNoteText ; bandeau .cooking-phase__note
- i18n : cookingSession.task.mergedSetupLabel, cookingSession.note.*
- tests : 7 cas Mocha « setup pooling » + test DB cooking-session,
  cas Cypress mode layout + scénario Gherkin

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 12:05:44 +02:00

364 lines
12 KiB
TypeScript

import { DateTime, formatDateOnly, getWeekStart, parseDateOnly } from "@batch-cooking/date-tools";
import type {
CookingBackgroundTaskView,
CookingPhaseView,
CookingSessionRecipeRef,
CookingTaskView,
OptimizedCookingPlanView,
} from "@batch-cooking/shared";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Link, useSearchParams } from "react-router-dom";
import { apiClient } from "../../api/client";
import { CheckboxOption } from "../../components/ui/Checkbox";
import { WeekNavigator } from "../../features/planning/WeekNavigator";
import { cookingProgress } from "./cooking-progress";
import {
phaseNoteText,
phaseProgress,
reconcileChecked,
taskHeadline,
taskRecipeNames,
} from "./cooking-session";
import "./cooking-session-page.scss";
/** Load state for the `GET /cooking-session` call — same discriminated-union shape as `ShoppingListPage`'s own state. */
type CookingSessionState =
| { status: "loading" }
| { status: "loaded"; plan: OptimizedCookingPlanView }
| { status: "error" };
/**
* 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
* planning page's "Commencer à cuisiner" button. Shows the household's week
* of planned recipes reorganized by the backend optimizer
* (`GET /cooking-session`, see the API's `cooking-optimizer.ts`) into
* ordered phases: a mise-en-place that pools shared prep, then cooking
* phases that interleave the recipes with passive cooks shown as running
* in the background.
*
* Two tabs: **Plan** walks the phases one at a time through a stepper, with
* every task tickable (progress kept in `localStorage` via
* {@link cookingProgress} — no server state, same recompute-on-every-visit
* 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() {
const { t } = useTranslation();
const [searchParams] = useSearchParams();
const [weekStart, setWeekStart] = useState<DateTime>(() => {
const fromQuery = parseDateOnly(searchParams.get("date") ?? "");
return getWeekStart(fromQuery ?? DateTime.utc());
});
const [state, setState] = useState<CookingSessionState>({ status: "loading" });
const [tab, setTab] = useState<CookingTab>("plan");
useEffect(() => {
let cancelled = false;
setState({ status: "loading" });
apiClient
.getCookingPlanForWeek(formatDateOnly(weekStart))
.then((plan) => {
if (!cancelled) setState({ status: "loaded", plan });
})
.catch(() => {
if (!cancelled) setState({ status: "error" });
});
return () => {
cancelled = true;
};
}, [weekStart]);
const weekKey = formatDateOnly(weekStart);
return (
<div className="cooking-session-page">
<div className="cooking-session-page__header">
<h1>{t("cookingSession.title")}</h1>
<WeekNavigator weekStart={weekStart} onChangeWeek={setWeekStart} />
</div>
<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" && (
<p className="cooking-session-page__status">{t("cookingSession.loading")}</p>
)}
{state.status === "error" && (
<p className="cooking-session-page__status cooking-session-page__status--error">
{t("common.loadError")}
</p>
)}
{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>
);
}
/** The "Recettes" tab — the week's planned recipes as a flat list, each linking to its own page. */
function RecipesTab({ recipes }: { recipes: readonly CookingSessionRecipeRef[] }) {
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) {
return <p className="cooking-session-page__status">{t("cookingSession.empty")}</p>;
}
// `stepIndex` can't actually drift out of range (the phase count is fixed
// for a mount and the nav buttons are bounded), but clamp defensively so
// a bad value degrades to the last phase rather than a blank panel.
const safeIndex = Math.min(Math.max(stepIndex, 0), plan.phases.length - 1);
const phase = plan.phases[safeIndex];
if (!phase) return null;
const { done, total } = phaseProgress(phase, checked);
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>
);
}
/** One phase: its background band (if any) then its tickable task cards. */
function PhaseSection({
phase,
checked,
onToggle,
}: {
phase: CookingPhaseView;
checked: Set<string>;
onToggle: (taskId: string, isChecked: boolean) => void;
}) {
const { t } = useTranslation();
return (
<section className={`cooking-phase cooking-phase--${phase.kind}`}>
{(phase.notes ?? []).map((note) => (
<p key={note.kind} className="cooking-phase__note">
{phaseNoteText(note, t)}
</p>
))}
{phase.background.length > 0 && (
<div className="cooking-phase__background">
<span className="cooking-phase__background-title">
{t("cookingSession.background.title")}
</span>
<ul>
{phase.background.map((task) => (
<li key={task.id}>
<BackgroundLine task={task} />
</li>
))}
</ul>
</div>
)}
<ul className="cooking-phase__tasks">
{phase.tasks.map((task) => (
<li key={task.id}>
<TaskCard
task={task}
checked={checked.has(task.id)}
onToggle={(isChecked) => onToggle(task.id, isChecked)}
/>
</li>
))}
</ul>
</section>
);
}
/** 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,
checked,
onToggle,
}: {
task: CookingTaskView;
checked: boolean;
onToggle: (isChecked: boolean) => void;
}) {
const { t } = useTranslation();
const isMerged = task.kind === "merged-prep" || task.kind === "merged-setup";
return (
<CheckboxOption
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)}
{isMerged && (
<span className="cooking-task__badge">{t("cookingSession.task.sharedBadge")}</span>
)}
</span>
<span className="cooking-task__recipes">
{t("cookingSession.task.forRecipes", { recipes: taskRecipeNames(task) })}
</span>
{task.utensils.length > 0 && (
<span className="cooking-task__utensils">
{t("cookingSession.task.utensils")} :{" "}
{task.utensils.map((utensil) => t(`catalog.utensils.${utensil.key}`)).join(", ")}
</span>
)}
</span>
</CheckboxOption>
);
}
/** One "meanwhile, X is cooking" line inside a phase's background band. */
function BackgroundLine({ task }: { task: CookingBackgroundTaskView }) {
const { t } = useTranslation();
const technique = task.technique ? `${t(`catalog.techSteps.${task.technique.key}`)} — ` : "";
return (
<span>
{technique}
{task.description} <em>({task.recipeName})</em>
</span>
);
}