diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 635cb55..1d62303 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -13,6 +13,7 @@ import { preferencesRouter } from "./modules/preferences/preferences.routes.js"; import { profileRouter } from "./modules/profile/profile.routes.js"; import { recipeRouter } from "./modules/recipe/recipe.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"; /** @@ -50,6 +51,7 @@ export function createServer(): ExpressServer { server.mountRouter("/profile", profileRouter); server.mountRouter("/recipes", recipeRouter); server.mountRouter("/reference", referenceRouter); + server.mountRouter("/shopping-list", shoppingListRouter); server.mountRouter("/sources", sourcesRouter); // Serves the built frontend (production Docker image only — see diff --git a/apps/api/src/modules/recipe/recipe.service.ts b/apps/api/src/modules/recipe/recipe.service.ts index 86d9697..7468ae3 100644 --- a/apps/api/src/modules/recipe/recipe.service.ts +++ b/apps/api/src/modules/recipe/recipe.service.ts @@ -56,11 +56,13 @@ function recipeInclude(viewerId: number) { type RecipeWithDetails = Prisma.RecipeGetPayload<{ include: ReturnType; }>; -type IngredientWithDetails = RecipeWithDetails["ingredients"][number]["ingredient"]; -type UnitWithDetails = RecipeWithDetails["ingredients"][number]["unit"]; +/** 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. */ +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. */ -function toUnitView(unit: UnitWithDetails): UnitView { +/** 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). */ +export function toUnitView(unit: UnitWithDetails): UnitView { return { id: unit.id, 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`. */ -function toIngredientView(ingredient: IngredientWithDetails): IngredientView { +/** 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`. */ +export function toIngredientView(ingredient: IngredientWithDetails): IngredientView { return { id: ingredient.id, key: ingredient.key, diff --git a/apps/api/src/modules/shopping-list/shopping-list.routes.ts b/apps/api/src/modules/shopping-list/shopping-list.routes.ts new file mode 100644 index 0000000..dae9ab3 --- /dev/null +++ b/apps/api/src/modules/shopping-list/shopping-list.routes.ts @@ -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(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); + }), +); diff --git a/apps/api/src/modules/shopping-list/shopping-list.service.ts b/apps/api/src/modules/shopping-list/shopping-list.service.ts new file mode 100644 index 0000000..50c1d10 --- /dev/null +++ b/apps/api/src/modules/shopping-list/shopping-list.service.ts @@ -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; +}>; + +/** 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(); + + 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 { + 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; + } +} diff --git a/apps/api/test/shopping-list.test.ts b/apps/api/test/shopping-list.test.ts new file mode 100644 index 0000000..27b7f15 --- /dev/null +++ b/apps/api/test/shopping-list.test.ts @@ -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 { + 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 { + 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([]); + }); + }); +}); diff --git a/apps/web/cypress/e2e/layout.cy.ts b/apps/web/cypress/e2e/layout.cy.ts index 1f2f8b6..05f1134 100644 --- a/apps/web/cypress/e2e/layout.cy.ts +++ b/apps/web/cypress/e2e/layout.cy.ts @@ -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. cy.visit("/parametres/compte"); 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"); - 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. */ @@ -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", () => { diff --git a/apps/web/cypress/e2e/planning-page.cy.ts b/apps/web/cypress/e2e/planning-page.cy.ts index 08610bc..3b4744d 100644 --- a/apps/web/cypress/e2e/planning-page.cy.ts +++ b/apps/web/cypress/e2e/planning-page.cy.ts @@ -33,7 +33,7 @@ describe("Sidebar navigation", () => { // 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. - 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", "Recettes").click(); diff --git a/apps/web/cypress/e2e/shopping-list.feature b/apps/web/cypress/e2e/shopping-list.feature new file mode 100644 index 0000000..8019072 --- /dev/null +++ b/apps/web/cypress/e2e/shopping-list.feature @@ -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" diff --git a/apps/web/cypress/e2e/shopping-list.ts b/apps/web/cypress/e2e/shopping-list.ts new file mode 100644 index 0000000..76aa323 --- /dev/null +++ b/apps/web/cypress/e2e/shopping-list.ts @@ -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); + }, +); diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts index 9cc6b9a..66d9594 100644 --- a/apps/web/src/api/client.ts +++ b/apps/web/src/api/client.ts @@ -17,6 +17,7 @@ import { type RecipeTab, type RecipeView, type SafeUserProfile, + type ShoppingListView, type SignupInput, type SourceView, type StepTechStepCorrectionView, @@ -163,6 +164,18 @@ export class ApiClient { 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 { + return this._request(`/shopping-list?date=${date}`); + } + /** Reference list of dietary regimes to pick from (signup wizard, `/foyer`). Public — no session required. */ public getDiets(): Promise { return this._request("/reference/diets"); diff --git a/apps/web/src/components/ui/ComingSoonPage.scss b/apps/web/src/components/ui/ComingSoonPage.scss deleted file mode 100644 index 1121fff..0000000 --- a/apps/web/src/components/ui/ComingSoonPage.scss +++ /dev/null @@ -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); - } -} diff --git a/apps/web/src/components/ui/ComingSoonPage.tsx b/apps/web/src/components/ui/ComingSoonPage.tsx deleted file mode 100644 index bebebf9..0000000 --- a/apps/web/src/components/ui/ComingSoonPage.tsx +++ /dev/null @@ -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 ( -
-

