batchCooking/apps/web/src/pages/cooking-session/CookingSessionPage.tsx
Nicolas e8ac2d1629
Some checks failed
CI / lint (push) Successful in 3m1s
CI / test (push) Failing after 3s
CI / build (push) Successful in 4m24s
CI / intent-service-test (push) Successful in 18m40s
CI / e2e (push) Successful in 13m15s
feat(cooking): page /cuisiner + bouton "Commencer a cuisiner"
- 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>
2026-08-28 19:06:24 +02:00

188 lines
6.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
);
}