feat(cooking-session): onglets Recettes/Plan + stepper d'étapes cochables #23

Merged
kyuno merged 4 commits from feat/cooking-session-stepper into main 2026-09-03 22:39:55 +02:00
8 changed files with 835 additions and 121 deletions
Showing only changes of commit dfe0310b63 - Show all commits

View file

@ -30,14 +30,14 @@ function ingredient(key: string) {
}; };
} }
/** A plan with a pooled prep task in mise-en-place and a passive cook floated into a later phase. */ /** A 3-phase plan: a pooled prep task in mise-en-place, then two cooking phases, the last with a passive cook floated into its background. */
function planFixture() { function planFixture() {
return { return {
startDate: "2026-08-17T00:00:00.000Z", startDate: "2026-08-17T00:00:00.000Z",
finishDate: "2026-08-23T00:00:00.000Z", finishDate: "2026-08-23T00:00:00.000Z",
recipes: [ recipes: [
{ recipeId: 1, name: "Soupe à l'oignon", portions: 4 }, { recipeId: 1, name: "Soupe à l'oignon", portions: 4 },
{ recipeId: 2, name: "Tarte à l'oignon", portions: 4 }, { recipeId: 2, name: "Tarte à l'oignon", portions: 6 },
], ],
phases: [ phases: [
{ {
@ -60,7 +60,7 @@ function planFixture() {
utensils: [{ id: 1, key: "knife" }], utensils: [{ id: 1, key: "knife" }],
sourceRecipes: [ sourceRecipes: [
{ recipeId: 1, name: "Soupe à l'oignon", portions: 4 }, { recipeId: 1, name: "Soupe à l'oignon", portions: 4 },
{ recipeId: 2, name: "Tarte à l'oignon", portions: 4 }, { recipeId: 2, name: "Tarte à l'oignon", portions: 6 },
], ],
originalSteps: [], originalSteps: [],
}, },
@ -103,7 +103,7 @@ function planFixture() {
description: "Enfourner la tarte", description: "Enfourner la tarte",
ingredients: [], ingredients: [],
utensils: [], utensils: [],
sourceRecipes: [{ recipeId: 2, name: "Tarte à l'oignon", portions: 4 }], sourceRecipes: [{ recipeId: 2, name: "Tarte à l'oignon", portions: 6 }],
originalSteps: [], originalSteps: [],
}, },
], ],
@ -136,7 +136,7 @@ describe("Cooking session page", () => {
cy.contains("Rien de planifié cette semaine à cuisiner").should("be.visible"); cy.contains("Rien de planifié cette semaine à cuisiner").should("be.visible");
}); });
it("renders each phase, the pooled prep task, and the 'meanwhile' band", () => { it("opens on the Plan tab: a stepper of every phase, only the first phase's tasks shown", () => {
cy.intercept("GET", /\/cooking-session\?/, { statusCode: 200, body: planFixture() }).as( cy.intercept("GET", /\/cooking-session\?/, { statusCode: 200, body: planFixture() }).as(
"getPlan", "getPlan",
); );
@ -144,21 +144,86 @@ describe("Cooking session page", () => {
cy.visit("/cuisiner?date=2026-08-17"); cy.visit("/cuisiner?date=2026-08-17");
cy.wait("@getPlan").its("request.url").should("include", "date=2026-08-17"); cy.wait("@getPlan").its("request.url").should("include", "date=2026-08-17");
// Mise en place: one pooled prep task, flagged shared, naming both recipes. // One stepper node per phase; the first is the current one.
cy.contains(".cooking-phase", "Mise en place").should("be.visible"); cy.get(".cooking-stepper__node").should("have.length", 3);
cy.get(".cooking-stepper__node").first().should("have.class", "cooking-stepper__node--current");
cy.contains(".cooking-plan__position", "Étape 1 / 3").should("be.visible");
cy.contains(".cooking-plan__count", "0 / 1 tâches").should("be.visible");
// Mise en place: the pooled prep task, flagged shared, naming both recipes.
cy.get(".cooking-task--merged-prep") cy.get(".cooking-task--merged-prep")
.should("contain.text", "Hacher") .should("contain.text", "Hacher")
.and("contain.text", "Oignon") .and("contain.text", "Oignon")
.and("contain.text", "Mutualisé"); .and("contain.text", "Mutualisé");
cy.contains(".cooking-task--merged-prep", "Soupe à l'oignon").should("exist");
// A later phase shows the simmering soup as still running in the background. // The next phase's step is not rendered yet.
cy.contains("Faire mijoter le bouillon").should("not.exist");
// Prev is disabled on the first phase.
cy.contains(".cooking-plan__nav-btn", "Étape précédente").should("be.disabled");
});
it("walks the phases with the next/prev buttons and by clicking a stepper node", () => {
cy.intercept("GET", /\/cooking-session\?/, { statusCode: 200, body: planFixture() });
cy.visit("/cuisiner?date=2026-08-17");
// Forward to phase 2.
cy.contains(".cooking-plan__nav-btn", "Étape suivante").click();
cy.contains(".cooking-plan__position", "Étape 2 / 3").should("be.visible");
cy.contains(".cooking-phase__tasks", "Faire mijoter le bouillon").should("be.visible");
// Forward to phase 3 — its background band shows the soup still simmering.
cy.contains(".cooking-plan__nav-btn", "Étape suivante").click();
cy.contains(".cooking-plan__position", "Étape 3 / 3").should("be.visible");
cy.contains(".cooking-phase__background", "Pendant ce temps") cy.contains(".cooking-phase__background", "Pendant ce temps")
.should("contain.text", "Faire mijoter le bouillon") .should("contain.text", "Faire mijoter le bouillon")
.and("contain.text", "Soupe à l'oignon"); .and("contain.text", "Soupe à l'oignon");
cy.contains(".cooking-phase__tasks", "Enfourner la tarte").should("be.visible");
cy.contains(".cooking-plan__nav-btn", "Étape suivante").should("be.disabled");
// Recipe legend is present. // Back one step.
cy.contains(".cooking-session__legend-item", "Tarte à l'oignon").should("be.visible"); cy.contains(".cooking-plan__nav-btn", "Étape précédente").click();
cy.contains(".cooking-plan__position", "Étape 2 / 3").should("be.visible");
// Jump straight back to the first phase from the stepper.
cy.get(".cooking-stepper__node").first().click();
cy.contains(".cooking-plan__position", "Étape 1 / 3").should("be.visible");
cy.get(".cooking-task--merged-prep").should("be.visible");
});
it("ticks a task, marks its phase done in the stepper, and keeps the tick after a reload", () => {
cy.intercept("GET", /\/cooking-session\?/, { statusCode: 200, body: planFixture() });
cy.visit("/cuisiner?date=2026-08-17");
cy.get(".cooking-task").first().find('input[type="checkbox"]').check({ force: true });
cy.get(".cooking-task").first().should("have.class", "cooking-task--done");
// The whole phase is now done — its stepper node reflects that.
cy.get(".cooking-stepper__node").first().should("have.class", "cooking-stepper__node--done");
cy.contains(".cooking-plan__count", "1 / 1 tâches").should("be.visible");
cy.reload();
cy.get(".cooking-task").first().should("have.class", "cooking-task--done");
cy.get(".cooking-stepper__node").first().should("have.class", "cooking-stepper__node--done");
});
it("lists the week's recipes on the Recettes tab, each linking to its own page", () => {
cy.intercept("GET", /\/cooking-session\?/, { statusCode: 200, body: planFixture() });
cy.visit("/cuisiner?date=2026-08-17");
cy.contains(".cooking-session-page__tab", "Recettes").click();
cy.get(".cooking-recipes__item").should("have.length", 2);
cy.contains(".cooking-recipes__item", "Soupe à l'oignon")
.find("a")
.should("have.attr", "href", "/recettes/1");
cy.contains(".cooking-recipes__item", "Tarte à l'oignon")
.should("contain.text", "6 portions")
.find("a")
.should("have.attr", "href", "/recettes/2");
// The stepper isn't part of this tab.
cy.get(".cooking-stepper").should("not.exist");
}); });
it("shows an error state when the request fails", () => { it("shows an error state when the request fails", () => {

View file

@ -22,3 +22,34 @@ Feature: Start cooking an optimized plan
And I should see "Mise en place" And I should see "Mise en place"
And the pooled prep task should mention "Hacher" and "Oignon" And the pooled prep task should mention "Hacher" and "Oignon"
And the pooled prep task should be flagged as shared And the pooled prep task should be flagged as shared
Scenario: The stepper walks the phases one at a time
Given the planning for this week has recipes "Soupe à l'oignon" and "Tarte à l'oignon"
And the cooking plan for "2026-08-17" has a mise en place then a cooking step
When I visit "/cuisiner?date=2026-08-17"
Then the current cooking step should be "Étape 1 / 2"
And I should not see "Faire mijoter le bouillon"
When I go to the next cooking step
Then the current cooking step should be "Étape 2 / 2"
And I should see "Faire mijoter le bouillon"
When I go to the previous cooking step
Then the current cooking step should be "Étape 1 / 2"
Scenario: A ticked task stays ticked while moving through the stepper
Given the planning for this week has recipes "Soupe à l'oignon" and "Tarte à l'oignon"
And the cooking plan for "2026-08-17" has a mise en place then a cooking step
When I visit "/cuisiner?date=2026-08-17"
And I tick the cooking task "Hacher"
Then the cooking task "Hacher" should be done
When I go to the next cooking step
And I go to the previous cooking step
Then the cooking task "Hacher" should be done
Scenario: The Recettes tab lists the week's planned recipes
Given the planning for this week has recipes "Soupe à l'oignon" and "Tarte à l'oignon"
And the cooking plan for "2026-08-17" has a mise en place then a cooking step
When I visit "/cuisiner?date=2026-08-17"
And I open the "Recettes" cooking tab
Then I should see "Soupe à l'oignon"
And the recipe "Soupe à l'oignon" should link to "/recettes/1"
And the recipe "Tarte à l'oignon" should link to "/recettes/2"

View file

@ -1,4 +1,4 @@
import { Given, Then } from "@badeball/cypress-cucumber-preprocessor"; import { Given, Then, When } from "@badeball/cypress-cucumber-preprocessor";
/** Bare `IngredientView` — only `key` drives the page's `catalog.ingredients.*` lookup. */ /** Bare `IngredientView` — only `key` drives the page's `catalog.ingredients.*` lookup. */
function ingredient(key: string) { function ingredient(key: string) {
@ -99,3 +99,94 @@ Then(
Then("the pooled prep task should be flagged as shared", () => { Then("the pooled prep task should be flagged as shared", () => {
cy.get(".cooking-task--merged-prep").contains("Mutualisé").should("be.visible"); cy.get(".cooking-task--merged-prep").contains("Mutualisé").should("be.visible");
}); });
Given("the cooking plan for {string} has a mise en place then a cooking step", (date: string) => {
// Two phases so the stepper actually has somewhere to step to: the
// pooled `chop onion` prep, then a `simmer` cooking step.
cy.intercept("GET", `**/cooking-session?date=${date}`, {
statusCode: 200,
body: {
startDate: `${date}T00:00:00.000Z`,
finishDate: "2026-08-23T00:00:00.000Z",
recipes: [
{ recipeId: 1, name: "Soupe à l'oignon", portions: 4 },
{ recipeId: 2, name: "Tarte à l'oignon", portions: 6 },
],
phases: [
{
index: 0,
kind: "mise-en-place",
background: [],
tasks: [
{
id: "prep:chop:onion",
kind: "merged-prep",
technique: { id: 1, key: "chop" },
description: null,
ingredients: [
{
ingredient: ingredient("onion"),
quantity: 5,
unit: { id: 2, key: "piece", type: "COUNT", toBaseFactor: 1 },
},
],
utensils: [],
sourceRecipes: [
{ recipeId: 1, name: "Soupe à l'oignon", portions: 4 },
{ recipeId: 2, name: "Tarte à l'oignon", portions: 6 },
],
originalSteps: [],
},
],
},
{
index: 1,
kind: "cooking",
background: [],
tasks: [
{
id: "step:0:1",
kind: "step",
technique: { id: 5, key: "simmer" },
description: "Faire mijoter le bouillon",
ingredients: [],
utensils: [],
sourceRecipes: [{ recipeId: 1, name: "Soupe à l'oignon", portions: 4 }],
originalSteps: [],
},
],
},
],
},
});
});
When("I open the {string} cooking tab", (label: string) => {
cy.contains(".cooking-session-page__tab", label).click();
});
// The task card is a `<label>` wrapping a visually-hidden checkbox — click
// the card, same approach as common.steps.ts's "I click the radio".
When("I tick the cooking task {string}", (headline: string) => {
cy.contains(".cooking-task", headline).click();
});
Then("the cooking task {string} should be done", (headline: string) => {
cy.contains(".cooking-task", headline).should("have.class", "cooking-task--done");
});
When("I go to the next cooking step", () => {
cy.contains(".cooking-plan__nav-btn", "Étape suivante").click();
});
When("I go to the previous cooking step", () => {
cy.contains(".cooking-plan__nav-btn", "Étape précédente").click();
});
Then("the current cooking step should be {string}", (position: string) => {
cy.contains(".cooking-plan__position", position).should("be.visible");
});
Then("the recipe {string} should link to {string}", (name: string, href: string) => {
cy.contains(".cooking-recipes__item", name).find("a").should("have.attr", "href", href);
});

View file

@ -321,12 +321,23 @@
"loading": "Optimisation du plan de cuisine…", "loading": "Optimisation du plan de cuisine…",
"empty": "Rien de planifié cette semaine à cuisiner — ajoutez des recettes à votre planning.", "empty": "Rien de planifié cette semaine à cuisiner — ajoutez des recettes à votre planning.",
"recipesLegend": "Recettes de la semaine", "recipesLegend": "Recettes de la semaine",
"tabs": {
"plan": "Plan",
"recipes": "Recettes"
},
"phase": { "phase": {
"label": "Étape {{index}}",
"mise-en-place": "Mise en place", "mise-en-place": "Mise en place",
"cooking": "Cuisson", "cooking": "Cuisson",
"finishing": "Dressage" "finishing": "Dressage"
}, },
"stepper": {
"prev": "Étape précédente",
"next": "Étape suivante",
"position": "Étape {{current}} / {{total}}"
},
"progress": {
"tasks": "{{done}} / {{total}} tâches"
},
"background": { "background": {
"title": "Pendant ce temps" "title": "Pendant ce temps"
}, },
@ -335,6 +346,10 @@
"forRecipes": "pour {{recipes}}", "forRecipes": "pour {{recipes}}",
"utensils": "Ustensiles", "utensils": "Ustensiles",
"sharedBadge": "Mutualisé" "sharedBadge": "Mutualisé"
},
"recipesTab": {
"portions": "{{n}} portions",
"viewRecipe": "Voir la recette"
} }
}, },
"account": { "account": {

View file

@ -2,15 +2,18 @@ import { DateTime, formatDateOnly, getWeekStart, parseDateOnly } from "@batch-co
import type { import type {
CookingBackgroundTaskView, CookingBackgroundTaskView,
CookingPhaseView, CookingPhaseView,
CookingSessionRecipeRef,
CookingTaskView, CookingTaskView,
OptimizedCookingPlanView, OptimizedCookingPlanView,
} from "@batch-cooking/shared"; } from "@batch-cooking/shared";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useSearchParams } from "react-router-dom"; import { Link, useSearchParams } from "react-router-dom";
import { apiClient } from "../../api/client"; import { apiClient } from "../../api/client";
import { CheckboxOption } from "../../components/ui/Checkbox";
import { WeekNavigator } from "../../features/planning/WeekNavigator"; import { WeekNavigator } from "../../features/planning/WeekNavigator";
import { taskHeadline, taskRecipeNames } from "./cooking-session"; import { cookingProgress } from "./cooking-progress";
import { phaseProgress, reconcileChecked, taskHeadline, taskRecipeNames } from "./cooking-session";
import "./cooking-session-page.scss"; import "./cooking-session-page.scss";
/** Load state for the `GET /cooking-session` call — same discriminated-union shape as `ShoppingListPage`'s own state. */ /** Load state for the `GET /cooking-session` call — same discriminated-union shape as `ShoppingListPage`'s own state. */
@ -19,6 +22,14 @@ type CookingSessionState =
| { status: "loaded"; plan: OptimizedCookingPlanView } | { status: "loaded"; plan: OptimizedCookingPlanView }
| { status: "error" }; | { 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 * "Cuisiner cette semaine" — routed at `/cuisiner`, reached from the
* planning page's "Commencer à cuisiner" button. Shows the household's week * planning page's "Commencer à cuisiner" button. Shows the household's week
@ -28,10 +39,13 @@ type CookingSessionState =
* phases that interleave the recipes with passive cooks shown as running * phases that interleave the recipes with passive cooks shown as running
* in the background. * in the background.
* *
* The week comes from a `?date=` query param (set by the planning button so * Two tabs: **Plan** walks the phases one at a time through a stepper, with
* the two pages stay on the same week); absent/invalid falls back to the * every task tickable (progress kept in `localStorage` via
* current week. Read-only and recomputed on every visit — no progress * {@link cookingProgress} — no server state, same recompute-on-every-visit
* state to keep in sync, same design stance as `ShoppingListPage`. * 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() { export function CookingSessionPage() {
const { t } = useTranslation(); const { t } = useTranslation();
@ -41,6 +55,7 @@ export function CookingSessionPage() {
return getWeekStart(fromQuery ?? DateTime.utc()); return getWeekStart(fromQuery ?? DateTime.utc());
}); });
const [state, setState] = useState<CookingSessionState>({ status: "loading" }); const [state, setState] = useState<CookingSessionState>({ status: "loading" });
const [tab, setTab] = useState<CookingTab>("plan");
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
@ -60,6 +75,8 @@ export function CookingSessionPage() {
}; };
}, [weekStart]); }, [weekStart]);
const weekKey = formatDateOnly(weekStart);
return ( return (
<div className="cooking-session-page"> <div className="cooking-session-page">
<div className="cooking-session-page__header"> <div className="cooking-session-page__header">
@ -68,6 +85,23 @@ export function CookingSessionPage() {
</div> </div>
<p className="cooking-session-page__subtitle">{t("cookingSession.subtitle")}</p> <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" && ( {state.status === "loading" && (
<p className="cooking-session-page__status">{t("cookingSession.loading")}</p> <p className="cooking-session-page__status">{t("cookingSession.loading")}</p>
)} )}
@ -78,52 +112,163 @@ export function CookingSessionPage() {
</p> </p>
)} )}
{state.status === "loaded" && <CookingPlan plan={state.plan} />} {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> </div>
); );
} }
/** The plan body — the recipe legend then every phase, or the empty-week message. */ /** The "Recettes" tab — the week's planned recipes as a flat list, each linking to its own page. */
function CookingPlan({ plan }: { plan: OptimizedCookingPlanView }) { function RecipesTab({ recipes }: { recipes: readonly CookingSessionRecipeRef[] }) {
const { t } = useTranslation(); 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) { if (plan.phases.length === 0) {
return <p className="cooking-session-page__status">{t("cookingSession.empty")}</p>; return <p className="cooking-session-page__status">{t("cookingSession.empty")}</p>;
} }
return ( // `stepIndex` can't actually drift out of range (the phase count is fixed
<div className="cooking-session"> // for a mount and the nav buttons are bounded), but clamp defensively so
<section className="cooking-session__legend" aria-label={t("cookingSession.recipesLegend")}> // a bad value degrades to the last phase rather than a blank panel.
{plan.recipes.map((recipe) => ( const safeIndex = Math.min(Math.max(stepIndex, 0), plan.phases.length - 1);
<span const phase = plan.phases[safeIndex];
key={`${recipe.recipeId}-${recipe.portions}`} if (!phase) return null;
className="cooking-session__legend-item"
>
{recipe.name} · ×{recipe.portions}
</span>
))}
</section>
{plan.phases.map((phase) => ( const { done, total } = phaseProgress(phase, checked);
<PhaseSection key={phase.index} phase={phase} />
))} 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> </div>
); );
} }
/** One phase: its background band (if any) then its task cards. */ /** One phase: its background band (if any) then its tickable task cards. */
function PhaseSection({ phase }: { phase: CookingPhaseView }) { function PhaseSection({
phase,
checked,
onToggle,
}: {
phase: CookingPhaseView;
checked: Set<string>;
onToggle: (taskId: string, isChecked: boolean) => void;
}) {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<section className={`cooking-phase cooking-phase--${phase.kind}`}> <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 && ( {phase.background.length > 0 && (
<div className="cooking-phase__background"> <div className="cooking-phase__background">
<span className="cooking-phase__background-title"> <span className="cooking-phase__background-title">
@ -142,7 +287,11 @@ function PhaseSection({ phase }: { phase: CookingPhaseView }) {
<ul className="cooking-phase__tasks"> <ul className="cooking-phase__tasks">
{phase.tasks.map((task) => ( {phase.tasks.map((task) => (
<li key={task.id}> <li key={task.id}>
<TaskCard task={task} /> <TaskCard
task={task}
checked={checked.has(task.id)}
onToggle={(isChecked) => onToggle(task.id, isChecked)}
/>
</li> </li>
))} ))}
</ul> </ul>
@ -150,28 +299,42 @@ function PhaseSection({ phase }: { phase: CookingPhaseView }) {
); );
} }
/** A single actionable task — a merged-prep pool or a plain recipe step. */ /** A single actionable task — a merged-prep pool or a plain recipe step — with a tick to mark it done. */
function TaskCard({ task }: { task: CookingTaskView }) { function TaskCard({
task,
checked,
onToggle,
}: {
task: CookingTaskView;
checked: boolean;
onToggle: (isChecked: boolean) => void;
}) {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<article className={`cooking-task cooking-task--${task.kind}`}> <CheckboxOption
<p className="cooking-task__headline"> checked={checked}
{taskHeadline(task, t)} onChange={onToggle}
{task.kind === "merged-prep" && ( className={`cooking-task cooking-task--${task.kind}${checked ? " cooking-task--done" : ""}`}
<span className="cooking-task__badge">{t("cookingSession.task.sharedBadge")}</span> >
<span className="cooking-task__body">
<span className="cooking-task__headline">
{taskHeadline(task, t)}
{task.kind === "merged-prep" && (
<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>
)} )}
</p> </span>
<p className="cooking-task__recipes"> </CheckboxOption>
{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>
); );
} }

View file

@ -0,0 +1,67 @@
/**
* Per-week "which cooking tasks are ticked off" checklist — persisted in the
* browser's `localStorage` only. This feature has no server-side progress
* state by design: `GET /cooking-session` recomputes the whole optimized
* plan on every visit (see `CookingSessionPage`), so a tick is a purely
* local convenience, not shared household state.
*
* A class with a shared singleton rather than two bare functions because it
* wraps Web Storage IO with named operations — same "behaviour with a verb
* ⇒ service class + exported instance" split as `ApiClient` /
* `ErrorMessageService` (`specs/dev-conventions.md`).
*/
export class CookingProgressStore {
/**
* Key namespace — every value this app stores in `localStorage` is
* `batchcooking:`-prefixed (cf. `batchcooking:sidebarCollapsed` in
* `AppLayout`). The week-start `YYYY-MM-DD` is appended so each week keeps
* an independent checklist and ticks never bleed across weeks.
*/
private readonly _keyPrefix = "batchcooking:cookingProgress:";
/**
* The ticked `CookingTaskView.id`s for the week starting `weekKey`
* (`YYYY-MM-DD`). Returns an empty set when nothing is stored yet, when
* `localStorage` is unavailable (private-mode Safari throws on access),
* or when the stored value is not the expected string array — a lost
* checklist just means the cook re-ticks, never a broken page (same
* non-fatal posture as `AuthContext`'s swallowed `me()`).
*/
public load(weekKey: string): Set<string> {
try {
const raw = localStorage.getItem(this._key(weekKey));
if (raw === null) return new Set();
const parsed: unknown = JSON.parse(raw);
if (!Array.isArray(parsed)) return new Set();
return new Set(parsed.filter((id): id is string => typeof id === "string"));
} catch {
// Unreadable/unparseable storage — degrade to "nothing ticked".
return new Set();
}
}
/**
* Replaces the stored checklist for `weekKey` with `checkedIds` (written
* as a plain string array). A no-op if `localStorage` can't be written —
* see {@link load} for why that's swallowed rather than surfaced.
*/
public save(weekKey: string, checkedIds: Set<string>): void {
try {
localStorage.setItem(this._key(weekKey), JSON.stringify([...checkedIds]));
} catch {
// Storage full or unavailable — the in-memory checklist still works
// for this session, it just won't survive a reload.
}
}
/** Full storage key for a given week. */
private _key(weekKey: string): string {
return `${this._keyPrefix}${weekKey}`;
}
}
/**
* Shared instance — the store holds no state of its own (everything lives in
* `localStorage`), so a single one serves every caller.
*/
export const cookingProgress = new CookingProgressStore();

View file

@ -1,9 +1,16 @@
// ============================================================================= // =============================================================================
// Styles specific to CookingSessionPage — colocated next to // Styles specific to CookingSessionPage — colocated next to
// CookingSessionPage.tsx since nothing else uses these classes. Same page // CookingSessionPage.tsx since nothing else uses these classes.
// shell/status conventions as shopping-list-page.scss //
// (`__header`/`__status`); below it, a vertical stack of phase sections // Layout: a fixed page shell (header + subtitle + tab bar), then one of two
// each holding a "meanwhile" band and a list of task cards. // tab panels. The "Recettes" panel is a plain scrolling list; the "Plan"
// panel (`.cooking-plan`) is itself a flex column — a pinned stepper and
// meta line on top, a pinned prev/next bar at the bottom, and the current
// phase scrolling in between so the nav is always reachable.
//
// Every value goes through a `var(--token)` from _theme.scss; light/dark is
// handled entirely by those tokens being redefined, nothing here branches
// on the theme.
// ============================================================================= // =============================================================================
.cooking-session-page { .cooking-session-page {
@ -23,7 +30,7 @@
&__subtitle { &__subtitle {
flex-shrink: 0; flex-shrink: 0;
margin: 0 0 var(--space-lg); margin: 0 0 var(--space-md);
color: var(--color-text-muted); color: var(--color-text-muted);
font-size: var(--font-size-md); font-size: var(--font-size-md);
} }
@ -36,33 +43,258 @@
&__status--error { &__status--error {
color: var(--color-error); color: var(--color-error);
} }
}
// --- Scrollable plan body -------------------------------------------------- // --- Tab bar ---------------------------------------------------------------
.cooking-session { // Same underline-tab language as the recipe catalog's `.recipe-tabs`
flex: 1; // (features/recipes/recipes.scss): scrolls within itself rather than
min-height: 0; // widening the page (global.scss forbids a horizontal body scroll).
overflow: auto; &__tabs {
display: flex; flex-shrink: 0;
flex-direction: column;
gap: var(--space-lg);
// Recipe legend — one chip per planned recipe/portion pairing.
&__legend {
display: flex; display: flex;
flex-wrap: wrap;
gap: var(--space-xs); gap: var(--space-xs);
overflow-x: auto;
border-bottom: 1px solid var(--color-border);
margin-bottom: var(--space-md);
} }
&__legend-item { &__tab {
padding: 0.15rem var(--space-sm); appearance: none;
flex-shrink: 0;
padding: var(--space-sm) var(--space-md);
font-family: var(--font-display);
font-size: var(--font-size-sm);
font-weight: 700;
letter-spacing: 0.02em;
color: var(--color-text-muted);
background: none;
border: none;
border-bottom: 2.5px solid transparent;
cursor: pointer;
transition:
color 0.15s ease,
border-color 0.15s ease;
&:hover {
color: var(--color-text);
}
&.active {
color: var(--color-primary);
border-color: var(--color-primary);
}
}
}
// --- "Recettes" tab ---------------------------------------------------------
// The week's planned recipes as a flat list — name, its own portion count,
// and a jump to the recipe's page. Deliberately thin: `plan.recipes` only
// carries `{recipeId, name, portions}`.
.cooking-recipes {
flex: 1;
min-height: 0;
overflow-y: auto;
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: var(--space-xs);
&__item {
display: flex;
align-items: center;
gap: var(--space-sm);
padding: var(--space-sm) var(--space-md);
background: var(--color-surface);
border-radius: var(--radius-md);
box-shadow: var(--shadow-sm);
}
&__name {
font-weight: 600;
color: var(--color-text);
}
&__portions {
padding: 0.1rem var(--space-sm);
border-radius: var(--radius-pill);
background: var(--color-surface-alt);
font-size: var(--font-size-sm);
color: var(--color-text-muted);
font-variant-numeric: tabular-nums;
}
// Pushed to the trailing edge — a quiet secondary action, not the point
// of the row.
&__link {
margin-left: auto;
flex-shrink: 0;
font-size: var(--font-size-sm);
font-weight: 600;
color: var(--color-primary);
}
}
// --- "Plan" tab -----------------------------------------------------------
.cooking-plan {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
gap: var(--space-md);
// Meta line under the stepper: "Étape 2 / 4 · Cuisson" on the left, the
// "{done} / {total} tâches" counter on the right.
&__meta {
flex-shrink: 0;
display: flex;
align-items: baseline;
justify-content: space-between;
flex-wrap: wrap;
gap: var(--space-sm);
}
&__position {
font-family: var(--font-display);
font-weight: 700;
font-size: var(--font-size-md);
color: var(--color-text);
}
&__count {
font-size: var(--font-size-sm);
color: var(--color-text-muted);
font-variant-numeric: tabular-nums;
}
// The current phase scrolls; everything around it stays put.
.cooking-phase {
flex: 1;
min-height: 0;
overflow-y: auto;
}
&__nav {
flex-shrink: 0;
display: flex;
justify-content: space-between;
gap: var(--space-sm);
}
&__nav-btn {
appearance: none;
padding: var(--space-sm) var(--space-lg);
border: 1.5px solid var(--color-primary);
border-radius: var(--radius-base);
background: var(--color-surface);
color: var(--color-primary);
font-family: var(--font-display);
font-weight: 700;
font-size: var(--font-size-sm);
cursor: pointer;
transition:
background-color 0.15s ease,
opacity 0.15s ease;
&:hover:not(:disabled) {
background: color-mix(in srgb, var(--color-primary) 10%, var(--color-surface));
}
&:disabled {
opacity: 0.4;
cursor: not-allowed;
}
}
}
// --- Stepper --------------------------------------------------------------
// One clickable node per phase, in a row that scrolls sideways within
// itself when the phases don't all fit. A thin connector sits between
// consecutive nodes.
.cooking-stepper {
flex-shrink: 0;
list-style: none;
margin: 0;
padding: 0 0 var(--space-xs);
display: flex;
gap: var(--space-xs);
overflow-x: auto;
&__step {
flex: 0 0 auto;
display: flex;
align-items: center;
&:not(:last-child)::after {
content: "";
width: var(--space-md);
height: 2px;
background: var(--color-border);
margin: 0 var(--space-xs);
}
}
&__node {
appearance: none;
display: flex;
align-items: center;
gap: var(--space-xs);
padding: var(--space-xs) var(--space-sm);
border: 1.5px solid var(--color-border);
border-radius: var(--radius-pill); border-radius: var(--radius-pill);
background: var(--color-surface); background: var(--color-surface);
box-shadow: var(--shadow-sm); color: var(--color-text-muted);
font-family: var(--font-display);
font-size: var(--font-size-sm); font-size: var(--font-size-sm);
color: var(--color-text); font-weight: 700;
white-space: nowrap;
cursor: pointer;
transition:
color 0.15s ease,
border-color 0.15s ease,
background-color 0.15s ease;
&:hover {
color: var(--color-text);
border-color: var(--color-primary);
}
// The phase currently on screen.
&--current {
color: var(--color-primary);
border-color: var(--color-primary);
background: color-mix(in srgb, var(--color-primary) 10%, var(--color-surface));
}
// Every task in the phase is ticked.
&--done {
color: var(--color-success);
border-color: var(--color-success);
}
}
// The leading circle — a step number, or a check once the phase is done.
&__marker {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.4rem;
height: 1.4rem;
border-radius: var(--radius-pill);
background: var(--color-surface-alt);
font-size: var(--font-size-xs);
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
} }
&__node--current &__marker {
background: var(--color-primary);
color: var(--color-surface);
}
&__node--done &__marker {
background: var(--color-success);
color: var(--color-surface);
}
} }
// --- One phase ---------------------------------------------------------------- // --- One phase ----------------------------------------------------------------
@ -71,27 +303,6 @@
flex-direction: column; flex-direction: column;
gap: var(--space-sm); gap: var(--space-sm);
&__title {
display: flex;
align-items: baseline;
gap: var(--space-sm);
margin: 0;
font-size: var(--font-size-md);
}
&__index {
font-weight: 700;
color: var(--color-text);
}
&__kind {
font-size: var(--font-size-sm);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-text-muted);
}
// "Pendant ce temps" — passive cooks still running from earlier phases. // "Pendant ce temps" — passive cooks still running from earlier phases.
&__background { &__background {
border-left: 3px solid var(--color-border); border-left: 3px solid var(--color-border);
@ -118,24 +329,54 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--space-sm); gap: var(--space-sm);
> li {
display: flex;
}
// The task card *is* a shared "selectable card" checkbox label (see
// global.scss's `label:has(> input[type="checkbox"])`), so its frame,
// hover, focus ring and hidden native input all come from there. The
// rules below only set what's task-specific — a top-aligned multi-line
// body, the merged-prep accent, and a faded/struck "done" look — and
// are kept at `.cooking-phase__tasks` depth on purpose so they outrank
// that shared rule's own `&.is-selected` treatment.
.cooking-task {
flex: 1;
align-items: flex-start;
}
.cooking-task--merged-prep {
border-left: 3px solid var(--color-accent);
}
// Ticked = done: cancel the shared rule's "selected" green tint/border
// (which would clash with the stepper's own green "current" node) and
// just fade the card and strike its headline.
.cooking-task--done {
opacity: 0.7;
background: var(--color-surface);
border-color: var(--color-border);
.cooking-task__headline {
text-decoration: line-through;
}
}
} }
} }
// --- One task card ---------------------------------------------------------- // --- One task card --------------------------------------------------------
// (frame comes from the shared checkbox-label rule — see above)
.cooking-task { .cooking-task {
background: var(--color-surface); &__body {
border-radius: var(--radius-md); flex: 1;
box-shadow: var(--shadow-sm); min-width: 0;
padding: var(--space-sm) var(--space-md); display: flex;
flex-direction: column;
// A pooled prep task is the headline feature of this page — give it a gap: 0.2rem;
// subtle accent border so it stands out from plain recipe steps.
&--merged-prep {
border-left: 3px solid var(--color-accent);
} }
&__headline { &__headline {
margin: 0;
display: flex; display: flex;
align-items: center; align-items: center;
flex-wrap: wrap; flex-wrap: wrap;
@ -144,6 +385,7 @@
color: var(--color-text); color: var(--color-text);
} }
// A pooled prep task is the headline feature of this page — flag it.
&__badge { &__badge {
padding: 0.05rem var(--space-xs); padding: 0.05rem var(--space-xs);
border-radius: var(--radius-pill); border-radius: var(--radius-pill);
@ -157,7 +399,7 @@
&__recipes, &__recipes,
&__utensils { &__utensils {
margin: 0.2rem 0 0; display: block;
font-size: var(--font-size-sm); font-size: var(--font-size-sm);
color: var(--color-text-muted); color: var(--color-text-muted);
} }

View file

@ -1,4 +1,9 @@
import type { CookingTaskIngredientView, CookingTaskView } from "@batch-cooking/shared"; import type {
CookingPhaseView,
CookingTaskIngredientView,
CookingTaskView,
OptimizedCookingPlanView,
} from "@batch-cooking/shared";
/** /**
* Minimal shape of `react-i18next`'s `t` — just what this module needs. * Minimal shape of `react-i18next`'s `t` — just what this module needs.
@ -54,3 +59,38 @@ export function taskHeadline(task: CookingTaskView, t: TranslateFn): string {
export function taskRecipeNames(task: CookingTaskView): string { export function taskRecipeNames(task: CookingTaskView): string {
return task.sourceRecipes.map((recipe) => recipe.name).join(", "); return task.sourceRecipes.map((recipe) => recipe.name).join(", ");
} }
/**
* Drops any ticked id that no longer names a task in the current plan.
* `CookingTaskView.id` is only stable *within one plan response* (see its
* doc comment) — if the household's planning changed since the checklist
* was saved, some stored ids point at tasks that no longer exist, so the
* page filters the restored set against what's actually on screen rather
* than carrying dangling ids around.
*/
export function reconcileChecked(stored: Set<string>, plan: OptimizedCookingPlanView): Set<string> {
const live = new Set<string>();
for (const phase of plan.phases) {
for (const task of phase.tasks) {
if (stored.has(task.id)) live.add(task.id);
}
}
return live;
}
/**
* How many of a phase's tasks are ticked — feeds the stepper node's
* "done" state (`done === total && total > 0`) and the "{{done}} / {{total}}
* tâches" counter shown for the current step. `background` lines don't
* count: they're status reminders for passive cooks, not work to check off.
*/
export function phaseProgress(
phase: CookingPhaseView,
checked: Set<string>,
): { done: number; total: number } {
let done = 0;
for (const task of phase.tasks) {
if (checked.has(task.id)) done++;
}
return { done, total: phase.tasks.length };
}