{title}

-

{description}

-
- ); -} diff --git a/apps/web/src/features/planning/WeekNavigator.tsx b/apps/web/src/features/planning/WeekNavigator.tsx new file mode 100644 index 0000000..1940be5 --- /dev/null +++ b/apps/web/src/features/planning/WeekNavigator.tsx @@ -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 ( +
+ + + + + + + {isCalendarOpen && ( + { + onChangeWeek(getWeekStart(day)); + setIsCalendarOpen(false); + }} + onClose={() => setIsCalendarOpen(false)} + /> + )} +
+ ); +} + +/** 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(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 ( +
+
+ + + {visibleMonth.toLocaleString({ month: "long", year: "numeric" }, { locale: "fr" })} + + +
+ +
+ {WEEK_DAYS.map((weekDay) => ( + + {t(`common.days.${weekDay}`).charAt(0)} + + ))} + + {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 ( + + ); + })} +
+
+ ); +} diff --git a/apps/web/src/features/planning/week-navigator.scss b/apps/web/src/features/planning/week-navigator.scss new file mode 100644 index 0000000..5223065 --- /dev/null +++ b/apps/web/src/features/planning/week-navigator.scss @@ -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; + } + } +} diff --git a/apps/web/src/locales/fr/translation.json b/apps/web/src/locales/fr/translation.json index a9ec9aa..e2769d5 100644 --- a/apps/web/src/locales/fr/translation.json +++ b/apps/web/src/locales/fr/translation.json @@ -2,7 +2,26 @@ "common": { "saving": "Enregistrement…", "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": { "VALIDATION_ERROR": "Erreur de validation", @@ -102,25 +121,6 @@ "planning": { "title": "Planning de la semaine", "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": { "petit-dejeuner": "Petit-déjeuner", "collation": "Collation", @@ -289,7 +289,8 @@ }, "shoppingList": { "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": { "title": "Compte", diff --git a/apps/web/src/pages/planning/PlanningPage.tsx b/apps/web/src/pages/planning/PlanningPage.tsx index 7b59c41..3c920f1 100644 --- a/apps/web/src/pages/planning/PlanningPage.tsx +++ b/apps/web/src/pages/planning/PlanningPage.tsx @@ -1,11 +1,4 @@ -import { - addWeeks, - buildCalendarMonth, - DateTime, - formatDateOnly, - getWeekStart, - toDateOnly, -} from "@batch-cooking/date-tools"; +import { DateTime, formatDateOnly, getWeekStart, toDateOnly } from "@batch-cooking/date-tools"; import { MEALS, type Meal, @@ -13,10 +6,11 @@ import { type PlanningView, WEEK_DAYS, } from "@batch-cooking/shared"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { apiClient } from "../../api/client"; import { type PlanningSlot, RecipePickerDialog } from "../../features/planning/RecipePickerDialog"; +import { WeekNavigator } from "../../features/planning/WeekNavigator"; 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. */ @@ -49,7 +43,7 @@ export function PlanningPage() { // closed. Mounting the dialog only while this is set (rather than an // always-mounted `isOpen` toggle) resets its internal filter/search // state for free on every open, same convention as `WeekNavigator`'s own - // `CalendarPopover` below. + // `CalendarPopover` (features/planning/WeekNavigator.tsx). const [openSlot, setOpenSlot] = useState(null); 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 ( -
- - - - - - - {isCalendarOpen && ( - { - onChangeWeek(getWeekStart(day)); - setIsCalendarOpen(false); - }} - onClose={() => setIsCalendarOpen(false)} - /> - )} -
- ); -} - -/** 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(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 ( -
-
- - - {visibleMonth.toLocaleString({ month: "long", year: "numeric" }, { locale: "fr" })} - - -
- -
- {WEEK_DAYS.map((weekDay) => ( - - {t(`planning.days.${weekDay}`).charAt(0)} - - ))} - - {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 ( - - ); - })} -
-
- ); -} - /** The week grid itself — 7 day columns × 5 meal rows. */ function PlanningGrid({ weekStart, @@ -323,7 +163,7 @@ function PlanningGrid({ {days.map(({ weekDay, date }) => ( - {t(`planning.days.${weekDay}`)} + {t(`common.days.${weekDay}`)} {date.day} ))} diff --git a/apps/web/src/pages/planning/planning-page.scss b/apps/web/src/pages/planning/planning-page.scss index f6c1431..8bf1fdc 100644 --- a/apps/web/src/pages/planning/planning-page.scss +++ b/apps/web/src/pages/planning/planning-page.scss @@ -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 -------------------------------------------------------- +// (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 { flex: 1; min-height: 0; diff --git a/apps/web/src/pages/shopping-list/ShoppingListPage.tsx b/apps/web/src/pages/shopping-list/ShoppingListPage.tsx index 5d76b17..e307a11 100644 --- a/apps/web/src/pages/shopping-list/ShoppingListPage.tsx +++ b/apps/web/src/pages/shopping-list/ShoppingListPage.tsx @@ -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 { 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() { const { t } = useTranslation(); + const [weekStart, setWeekStart] = useState(() => getWeekStart(DateTime.utc())); + const [state, setState] = useState({ 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 ( - +
+
+

{t("shoppingList.title")}

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

{t("shoppingList.loading")}

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

+ {t("common.loadError")} +

+ )} + + {state.status === "loaded" && } +
+ ); +} + +/** 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

{t("shoppingList.empty")}

; + } + + const groups = groupShoppingListItems(items, (item) => + t(`catalog.ingredients.${item.ingredient.key}`), + ); + + return ( +
+ {groups.map((group) => ( +
+

+ + {t(`recipes.form.category.${group.category}`)} +

+
    + {group.items.map((item) => ( +
  • + + + {t(`catalog.ingredients.${item.ingredient.key}`)} + + + {formatShoppingListQuantity(item.quantity)} {t(`catalog.units.${item.unit.key}`)} + +
  • + ))} +
+
+ ))} +
); } diff --git a/apps/web/src/pages/shopping-list/shopping-list-page.scss b/apps/web/src/pages/shopping-list/shopping-list-page.scss new file mode 100644 index 0000000..5b391f9 --- /dev/null +++ b/apps/web/src/pages/shopping-list/shopping-list-page.scss @@ -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; + } +} diff --git a/apps/web/src/pages/shopping-list/shopping-list.ts b/apps/web/src/pages/shopping-list/shopping-list.ts new file mode 100644 index 0000000..7ddb64e --- /dev/null +++ b/apps/web/src/pages/shopping-list/shopping-list.ts @@ -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(); + 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 }); +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 1af4fa6..73fb904 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -13,6 +13,7 @@ export * from "./schemas/planning.js"; export * from "./schemas/preferences.js"; export * from "./schemas/profile.js"; export * from "./schemas/recipe.js"; +export * from "./schemas/shopping-list.js"; export * from "./schemas/sources.js"; export * from "./schemas/tech-step-worker.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/recipe.js"; export * from "./types/reference.js"; +export * from "./types/shopping-list.js"; export * from "./types/sources.js"; export * from "./types/tech-step-worker.js"; export * from "./types/user-profile.js"; diff --git a/packages/shared/src/schemas/shopping-list.ts b/packages/shared/src/schemas/shopping-list.ts new file mode 100644 index 0000000..567299c --- /dev/null +++ b/packages/shared/src/schemas/shopping-list.ts @@ -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; diff --git a/packages/shared/src/types/shopping-list.ts b/packages/shared/src/types/shopping-list.ts new file mode 100644 index 0000000..ab1587d --- /dev/null +++ b/packages/shared/src/types/shopping-list.ts @@ -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[]; +} diff --git a/specs/backend-architecture.md b/specs/backend-architecture.md index 9fa7f65..d89af2f 100644 --- a/specs/backend-architecture.md +++ b/specs/backend-architecture.md @@ -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) Router `/reference` (`reference.routes.ts`/`.service.ts`) — **toutes les diff --git a/specs/batch-cooking-architecture.md b/specs/batch-cooking-architecture.md index adc4add..f8d9881 100644 --- a/specs/batch-cooking-architecture.md +++ b/specs/batch-cooking-architecture.md @@ -87,9 +87,16 @@ détectées, favoris, visibilité des recettes). ## Notes -- Le module de calcul batch-cooking (et la « Liste de courses », son - débouché naturel — `ShoppingListPage` reste un stub côté web) est le - principal chantier restant côté serveur. +- **La « Liste de courses » est implémentée** (`GET /shopping-list`, voir + [backend-architecture.md](./backend-architecture.md#liste-de-courses--agrégation-des-ingrédients-planifiés)) + — 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 été construit — rien ne le remplace aujourd'hui (pas de polling), à reconsidérer au moment d'attaquer le calcul batch-cooking. diff --git a/specs/frontend-architecture.md b/specs/frontend-architecture.md index f36d477..f764620 100644 --- a/specs/frontend-architecture.md +++ b/specs/frontend-architecture.md @@ -21,8 +21,7 @@ apps/web/src/ │ └── ui/ # primitives réutilisables partout, voir plus bas │ ├── Dialog.tsx + dialog.scss # modale (élément natif) │ ├── Checkbox.tsx / Radio.tsx # "carte sélectionnable" (CheckboxOption/RadioOption) -│ ├── 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 +│ └── Tooltip.tsx + tooltip.scss # infobulle CSS-only ├── features/ │ ├── auth/ # authentification │ │ ├── AuthContext.tsx # état global (profil connecté, login/signup/logout, refreshUser, deleteAccount) @@ -38,7 +37,8 @@ apps/web/src/ │ │ └── house-forms.scss │ ├── planning/ │ │ ├── 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 │ ├── 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 @@ -63,7 +63,8 @@ apps/web/src/ │ │ ├── RecipeFormPage.tsx # création/édition manuelle (/recettes/nouvelle, /recettes/:id/modifier) │ │ └── ImportRecipePage.tsx # route de secours autonome pour un import (/recettes/importer/:sourceKey/:externalId) │ ├── 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 │ │ ├── AccountSettingsPage.tsx / HouseholdSettingsPage.tsx / PreferencesPage.tsx │ │ ├── UserPreferencesPage.tsx / CreditsPage.tsx @@ -137,7 +138,7 @@ flowchart TB rendent **le même composant** `RecipesPage`, voir plus bas), `/recettes/nouvelle` / `/recettes/:id/modifier` (`RecipeFormPage`), `/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, 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 @@ -191,14 +192,15 @@ La sidebar a grandi avec l'app : `layout.settings.nav.` — ajouter une entrée de nav est un item de 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é -(le module « Calcul batch-cooking » reste `TODO`, voir -[batch-cooking-architecture.md](./batch-cooking-architecture.md)) et rend le -composant partagé `ComingSoonPage` (`title`/`description`). `Recettes` a -maintenant un vrai backend complet (catalogue, import depuis des sources -externes, favoris — voir plus bas) et ne passe plus par ce stub. +Plus aucune section de la sidebar ne rend un placeholder générique — `Liste +de courses` (`ShoppingListPage`) a désormais un vrai backend (voir la section +dédiée plus bas), et `Recettes` en a un depuis plus longtemps (catalogue, +import depuis des sources externes, favoris — voir plus bas). Le composant +`ComingSoonPage` (`components/ui/`) qui servait de stub pour ces deux pages a +é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 une bordure appuyée après `collation`/`dejeuner`/`gouter`. -- **Navigation de semaine** — `WeekNavigator` (flèches précédent/suivant, - `addWeeks(weekStart, ±1)` de `@batch-cooking/date-tools`) + un libellé +- **Navigation de semaine** — `WeekNavigator` (`features/planning/WeekNavigator.tsx` + + `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 `buildCalendarMonth`, cliquer un jour saute à sa semaine, lundi-first). Un 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 un état normal (rien à afficher pour cette semaine), pas un message 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.` + (`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 `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 élément focusable ; cloné pour y attacher `aria-describedby` (lecteurs 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. - `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 - 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 `layout.settings.nav.*` pour le sous-menu Paramètres — `AppLayout`), `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 + `form.*`, champs partagés par le wizard et `/parametres/foyer`, plus `household.sources.*` pour le badge officiel/non-officiel), `account` /