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>
364 lines
12 KiB
TypeScript
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>
|
|
);
|
|
}
|