Compare commits
1 commit
main
...
feat/shopp
| Author | SHA1 | Date | |
|---|---|---|---|
| 27bfa3f6ab |
26 changed files with 1455 additions and 417 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -56,11 +56,13 @@ function recipeInclude(viewerId: number) {
|
|||
type RecipeWithDetails = Prisma.RecipeGetPayload<{
|
||||
include: ReturnType<typeof recipeInclude>;
|
||||
}>;
|
||||
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,
|
||||
|
|
|
|||
36
apps/api/src/modules/shopping-list/shopping-list.routes.ts
Normal file
36
apps/api/src/modules/shopping-list/shopping-list.routes.ts
Normal 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);
|
||||
}),
|
||||
);
|
||||
154
apps/api/src/modules/shopping-list/shopping-list.service.ts
Normal file
154
apps/api/src/modules/shopping-list/shopping-list.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
336
apps/api/test/shopping-list.test.ts
Normal file
336
apps/api/test/shopping-list.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
32
apps/web/cypress/e2e/shopping-list.feature
Normal file
32
apps/web/cypress/e2e/shopping-list.feature
Normal 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"
|
||||
72
apps/web/cypress/e2e/shopping-list.ts
Normal file
72
apps/web/cypress/e2e/shopping-list.ts
Normal 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);
|
||||
},
|
||||
);
|
||||
|
|
@ -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<ShoppingListView> {
|
||||
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<DietView[]> {
|
||||
return this._request("/reference/diets");
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
174
apps/web/src/features/planning/WeekNavigator.tsx
Normal file
174
apps/web/src/features/planning/WeekNavigator.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
143
apps/web/src/features/planning/week-navigator.scss
Normal file
143
apps/web/src/features/planning/week-navigator.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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<PlanningSlot | null>(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 (
|
||||
<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. */
|
||||
function PlanningGrid({
|
||||
weekStart,
|
||||
|
|
@ -323,7 +163,7 @@ function PlanningGrid({
|
|||
<th />
|
||||
{days.map(({ weekDay, date }) => (
|
||||
<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>
|
||||
</th>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<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 (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
102
apps/web/src/pages/shopping-list/shopping-list-page.scss
Normal file
102
apps/web/src/pages/shopping-list/shopping-list-page.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
64
apps/web/src/pages/shopping-list/shopping-list.ts
Normal file
64
apps/web/src/pages/shopping-list/shopping-list.ts
Normal 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 });
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
19
packages/shared/src/schemas/shopping-list.ts
Normal file
19
packages/shared/src/schemas/shopping-list.ts
Normal 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>;
|
||||
37
packages/shared/src/types/shopping-list.ts
Normal file
37
packages/shared/src/types/shopping-list.ts
Normal 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[];
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -21,8 +21,7 @@ apps/web/src/
|
|||
│ └── ui/ # primitives réutilisables partout, voir plus bas
|
||||
│ ├── Dialog.tsx + dialog.scss # modale (élément <dialog> 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.<clé>` — 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.<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
|
||||
|
||||
`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` /
|
||||
|
|
|
|||
Loading…
Reference in a new issue