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(() => { const fromQuery = parseDateOnly(searchParams.get("date") ?? ""); return getWeekStart(fromQuery ?? DateTime.utc()); }); const [state, setState] = useState({ status: "loading" }); const [tab, setTab] = useState("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 (

{t("cookingSession.title")}

{t("cookingSession.subtitle")}

{state.status === "loading" && (

{t("cookingSession.loading")}

)} {state.status === "error" && (

{t("common.loadError")}

)} {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. ) : ( ))}
); } /** 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

{t("cookingSession.empty")}

; } return (
    {recipes.map((recipe) => (
  • {recipe.name} {t("cookingSession.recipesTab.portions", { n: recipe.portions })} {t("cookingSession.recipesTab.viewRecipe")}
  • ))}
); } /** * 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>(() => reconcileChecked(cookingProgress.load(weekKey), plan), ); const [stepIndex, setStepIndex] = useState(0); if (plan.phases.length === 0) { return

{t("cookingSession.empty")}

; } // `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 (
    {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 (
  1. ); })}
{t("cookingSession.stepper.position", { current: safeIndex + 1, total: plan.phases.length, })}{" "} · {t(`cookingSession.phase.${phase.kind}`)} {t("cookingSession.progress.tasks", { done, total })}
); } /** One phase: its background band (if any) then its tickable task cards. */ function PhaseSection({ phase, checked, onToggle, }: { phase: CookingPhaseView; checked: Set; onToggle: (taskId: string, isChecked: boolean) => void; }) { const { t } = useTranslation(); return (
{(phase.notes ?? []).map((note) => (

{phaseNoteText(note, t)}

))} {phase.background.length > 0 && (
{t("cookingSession.background.title")}
    {phase.background.map((task) => (
  • ))}
)}
    {phase.tasks.map((task) => (
  • onToggle(task.id, isChecked)} />
  • ))}
); } /** 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 ( {taskHeadline(task, t)} {isMerged && ( {t("cookingSession.task.sharedBadge")} )} {t("cookingSession.task.forRecipes", { recipes: taskRecipeNames(task) })} {task.utensils.length > 0 && ( {t("cookingSession.task.utensils")} :{" "} {task.utensils.map((utensil) => t(`catalog.utensils.${utensil.key}`)).join(", ")} )} ); } /** 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 ( {technique} {task.description} ({task.recipeName}) ); }