- Bouton "Commencer a cuisiner" dans l'en-tete du planning, actif seulement quand la semaine affichee contient >= 1 recette ; navigue vers /cuisiner?date=<semaine>. - Page CookingSessionPage (/cuisiner) : consomme GET /cooking-session, rend les phases (mise en place / cuisson / dressage), les taches mutualisees avec badge "Mutualise" + ingredients/ustensiles resolus, et la bande "Pendant ce temps" pour les cuissons de fond. Semaine lue depuis ?date=, WeekNavigator en fallback ; recalcul a chaque visite (comme la liste de courses). - Logique pure extraite dans cooking-session.ts (composition des libelles, formatage des quantites). - i18n : bloc cookingSession.* + planning.startCooking. - apiClient.getCookingPlanForWeek. - Tests Cypress : cooking-session-page.cy.ts (layout, 3 cas), cooking-session .feature (parcours planning -> /cuisiner), assertions bouton dans planning-page.cy.ts ; step generique "button should be disabled" mutualise. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
188 lines
6.3 KiB
TypeScript
188 lines
6.3 KiB
TypeScript
import { DateTime, formatDateOnly, getWeekStart, parseDateOnly } from "@batch-cooking/date-tools";
|
||
import type {
|
||
CookingBackgroundTaskView,
|
||
CookingPhaseView,
|
||
CookingTaskView,
|
||
OptimizedCookingPlanView,
|
||
} from "@batch-cooking/shared";
|
||
import { useEffect, useState } from "react";
|
||
import { useTranslation } from "react-i18next";
|
||
import { useSearchParams } from "react-router-dom";
|
||
import { apiClient } from "../../api/client";
|
||
import { WeekNavigator } from "../../features/planning/WeekNavigator";
|
||
import { taskHeadline, taskRecipeNames } from "./cooking-session";
|
||
import "./cooking-session-page.scss";
|
||
|
||
/** Load state for the `GET /cooking-session` call — same discriminated-union shape as `ShoppingListPage`'s own state. */
|
||
type CookingSessionState =
|
||
| { status: "loading" }
|
||
| { status: "loaded"; plan: OptimizedCookingPlanView }
|
||
| { status: "error" };
|
||
|
||
/**
|
||
* "Cuisiner cette semaine" — routed at `/cuisiner`, reached from the
|
||
* planning page's "Commencer à cuisiner" button. Shows the household's week
|
||
* of planned recipes reorganized by the backend optimizer
|
||
* (`GET /cooking-session`, see the API's `cooking-optimizer.ts`) into
|
||
* ordered phases: a mise-en-place that pools shared prep, then cooking
|
||
* phases that interleave the recipes with passive cooks shown as running
|
||
* in the background.
|
||
*
|
||
* The week comes from a `?date=` query param (set by the planning button so
|
||
* the two pages stay on the same week); absent/invalid falls back to the
|
||
* current week. Read-only and recomputed on every visit — no progress
|
||
* state to keep in sync, same design stance as `ShoppingListPage`.
|
||
*/
|
||
export function CookingSessionPage() {
|
||
const { t } = useTranslation();
|
||
const [searchParams] = useSearchParams();
|
||
const [weekStart, setWeekStart] = useState<DateTime>(() => {
|
||
const fromQuery = parseDateOnly(searchParams.get("date") ?? "");
|
||
return getWeekStart(fromQuery ?? DateTime.utc());
|
||
});
|
||
const [state, setState] = useState<CookingSessionState>({ status: "loading" });
|
||
|
||
useEffect(() => {
|
||
let cancelled = false;
|
||
setState({ status: "loading" });
|
||
|
||
apiClient
|
||
.getCookingPlanForWeek(formatDateOnly(weekStart))
|
||
.then((plan) => {
|
||
if (!cancelled) setState({ status: "loaded", plan });
|
||
})
|
||
.catch(() => {
|
||
if (!cancelled) setState({ status: "error" });
|
||
});
|
||
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, [weekStart]);
|
||
|
||
return (
|
||
<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>
|
||
|
||
{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" && <CookingPlan plan={state.plan} />}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/** The plan body — the recipe legend then every phase, or the empty-week message. */
|
||
function CookingPlan({ plan }: { plan: OptimizedCookingPlanView }) {
|
||
const { t } = useTranslation();
|
||
|
||
if (plan.phases.length === 0) {
|
||
return <p className="cooking-session-page__status">{t("cookingSession.empty")}</p>;
|
||
}
|
||
|
||
return (
|
||
<div className="cooking-session">
|
||
<section className="cooking-session__legend" aria-label={t("cookingSession.recipesLegend")}>
|
||
{plan.recipes.map((recipe) => (
|
||
<span
|
||
key={`${recipe.recipeId}-${recipe.portions}`}
|
||
className="cooking-session__legend-item"
|
||
>
|
||
{recipe.name} · ×{recipe.portions}
|
||
</span>
|
||
))}
|
||
</section>
|
||
|
||
{plan.phases.map((phase) => (
|
||
<PhaseSection key={phase.index} phase={phase} />
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/** One phase: its background band (if any) then its task cards. */
|
||
function PhaseSection({ phase }: { phase: CookingPhaseView }) {
|
||
const { t } = useTranslation();
|
||
|
||
return (
|
||
<section className={`cooking-phase cooking-phase--${phase.kind}`}>
|
||
<h2 className="cooking-phase__title">
|
||
<span className="cooking-phase__index">
|
||
{t("cookingSession.phase.label", { index: phase.index + 1 })}
|
||
</span>
|
||
<span className="cooking-phase__kind">{t(`cookingSession.phase.${phase.kind}`)}</span>
|
||
</h2>
|
||
|
||
{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} />
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
/** A single actionable task — a merged-prep pool or a plain recipe step. */
|
||
function TaskCard({ task }: { task: CookingTaskView }) {
|
||
const { t } = useTranslation();
|
||
|
||
return (
|
||
<article className={`cooking-task cooking-task--${task.kind}`}>
|
||
<p className="cooking-task__headline">
|
||
{taskHeadline(task, t)}
|
||
{task.kind === "merged-prep" && (
|
||
<span className="cooking-task__badge">{t("cookingSession.task.sharedBadge")}</span>
|
||
)}
|
||
</p>
|
||
<p className="cooking-task__recipes">
|
||
{t("cookingSession.task.forRecipes", { recipes: taskRecipeNames(task) })}
|
||
</p>
|
||
{task.utensils.length > 0 && (
|
||
<p className="cooking-task__utensils">
|
||
{t("cookingSession.task.utensils")} :{" "}
|
||
{task.utensils.map((utensil) => t(`catalog.utensils.${utensil.key}`)).join(", ")}
|
||
</p>
|
||
)}
|
||
</article>
|
||
);
|
||
}
|
||
|
||
/** 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>
|
||
);
|
||
}
|