feat(cooking-session): onglets Recettes/Plan + stepper d'étapes cochables #23
8 changed files with 835 additions and 121 deletions
|
|
@ -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() {
|
||||
return {
|
||||
startDate: "2026-08-17T00: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: 4 },
|
||||
{ recipeId: 2, name: "Tarte à l'oignon", portions: 6 },
|
||||
],
|
||||
phases: [
|
||||
{
|
||||
|
|
@ -60,7 +60,7 @@ function planFixture() {
|
|||
utensils: [{ id: 1, key: "knife" }],
|
||||
sourceRecipes: [
|
||||
{ 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: [],
|
||||
},
|
||||
|
|
@ -103,7 +103,7 @@ function planFixture() {
|
|||
description: "Enfourner la tarte",
|
||||
ingredients: [],
|
||||
utensils: [],
|
||||
sourceRecipes: [{ recipeId: 2, name: "Tarte à l'oignon", portions: 4 }],
|
||||
sourceRecipes: [{ recipeId: 2, name: "Tarte à l'oignon", portions: 6 }],
|
||||
originalSteps: [],
|
||||
},
|
||||
],
|
||||
|
|
@ -136,7 +136,7 @@ describe("Cooking session page", () => {
|
|||
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(
|
||||
"getPlan",
|
||||
);
|
||||
|
|
@ -144,21 +144,86 @@ describe("Cooking session page", () => {
|
|||
cy.visit("/cuisiner?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.
|
||||
cy.contains(".cooking-phase", "Mise en place").should("be.visible");
|
||||
// One stepper node per phase; the first is the current one.
|
||||
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")
|
||||
.should("contain.text", "Hacher")
|
||||
.and("contain.text", "Oignon")
|
||||
.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")
|
||||
.should("contain.text", "Faire mijoter le bouillon")
|
||||
.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.
|
||||
cy.contains(".cooking-session__legend-item", "Tarte à l'oignon").should("be.visible");
|
||||
// Back one step.
|
||||
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", () => {
|
||||
|
|
|
|||
|
|
@ -22,3 +22,34 @@ Feature: Start cooking an optimized plan
|
|||
And I should see "Mise en place"
|
||||
And the pooled prep task should mention "Hacher" and "Oignon"
|
||||
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"
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
function ingredient(key: string) {
|
||||
|
|
@ -99,3 +99,94 @@ Then(
|
|||
Then("the pooled prep task should be flagged as shared", () => {
|
||||
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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -321,12 +321,23 @@
|
|||
"loading": "Optimisation du plan de cuisine…",
|
||||
"empty": "Rien de planifié cette semaine à cuisiner — ajoutez des recettes à votre planning.",
|
||||
"recipesLegend": "Recettes de la semaine",
|
||||
"tabs": {
|
||||
"plan": "Plan",
|
||||
"recipes": "Recettes"
|
||||
},
|
||||
"phase": {
|
||||
"label": "Étape {{index}}",
|
||||
"mise-en-place": "Mise en place",
|
||||
"cooking": "Cuisson",
|
||||
"finishing": "Dressage"
|
||||
},
|
||||
"stepper": {
|
||||
"prev": "Étape précédente",
|
||||
"next": "Étape suivante",
|
||||
"position": "Étape {{current}} / {{total}}"
|
||||
},
|
||||
"progress": {
|
||||
"tasks": "{{done}} / {{total}} tâches"
|
||||
},
|
||||
"background": {
|
||||
"title": "Pendant ce temps"
|
||||
},
|
||||
|
|
@ -335,6 +346,10 @@
|
|||
"forRecipes": "pour {{recipes}}",
|
||||
"utensils": "Ustensiles",
|
||||
"sharedBadge": "Mutualisé"
|
||||
},
|
||||
"recipesTab": {
|
||||
"portions": "{{n}} portions",
|
||||
"viewRecipe": "Voir la recette"
|
||||
}
|
||||
},
|
||||
"account": {
|
||||
|
|
|
|||
|
|
@ -2,15 +2,18 @@ import { DateTime, formatDateOnly, getWeekStart, parseDateOnly } from "@batch-co
|
|||
import type {
|
||||
CookingBackgroundTaskView,
|
||||
CookingPhaseView,
|
||||
CookingSessionRecipeRef,
|
||||
CookingTaskView,
|
||||
OptimizedCookingPlanView,
|
||||
} from "@batch-cooking/shared";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
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 { taskHeadline, taskRecipeNames } from "./cooking-session";
|
||||
import { cookingProgress } from "./cooking-progress";
|
||||
import { 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. */
|
||||
|
|
@ -19,6 +22,14 @@ type CookingSessionState =
|
|||
| { 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
|
||||
|
|
@ -28,10 +39,13 @@ type CookingSessionState =
|
|||
* 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`.
|
||||
* 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();
|
||||
|
|
@ -41,6 +55,7 @@ export function CookingSessionPage() {
|
|||
return getWeekStart(fromQuery ?? DateTime.utc());
|
||||
});
|
||||
const [state, setState] = useState<CookingSessionState>({ status: "loading" });
|
||||
const [tab, setTab] = useState<CookingTab>("plan");
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
|
@ -60,6 +75,8 @@ export function CookingSessionPage() {
|
|||
};
|
||||
}, [weekStart]);
|
||||
|
||||
const weekKey = formatDateOnly(weekStart);
|
||||
|
||||
return (
|
||||
<div className="cooking-session-page">
|
||||
<div className="cooking-session-page__header">
|
||||
|
|
@ -68,6 +85,23 @@ export function CookingSessionPage() {
|
|||
</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>
|
||||
)}
|
||||
|
|
@ -78,52 +112,163 @@ export function CookingSessionPage() {
|
|||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
/** The plan body — the recipe legend then every phase, or the empty-week message. */
|
||||
function CookingPlan({ plan }: { plan: OptimizedCookingPlanView }) {
|
||||
/** 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>;
|
||||
}
|
||||
|
||||
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>
|
||||
// `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;
|
||||
|
||||
{plan.phases.map((phase) => (
|
||||
<PhaseSection key={phase.index} phase={phase} />
|
||||
))}
|
||||
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 task cards. */
|
||||
function PhaseSection({ phase }: { phase: CookingPhaseView }) {
|
||||
/** 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}`}>
|
||||
<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">
|
||||
|
|
@ -142,7 +287,11 @@ function PhaseSection({ phase }: { phase: CookingPhaseView }) {
|
|||
<ul className="cooking-phase__tasks">
|
||||
{phase.tasks.map((task) => (
|
||||
<li key={task.id}>
|
||||
<TaskCard task={task} />
|
||||
<TaskCard
|
||||
task={task}
|
||||
checked={checked.has(task.id)}
|
||||
onToggle={(isChecked) => onToggle(task.id, isChecked)}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
|
@ -150,28 +299,42 @@ function PhaseSection({ phase }: { phase: CookingPhaseView }) {
|
|||
);
|
||||
}
|
||||
|
||||
/** A single actionable task — a merged-prep pool or a plain recipe step. */
|
||||
function TaskCard({ task }: { task: CookingTaskView }) {
|
||||
/** A single actionable task — a merged-prep pool 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();
|
||||
|
||||
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>
|
||||
<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)}
|
||||
{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>
|
||||
<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>
|
||||
</span>
|
||||
</CheckboxOption>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
67
apps/web/src/pages/cooking-session/cooking-progress.ts
Normal file
67
apps/web/src/pages/cooking-session/cooking-progress.ts
Normal 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();
|
||||
|
|
@ -1,9 +1,16 @@
|
|||
// =============================================================================
|
||||
// Styles specific to CookingSessionPage — colocated next to
|
||||
// CookingSessionPage.tsx since nothing else uses these classes. Same page
|
||||
// shell/status conventions as shopping-list-page.scss
|
||||
// (`__header`/`__status`); below it, a vertical stack of phase sections
|
||||
// each holding a "meanwhile" band and a list of task cards.
|
||||
// CookingSessionPage.tsx since nothing else uses these classes.
|
||||
//
|
||||
// Layout: a fixed page shell (header + subtitle + tab bar), then one of two
|
||||
// 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 {
|
||||
|
|
@ -23,7 +30,7 @@
|
|||
|
||||
&__subtitle {
|
||||
flex-shrink: 0;
|
||||
margin: 0 0 var(--space-lg);
|
||||
margin: 0 0 var(--space-md);
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--font-size-md);
|
||||
}
|
||||
|
|
@ -36,33 +43,258 @@
|
|||
&__status--error {
|
||||
color: var(--color-error);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Scrollable plan body --------------------------------------------------
|
||||
.cooking-session {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-lg);
|
||||
|
||||
// Recipe legend — one chip per planned recipe/portion pairing.
|
||||
&__legend {
|
||||
// --- Tab bar ---------------------------------------------------------------
|
||||
// Same underline-tab language as the recipe catalog's `.recipe-tabs`
|
||||
// (features/recipes/recipes.scss): scrolls within itself rather than
|
||||
// widening the page (global.scss forbids a horizontal body scroll).
|
||||
&__tabs {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-xs);
|
||||
overflow-x: auto;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
margin-bottom: var(--space-md);
|
||||
}
|
||||
|
||||
&__legend-item {
|
||||
padding: 0.15rem var(--space-sm);
|
||||
&__tab {
|
||||
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);
|
||||
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);
|
||||
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;
|
||||
}
|
||||
|
||||
&__node--current &__marker {
|
||||
background: var(--color-primary);
|
||||
color: var(--color-surface);
|
||||
}
|
||||
|
||||
&__node--done &__marker {
|
||||
background: var(--color-success);
|
||||
color: var(--color-surface);
|
||||
}
|
||||
}
|
||||
|
||||
// --- One phase ----------------------------------------------------------------
|
||||
|
|
@ -71,27 +303,6 @@
|
|||
flex-direction: column;
|
||||
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.
|
||||
&__background {
|
||||
border-left: 3px solid var(--color-border);
|
||||
|
|
@ -118,24 +329,54 @@
|
|||
display: flex;
|
||||
flex-direction: column;
|
||||
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 {
|
||||
background: var(--color-surface);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-sm);
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
|
||||
// A pooled prep task is the headline feature of this page — give it a
|
||||
// subtle accent border so it stands out from plain recipe steps.
|
||||
&--merged-prep {
|
||||
border-left: 3px solid var(--color-accent);
|
||||
&__body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
&__headline {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
|
|
@ -144,6 +385,7 @@
|
|||
color: var(--color-text);
|
||||
}
|
||||
|
||||
// A pooled prep task is the headline feature of this page — flag it.
|
||||
&__badge {
|
||||
padding: 0.05rem var(--space-xs);
|
||||
border-radius: var(--radius-pill);
|
||||
|
|
@ -157,7 +399,7 @@
|
|||
|
||||
&__recipes,
|
||||
&__utensils {
|
||||
margin: 0.2rem 0 0;
|
||||
display: block;
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -54,3 +59,38 @@ export function taskHeadline(task: CookingTaskView, t: TranslateFn): string {
|
|||
export function taskRecipeNames(task: CookingTaskView): string {
|
||||
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 };
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue