feat(shopping-list): liste de courses agrégée depuis le planning (#73)

GET /shopping-list?date= (shopping-list.service.ts/.routes.ts) somme les
ingrédients de chaque recette planifiée sur la semaine, mis à l'échelle par
les portions de chaque créneau (PlanningItem.portions / Recipe.portions),
regroupés par paire (ingredientId, unitId) — jamais null contrairement à
GET /planning, une semaine vide redescend en items: [].

Côté web, ShoppingListPage rend cette liste groupée par rayon (même
IngredientCategory que IngredientPicker), triée alphabétiquement en
français à l'intérieur d'un rayon (shopping-list.ts, logique pure extraite
du composant). WeekNavigator (flèches + calendrier) est extrait de
PlanningPage vers features/planning/ pour être partagé entre les deux
pages ; ses libellés migrent de planning.* vers common.weekNav.*/
common.calendar.*/common.days.*, plus génériques pour une page qui n'est
plus seulement le planning.

ComingSoonPage retiré (plus aucun appelant, Liste de courses avait le
dernier stub restant).

Tests : Mocha (agrégation, mise à l'échelle par portions, unités non
fusionnées) + Cucumber (shopping-list.feature : liste vide, groupement/tri,
navigation de semaine) + mise à jour de layout.cy.ts/planning-page.cy.ts
pour le nouveau rendu.

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
kyuno053 2026-08-22 22:45:06 +02:00 committed by GitHub
parent 520e539fe6
commit 109dde9c7b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 1455 additions and 417 deletions

View file

@ -13,6 +13,7 @@ import { preferencesRouter } from "./modules/preferences/preferences.routes.js";
import { profileRouter } from "./modules/profile/profile.routes.js"; import { profileRouter } from "./modules/profile/profile.routes.js";
import { recipeRouter } from "./modules/recipe/recipe.routes.js"; import { recipeRouter } from "./modules/recipe/recipe.routes.js";
import { referenceRouter } from "./modules/reference/reference.routes.js"; import { referenceRouter } from "./modules/reference/reference.routes.js";
import { shoppingListRouter } from "./modules/shopping-list/shopping-list.routes.js";
import { sourcesRouter } from "./modules/sources/sources.routes.js"; import { sourcesRouter } from "./modules/sources/sources.routes.js";
/** /**
@ -50,6 +51,7 @@ export function createServer(): ExpressServer {
server.mountRouter("/profile", profileRouter); server.mountRouter("/profile", profileRouter);
server.mountRouter("/recipes", recipeRouter); server.mountRouter("/recipes", recipeRouter);
server.mountRouter("/reference", referenceRouter); server.mountRouter("/reference", referenceRouter);
server.mountRouter("/shopping-list", shoppingListRouter);
server.mountRouter("/sources", sourcesRouter); server.mountRouter("/sources", sourcesRouter);
// Serves the built frontend (production Docker image only — see // Serves the built frontend (production Docker image only — see

View file

@ -56,11 +56,13 @@ function recipeInclude(viewerId: number) {
type RecipeWithDetails = Prisma.RecipeGetPayload<{ type RecipeWithDetails = Prisma.RecipeGetPayload<{
include: ReturnType<typeof recipeInclude>; include: ReturnType<typeof recipeInclude>;
}>; }>;
type IngredientWithDetails = RecipeWithDetails["ingredients"][number]["ingredient"]; /** Exported — `shopping-list.service.ts` fetches its own, narrower ingredient include (no need for a whole `RecipeWithDetails`) but shapes the same `allergies`/`diets` nesting, so it reuses {@link toIngredientView} directly instead of re-deriving this type. */
type UnitWithDetails = RecipeWithDetails["ingredients"][number]["unit"]; export type IngredientWithDetails = RecipeWithDetails["ingredients"][number]["ingredient"];
/** Exported — see {@link IngredientWithDetails}, same reuse by `shopping-list.service.ts`. */
export type UnitWithDetails = RecipeWithDetails["ingredients"][number]["unit"];
/** Shapes a Prisma `Unit` row into the public {@link UnitView} — same "Decimal → number" conversion `reference.service.ts`'s `getUnits` does. */ /** Shapes a Prisma `Unit` row into the public {@link UnitView} — same "Decimal → number" conversion `reference.service.ts`'s `getUnits` does. Exported — reused as-is by `shopping-list.service.ts` (a shopping list resolves the same reference data, no need for a second copy of this mapping). */
function toUnitView(unit: UnitWithDetails): UnitView { export function toUnitView(unit: UnitWithDetails): UnitView {
return { return {
id: unit.id, id: unit.id,
key: unit.key, key: unit.key,
@ -69,8 +71,8 @@ function toUnitView(unit: UnitWithDetails): UnitView {
}; };
} }
/** Shapes a Prisma `Ingredient` (with its `allergies`/`diets` relations included) into the public {@link IngredientView} — same aplattening as `reference.service.ts`'s `getIngredients`. */ /** Shapes a Prisma `Ingredient` (with its `allergies`/`diets` relations included) into the public {@link IngredientView} — same flattening as `reference.service.ts`'s `getIngredients`. Exported — see {@link toUnitView}'s doc comment, same reuse by `shopping-list.service.ts`. */
function toIngredientView(ingredient: IngredientWithDetails): IngredientView { export function toIngredientView(ingredient: IngredientWithDetails): IngredientView {
return { return {
id: ingredient.id, id: ingredient.id,
key: ingredient.key, key: ingredient.key,

View file

@ -0,0 +1,36 @@
import { parseDateOnly } from "@batch-cooking/date-tools";
import { HttpError } from "@batch-cooking/error-tools";
import { wrapAsyncHandler } from "@batch-cooking/express-tools";
import { ErrorCode, getShoppingListSchema } from "@batch-cooking/shared";
import { Router } from "express";
import { type AuthLocals, requireAuth } from "../../middlewares/require-auth.js";
import { getShoppingListForDate } from "./shopping-list.service.js";
/** Router mounted at `/shopping-list` in app.ts. */
export const shoppingListRouter = Router();
/**
* Returns the authenticated user's household's shopping list for the week
* covering `?date=` (`YYYY-MM-DD`) every ingredient line of every recipe
* planned that week, summed (see {@link getShoppingListForDate}). Always
* `200`, never `null` no household or nothing planned that week both
* come back as a normal `ShoppingListView` with an empty `items` array.
*/
shoppingListRouter.get(
"/",
requireAuth,
wrapAsyncHandler<unknown, AuthLocals>(async (req, res) => {
const input = getShoppingListSchema.parse(req.query);
const date = parseDateOnly(input.date);
if (date === null) {
throw new HttpError(
400,
ErrorCode.VALIDATION_ERROR,
`Not a real calendar date: ${input.date}`,
);
}
const shoppingList = await getShoppingListForDate(res.locals.userProfile.houseId, date);
res.status(200).json(shoppingList);
}),
);

View file

@ -0,0 +1,154 @@
import { type DateTime, getWeekStart, toDateOnly } from "@batch-cooking/date-tools";
import type {
IngredientView,
ShoppingListItemView,
ShoppingListView,
UnitView,
} from "@batch-cooking/shared";
import type { Prisma } from "@prisma/client";
import { prisma } from "../../db/prisma.js";
import { toIngredientView, toUnitView } from "../recipe/recipe.service.js";
/** Prisma `include` for a `Planning` query that needs, for every item, just enough of its recipe to compute a shopping list — `portions` (to scale `RecipeIngredient.quantity`) and the ingredient lines themselves, each resolved the same way `recipe.service.ts`'s own `recipeInclude` resolves them (so {@link toIngredientView}/{@link toUnitView} can be reused as-is). Deliberately narrower than a full `RecipeView` fetch — steps/diets/favorites are never read here. */
function shoppingListPlanningInclude() {
return {
items: {
include: {
recipe: {
select: {
portions: true,
ingredients: {
include: {
ingredient: {
include: {
allergies: { include: { allergy: { include: { category: true } } } },
diets: { include: { diet: true } },
},
},
unit: true,
},
},
},
},
},
},
} satisfies Prisma.PlanningInclude;
}
type PlanningWithIngredients = Prisma.PlanningGetPayload<{
include: ReturnType<typeof shoppingListPlanningInclude>;
}>;
/** Accumulates a running sum per `(ingredientId, unitId)` pair while walking every planning item's ingredient lines — see {@link aggregateShoppingList}. */
interface RunningTotal {
ingredient: IngredientView;
unit: UnitView;
quantity: number;
}
/**
* Sums every ingredient line across `items`, each scaled by that planning
* item's own portion count relative to its recipe's as-written yield
* (`RecipeIngredient.quantity × PlanningItem.portions / Recipe.portions`,
* see `PlanningItem.portions`'s doc comment in schema.prisma for why the
* two can differ). Grouped by `(ingredientId, unitId)` **not** just
* `ingredientId` since summing across units isn't implemented yet (see
* `ShoppingListItemView`'s doc comment): the same ingredient requested in
* two different units stays two separate lines rather than silently
* guessing a conversion. Pure/synchronous, factored out from
* {@link getShoppingListForDate} so the aggregation itself is testable
* without a database round-trip.
*/
function aggregateShoppingList(items: PlanningWithIngredients["items"]): ShoppingListItemView[] {
const totals = new Map<string, RunningTotal>();
for (const item of items) {
const scale = item.portions / item.recipe.portions;
for (const recipeIngredient of item.recipe.ingredients) {
const key = `${recipeIngredient.ingredientId}:${recipeIngredient.unitId}`;
const addedQuantity = Number(recipeIngredient.quantity) * scale;
const existing = totals.get(key);
if (existing) {
existing.quantity += addedQuantity;
} else {
totals.set(key, {
ingredient: toIngredientView(recipeIngredient.ingredient),
unit: toUnitView(recipeIngredient.unit),
quantity: addedQuantity,
});
}
}
}
// Deterministic order (by the ingredient's stable `key`, not its id —
// insertion order would otherwise depend on which recipe happened to be
// read first) — the frontend re-sorts by translated label/aisle for
// display, this is just so two identical plannings always produce the
// same JSON.
return [...totals.values()].sort((a, b) => a.ingredient.key.localeCompare(b.ingredient.key));
}
/**
* Builds the household's shopping list for the week covering `date`
* every ingredient line of every recipe planned that week, aggregated (see
* {@link aggregateShoppingList}). `date` is whatever the caller wants "that
* week" to mean, same convention as `planning.service.ts`'s
* `getPlanningForDate` (a caller-parsed `?date=`, not necessarily a
* Monday).
*
* Unlike `getPlanningForDate`, this **never** returns `null` no household
* and "no planning covers this week yet" both degrade to an empty `items`
* array on an otherwise normal `ShoppingListView` (the week's date range is
* always computable from `date` alone, even with nothing planned in it),
* rather than a separate "nothing to show" state the frontend would have to
* branch on.
*/
export async function getShoppingListForDate(
houseId: number | null,
date: DateTime,
): Promise<ShoppingListView> {
try {
const weekStart = getWeekStart(toDateOnly(date));
const weekFinish = weekStart.plus({ days: 6 });
const emptyList: ShoppingListView = {
startDate: weekStart.toJSDate().toISOString(),
finishDate: weekFinish.toJSDate().toISOString(),
items: [],
};
if (houseId === null) {
return emptyList;
}
// Same "covering range" lookup as getPlanningForDate — see that
// function's doc comment for why this compares against a UTC-midnight
// JS Date rather than `weekStart`/`weekFinish` directly.
const dateOnly = toDateOnly(date).toJSDate();
const planning = await prisma.planning.findFirst({
where: {
houseId,
startDate: { lte: dateOnly },
finishDate: { gte: dateOnly },
},
orderBy: { startDate: "desc" },
include: shoppingListPlanningInclude(),
});
if (!planning) {
return emptyList;
}
return {
startDate: planning.startDate.toISOString(),
finishDate: planning.finishDate.toISOString(),
items: aggregateShoppingList(planning.items),
};
} catch (err) {
// Rethrown as-is — `wrapAsyncHandler`/the error middleware (which
// already logs it, see `error-logger.ts`) is what actually handles it,
// this service layer just isn't allowed a bare `await` per the repo's
// async/try-catch convention.
throw err;
}
}

View file

@ -0,0 +1,336 @@
import type { DateTime } from "@batch-cooking/date-tools";
import { ErrorCode, type SignupInput } from "@batch-cooking/shared";
import { faker } from "@faker-js/faker";
import { expect } from "chai";
import request from "supertest";
import { createApp } from "../src/app.js";
import { prisma } from "../src/db/prisma.js";
import { TEST_REFERENCE_DATE } from "../test-support/reference-date.js";
import { resetDatabase } from "../test-support/reset-db.js";
/** See `auth.test.ts` — same rationale for generating rather than hardcoding. */
function buildSignupPayload(): SignupInput {
const firstName = faker.person.firstName();
const lastName = faker.person.lastName();
return {
firstName,
lastName,
email: faker.internet.email({ firstName, lastName }).toLowerCase(),
password: faker.internet.password({ length: 16 }),
};
}
/** The fixed test "today", as the `YYYY-MM-DD` string `GET /shopping-list`'s `?date=` expects. */
function today(): string {
return isoDate(TEST_REFERENCE_DATE);
}
/** `toISODate()` only returns `null` for an invalid `DateTime` — never the case for the always-valid values built in this file. */
function isoDate(date: DateTime): string {
const iso = date.toISODate();
if (iso === null) throw new Error("Unexpectedly invalid DateTime in a test helper");
return iso;
}
/** Resolves a reference ingredient's id by its `reference-seed-data.ts` uid (also its DB `key`) — same helper as `recipe.test.ts`. */
async function ingredientId(key: string): Promise<number> {
const ingredient = await prisma.ingredient.findFirstOrThrow({ where: { key } });
return ingredient.id;
}
/** Resolves a reference unit's id by its `reference-seed-data.ts` uid — same helper as `recipe.test.ts`. */
async function unitId(key: string): Promise<number> {
const unit = await prisma.unit.findFirstOrThrow({ where: { key } });
return unit.id;
}
describe("Shopping list", () => {
const app = createApp();
beforeEach(async () => {
await resetDatabase();
});
after(async () => {
await prisma.$disconnect();
});
describe("GET /shopping-list", () => {
it("rejects requests without a session cookie with 401 NOT_AUTHENTICATED", async () => {
const res = await request(app).get("/shopping-list").query({ date: today() });
expect(res.status).to.equal(401);
expect(res.body.code).to.equal(ErrorCode.NOT_AUTHENTICATED);
});
it("rejects a missing date with 400 VALIDATION_ERROR", async () => {
const agent = request.agent(app);
await agent.post("/auth/signup").send(buildSignupPayload());
const res = await agent.get("/shopping-list");
expect(res.status).to.equal(400);
expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
});
it("rejects a malformed date with 400 VALIDATION_ERROR", async () => {
const agent = request.agent(app);
await agent.post("/auth/signup").send(buildSignupPayload());
const res = await agent.get("/shopping-list").query({ date: "not-a-date" });
expect(res.status).to.equal(400);
expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
});
it("rejects a date shaped right but calendarially impossible with 400 VALIDATION_ERROR", async () => {
const agent = request.agent(app);
await agent.post("/auth/signup").send(buildSignupPayload());
const res = await agent.get("/shopping-list").query({ date: "2026-02-30" });
expect(res.status).to.equal(400);
expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
});
it("returns an empty list when the profile has no household", async () => {
const agent = request.agent(app);
await agent.post("/auth/signup").send(buildSignupPayload());
const res = await agent.get("/shopping-list").query({ date: today() });
expect(res.status).to.equal(200);
expect(res.body.items).to.deep.equal([]);
});
it("returns an empty list when the household has no planning covering that date", async () => {
const agent = request.agent(app);
await agent.post("/auth/signup").send(buildSignupPayload());
await agent.post("/house").send({ name: "Chez moi" });
const res = await agent.get("/shopping-list").query({ date: today() });
expect(res.status).to.equal(200);
expect(res.body.items).to.deep.equal([]);
});
it("sums one recipe's ingredient across two planning slots, scaled by each slot's own portions", async () => {
const agent = request.agent(app);
await agent.post("/auth/signup").send(buildSignupPayload());
const houseRes = await agent.post("/house").send({ name: "Chez moi" });
const houseId: number = houseRes.body.id;
const authorId: number = houseRes.body.adminId;
const tomatoId = await ingredientId("tomato");
const gramId = await unitId("gram");
// Written for 2 portions, 100g tomato — planned twice this week at
// 4 portions each, so the shopping list should show 100 × (4/2) × 2
// = 400g, not the raw 200g the recipe itself lists.
const recipe = await prisma.recipe.create({
data: {
name: "Salade de tomates",
authorId,
portions: 2,
ingredients: { create: [{ ingredientId: tomatoId, quantity: 100, unitId: gramId }] },
},
});
const planning = await prisma.planning.create({
data: {
houseId,
startDate: TEST_REFERENCE_DATE.minus({ days: 2 }).toJSDate(),
finishDate: TEST_REFERENCE_DATE.plus({ days: 2 }).toJSDate(),
},
});
await prisma.planningItem.createMany({
data: [
{
planningId: planning.id,
weekDay: "lundi",
meal: "dejeuner",
recipeId: recipe.id,
portions: 4,
},
{
planningId: planning.id,
weekDay: "mercredi",
meal: "diner",
recipeId: recipe.id,
portions: 4,
},
],
});
const res = await agent.get("/shopping-list").query({ date: today() });
expect(res.status).to.equal(200);
expect(res.body.items).to.have.length(1);
expect(res.body.items[0].ingredient.key).to.equal("tomato");
expect(res.body.items[0].unit.key).to.equal("gram");
expect(res.body.items[0].quantity).to.equal(400);
});
it("sums the same ingredient across two different recipes sharing a unit", async () => {
const agent = request.agent(app);
await agent.post("/auth/signup").send(buildSignupPayload());
const houseRes = await agent.post("/house").send({ name: "Chez moi" });
const houseId: number = houseRes.body.id;
const authorId: number = houseRes.body.adminId;
const onionId = await ingredientId("onion");
const gramId = await unitId("gram");
const recipeA = await prisma.recipe.create({
data: {
name: "Soupe à l'oignon",
authorId,
portions: 4,
ingredients: { create: [{ ingredientId: onionId, quantity: 200, unitId: gramId }] },
},
});
const recipeB = await prisma.recipe.create({
data: {
name: "Tarte à l'oignon",
authorId,
portions: 4,
ingredients: { create: [{ ingredientId: onionId, quantity: 150, unitId: gramId }] },
},
});
const planning = await prisma.planning.create({
data: {
houseId,
startDate: TEST_REFERENCE_DATE.minus({ days: 2 }).toJSDate(),
finishDate: TEST_REFERENCE_DATE.plus({ days: 2 }).toJSDate(),
},
});
await prisma.planningItem.createMany({
data: [
{
planningId: planning.id,
weekDay: "lundi",
meal: "dejeuner",
recipeId: recipeA.id,
portions: 4,
},
{
planningId: planning.id,
weekDay: "mardi",
meal: "diner",
recipeId: recipeB.id,
portions: 4,
},
],
});
const res = await agent.get("/shopping-list").query({ date: today() });
expect(res.status).to.equal(200);
expect(res.body.items).to.have.length(1);
expect(res.body.items[0].ingredient.key).to.equal("onion");
expect(res.body.items[0].quantity).to.equal(350);
});
it("keeps the same ingredient in two different units as two separate lines", async () => {
const agent = request.agent(app);
await agent.post("/auth/signup").send(buildSignupPayload());
const houseRes = await agent.post("/house").send({ name: "Chez moi" });
const houseId: number = houseRes.body.id;
const authorId: number = houseRes.body.adminId;
const tomatoId = await ingredientId("tomato");
const gramId = await unitId("gram");
const kilogramId = await unitId("kilogram");
const recipeA = await prisma.recipe.create({
data: {
name: "Recette A",
authorId,
portions: 2,
ingredients: { create: [{ ingredientId: tomatoId, quantity: 100, unitId: gramId }] },
},
});
const recipeB = await prisma.recipe.create({
data: {
name: "Recette B",
authorId,
portions: 2,
ingredients: { create: [{ ingredientId: tomatoId, quantity: 1, unitId: kilogramId }] },
},
});
const planning = await prisma.planning.create({
data: {
houseId,
startDate: TEST_REFERENCE_DATE.minus({ days: 2 }).toJSDate(),
finishDate: TEST_REFERENCE_DATE.plus({ days: 2 }).toJSDate(),
},
});
await prisma.planningItem.createMany({
data: [
{
planningId: planning.id,
weekDay: "lundi",
meal: "dejeuner",
recipeId: recipeA.id,
portions: 2,
},
{
planningId: planning.id,
weekDay: "mardi",
meal: "diner",
recipeId: recipeB.id,
portions: 2,
},
],
});
const res = await agent.get("/shopping-list").query({ date: today() });
expect(res.status).to.equal(200);
expect(res.body.items).to.have.length(2);
const units = res.body.items.map((item: { unit: { key: string } }) => item.unit.key).sort();
expect(units).to.deep.equal(["gram", "kilogram"]);
});
it("returns a different week's shopping list when asked for a date outside the current one", async () => {
const agent = request.agent(app);
await agent.post("/auth/signup").send(buildSignupPayload());
const houseRes = await agent.post("/house").send({ name: "Chez moi" });
const houseId: number = houseRes.body.id;
const authorId: number = houseRes.body.adminId;
const tomatoId = await ingredientId("tomato");
const gramId = await unitId("gram");
const recipe = await prisma.recipe.create({
data: {
name: "Curry de lentilles",
authorId,
portions: 2,
ingredients: { create: [{ ingredientId: tomatoId, quantity: 100, unitId: gramId }] },
},
});
const nextWeek = TEST_REFERENCE_DATE.plus({ weeks: 1 });
const planning = await prisma.planning.create({
data: {
houseId,
startDate: nextWeek.startOf("week").toJSDate(),
finishDate: nextWeek.endOf("week").startOf("day").toJSDate(),
},
});
await prisma.planningItem.create({
data: {
planningId: planning.id,
weekDay: "mardi",
meal: "dejeuner",
recipeId: recipe.id,
portions: 2,
},
});
const nextWeekRes = await agent.get("/shopping-list").query({ date: isoDate(nextWeek) });
expect(nextWeekRes.body.items).to.have.length(1);
const thisWeekRes = await agent.get("/shopping-list").query({ date: today() });
expect(thisWeekRes.body.items).to.deep.equal([]);
});
});
});

View file

@ -147,11 +147,13 @@ describe("Page width — full-bleed pages vs. centered reading columns (#21 regr
// that cap was dropped so they fill the width like every other page. // that cap was dropped so they fill the width like every other page.
cy.visit("/parametres/compte"); cy.visit("/parametres/compte");
assertFillsContentWidth(".settings-page"); assertFillsContentWidth(".settings-page");
});
it("centers the Liste de courses stub, with equal space on both sides", () => { cy.intercept("GET", /\/shopping-list\?/, {
statusCode: 200,
body: { startDate: "2026-08-17", finishDate: "2026-08-23", items: [] },
});
cy.visit("/liste-de-courses"); cy.visit("/liste-de-courses");
assertCenteredColumn(".coming-soon-page", 640); // max-width: 40rem assertFillsContentWidth(".shopping-list-page");
}); });
/** Fills `.app-content`'s available (padding-excluded) width, within a couple px of scrollbar/rounding slack. */ /** Fills `.app-content`'s available (padding-excluded) width, within a couple px of scrollbar/rounding slack. */
@ -168,22 +170,6 @@ describe("Page width — full-bleed pages vs. centered reading columns (#21 regr
}); });
}); });
} }
/** Capped at `maxWidthPx` (not stretched full-bleed) and horizontally centered — equal left/right gap within `.app-content`. */
function assertCenteredColumn(selector: string, maxWidthPx: number) {
cy.get(".app-content").then(($content) => {
const contentRect = $content[0].getBoundingClientRect();
cy.get(selector).should(($page) => {
const pageRect = $page[0].getBoundingClientRect();
expect(pageRect.width).to.be.closeTo(maxWidthPx, 2);
const leftGap = pageRect.left - contentRect.left;
const rightGap = contentRect.right - pageRect.right;
expect(leftGap).to.be.closeTo(rightGap, 2);
});
});
}
}); });
describe("Responsive breakpoint — sidebar becomes a horizontal top bar under 640px", () => { describe("Responsive breakpoint — sidebar becomes a horizontal top bar under 640px", () => {

View file

@ -33,7 +33,7 @@ describe("Sidebar navigation", () => {
// The Foyer/Compte/Préférences links — behind the sidebar's "Paramètres" // The Foyer/Compte/Préférences links — behind the sidebar's "Paramètres"
// toggle, not the main nav tested here — are covered by sidebar.cy.ts. // toggle, not the main nav tested here — are covered by sidebar.cy.ts.
it("highlights the current section and navigates between stub pages", () => { it("highlights the current section and navigates between pages", () => {
cy.contains("nav a", "Planning").should("have.class", "active"); cy.contains("nav a", "Planning").should("have.class", "active");
cy.contains("nav a", "Recettes").click(); cy.contains("nav a", "Recettes").click();

View file

@ -0,0 +1,32 @@
Feature: Shopping list
As a signed-in user
I want to see every ingredient needed for this week's planned recipes, already summed
So that I know what to buy without recomputing it myself
Background:
Given I am signed in as "Alice" "Martin"
And today is frozen at "2026-08-17T09:00:00.000Z"
Scenario: Nothing planned this week shows the empty message, not an error
Given the shopping list for "2026-08-17" is empty
When I visit "/liste-de-courses"
Then I should see "Aucun ingrédient à acheter pour cette semaine ajoutez des recettes à votre planning."
Scenario: Ingredients are grouped by aisle, in canonical order, each with its summed quantity
Given the shopping list for "2026-08-17" contains:
| ingredientKey | icon | category | quantity | unitKey |
| egg | EGG | dairyAndCheese | 6 | piece |
| tomato | VEGETABLE | freshProduce | 400 | gram |
When I visit "/liste-de-courses"
Then the shopping list group "Produits frais" should appear before "Crémerie & fromage"
And the shopping list should show "Tomate" at quantity "400 g"
And the shopping list should show "Oeuf" at quantity "6 unité"
Scenario: Navigating to another week fetches and shows that week's own list
Given the shopping list for "2026-08-17" is empty
And the shopping list for "2026-08-24" contains:
| ingredientKey | icon | category | quantity | unitKey |
| onion | VEGETABLE | freshProduce | 1 | kilogram |
When I visit "/liste-de-courses"
And I click the next week arrow
Then the shopping list should show "Oignon" at quantity "1 kg"

View file

@ -0,0 +1,72 @@
import { type DataTable, Given, Then, When } from "@badeball/cypress-cucumber-preprocessor";
/**
* One data-table row a fake `ShoppingListItemView` same minimal-fixture
* convention as `recipe-form.ts`'s ingredient fixtures (only the fields
* `ShoppingListPage` actually reads at runtime: the ingredient's `key`/
* `icon`/`category` for `IngredientTypeIcon`/`CategoryIcon`/translation, the
* unit's `key`; `id` only needs to be unique per row for the React list
* key). `index` seeds both ids so two rows never collide.
*/
function buildShoppingListItem(
row: { ingredientKey: string; icon: string; category: string; quantity: string; unitKey: string },
index: number,
) {
return {
ingredient: {
id: index,
key: row.ingredientKey,
icon: row.icon,
category: row.category,
subcategory: row.category,
reproducible: false,
allergens: [],
diets: [],
},
quantity: Number(row.quantity),
unit: { id: index, key: row.unitKey, type: "MASS", toBaseFactor: 1 },
};
}
Given("the shopping list for {string} is empty", (date: string) => {
cy.intercept("GET", `**/shopping-list?date=${date}`, {
statusCode: 200,
body: { startDate: date, finishDate: date, items: [] },
});
});
Given("the shopping list for {string} contains:", (date: string, dataTable: DataTable) => {
const items = dataTable.hashes().map((row, i) => buildShoppingListItem(row, i + 1));
cy.intercept("GET", `**/shopping-list?date=${date}`, {
statusCode: 200,
body: { startDate: date, finishDate: date, items },
});
});
// Same class as PlanningPage's own week navigator (`WeekNavigator`, now
// shared between the two pages) — `planning-page.cy.ts` already exercises
// the prev/next arrows directly by class, same approach here.
When("I click the next week arrow", () => {
cy.get(".week-nav__arrow").last().click();
});
Then(
"the shopping list group {string} should appear before {string}",
(first: string, second: string) => {
cy.get(".shopping-list__group-title").then(($titles) => {
const texts = [...$titles].map((el) => el.textContent?.trim() ?? "");
const firstIndex = texts.findIndex((text) => text.includes(first));
const secondIndex = texts.findIndex((text) => text.includes(second));
expect(firstIndex, `"${first}" should be a rendered group`).to.be.greaterThan(-1);
expect(secondIndex, `"${second}" should be a rendered group`).to.be.greaterThan(-1);
expect(firstIndex).to.be.lessThan(secondIndex);
});
},
);
Then(
"the shopping list should show {string} at quantity {string}",
(name: string, quantity: string) => {
cy.contains(".shopping-list__item", name).should("contain.text", quantity);
},
);

View file

@ -17,6 +17,7 @@ import {
type RecipeTab, type RecipeTab,
type RecipeView, type RecipeView,
type SafeUserProfile, type SafeUserProfile,
type ShoppingListView,
type SignupInput, type SignupInput,
type SourceView, type SourceView,
type StepTechStepCorrectionView, type StepTechStepCorrectionView,
@ -163,6 +164,18 @@ export class ApiClient {
return this._request(`/planning/items/${id}`, { method: "DELETE" }); return this._request(`/planning/items/${id}`, { method: "DELETE" });
} }
/**
* Fetches the current user's household's shopping list for the week
* covering `date` (`YYYY-MM-DD`, e.g. from `date-tools`'s
* `formatDateOnly`) every ingredient across that week's planned
* recipes, summed. Unlike {@link getPlanningForWeek}, never resolves to
* `null`: no household or nothing planned that week both come back as a
* normal list with an empty `items` array.
*/
public getShoppingListForWeek(date: string): Promise<ShoppingListView> {
return this._request(`/shopping-list?date=${date}`);
}
/** Reference list of dietary regimes to pick from (signup wizard, `/foyer`). Public — no session required. */ /** Reference list of dietary regimes to pick from (signup wizard, `/foyer`). Public — no session required. */
public getDiets(): Promise<DietView[]> { public getDiets(): Promise<DietView[]> {
return this._request("/reference/diets"); return this._request("/reference/diets");

View file

@ -1,17 +0,0 @@
// =============================================================================
// Styles for ComingSoonPage shared by every stub section page.
// =============================================================================
// Centered, not pinned to `.app-content`'s left edge — same reasoning as
// `.settings-page` (settings-pages.scss): on a wide desktop viewport a
// left-aligned `max-width` here just left a lopsided gap down the right
// side instead of framing the placeholder copy.
.coming-soon-page {
max-width: 40rem;
margin: 0 auto;
p {
color: var(--color-text-muted);
font-size: var(--font-size-md);
}
}

View file

@ -1,26 +0,0 @@
import "./ComingSoonPage.scss";
interface ComingSoonPageProps {
title: string;
description: string;
}
/**
* Placeholder rendered by a section that has a route/sidebar entry but no
* real feature behind it yet today only `pages/shopping-list/ShoppingListPage.tsx`
* (`Recettes`/`Foyer & profil` both grew real backends since this was
* written, see `pages/recipes/`/`pages/settings/`). Kept as a shared,
* reusable component (`components/ui/`, not itself a routed page) rather
* than inlined into that one page, so a future stub section doesn't need to
* hand-roll the same markup the page that needs it still gets its own
* file (and its own copy, via i18n), just wrapping this instead of
* rewriting it.
*/
export function ComingSoonPage({ title, description }: ComingSoonPageProps) {
return (
<div className="coming-soon-page">
<h1>{title}</h1>
<p>{description}</p>
</div>
);
}

View file

@ -0,0 +1,174 @@
import {
addWeeks,
buildCalendarMonth,
DateTime,
getWeekStart,
toDateOnly,
} from "@batch-cooking/date-tools";
import { WEEK_DAYS } from "@batch-cooking/shared";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import "./week-navigator.scss";
/** "17 au 23 août 2026" — collapses the month/year to just the end date when both ends of the week share it, spells it out on both ends otherwise (e.g. a week straddling two months). */
function formatWeekRange(weekStart: DateTime): string {
const weekEnd = weekStart.plus({ days: 6 });
const sameMonth = weekStart.hasSame(weekEnd, "month");
const startLabel = weekStart.toLocaleString(
sameMonth ? { day: "numeric" } : { day: "numeric", month: "long" },
{ locale: "fr" },
);
const endLabel = weekEnd.toLocaleString(
{ day: "numeric", month: "long", year: "numeric" },
{ locale: "fr" },
);
return `${startLabel} au ${endLabel}`;
}
/**
* Arrows + clickable label opening {@link CalendarPopover} week-selection
* UI shared by any page organized around "one week at a time" (originally
* `PlanningPage`'s own grid, now also `ShoppingListPage` both just need a
* `weekStart` in/out, neither cares how the other renders its own content
* for that week). Copy comes from `common.weekNav.*`/`common.calendar.*`/
* `common.days.*` rather than `planning.*` generic enough ("Semaine
* précédente", day names) to not read as planning-specific from a page that
* isn't the planning grid.
*/
export function WeekNavigator({
weekStart,
onChangeWeek,
}: {
weekStart: DateTime;
onChangeWeek: (weekStart: DateTime) => void;
}) {
const { t } = useTranslation();
const [isCalendarOpen, setIsCalendarOpen] = useState(false);
const isThisWeek = weekStart.hasSame(getWeekStart(DateTime.utc()), "day");
return (
<div className="week-nav">
<button
type="button"
className="week-nav__arrow"
title={t("common.weekNav.prevWeek")}
onClick={() => onChangeWeek(addWeeks(weekStart, -1))}
>
</button>
<button
type="button"
className="week-nav__label"
onClick={() => setIsCalendarOpen((open) => !open)}
>
📅 {t("common.weekNav.label", { range: formatWeekRange(weekStart) })}
{isThisWeek && <span className="today-badge">{t("common.weekNav.thisWeek")}</span>}
</button>
<button
type="button"
className="week-nav__arrow"
title={t("common.weekNav.nextWeek")}
onClick={() => onChangeWeek(addWeeks(weekStart, 1))}
>
</button>
{isCalendarOpen && (
<CalendarPopover
selectedWeekStart={weekStart}
onSelectDay={(day) => {
onChangeWeek(getWeekStart(day));
setIsCalendarOpen(false);
}}
onClose={() => setIsCalendarOpen(false)}
/>
)}
</div>
);
}
/** Month calendar letting the visitor jump to any week at once — selecting a day selects its whole (Monday-first) week. Closes itself on an outside click. */
function CalendarPopover({
selectedWeekStart,
onSelectDay,
onClose,
}: {
selectedWeekStart: DateTime;
onSelectDay: (day: DateTime) => void;
onClose: () => void;
}) {
const { t } = useTranslation();
// Its own state: browsing to a different month to pick a week there
// shouldn't jump back every render — only re-anchors when the popover is
// first opened (`selectedWeekStart` at that point), not while it's open.
const [visibleMonth, setVisibleMonth] = useState(() => selectedWeekStart.startOf("month"));
const popoverRef = useRef<HTMLDivElement>(null);
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) {
onClose();
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [onClose]);
const today = toDateOnly(DateTime.utc());
const selectedWeekEnd = selectedWeekStart.plus({ days: 6 });
const weeks = buildCalendarMonth(visibleMonth);
return (
<div className="calendar-popover" ref={popoverRef}>
<div className="calendar-popover__header">
<button
type="button"
title={t("common.calendar.prevMonth")}
onClick={() => setVisibleMonth((month) => month.minus({ months: 1 }))}
>
</button>
<span>
{visibleMonth.toLocaleString({ month: "long", year: "numeric" }, { locale: "fr" })}
</span>
<button
type="button"
title={t("common.calendar.nextMonth")}
onClick={() => setVisibleMonth((month) => month.plus({ months: 1 }))}
>
</button>
</div>
<div className="calendar-grid">
{WEEK_DAYS.map((weekDay) => (
<span key={weekDay} className="calendar-grid__weekday">
{t(`common.days.${weekDay}`).charAt(0)}
</span>
))}
{weeks.flat().map((day) => {
const classNames = ["calendar-grid__day"];
if (!day.hasSame(visibleMonth, "month")) classNames.push("calendar-grid__day--muted");
if (day >= selectedWeekStart && day <= selectedWeekEnd) {
classNames.push("calendar-grid__day--in-selected-week");
}
if (day.hasSame(today, "day")) classNames.push("calendar-grid__day--today");
return (
<button
key={day.toISO()}
type="button"
className={classNames.join(" ")}
onClick={() => onSelectDay(day)}
>
{day.day}
</button>
);
})}
</div>
</div>
);
}

View file

@ -0,0 +1,143 @@
// =============================================================================
// Styles for WeekNavigator.tsx (arrows + label + calendar popover)
// colocated next to the component since nothing else uses these classes.
// Extracted from planning-page.scss once ShoppingListPage started reusing
// the component same design tokens, no light/dark duplication needed
// (every `var(--color-*)` below already resolves per-theme globally, see
// styles/_theme.scss).
// =============================================================================
.week-nav {
position: relative;
display: flex;
align-items: center;
gap: var(--space-xs);
&__arrow {
width: 2rem;
height: 2rem;
display: grid;
place-items: center;
border: 1px solid var(--color-border);
border-radius: var(--radius-base);
background: var(--color-surface);
color: var(--color-text);
font-size: var(--font-size-md);
cursor: pointer;
&:hover {
background: var(--color-surface-alt);
}
}
&__label {
display: flex;
align-items: center;
gap: var(--space-xs);
padding: 0.45rem var(--space-md);
border: 1px solid var(--color-border);
border-radius: var(--radius-base);
background: var(--color-surface);
color: var(--color-text);
font-weight: 600;
font-size: var(--font-size-sm);
cursor: pointer;
&:hover {
background: var(--color-surface-alt);
}
}
}
.today-badge {
font-size: var(--font-size-xs);
font-weight: 600;
color: var(--color-primary);
background: color-mix(in srgb, var(--color-primary) 14%, transparent);
padding: 0.1rem 0.4rem;
border-radius: var(--radius-pill);
}
// --- Calendar popover -------------------------------------------------------
.calendar-popover {
position: absolute;
top: calc(100% + var(--space-xs));
right: 0;
z-index: 10;
width: 18rem;
padding: var(--space-md);
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-md);
&__header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--space-sm);
font-weight: 700;
font-size: var(--font-size-sm);
text-transform: capitalize;
button {
width: 1.6rem;
height: 1.6rem;
border: none;
background: none;
cursor: pointer;
font-size: var(--font-size-base);
color: var(--color-text-muted);
border-radius: var(--radius-base);
&:hover {
background: var(--color-surface-alt);
}
}
}
}
.calendar-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 2px;
&__weekday {
text-align: center;
font-size: var(--font-size-xs);
color: var(--color-text-muted);
font-weight: 600;
padding-bottom: var(--space-xs);
}
&__day {
aspect-ratio: 1;
display: grid;
place-items: center;
font-size: var(--font-size-sm);
border-radius: var(--radius-base);
cursor: pointer;
color: var(--color-text);
border: none;
background: none;
font: inherit;
&:hover {
background: var(--color-surface-alt);
}
&--muted {
color: var(--color-border);
}
&--in-selected-week {
background: color-mix(in srgb, var(--color-primary) 14%, transparent);
border-radius: 0;
}
&--today {
box-shadow: inset 0 0 0 2px var(--color-primary);
font-weight: 700;
}
}
}

View file

@ -2,7 +2,26 @@
"common": { "common": {
"saving": "Enregistrement…", "saving": "Enregistrement…",
"saved": "Enregistré ✓", "saved": "Enregistré ✓",
"loadError": "Impossible de charger le planning, réessayez plus tard" "loadError": "Impossible de charger le planning, réessayez plus tard",
"weekNav": {
"thisWeek": "Cette semaine",
"prevWeek": "Semaine précédente",
"nextWeek": "Semaine suivante",
"label": "Semaine du {{range}}"
},
"calendar": {
"prevMonth": "Mois précédent",
"nextMonth": "Mois suivant"
},
"days": {
"lundi": "Lundi",
"mardi": "Mardi",
"mercredi": "Mercredi",
"jeudi": "Jeudi",
"vendredi": "Vendredi",
"samedi": "Samedi",
"dimanche": "Dimanche"
}
}, },
"errors": { "errors": {
"VALIDATION_ERROR": "Erreur de validation", "VALIDATION_ERROR": "Erreur de validation",
@ -102,25 +121,6 @@
"planning": { "planning": {
"title": "Planning de la semaine", "title": "Planning de la semaine",
"loading": "Chargement du planning…", "loading": "Chargement du planning…",
"weekNav": {
"thisWeek": "Cette semaine",
"prevWeek": "Semaine précédente",
"nextWeek": "Semaine suivante",
"label": "Semaine du {{range}}"
},
"calendar": {
"prevMonth": "Mois précédent",
"nextMonth": "Mois suivant"
},
"days": {
"lundi": "Lundi",
"mardi": "Mardi",
"mercredi": "Mercredi",
"jeudi": "Jeudi",
"vendredi": "Vendredi",
"samedi": "Samedi",
"dimanche": "Dimanche"
},
"meals": { "meals": {
"petit-dejeuner": "Petit-déjeuner", "petit-dejeuner": "Petit-déjeuner",
"collation": "Collation", "collation": "Collation",
@ -289,7 +289,8 @@
}, },
"shoppingList": { "shoppingList": {
"title": "Liste de courses", "title": "Liste de courses",
"comingSoon": "Cette section arrive bientôt." "loading": "Chargement de la liste de courses…",
"empty": "Aucun ingrédient à acheter pour cette semaine — ajoutez des recettes à votre planning."
}, },
"account": { "account": {
"title": "Compte", "title": "Compte",

View file

@ -1,11 +1,4 @@
import { import { DateTime, formatDateOnly, getWeekStart, toDateOnly } from "@batch-cooking/date-tools";
addWeeks,
buildCalendarMonth,
DateTime,
formatDateOnly,
getWeekStart,
toDateOnly,
} from "@batch-cooking/date-tools";
import { import {
MEALS, MEALS,
type Meal, type Meal,
@ -13,10 +6,11 @@ import {
type PlanningView, type PlanningView,
WEEK_DAYS, WEEK_DAYS,
} from "@batch-cooking/shared"; } from "@batch-cooking/shared";
import { useEffect, useRef, useState } from "react"; import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { apiClient } from "../../api/client"; import { apiClient } from "../../api/client";
import { type PlanningSlot, RecipePickerDialog } from "../../features/planning/RecipePickerDialog"; import { type PlanningSlot, RecipePickerDialog } from "../../features/planning/RecipePickerDialog";
import { WeekNavigator } from "../../features/planning/WeekNavigator";
import "./planning-page.scss"; import "./planning-page.scss";
/** Load state for the `GET /planning` call — a discriminated union so a stale/impossible combination (e.g. "loading" with data) can't be represented. */ /** Load state for the `GET /planning` call — a discriminated union so a stale/impossible combination (e.g. "loading" with data) can't be represented. */
@ -49,7 +43,7 @@ export function PlanningPage() {
// closed. Mounting the dialog only while this is set (rather than an // closed. Mounting the dialog only while this is set (rather than an
// always-mounted `isOpen` toggle) resets its internal filter/search // always-mounted `isOpen` toggle) resets its internal filter/search
// state for free on every open, same convention as `WeekNavigator`'s own // state for free on every open, same convention as `WeekNavigator`'s own
// `CalendarPopover` below. // `CalendarPopover` (features/planning/WeekNavigator.tsx).
const [openSlot, setOpenSlot] = useState<PlanningSlot | null>(null); const [openSlot, setOpenSlot] = useState<PlanningSlot | null>(null);
useEffect(() => { useEffect(() => {
@ -144,160 +138,6 @@ export function PlanningPage() {
); );
} }
/** "17 au 23 août 2026" — collapses the month/year to just the end date when both ends of the week share it, spells it out on both ends otherwise (e.g. a week straddling two months). */
function formatWeekRange(weekStart: DateTime): string {
const weekEnd = weekStart.plus({ days: 6 });
const sameMonth = weekStart.hasSame(weekEnd, "month");
const startLabel = weekStart.toLocaleString(
sameMonth ? { day: "numeric" } : { day: "numeric", month: "long" },
{ locale: "fr" },
);
const endLabel = weekEnd.toLocaleString(
{ day: "numeric", month: "long", year: "numeric" },
{ locale: "fr" },
);
return `${startLabel} au ${endLabel}`;
}
/** Arrows + clickable label opening {@link CalendarPopover} — the week-selection UI at the top of the page. */
function WeekNavigator({
weekStart,
onChangeWeek,
}: {
weekStart: DateTime;
onChangeWeek: (weekStart: DateTime) => void;
}) {
const { t } = useTranslation();
const [isCalendarOpen, setIsCalendarOpen] = useState(false);
const isThisWeek = weekStart.hasSame(getWeekStart(DateTime.utc()), "day");
return (
<div className="week-nav">
<button
type="button"
className="week-nav__arrow"
title={t("planning.weekNav.prevWeek")}
onClick={() => onChangeWeek(addWeeks(weekStart, -1))}
>
</button>
<button
type="button"
className="week-nav__label"
onClick={() => setIsCalendarOpen((open) => !open)}
>
📅 {t("planning.weekNav.label", { range: formatWeekRange(weekStart) })}
{isThisWeek && <span className="today-badge">{t("planning.weekNav.thisWeek")}</span>}
</button>
<button
type="button"
className="week-nav__arrow"
title={t("planning.weekNav.nextWeek")}
onClick={() => onChangeWeek(addWeeks(weekStart, 1))}
>
</button>
{isCalendarOpen && (
<CalendarPopover
selectedWeekStart={weekStart}
onSelectDay={(day) => {
onChangeWeek(getWeekStart(day));
setIsCalendarOpen(false);
}}
onClose={() => setIsCalendarOpen(false)}
/>
)}
</div>
);
}
/** Month calendar letting the visitor jump to any week at once — selecting a day selects its whole (Monday-first) week. Closes itself on an outside click. */
function CalendarPopover({
selectedWeekStart,
onSelectDay,
onClose,
}: {
selectedWeekStart: DateTime;
onSelectDay: (day: DateTime) => void;
onClose: () => void;
}) {
const { t } = useTranslation();
// Its own state: browsing to a different month to pick a week there
// shouldn't jump back every render — only re-anchors when the popover is
// first opened (`selectedWeekStart` at that point), not while it's open.
const [visibleMonth, setVisibleMonth] = useState(() => selectedWeekStart.startOf("month"));
const popoverRef = useRef<HTMLDivElement>(null);
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) {
onClose();
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [onClose]);
const today = toDateOnly(DateTime.utc());
const selectedWeekEnd = selectedWeekStart.plus({ days: 6 });
const weeks = buildCalendarMonth(visibleMonth);
return (
<div className="calendar-popover" ref={popoverRef}>
<div className="calendar-popover__header">
<button
type="button"
title={t("planning.calendar.prevMonth")}
onClick={() => setVisibleMonth((month) => month.minus({ months: 1 }))}
>
</button>
<span>
{visibleMonth.toLocaleString({ month: "long", year: "numeric" }, { locale: "fr" })}
</span>
<button
type="button"
title={t("planning.calendar.nextMonth")}
onClick={() => setVisibleMonth((month) => month.plus({ months: 1 }))}
>
</button>
</div>
<div className="calendar-grid">
{WEEK_DAYS.map((weekDay) => (
<span key={weekDay} className="calendar-grid__weekday">
{t(`planning.days.${weekDay}`).charAt(0)}
</span>
))}
{weeks.flat().map((day) => {
const classNames = ["calendar-grid__day"];
if (!day.hasSame(visibleMonth, "month")) classNames.push("calendar-grid__day--muted");
if (day >= selectedWeekStart && day <= selectedWeekEnd) {
classNames.push("calendar-grid__day--in-selected-week");
}
if (day.hasSame(today, "day")) classNames.push("calendar-grid__day--today");
return (
<button
key={day.toISO()}
type="button"
className={classNames.join(" ")}
onClick={() => onSelectDay(day)}
>
{day.day}
</button>
);
})}
</div>
</div>
);
}
/** The week grid itself — 7 day columns × 5 meal rows. */ /** The week grid itself — 7 day columns × 5 meal rows. */
function PlanningGrid({ function PlanningGrid({
weekStart, weekStart,
@ -323,7 +163,7 @@ function PlanningGrid({
<th /> <th />
{days.map(({ weekDay, date }) => ( {days.map(({ weekDay, date }) => (
<th key={weekDay} className={date.hasSame(today, "day") ? "today" : undefined}> <th key={weekDay} className={date.hasSame(today, "day") ? "today" : undefined}>
<span className="day-name">{t(`planning.days.${weekDay}`)}</span> <span className="day-name">{t(`common.days.${weekDay}`)}</span>
<span className="day-date">{date.day}</span> <span className="day-date">{date.day}</span>
</th> </th>
))} ))}

View file

@ -38,143 +38,11 @@
} }
} }
// --- Week navigator (arrows + clickable label opening the calendar) -------
.week-nav {
position: relative;
display: flex;
align-items: center;
gap: var(--space-xs);
&__arrow {
width: 2rem;
height: 2rem;
display: grid;
place-items: center;
border: 1px solid var(--color-border);
border-radius: var(--radius-base);
background: var(--color-surface);
color: var(--color-text);
font-size: var(--font-size-md);
cursor: pointer;
&:hover {
background: var(--color-surface-alt);
}
}
&__label {
display: flex;
align-items: center;
gap: var(--space-xs);
padding: 0.45rem var(--space-md);
border: 1px solid var(--color-border);
border-radius: var(--radius-base);
background: var(--color-surface);
color: var(--color-text);
font-weight: 600;
font-size: var(--font-size-sm);
cursor: pointer;
&:hover {
background: var(--color-surface-alt);
}
}
}
.today-badge {
font-size: var(--font-size-xs);
font-weight: 600;
color: var(--color-primary);
background: color-mix(in srgb, var(--color-primary) 14%, transparent);
padding: 0.1rem 0.4rem;
border-radius: var(--radius-pill);
}
// --- Calendar popover -------------------------------------------------------
.calendar-popover {
position: absolute;
top: calc(100% + var(--space-xs));
right: 0;
z-index: 10;
width: 18rem;
padding: var(--space-md);
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-md);
&__header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--space-sm);
font-weight: 700;
font-size: var(--font-size-sm);
text-transform: capitalize;
button {
width: 1.6rem;
height: 1.6rem;
border: none;
background: none;
cursor: pointer;
font-size: var(--font-size-base);
color: var(--color-text-muted);
border-radius: var(--radius-base);
&:hover {
background: var(--color-surface-alt);
}
}
}
}
.calendar-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 2px;
&__weekday {
text-align: center;
font-size: var(--font-size-xs);
color: var(--color-text-muted);
font-weight: 600;
padding-bottom: var(--space-xs);
}
&__day {
aspect-ratio: 1;
display: grid;
place-items: center;
font-size: var(--font-size-sm);
border-radius: var(--radius-base);
cursor: pointer;
color: var(--color-text);
border: none;
background: none;
font: inherit;
&:hover {
background: var(--color-surface-alt);
}
&--muted {
color: var(--color-border);
}
&--in-selected-week {
background: color-mix(in srgb, var(--color-primary) 14%, transparent);
border-radius: 0;
}
&--today {
box-shadow: inset 0 0 0 2px var(--color-primary);
font-weight: 700;
}
}
}
// --- The grid itself -------------------------------------------------------- // --- The grid itself --------------------------------------------------------
// (Week navigator + calendar popover styles now live in
// features/planning/week-navigator.scss, imported by WeekNavigator.tsx
// directly extracted once ShoppingListPage started reusing that
// component too.)
.planning-grid-wrapper { .planning-grid-wrapper {
flex: 1; flex: 1;
min-height: 0; min-height: 0;

View file

@ -1,10 +1,114 @@
import { DateTime, formatDateOnly, getWeekStart } from "@batch-cooking/date-tools";
import type { ShoppingListItemView, ShoppingListView } from "@batch-cooking/shared";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { ComingSoonPage } from "../../components/ui/ComingSoonPage"; import { apiClient } from "../../api/client";
import { WeekNavigator } from "../../features/planning/WeekNavigator";
import {
CategoryIcon,
IngredientTypeIcon,
} from "../../features/recipes/ingredients/ingredient-icons";
import { formatShoppingListQuantity, groupShoppingListItems } from "./shopping-list";
import "./shopping-list-page.scss";
/** Shopping list section — routed at `/liste-de-courses`. No backend yet, stub for now. */ /** Load state for the `GET /shopping-list` call — same discriminated-union shape as `PlanningPage`'s own `PlanningState`. */
type ShoppingListState =
| { status: "loading" }
| { status: "loaded"; list: ShoppingListView }
| { status: "error" };
/**
* Shopping list section routed at `/liste-de-courses`. Every ingredient
* line of every recipe planned for a selectable week, aggregated server-side
* (`GET /shopping-list`, see the API's `shopping-list.service.ts`) into one
* quantity per (ingredient, unit) pair, grouped by supermarket aisle for
* display. Deliberately simple by design a read-only list, no
* checkboxes/crossing-off state: the source of truth for what's needed is
* the planning itself, not a separate to-do list this page would have to
* keep in sync with it.
*/
export function ShoppingListPage() { export function ShoppingListPage() {
const { t } = useTranslation(); const { t } = useTranslation();
const [weekStart, setWeekStart] = useState<DateTime>(() => getWeekStart(DateTime.utc()));
const [state, setState] = useState<ShoppingListState>({ status: "loading" });
useEffect(() => {
let cancelled = false;
setState({ status: "loading" });
apiClient
.getShoppingListForWeek(formatDateOnly(weekStart))
.then((list) => {
if (!cancelled) setState({ status: "loaded", list });
})
.catch(() => {
if (!cancelled) setState({ status: "error" });
});
return () => {
cancelled = true;
};
}, [weekStart]);
return ( return (
<ComingSoonPage title={t("shoppingList.title")} description={t("shoppingList.comingSoon")} /> <div className="shopping-list-page">
<div className="shopping-list-page__header">
<h1>{t("shoppingList.title")}</h1>
<WeekNavigator weekStart={weekStart} onChangeWeek={setWeekStart} />
</div>
{state.status === "loading" && (
<p className="shopping-list-page__status">{t("shoppingList.loading")}</p>
)}
{state.status === "error" && (
<p className="shopping-list-page__status shopping-list-page__status--error">
{t("common.loadError")}
</p>
)}
{state.status === "loaded" && <ShoppingListItems items={state.list.items} />}
</div>
);
}
/** The list itself, grouped by aisle (see `groupShoppingListItems`) — or the empty-week message if nothing's planned. */
function ShoppingListItems({ items }: { items: ShoppingListItemView[] }) {
const { t } = useTranslation();
if (items.length === 0) {
return <p className="shopping-list-page__status">{t("shoppingList.empty")}</p>;
}
const groups = groupShoppingListItems(items, (item) =>
t(`catalog.ingredients.${item.ingredient.key}`),
);
return (
<div className="shopping-list">
{groups.map((group) => (
<section key={group.category} className="shopping-list__group">
<h2 className="shopping-list__group-title">
<CategoryIcon category={group.category} />
{t(`recipes.form.category.${group.category}`)}
</h2>
<ul className="shopping-list__items">
{group.items.map((item) => (
<li key={`${item.ingredient.id}-${item.unit.id}`} className="shopping-list__item">
<span className="shopping-list__item-icon" aria-hidden="true">
<IngredientTypeIcon icon={item.ingredient.icon} />
</span>
<span className="shopping-list__item-name">
{t(`catalog.ingredients.${item.ingredient.key}`)}
</span>
<span className="shopping-list__item-quantity">
{formatShoppingListQuantity(item.quantity)} {t(`catalog.units.${item.unit.key}`)}
</span>
</li>
))}
</ul>
</section>
))}
</div>
); );
} }

View file

@ -0,0 +1,102 @@
// =============================================================================
// Styles specific to ShoppingListPage colocated next to
// ShoppingListPage.tsx since nothing else uses these classes. Same page
// shell/status conventions as planning-page.scss (`.planning-page__header`/
// `__status`), a simple grouped list rather than a grid below it.
// =============================================================================
.shopping-list-page {
height: 100%;
display: flex;
flex-direction: column;
&__header {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: var(--space-md);
margin-bottom: var(--space-lg);
}
&__status {
color: var(--color-text-muted);
font-size: var(--font-size-md);
}
&__status--error {
color: var(--color-error);
}
}
// --- The list itself, grouped by aisle --------------------------------------
.shopping-list {
flex: 1;
min-height: 0;
overflow: auto;
display: flex;
flex-direction: column;
gap: var(--space-lg);
}
.shopping-list__group-title {
display: flex;
align-items: center;
gap: var(--space-xs);
margin: 0 0 var(--space-sm);
font-size: var(--font-size-md);
font-weight: 700;
color: var(--color-text);
svg {
width: 1.2rem;
height: 1.2rem;
color: var(--color-text-muted);
}
}
.shopping-list__items {
display: flex;
flex-direction: column;
background: var(--color-surface);
border-radius: var(--radius-md);
box-shadow: var(--shadow-sm);
}
.shopping-list__item {
display: flex;
align-items: center;
gap: var(--space-sm);
padding: var(--space-sm) var(--space-md);
border-bottom: 1px solid var(--color-border);
&:last-child {
border-bottom: none;
}
&-icon {
flex-shrink: 0;
display: grid;
place-items: center;
color: var(--color-text-muted);
svg {
width: 1.25rem;
height: 1.25rem;
}
}
&-name {
flex: 1;
min-width: 0;
color: var(--color-text);
}
&-quantity {
flex-shrink: 0;
font-weight: 600;
color: var(--color-text);
font-variant-numeric: tabular-nums;
}
}

View file

@ -0,0 +1,64 @@
import {
INGREDIENT_CATEGORIES,
type IngredientCategory,
type ShoppingListItemView,
} from "@batch-cooking/shared";
/** One aisle's worth of shopping list lines — see {@link groupShoppingListItems}. */
export interface ShoppingListGroup {
category: IngredientCategory;
items: ShoppingListItemView[];
}
/**
* Groups `items` by their ingredient's supermarket-aisle category (the same
* `IngredientCategory` the recipe form's `IngredientPicker` already browses
* by, see `ingredient-icons.tsx`'s `CategoryIcon`), in the app's canonical
* `INGREDIENT_CATEGORIES` order a shopping list read aisle-by-aisle is far
* more useful in-store than one flat list. Within a group, lines are sorted
* by `ingredientLabel` the caller's *already-translated* display name for
* that line, not the untranslated English `key` so alphabetical order
* reads correctly in French; kept as a parameter (rather than calling
* `useTranslation` in here) so this stays a pure function the component can
* unit test without mounting i18next, same "logic extracted from the .tsx"
* split as every other feature in this codebase.
*/
export function groupShoppingListItems(
items: ShoppingListItemView[],
ingredientLabel: (item: ShoppingListItemView) => string,
): ShoppingListGroup[] {
const byCategory = new Map<IngredientCategory, ShoppingListItemView[]>();
for (const item of items) {
const category = item.ingredient.category;
const group = byCategory.get(category);
if (group) {
group.push(item);
} else {
byCategory.set(category, [item]);
}
}
const groups: ShoppingListGroup[] = [];
for (const category of INGREDIENT_CATEGORIES) {
const groupItems = byCategory.get(category);
if (!groupItems) continue;
groups.push({
category,
items: [...groupItems].sort((a, b) =>
ingredientLabel(a).localeCompare(ingredientLabel(b), "fr"),
),
});
}
return groups;
}
/**
* Formats an aggregated quantity for display French grouping/decimal
* conventions, at most 2 decimals (e.g. `"1,5"`, `"250"`) so summing several
* recipes' quantities (`shopping-list.service.ts`'s `aggregateShoppingList`,
* floating-point addition) never surfaces a long trailing-digit artifact
* like `"149.99999999999997"`.
*/
export function formatShoppingListQuantity(quantity: number): string {
return quantity.toLocaleString("fr-FR", { maximumFractionDigits: 2 });
}

View file

@ -13,6 +13,7 @@ export * from "./schemas/planning.js";
export * from "./schemas/preferences.js"; export * from "./schemas/preferences.js";
export * from "./schemas/profile.js"; export * from "./schemas/profile.js";
export * from "./schemas/recipe.js"; export * from "./schemas/recipe.js";
export * from "./schemas/shopping-list.js";
export * from "./schemas/sources.js"; export * from "./schemas/sources.js";
export * from "./schemas/tech-step-worker.js"; export * from "./schemas/tech-step-worker.js";
export * from "./tools/assert-is-never.js"; export * from "./tools/assert-is-never.js";
@ -21,6 +22,7 @@ export * from "./types/planning.js";
export * from "./types/preferences.js"; export * from "./types/preferences.js";
export * from "./types/recipe.js"; export * from "./types/recipe.js";
export * from "./types/reference.js"; export * from "./types/reference.js";
export * from "./types/shopping-list.js";
export * from "./types/sources.js"; export * from "./types/sources.js";
export * from "./types/tech-step-worker.js"; export * from "./types/tech-step-worker.js";
export * from "./types/user-profile.js"; export * from "./types/user-profile.js";

View file

@ -0,0 +1,19 @@
import { z } from "zod";
// See schemas/auth.ts for the shared client/server validation rationale.
/**
* Payload accepted by `GET /shopping-list`'s `?date=` query param same
* shape/rationale as `schemas/planning.ts`'s `getPlanningByDateSchema`
* (only checks the `YYYY-MM-DD` shape, real-calendar-date validation is
* service-side via `@batch-cooking/date-tools`'s `parseDateOnly`). Kept as
* its own schema rather than importing `getPlanningByDateSchema` each
* router module owns its own request contract in this repo, even when two
* happen to share a shape (see the two near-identical `date` fields already
* inside `schemas/planning.ts` itself).
*/
export const getShoppingListSchema = z.object({
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Date invalide"),
});
/** Inferred TS type for {@link getShoppingListSchema}'s validated output. */
export type GetShoppingListInput = z.infer<typeof getShoppingListSchema>;

View file

@ -0,0 +1,37 @@
import type { IngredientView, UnitView } from "./reference.js";
/**
* One aggregated ingredient line in a shopping list every
* `RecipeIngredient` line of every recipe planned for the week, summed
* across recipes/planning items. `quantity` already accounts for each
* planning item's own portion count (`RecipeIngredient.quantity ×
* PlanningItem.portions / Recipe.portions`, see the API's
* `shopping-list.service.ts`), so this is the real amount to buy, not the
* recipe's as-written quantity.
*
* Quantities are only ever summed when both `ingredient` **and** `unit`
* match exactly `UnitView.toBaseFactor` exists as groundwork for a future
* cross-unit conversion (e.g. summing "500g" + "0.5kg" into "1kg"), not yet
* built (see that field's own doc comment), so the same ingredient
* requested in two different units surfaces as two separate lines rather
* than silently guessing a conversion.
*/
export interface ShoppingListItemView {
ingredient: IngredientView;
quantity: number;
unit: UnitView;
}
/**
* A household's shopping list for the week starting `startDate`, as
* returned by `GET /shopping-list`. Unlike `PlanningView`, this is
* **never** `null` a caller with no household, or whose household has no
* planning for that week yet, both degrade to an empty `items` array
* (nothing to shop for is a normal state to render directly, not a
* separate "no list" case to branch on).
*/
export interface ShoppingListView {
startDate: string;
finishDate: string;
items: ShoppingListItemView[];
}

View file

@ -258,6 +258,44 @@ feature. Détail du flux complet :
--- ---
## Liste de courses — agrégation des ingrédients planifiés
Router `/shopping-list` (`shopping-list.routes.ts`/`.service.ts`),
`requireAuth` — un seul endpoint : `GET /shopping-list?date=YYYY-MM-DD`
`getShoppingListForDate``ShoppingListView`. Même contrat `?date=` que
`GET /planning` (même schéma de requête shape-only, validation calendaire
réelle via `date-tools`'s `parseDateOnly`), même requête "plage couvrante"
(`startDate <= date <= finishDate`) que `getPlanningForDate` — mais
**jamais `null`** : pas de foyer, ou aucun `Planning` ne couvre la semaine,
retombent tous deux sur un `ShoppingListView` normal à `items: []` plutôt
qu'un état à part que le frontend devrait distinguer.
**Agrégation** (`aggregateShoppingList`, pure/synchrone — testable sans base)
— pour chaque `PlanningItem` de la semaine, chaque ligne
`RecipeIngredient` de sa recette est mise à l'échelle
(`quantity × PlanningItem.portions / Recipe.portions`, cf.
`PlanningItem.portions`'s doc comment dans schema.prisma) puis sommée dans
une `Map` clée par **`(ingredientId, unitId)`** — pas juste `ingredientId` :
la même ligne d'ingrédient dans deux unités différentes (ex. une recette en
grammes, une autre en kilogrammes pour le même ingrédient) reste deux lignes
séparées, aucune conversion inter-unités n'étant construite (voir
`UnitView.toBaseFactor`'s doc comment, `packages/shared`). L'ordre final
(par `Ingredient.key`) n'est là que pour un JSON déterministe en test — le
frontend retrie par rayon/libellé traduit pour l'affichage (voir
[frontend-architecture.md](./frontend-architecture.md), section "Liste de
courses").
`shopping-list.service.ts` réutilise directement `recipe.service.ts`'s
`toIngredientView`/`toUnitView` (exportées pour cette raison) plutôt que de
re-dupliquer le même mapping Prisma → vue publique — sa propre requête
Prisma ne charge qu'un sous-ensemble de `Recipe` (juste `portions` +
`ingredients`, pas `steps`/`diets`/`favoritedBy`) mais avec exactement la
même forme imbriquée `ingredient.allergies`/`ingredient.diets` que
`recipe.service.ts`'s `recipeInclude`, donc les deux fonctions s'appliquent
telles quelles par typage structurel.
---
## `reference` — catalogues publics (pas de session requise) ## `reference` — catalogues publics (pas de session requise)
Router `/reference` (`reference.routes.ts`/`.service.ts`) — **toutes les Router `/reference` (`reference.routes.ts`/`.service.ts`) — **toutes les

View file

@ -87,9 +87,16 @@ détectées, favoris, visibilité des recettes).
## Notes ## Notes
- Le module de calcul batch-cooking (et la « Liste de courses », son - **La « Liste de courses » est implémentée** (`GET /shopping-list`, voir
débouché naturel — `ShoppingListPage` reste un stub côté web) est le [backend-architecture.md](./backend-architecture.md#liste-de-courses--agrégation-des-ingrédients-planifiés))
principal chantier restant côté serveur. — une simple **agrégation** des ingrédients déjà planifiés (somme par
ingrédient/unité, mise à l'échelle par les portions de chaque créneau),
pas une optimisation. Le module « Calcul batch-cooking » lui-même reste
`TODO` : il désigne quelque chose de plus ambitieux qu'une somme
d'ingrédients — optimiser le planning/les recettes entre elles (ex.
mutualiser une préparation entre plusieurs recettes de la semaine), pas
encore défini plus précisément. C'est le principal chantier restant côté
serveur.
- Le canal websocket envisagé pour la communication temps réel n'a pas encore - Le canal websocket envisagé pour la communication temps réel n'a pas encore
été construit — rien ne le remplace aujourd'hui (pas de polling), à été construit — rien ne le remplace aujourd'hui (pas de polling), à
reconsidérer au moment d'attaquer le calcul batch-cooking. reconsidérer au moment d'attaquer le calcul batch-cooking.

View file

@ -21,8 +21,7 @@ apps/web/src/
│ └── ui/ # primitives réutilisables partout, voir plus bas │ └── ui/ # primitives réutilisables partout, voir plus bas
│ ├── Dialog.tsx + dialog.scss # modale (élément <dialog> natif) │ ├── Dialog.tsx + dialog.scss # modale (élément <dialog> natif)
│ ├── Checkbox.tsx / Radio.tsx # "carte sélectionnable" (CheckboxOption/RadioOption) │ ├── Checkbox.tsx / Radio.tsx # "carte sélectionnable" (CheckboxOption/RadioOption)
│ ├── Tooltip.tsx + tooltip.scss # infobulle CSS-only │ └── Tooltip.tsx + tooltip.scss # infobulle CSS-only
│ └── ComingSoonPage.tsx + .scss # placeholder générique, section sans backend (ex. Liste de courses) — pas une page routée elle-même, un composant que la page routée (pages/shopping-list/ShoppingListPage.tsx) enveloppe
├── features/ ├── features/
│ ├── auth/ # authentification │ ├── auth/ # authentification
│ │ ├── AuthContext.tsx # état global (profil connecté, login/signup/logout, refreshUser, deleteAccount) │ │ ├── AuthContext.tsx # état global (profil connecté, login/signup/logout, refreshUser, deleteAccount)
@ -38,7 +37,8 @@ apps/web/src/
│ │ └── house-forms.scss │ │ └── house-forms.scss
│ ├── planning/ │ ├── planning/
│ │ ├── RecipePickerDialog.tsx # dialogue "ajouter au planning" — parcourir/prévisualiser/importer, voir plus bas │ │ ├── RecipePickerDialog.tsx # dialogue "ajouter au planning" — parcourir/prévisualiser/importer, voir plus bas
│ │ └── recipe-picker-dialog.scss │ │ ├── recipe-picker-dialog.scss
│ │ └── WeekNavigator.tsx + week-navigator.scss # arrows + calendrier de sélection de semaine — partagé par PlanningPage et ShoppingListPage, voir plus bas
│ └── recipes/ # catalogue, import, édition — voir plus bas ; sous-dossiers par sous-domaine, pas de fichiers à plat │ └── recipes/ # catalogue, import, édition — voir plus bas ; sous-dossiers par sous-domaine, pas de fichiers à plat
│ ├── RecipeTable.tsx / RecipeTabs.tsx / RecipeDetailPanel.tsx # racine : composants transverses au sous-domaine (utilisés par plusieurs des sous-dossiers ci-dessous) │ ├── RecipeTable.tsx / RecipeTabs.tsx / RecipeDetailPanel.tsx # racine : composants transverses au sous-domaine (utilisés par plusieurs des sous-dossiers ci-dessous)
│ ├── recipes.scss # feuille de style partagée, importée depuis chaque sous-dossier via ../recipes.scss │ ├── recipes.scss # feuille de style partagée, importée depuis chaque sous-dossier via ../recipes.scss
@ -63,7 +63,8 @@ apps/web/src/
│ │ ├── RecipeFormPage.tsx # création/édition manuelle (/recettes/nouvelle, /recettes/:id/modifier) │ │ ├── RecipeFormPage.tsx # création/édition manuelle (/recettes/nouvelle, /recettes/:id/modifier)
│ │ └── ImportRecipePage.tsx # route de secours autonome pour un import (/recettes/importer/:sourceKey/:externalId) │ │ └── ImportRecipePage.tsx # route de secours autonome pour un import (/recettes/importer/:sourceKey/:externalId)
│ ├── shopping-list/ │ ├── shopping-list/
│ │ └── ShoppingListPage.tsx # enveloppe components/ui/ComingSoonPage.tsx — section sans backend │ │ ├── ShoppingListPage.tsx + shopping-list-page.scss # liste agrégée (GET /shopping-list), groupée par rayon, voir plus bas
│ │ └── shopping-list.ts # logique pure (groupement/tri/formatage) extraite du composant, voir plus bas
│ ├── settings/ # ancienne HouseholdPage éclatée en 5 pages, voir plus bas │ ├── settings/ # ancienne HouseholdPage éclatée en 5 pages, voir plus bas
│ │ ├── AccountSettingsPage.tsx / HouseholdSettingsPage.tsx / PreferencesPage.tsx │ │ ├── AccountSettingsPage.tsx / HouseholdSettingsPage.tsx / PreferencesPage.tsx
│ │ ├── UserPreferencesPage.tsx / CreditsPage.tsx │ │ ├── UserPreferencesPage.tsx / CreditsPage.tsx
@ -137,7 +138,7 @@ flowchart TB
rendent **le même composant** `RecipesPage`, voir plus bas), `/recettes/nouvelle` rendent **le même composant** `RecipesPage`, voir plus bas), `/recettes/nouvelle`
/ `/recettes/:id/modifier` (`RecipeFormPage`), / `/recettes/:id/modifier` (`RecipeFormPage`),
`/recettes/importer/:sourceKey/:externalId` (`ImportRecipePage`, route de `/recettes/importer/:sourceKey/:externalId` (`ImportRecipePage`, route de
secours autonome), `/liste-de-courses` (`ShoppingListPage`, toujours un stub), secours autonome), `/liste-de-courses` (`ShoppingListPage`),
et les cinq pages `/parametres/*` (compte, préférences, foyer, et les cinq pages `/parametres/*` (compte, préférences, foyer,
préférences-utilisateur, crédits — voir plus bas). `/foyer` (l'URL de préférences-utilisateur, crédits — voir plus bas). `/foyer` (l'URL de
l'ancienne page combinée) redirige vers `/parametres/foyer` pour ne pas casser l'ancienne page combinée) redirige vers `/parametres/foyer` pour ne pas casser
@ -191,14 +192,15 @@ La sidebar a grandi avec l'app :
`layout.settings.nav.<clé>` — ajouter une entrée de nav est un item de `layout.settings.nav.<clé>` — ajouter une entrée de nav est un item de
tableau + une clé de locale, rien d'autre. tableau + une clé de locale, rien d'autre.
### Sections sans backend`ComingSoonPage` ### Sections sans backend
Seule `Liste de courses` (`ShoppingListPage`) n'a pas encore de backend dédié Plus aucune section de la sidebar ne rend un placeholder générique — `Liste
(le module « Calcul batch-cooking » reste `TODO`, voir de courses` (`ShoppingListPage`) a désormais un vrai backend (voir la section
[batch-cooking-architecture.md](./batch-cooking-architecture.md)) et rend le dédiée plus bas), et `Recettes` en a un depuis plus longtemps (catalogue,
composant partagé `ComingSoonPage` (`title`/`description`). `Recettes` a import depuis des sources externes, favoris — voir plus bas). Le composant
maintenant un vrai backend complet (catalogue, import depuis des sources `ComingSoonPage` (`components/ui/`) qui servait de stub pour ces deux pages a
externes, favoris — voir plus bas) et ne passe plus par ce stub. été retiré une fois son dernier appelant (`ShoppingListPage`) migré vers un
vrai rendu.
--- ---
@ -362,12 +364,17 @@ L'ancienne `HouseholdPage` combinée est éclatée en 5 pages dédiées
regroupement visuel "moments de la journée" (matin/midi/après-midi/soir) via regroupement visuel "moments de la journée" (matin/midi/après-midi/soir) via
une bordure appuyée après `collation`/`dejeuner`/`gouter`. une bordure appuyée après `collation`/`dejeuner`/`gouter`.
- **Navigation de semaine**`WeekNavigator` (flèches précédent/suivant, - **Navigation de semaine**`WeekNavigator` (`features/planning/WeekNavigator.tsx` +
`addWeeks(weekStart, ±1)` de `@batch-cooking/date-tools`) + un libellé `week-navigator.scss` — extrait de `PlanningPage` une fois `ShoppingListPage`
devenue une deuxième consommatrice, voir plus bas) : flèches précédent/suivant
(`addWeeks(weekStart, ±1)` de `@batch-cooking/date-tools`) + un libellé
cliquable ouvrant un `CalendarPopover` (grille mensuelle via cliquable ouvrant un `CalendarPopover` (grille mensuelle via
`buildCalendarMonth`, cliquer un jour saute à sa semaine, lundi-first). Un `buildCalendarMonth`, cliquer un jour saute à sa semaine, lundi-first). Un
badge "aujourd'hui" s'affiche quand la semaine visible est la semaine badge "aujourd'hui" s'affiche quand la semaine visible est la semaine
courante. courante. Ses propres libellés viennent de `common.weekNav.*`/
`common.calendar.*`/`common.days.*` (pas `planning.*`) — assez génériques
("Semaine précédente", noms de jours) pour ne pas paraître hors-sujet
depuis une page qui n'est pas la grille de planning.
- `GET /planning?date=YYYY-MM-DD` renvoie `PlanningView | null``null` est - `GET /planning?date=YYYY-MM-DD` renvoie `PlanningView | null``null` est
un état normal (rien à afficher pour cette semaine), pas un message un état normal (rien à afficher pour cette semaine), pas un message
d'erreur séparé : les boutons "+" de chaque case suffisent à communiquer d'erreur séparé : les boutons "+" de chaque case suffisent à communiquer
@ -433,6 +440,49 @@ quelqu'un l'ajoute à son planning" :
--- ---
## Liste de courses (`/liste-de-courses`)
`pages/shopping-list/ShoppingListPage.tsx` (+ `shopping-list-page.scss`,
`shopping-list.ts`) — même `WeekNavigator` que `PlanningPage` (semaine
sélectionnable), mais un rendu bien plus simple en dessous : une liste,
volontairement pas une grille. Délibérément **en lecture seule** — pas de
case à cocher/état "acheté" à faire persister : la source de vérité de ce
qu'il faut acheter reste le planning lui-même, pas une liste de courses
séparée qu'il faudrait garder synchronisée avec lui lorsqu'une recette est
ajoutée/retirée après coup.
- `GET /shopping-list?date=YYYY-MM-DD` renvoie toujours un `ShoppingListView`
(jamais `null`, contrairement à `GET /planning`) — chaque ingrédient de
chaque recette planifiée cette semaine, déjà sommé côté serveur
(`RecipeIngredient.quantity × PlanningItem.portions / Recipe.portions`,
additionné par paire `(ingredientId, unitId)` — voir
[backend-architecture.md](./backend-architecture.md#liste-de-courses--agrégation-des-ingrédients-planifiés)).
Aucun état foyer/semaine vide n'est un cas d'erreur séparé : les deux
redescendent en un `items: []` normal, affiché via `shoppingList.empty`.
- **Groupement par rayon**`shopping-list.ts`'s `groupShoppingListItems`
(logique pure, extraite du composant, testable sans monter i18next) trie
les lignes par `IngredientCategory` (même catalogue "rayon de
supermarché" que `IngredientPicker`, ordre canonique
`INGREDIENT_CATEGORIES` de `packages/shared`), puis alphabétiquement à
l'intérieur d'un rayon — sur le libellé **déjà traduit** (pas la `key`
anglaise), pour un tri qui se lit correctement en français. Chaque
en-tête de groupe réutilise `CategoryIcon`/`recipes.form.category.<clé>`
(`ingredient-icons.tsx`), déjà utilisés par `IngredientPicker` — pas de
nouveau jeu d'icônes/libellés pour cette page.
- **Formatage des quantités**`shopping-list.ts`'s
`formatShoppingListQuantity` (`Number.prototype.toLocaleString("fr-FR",
{maximumFractionDigits: 2})`) — évite qu'une somme de plusieurs recettes
(addition flottante côté serveur) affiche un résidu du type
`"149.99999999999997"`.
- Aucune conversion d'unité — deux lignes du même ingrédient dans deux
unités différentes (ex. "tomate" en grammes sur une recette, en
kilogrammes sur une autre) restent deux lignes séparées plutôt que d'être
fusionnées par une conversion devinée ; voir `UnitView.toBaseFactor`'s doc
comment (`packages/shared`) — la conversion inter-unités reste posée
comme fondation pour plus tard, pas construite.
---
## Recettes — catalogue, favoris, import depuis une source externe ## Recettes — catalogue, favoris, import depuis une source externe
`pages/recipes/RecipesPage.tsx` — routée sur `/recettes`, `/recettes/:id` **et** `pages/recipes/RecipesPage.tsx` — routée sur `/recettes`, `/recettes/:id` **et**
@ -628,9 +678,6 @@ Clic sur une ligne :
`:hover`/`:focus-within` (aucun état JS). `children` doit être un seul `:hover`/`:focus-within` (aucun état JS). `children` doit être un seul
élément focusable ; cloné pour y attacher `aria-describedby` (lecteurs élément focusable ; cloné pour y attacher `aria-describedby` (lecteurs
d'écran). Utilisé par `StepDescription.tsx` pour l'infobulle des techniques. d'écran). Utilisé par `StepDescription.tsx` pour l'infobulle des techniques.
- **`ComingSoonPage.tsx`** (+ `.scss`) — placeholder générique (`title`/
`description`) pour une section routée sans backend, voir
[Sections sans backend](#sections-sans-backend--comingsoonpage) plus haut.
--- ---
@ -659,11 +706,13 @@ JSON, jamais codé en dur dans un composant.
une seule fois pour son effet de bord dans `main.tsx`, avant le premier rendu. une seule fois pour son effet de bord dans `main.tsx`, avant le premier rendu.
- `locales/fr/translation.json` — toutes les chaînes françaises, organisées par - `locales/fr/translation.json` — toutes les chaînes françaises, organisées par
namespace de premier niveau : `common` (libellés génériques réutilisés namespace de premier niveau : `common` (libellés génériques réutilisés
partout), `errors` (voir [error-handling.md](./error-handling.md)), `auth` partout — dont `common.weekNav.*`/`common.calendar.*`/`common.days.*`,
partagés par `WeekNavigator` entre `PlanningPage` et `ShoppingListPage`),
`errors` (voir [error-handling.md](./error-handling.md)), `auth`
(`auth.login.*`/`auth.signup.*`), `layout` (nav de la sidebar dont (`auth.login.*`/`auth.signup.*`), `layout` (nav de la sidebar dont
`layout.settings.nav.*` pour le sous-menu Paramètres — `AppLayout`), `layout.settings.nav.*` pour le sous-menu Paramètres — `AppLayout`),
`planning` (grille de la semaine, `RecipePickerDialog`), `recipes` `planning` (grille de la semaine, `RecipePickerDialog`), `recipes`
(catalogue, tabs, import), `shoppingList` (page stub, voir `ComingSoonPage` (catalogue, tabs, import), `shoppingList` (page `/liste-de-courses`, voir
plus haut), `onboarding` (wizard d'inscription), `household` (titre + plus haut), `onboarding` (wizard d'inscription), `household` (titre +
`form.*`, champs partagés par le wizard et `/parametres/foyer`, plus `form.*`, champs partagés par le wizard et `/parametres/foyer`, plus
`household.sources.*` pour le badge officiel/non-officiel), `account` / `household.sources.*` pour le badge officiel/non-officiel), `account` /