Merge pull request #12 from kyuno053/feat/planning-page-design

Page Planning — grille hebdomadaire et navigation par semaine
This commit is contained in:
kyuno053 2026-08-17 19:02:13 +02:00 committed by GitHub
commit 1a0d4e3962
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
27 changed files with 1230 additions and 297 deletions

3
.gitignore vendored
View file

@ -144,3 +144,6 @@ vite.config.ts.timestamp-*
# Project docs not meant to be committed # Project docs not meant to be committed
Projet batch cooking.pdf Projet batch cooking.pdf
# Throwaway HTML mockups used to review a design before implementing it
tmp-mockups/

View file

@ -1,10 +1,12 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { DateTime } from "@batch-cooking/date-tools";
import { Given, Then, When } from "@cucumber/cucumber"; import { Given, Then, When } from "@cucumber/cucumber";
import { prisma } from "../../src/db/prisma.js"; import { prisma } from "../../src/db/prisma.js";
import type { CustomWorld } from "../support/world.js"; import type { CustomWorld } from "../support/world.js";
/** `GET /planning` takes `?date=` explicitly — this scenario wording ("the current planning") maps to "today". */
When("I request the current planning", async function (this: CustomWorld) { When("I request the current planning", async function (this: CustomWorld) {
this.response = await this.agent.get("/planning/current"); this.response = await this.agent.get("/planning").query({ date: DateTime.utc().toISODate() });
}); });
Then("the current planning response should be empty", function (this: CustomWorld) { Then("the current planning response should be empty", function (this: CustomWorld) {

View file

@ -18,6 +18,7 @@
"seed": "tsx prisma/seed.ts" "seed": "tsx prisma/seed.ts"
}, },
"dependencies": { "dependencies": {
"@batch-cooking/date-tools": "workspace:*",
"@batch-cooking/error-tools": "workspace:*", "@batch-cooking/error-tools": "workspace:*",
"@batch-cooking/express-tools": "workspace:*", "@batch-cooking/express-tools": "workspace:*",
"@batch-cooking/shared": "workspace:*", "@batch-cooking/shared": "workspace:*",

View file

@ -1,21 +1,36 @@
import { parseDateOnly } from "@batch-cooking/date-tools";
import { HttpError } from "@batch-cooking/error-tools";
import { wrapAsyncHandler } from "@batch-cooking/express-tools"; import { wrapAsyncHandler } from "@batch-cooking/express-tools";
import { ErrorCode, getPlanningByDateSchema } from "@batch-cooking/shared";
import { Router } from "express"; import { Router } from "express";
import { type AuthLocals, requireAuth } from "../../middlewares/require-auth.js"; import { type AuthLocals, requireAuth } from "../../middlewares/require-auth.js";
import { getCurrentPlanning } from "./planning.service.js"; import { getPlanningForDate } from "./planning.service.js";
/** Router mounted at `/planning` in app.ts. */ /** Router mounted at `/planning` in app.ts. */
export const planningRouter = Router(); export const planningRouter = Router();
/** /**
* Returns the authenticated user's household's planning for today, or * Returns the authenticated user's household's planning covering `?date=`
* `null` if none exists yet a valid, common response, not an error (see * (`YYYY-MM-DD`), or `null` if none exists yet a valid, common response,
* {@link getCurrentPlanning}). * not an error (see {@link getPlanningForDate}). Used both for "today"
* (the planning page's initial load) and for any other week the planning
* page's week navigator/calendar picks.
*/ */
planningRouter.get( planningRouter.get(
"/current", "/",
requireAuth, requireAuth,
wrapAsyncHandler<unknown, AuthLocals>(async (_req, res) => { wrapAsyncHandler<unknown, AuthLocals>(async (req, res) => {
const planning = await getCurrentPlanning(res.locals.userProfile.houseId); const input = getPlanningByDateSchema.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 planning = await getPlanningForDate(res.locals.userProfile.houseId, date);
res.status(200).json(planning); res.status(200).json(planning);
}), }),
); );

View file

@ -1,35 +1,40 @@
import { type DateTime, toDateOnly } from "@batch-cooking/date-tools";
import type { PlanningView } from "@batch-cooking/shared"; import type { PlanningView } from "@batch-cooking/shared";
import { prisma } from "../../db/prisma.js"; import { prisma } from "../../db/prisma.js";
/** /**
* Finds the household's planning that covers today's date and shapes it * Finds the household's planning that covers `date` and shapes it into a
* into a {@link PlanningView} (recipes resolved to `{id, name}`). * {@link PlanningView} (recipes resolved to `{id, name}`). `date` is
* whatever the caller wants "now" to mean the current `/planning` route
* passes a `date-tools`-parsed `?date=` query param, letting a caller look
* up any week's planning, not just the one covering today.
* *
* Returns `null` for two distinct, both entirely normal states a `house_id` * Returns `null` for two distinct, both entirely normal states a
* of `null` (a profile always gets a house at signup today, but the column * `houseId` of `null` (the profile has no household yet households are no
* is nullable) and "no planning row covers today" (the expected case until * longer created automatically at signup, see `auth.service.ts`) and "no
* planning creation is built) neither is an error, so both collapse to * planning row covers this date" (the expected case until planning
* the same "nothing to show yet" result rather than throwing. * creation is built) neither is an error, so both collapse to the same
* "nothing to show yet" result rather than throwing.
*/ */
export async function getCurrentPlanning(houseId: number | null): Promise<PlanningView | null> { export async function getPlanningForDate(
houseId: number | null,
date: DateTime,
): Promise<PlanningView | null> {
if (houseId === null) { if (houseId === null) {
return null; return null;
} }
// `startDate`/`finishDate` are `@db.Date` columns (no time-of-day // `startDate`/`finishDate` are `@db.Date` columns (no time-of-day
// component) — compare against today's date at UTC midnight so the // component) — comparing against a UTC-midnight JS `Date` lines up with
// comparison lines up with how Postgres stores/returns them, regardless // how Postgres stores/returns them, regardless of the server's local
// of the server's local timezone. // timezone.
const today = new Date(); const dateOnly = toDateOnly(date).toJSDate();
const todayDateOnly = new Date(
Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate()),
);
const planning = await prisma.planning.findFirst({ const planning = await prisma.planning.findFirst({
where: { where: {
houseId, houseId,
startDate: { lte: todayDateOnly }, startDate: { lte: dateOnly },
finishDate: { gte: todayDateOnly }, finishDate: { gte: dateOnly },
}, },
// A household should never have two plannings covering the same day, // A household should never have two plannings covering the same day,
// but nothing in the schema enforces that yet — pick the most recently // but nothing in the schema enforces that yet — pick the most recently

View file

@ -1,3 +1,4 @@
import { DateTime } from "@batch-cooking/date-tools";
import { ErrorCode, type SignupInput } from "@batch-cooking/shared"; import { ErrorCode, type SignupInput } from "@batch-cooking/shared";
import { faker } from "@faker-js/faker"; import { faker } from "@faker-js/faker";
import { expect } from "chai"; import { expect } from "chai";
@ -18,6 +19,18 @@ function buildSignupPayload(): SignupInput {
}; };
} }
/** Today, as the `YYYY-MM-DD` string `GET /planning`'s `?date=` expects. */
function today(): string {
return isoDate(DateTime.utc());
}
/** `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;
}
describe("Planning", () => { describe("Planning", () => {
const app = createApp(); const app = createApp();
@ -29,63 +42,93 @@ describe("Planning", () => {
await prisma.$disconnect(); await prisma.$disconnect();
}); });
describe("GET /planning/current", () => { describe("GET /planning", () => {
it("rejects requests without a session cookie with 401 NOT_AUTHENTICATED", async () => { it("rejects requests without a session cookie with 401 NOT_AUTHENTICATED", async () => {
const res = await request(app).get("/planning/current"); const res = await request(app).get("/planning").query({ date: today() });
expect(res.status).to.equal(401); expect(res.status).to.equal(401);
expect(res.body.code).to.equal(ErrorCode.NOT_AUTHENTICATED); expect(res.body.code).to.equal(ErrorCode.NOT_AUTHENTICATED);
}); });
it("returns null when the household has no planning covering today", async () => { it("rejects a missing date with 400 VALIDATION_ERROR", async () => {
const agent = request.agent(app); const agent = request.agent(app);
await agent.post("/auth/signup").send(buildSignupPayload()); await agent.post("/auth/signup").send(buildSignupPayload());
const res = await agent.get("/planning/current"); const res = await agent.get("/planning");
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("/planning").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("/planning").query({ date: "2026-02-30" });
expect(res.status).to.equal(400);
expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
});
it("returns null when the household has no planning covering that date", async () => {
const agent = request.agent(app);
await agent.post("/auth/signup").send(buildSignupPayload());
const res = await agent.get("/planning").query({ date: today() });
expect(res.status).to.equal(200); expect(res.status).to.equal(200);
expect(res.body).to.equal(null); expect(res.body).to.equal(null);
}); });
it("returns the household's planning covering today, with recipes resolved", async () => { it("returns the household's planning covering that date, with recipes resolved", async () => {
const agent = request.agent(app); const agent = request.agent(app);
await agent.post("/auth/signup").send(buildSignupPayload()); await agent.post("/auth/signup").send(buildSignupPayload());
const houseRes = await agent.post("/house").send({ name: "Chez moi" }); const houseRes = await agent.post("/house").send({ name: "Chez moi" });
const houseId: number = houseRes.body.id; const houseId: number = houseRes.body.id;
const recipe = await prisma.recipe.create({ data: { name: "Ratatouille" } }); const recipe = await prisma.recipe.create({ data: { name: "Ratatouille" } });
const today = new Date(); const now = new Date();
const planning = await prisma.planning.create({ const planning = await prisma.planning.create({
data: { data: {
houseId, houseId,
startDate: new Date( startDate: new Date(
Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate() - 2), Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() - 2),
), ),
finishDate: new Date( finishDate: new Date(
Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate() + 2), Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() + 2),
), ),
}, },
}); });
await prisma.planningItem.create({ await prisma.planningItem.create({
data: { planningId: planning.id, weekDay: "monday", meal: "dinner", recipeId: recipe.id }, data: { planningId: planning.id, weekDay: "lundi", meal: "diner", recipeId: recipe.id },
}); });
const res = await agent.get("/planning/current"); const res = await agent.get("/planning").query({ date: today() });
expect(res.status).to.equal(200); expect(res.status).to.equal(200);
expect(res.body.id).to.equal(planning.id); expect(res.body.id).to.equal(planning.id);
expect(res.body.items).to.have.length(1); expect(res.body.items).to.have.length(1);
expect(res.body.items[0]).to.include({ weekDay: "monday", meal: "dinner" }); expect(res.body.items[0]).to.include({ weekDay: "lundi", meal: "diner" });
expect(res.body.items[0].recipe).to.include({ id: recipe.id, name: "Ratatouille" }); expect(res.body.items[0].recipe).to.include({ id: recipe.id, name: "Ratatouille" });
}); });
it("returns null when the household's planning does not cover today", async () => { it("returns null when the household's planning does not cover that date", async () => {
const agent = request.agent(app); const agent = request.agent(app);
await agent.post("/auth/signup").send(buildSignupPayload()); await agent.post("/auth/signup").send(buildSignupPayload());
const houseRes = await agent.post("/house").send({ name: "Chez moi" }); const houseRes = await agent.post("/house").send({ name: "Chez moi" });
const houseId: number = houseRes.body.id; const houseId: number = houseRes.body.id;
// A planning entirely in the past — shouldn't be picked up as "current". // A planning entirely in the past — shouldn't be picked up for today.
await prisma.planning.create({ await prisma.planning.create({
data: { data: {
houseId, houseId,
@ -94,10 +137,38 @@ describe("Planning", () => {
}, },
}); });
const res = await agent.get("/planning/current"); const res = await agent.get("/planning").query({ date: today() });
expect(res.status).to.equal(200); expect(res.status).to.equal(200);
expect(res.body).to.equal(null); expect(res.body).to.equal(null);
}); });
it("returns a different week's planning 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 recipe = await prisma.recipe.create({ data: { name: "Curry de lentilles" } });
const nextWeek = DateTime.utc().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 },
});
const res = await agent.get("/planning").query({ date: isoDate(nextWeek) });
expect(res.status).to.equal(200);
expect(res.body.id).to.equal(planning.id);
const thisWeekRes = await agent.get("/planning").query({ date: today() });
expect(thisWeekRes.body).to.equal(null);
});
}); });
}); });

View file

@ -1,104 +0,0 @@
// Mocks the API via cy.intercept — see auth.cy.ts for the rationale (no
// live backend in this CI job; apps/api's own Mocha/Cucumber suites cover
// real API behavior against a real database).
const authenticatedProfile = {
id: 1,
firstName: "Alice",
lastName: "Martin",
email: "alice@example.com",
tokenVersion: 0,
houseId: 1,
dietId: null,
};
describe("Sidebar navigation", () => {
beforeEach(() => {
cy.intercept("GET", "**/auth/me", { statusCode: 200, body: authenticatedProfile });
cy.intercept("GET", "**/planning/current", { statusCode: 200, body: null });
cy.visit("/");
});
// 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", () => {
cy.contains("nav a", "Planning").should("have.class", "active");
cy.contains("nav a", "Recettes").click();
cy.url().should("include", "/recettes");
cy.contains("h1", "Recettes").should("be.visible");
cy.contains("nav a", "Recettes").should("have.class", "active");
cy.contains("nav a", "Planning").should("not.have.class", "active");
cy.contains("nav a", "Liste de courses").click();
cy.url().should("include", "/liste-de-courses");
cy.contains("h1", "Liste de courses").should("be.visible");
cy.contains("nav a", "Planning").click();
cy.url().should("eq", `${Cypress.config().baseUrl}/`);
cy.contains("h1", "Planning de la semaine").should("be.visible");
});
it("shows the signed-in user's name and lets them log out from the account menu", () => {
cy.intercept("POST", "**/auth/logout", { statusCode: 204 }).as("logout");
cy.contains("button", "Bonjour Alice").should("be.visible").click();
cy.contains("button", "Se déconnecter").click();
cy.wait("@logout");
cy.url().should("include", "/login");
});
});
describe("Home planning view", () => {
it("shows an empty state when the household has no current planning", () => {
cy.intercept("GET", "**/auth/me", { statusCode: 200, body: authenticatedProfile });
cy.intercept("GET", "**/planning/current", { statusCode: 200, body: null });
cy.visit("/");
cy.contains("h1", "Planning de la semaine").should("be.visible");
cy.contains("Aucun planning pour cette semaine.").should("be.visible");
cy.get("table").should("not.exist");
});
it("renders the current planning's meals when there is one", () => {
cy.intercept("GET", "**/auth/me", { statusCode: 200, body: authenticatedProfile });
cy.intercept("GET", "**/planning/current", {
statusCode: 200,
body: {
id: 1,
startDate: "2026-08-10T00:00:00.000Z",
finishDate: "2026-08-16T00:00:00.000Z",
items: [
{ id: 1, weekDay: "lundi", meal: "Dîner", recipe: { id: 1, name: "Ratatouille" } },
{
id: 2,
weekDay: "mardi",
meal: "Déjeuner",
recipe: { id: 2, name: "Curry de lentilles" },
},
],
},
});
cy.visit("/");
cy.contains("Aucun planning pour cette semaine.").should("not.exist");
cy.get("table.planning-table tbody tr").should("have.length", 2);
cy.contains("td", "Ratatouille").should("be.visible");
cy.contains("td", "Curry de lentilles").should("be.visible");
});
it("shows an error state when the planning request fails", () => {
cy.intercept("GET", "**/auth/me", { statusCode: 200, body: authenticatedProfile });
cy.intercept("GET", "**/planning/current", {
statusCode: 500,
body: { code: 5000, message: "boom" },
});
cy.visit("/");
cy.contains("Impossible de charger le planning, réessayez plus tard").should("be.visible");
});
});

View file

@ -0,0 +1,163 @@
// Mocks the API via cy.intercept — see auth.cy.ts for the rationale (no
// live backend in this CI job; apps/api's own Mocha/Cucumber suites cover
// real API behavior against a real database).
const authenticatedProfile = {
id: 1,
firstName: "Alice",
lastName: "Martin",
email: "alice@example.com",
tokenVersion: 0,
houseId: 1,
dietId: null,
};
// 2026-08-17 is a Monday — frozen via `cy.clock` so "today"/"this week"
// assertions are deterministic instead of depending on the day the suite
// happens to run.
const TODAY = new Date("2026-08-17T09:00:00Z");
function freezeToday() {
cy.clock(TODAY, ["Date"]);
}
describe("Sidebar navigation", () => {
beforeEach(() => {
cy.intercept("GET", "**/auth/me", { statusCode: 200, body: authenticatedProfile });
cy.intercept("GET", /\/planning\?/, { statusCode: 200, body: null });
cy.visit("/");
});
// 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", () => {
cy.contains("nav a", "Planning").should("have.class", "active");
cy.contains("nav a", "Recettes").click();
cy.url().should("include", "/recettes");
cy.contains("h1", "Recettes").should("be.visible");
cy.contains("nav a", "Recettes").should("have.class", "active");
cy.contains("nav a", "Planning").should("not.have.class", "active");
cy.contains("nav a", "Liste de courses").click();
cy.url().should("include", "/liste-de-courses");
cy.contains("h1", "Liste de courses").should("be.visible");
cy.contains("nav a", "Planning").click();
cy.url().should("eq", `${Cypress.config().baseUrl}/`);
cy.contains("h1", "Planning de la semaine").should("be.visible");
});
it("shows the signed-in user's name and lets them log out from the account menu", () => {
cy.intercept("POST", "**/auth/logout", { statusCode: 204 }).as("logout");
cy.contains("button", "Bonjour Alice").should("be.visible").click();
cy.contains("button", "Se déconnecter").click();
cy.wait("@logout");
cy.url().should("include", "/login");
});
});
describe("Planning grid", () => {
beforeEach(() => {
// Desktop-only design (see the plan/PR description) — wider than
// Cypress's default 1000×660 so all 7 day columns fit without the grid's
// horizontal scroll hiding the later ones from visibility assertions.
cy.viewport(1600, 900);
freezeToday();
cy.intercept("GET", "**/auth/me", { statusCode: 200, body: authenticatedProfile });
});
it("shows an empty grid (every slot just offering '+') when the household has no planning yet", () => {
cy.intercept("GET", /\/planning\?/, { statusCode: 200, body: null }).as("getPlanning");
cy.visit("/");
cy.wait("@getPlanning").its("request.url").should("include", "date=2026-08-17");
cy.contains("h1", "Planning de la semaine").should("be.visible");
// 5 meal rows × 7 days = 35 empty slots, each just a "+".
cy.get(".add-recipe-btn").should("have.length", 35);
cy.get(".recipe-chip").should("not.exist");
});
it("renders each recipe in its (day, meal) cell, and highlights today's column", () => {
cy.intercept("GET", /\/planning\?/, {
statusCode: 200,
body: {
id: 1,
startDate: "2026-08-17T00:00:00.000Z",
finishDate: "2026-08-23T00:00:00.000Z",
items: [
{ id: 1, weekDay: "mardi", meal: "diner", recipe: { id: 1, name: "Ratatouille" } },
{
id: 2,
weekDay: "mercredi",
meal: "dejeuner",
recipe: { id: 2, name: "Curry de lentilles" },
},
],
},
});
cy.visit("/");
cy.contains("th", "Lundi").should("be.visible");
cy.contains("th", "Dimanche").should("be.visible");
cy.contains(".recipe-chip", "Ratatouille").should("be.visible");
cy.contains(".recipe-chip", "Curry de lentilles").should("be.visible");
// Today (17 août, Lundi) is marked — its column header carries `.today`.
cy.contains("th.today .day-date", "17").should("be.visible");
});
it("shows a loading state, then an error state when the request fails", () => {
cy.intercept("GET", /\/planning\?/, {
statusCode: 500,
body: { code: 5000, message: "boom" },
});
cy.visit("/");
cy.contains("Impossible de charger le planning, réessayez plus tard").should("be.visible");
});
// Assertions below check the rendered week label/badge, not the intercepted
// request count — React StrictMode (see main.tsx) double-invokes effects in
// dev, so the `GET /planning` mount effect can fire twice per navigation;
// counting exact `cy.wait` calls against that would be flaky, but the
// rendered result is the same either way.
it("navigates to the next/previous week, re-fetching each time", () => {
cy.intercept("GET", /\/planning\?/, { statusCode: 200, body: null }).as("getPlanning");
cy.visit("/");
cy.wait("@getPlanning").its("request.url").should("include", "date=2026-08-17");
cy.contains("Semaine du 17 au 23 août 2026").should("be.visible");
cy.contains("Cette semaine").should("be.visible");
cy.get(".week-nav__arrow").last().click();
cy.contains("Semaine du 24 au 30 août 2026").should("be.visible");
cy.contains("Cette semaine").should("not.exist");
cy.get(".week-nav__arrow").first().click();
cy.contains("Semaine du 17 au 23 août 2026").should("be.visible");
cy.get(".week-nav__arrow").first().click();
cy.contains("Semaine du 10 au 16 août 2026").should("be.visible");
});
it("jumps to an arbitrary week by picking a day in the calendar popover", () => {
cy.intercept("GET", /\/planning\?/, { statusCode: 200, body: null }).as("getPlanning");
cy.visit("/");
cy.wait("@getPlanning");
cy.contains("button", "Semaine du").click();
cy.get(".calendar-popover").should("be.visible");
// Picking the 25th (still August, unambiguous in the visible grid)
// should jump to the week of the 24th30th.
cy.get(".calendar-grid__day").contains(/^25$/).click();
cy.contains("Semaine du 24 au 30 août 2026").should("be.visible");
cy.get(".calendar-popover").should("not.exist");
});
});

View file

@ -13,6 +13,7 @@
"e2e": "start-server-and-test dev http://localhost:5173 cy:run" "e2e": "start-server-and-test dev http://localhost:5173 cy:run"
}, },
"dependencies": { "dependencies": {
"@batch-cooking/date-tools": "workspace:*",
"@batch-cooking/shared": "workspace:*", "@batch-cooking/shared": "workspace:*",
"i18next": "^26.3.6", "i18next": "^26.3.6",
"react": "^18.3.1", "react": "^18.3.1",

View file

@ -2,8 +2,8 @@ import { Navigate, Route, Routes } from "react-router-dom";
import { RedirectIfAuthenticated } from "./features/auth/RedirectIfAuthenticated"; import { RedirectIfAuthenticated } from "./features/auth/RedirectIfAuthenticated";
import { RequireAuth } from "./features/auth/RequireAuth"; import { RequireAuth } from "./features/auth/RequireAuth";
import { AppLayout } from "./layouts/AppLayout"; import { AppLayout } from "./layouts/AppLayout";
import { HomePage } from "./pages/HomePage";
import { LoginPage } from "./pages/LoginPage"; import { LoginPage } from "./pages/LoginPage";
import { PlanningPage } from "./pages/PlanningPage";
import { RecipesPage } from "./pages/RecipesPage"; import { RecipesPage } from "./pages/RecipesPage";
import { ShoppingListPage } from "./pages/ShoppingListPage"; import { ShoppingListPage } from "./pages/ShoppingListPage";
import { SignupPage } from "./pages/SignupPage"; import { SignupPage } from "./pages/SignupPage";
@ -45,7 +45,7 @@ export function App() {
</RequireAuth> </RequireAuth>
} }
> >
<Route path="/" element={<HomePage />} /> <Route path="/" element={<PlanningPage />} />
<Route path="/recettes" element={<RecipesPage />} /> <Route path="/recettes" element={<RecipesPage />} />
<Route path="/liste-de-courses" element={<ShoppingListPage />} /> <Route path="/liste-de-courses" element={<ShoppingListPage />} />
<Route path="/parametres/compte" element={<AccountSettingsPage />} /> <Route path="/parametres/compte" element={<AccountSettingsPage />} />

View file

@ -103,9 +103,13 @@ export class ApiClient {
return this.request("/auth/me", { method: "DELETE", body: JSON.stringify({ password }) }); return this.request("/auth/me", { method: "DELETE", body: JSON.stringify({ password }) });
} }
/** Fetches the current user's household's planning for today, or `null` if there isn't one yet. */ /**
public getCurrentPlanning(): Promise<PlanningView | null> { * Fetches the current user's household's planning covering `date`
return this.request("/planning/current"); * (`YYYY-MM-DD`, e.g. from `date-tools`'s `formatDateOnly`), or `null` if
* there isn't one for that week yet.
*/
public getPlanningForWeek(date: string): Promise<PlanningView | null> {
return this.request(`/planning?date=${date}`);
} }
/** Reference list of dietary regimes to pick from (signup wizard, `/foyer`). Public — no session required. */ /** Reference list of dietary regimes to pick from (signup wizard, `/foyer`). Public — no session required. */

View file

@ -1,7 +1,8 @@
{ {
"common": { "common": {
"saving": "Enregistrement…", "saving": "Enregistrement…",
"saved": "Enregistré ✓" "saved": "Enregistré ✓",
"loadError": "Impossible de charger le planning, réessayez plus tard"
}, },
"errors": { "errors": {
"VALIDATION_ERROR": "Erreur de validation", "VALIDATION_ERROR": "Erreur de validation",
@ -77,15 +78,38 @@
"greeting": "Bonjour {{firstName}} 👋", "greeting": "Bonjour {{firstName}} 👋",
"logout": "Se déconnecter" "logout": "Se déconnecter"
}, },
"home": { "planning": {
"title": "Planning de la semaine", "title": "Planning de la semaine",
"loading": "Chargement du planning…", "loading": "Chargement du planning…",
"error": "Impossible de charger le planning, réessayez plus tard", "weekNav": {
"empty": "Aucun planning pour cette semaine.", "thisWeek": "Cette semaine",
"table": { "prevWeek": "Semaine précédente",
"day": "Jour", "nextWeek": "Semaine suivante",
"meal": "Repas", "label": "Semaine du {{range}}"
"recipe": "Recette" },
"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",
"dejeuner": "Déjeuner",
"gouter": "Goûter",
"diner": "Dîner"
},
"grid": {
"addRecipeSoon": "Recherche de recettes à venir",
"removeRecipe": "Retirer cette recette"
} }
}, },
"recipes": { "recipes": {

View file

@ -1,56 +0,0 @@
// =============================================================================
// Styles specific to HomePage colocated next to HomePage.tsx since nothing
// else uses these classes.
// =============================================================================
// No `@use` of the theme partial needed here: every design token below is a
// CSS custom property (--color-*, --space-*...) declared once on :root in
// styles/global.scss and available globally at runtime not a Sass-level
// variable/mixin that would require an explicit compile-time import.
// No outer centering wrapper here (unlike the old version of this file):
// AppLayout's `.app-content` already owns the page background/padding —
// this is just the page's own content.
.home-page {
&__status {
color: var(--color-text-muted);
font-size: var(--font-size-md);
}
&__status--error {
color: var(--color-error);
}
}
// The current planning, one row per meal slot. Raised on its own surface,
// same card treatment used elsewhere in the app, so it reads as a distinct
// piece of content rather than bare text on the page background.
.planning-table {
width: 100%;
max-width: 40rem;
margin-top: var(--space-md);
border-collapse: collapse;
background: var(--color-surface);
border-radius: var(--radius-md);
overflow: hidden;
box-shadow: var(--shadow-sm);
th,
td {
padding: var(--space-sm) var(--space-md);
text-align: left;
border-bottom: 1px solid var(--color-border);
}
th {
background: var(--color-surface-alt);
color: var(--color-text-muted);
font-size: var(--font-size-xs);
text-transform: uppercase;
letter-spacing: 0.04em;
}
tr:last-child td {
border-bottom: none;
}
}

View file

@ -1,81 +0,0 @@
import type { PlanningView } from "@batch-cooking/shared";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { apiClient } from "../api/client";
import "./HomePage.scss";
/** Load state for the `GET /planning/current` call — a discriminated union so a stale/impossible combination (e.g. "loading" with data) can't be represented. */
type PlanningState =
| { status: "loading" }
| { status: "loaded"; planning: PlanningView | null }
| { status: "error" };
/**
* Landing page for an authenticated visitor the household's current
* planning. Behind {@link RequireAuth} (via `AppLayout`), so this only
* renders once a session is confirmed; the planning itself still has to be
* fetched separately, hence the loading/error/empty/loaded states below.
* `null` from the API is a normal, common state (no planning created yet),
* not an error see `apps/api`'s `planning.service.ts`.
*/
export function HomePage() {
const { t } = useTranslation();
const [state, setState] = useState<PlanningState>({ status: "loading" });
useEffect(() => {
// Guards against setting state after unmount (e.g. the user navigates
// away before the request resolves) — no cleanup-worthy resource here,
// just avoids a "set state on unmounted component" warning.
let cancelled = false;
apiClient
.getCurrentPlanning()
.then((planning) => {
if (!cancelled) setState({ status: "loaded", planning });
})
.catch(() => {
if (!cancelled) setState({ status: "error" });
});
return () => {
cancelled = true;
};
}, []);
return (
<div className="home-page">
<h1>{t("home.title")}</h1>
{state.status === "loading" && <p className="home-page__status">{t("home.loading")}</p>}
{state.status === "error" && (
<p className="home-page__status home-page__status--error">{t("home.error")}</p>
)}
{state.status === "loaded" && state.planning === null && (
<p className="home-page__status">{t("home.empty")}</p>
)}
{state.status === "loaded" && state.planning !== null && (
<table className="planning-table">
<thead>
<tr>
<th>{t("home.table.day")}</th>
<th>{t("home.table.meal")}</th>
<th>{t("home.table.recipe")}</th>
</tr>
</thead>
<tbody>
{state.planning.items.map((item) => (
<tr key={item.id}>
<td>{item.weekDay}</td>
<td>{item.meal}</td>
<td>{item.recipe.name}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
);
}

View file

@ -0,0 +1,318 @@
import {
DateTime,
addWeeks,
buildCalendarMonth,
formatDateOnly,
getWeekStart,
toDateOnly,
} from "@batch-cooking/date-tools";
import { MEALS, type Meal, type PlanningView, WEEK_DAYS } from "@batch-cooking/shared";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { apiClient } from "../api/client";
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. */
type PlanningState =
| { status: "loading" }
| { status: "loaded"; planning: PlanningView | null }
| { status: "error" };
/** Meals that close out a "moment of the day" group (Matin/Midi/Après-midi/Soir) — see `.band-end` in planning-page.scss for the resulting border treatment. */
const BAND_END_MEALS: ReadonlySet<Meal> = new Set(["collation", "dejeuner", "gouter"]);
/**
* Landing page for an authenticated visitor the household's planning for
* a selectable week, laid out as a grid (days × meals). Behind
* {@link RequireAuth} (via `AppLayout`), so this only renders once a
* session is confirmed.
*
* `null` from the API is a normal, common state (no planning for that week
* yet) unlike the previous single-day table view this replaces, it isn't
* rendered as a separate "empty" message: the grid itself, with every cell
* showing just its "+" button, already communicates that. The "+" itself
* isn't wired to anything yet (no recipe catalog to search see the
* planning page's plan/PR description) a future task.
*/
export function PlanningPage() {
const { t } = useTranslation();
const [weekStart, setWeekStart] = useState<DateTime>(() => getWeekStart(DateTime.utc()));
const [state, setState] = useState<PlanningState>({ status: "loading" });
useEffect(() => {
let cancelled = false;
setState({ status: "loading" });
apiClient
.getPlanningForWeek(formatDateOnly(weekStart))
.then((planning) => {
if (!cancelled) setState({ status: "loaded", planning });
})
.catch(() => {
if (!cancelled) setState({ status: "error" });
});
return () => {
cancelled = true;
};
}, [weekStart]);
return (
<div className="planning-page">
<div className="planning-page__header">
<h1>{t("planning.title")}</h1>
<WeekNavigator weekStart={weekStart} onChangeWeek={setWeekStart} />
</div>
{state.status === "loading" && (
<p className="planning-page__status">{t("planning.loading")}</p>
)}
{state.status === "error" && (
<p className="planning-page__status planning-page__status--error">
{t("common.loadError")}
</p>
)}
{state.status === "loaded" && (
<PlanningGrid weekStart={weekStart} planning={state.planning} />
)}
</div>
);
}
/** "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,
planning,
}: { weekStart: DateTime; planning: PlanningView | null }) {
const { t } = useTranslation();
const today = toDateOnly(DateTime.utc());
const days = WEEK_DAYS.map((weekDay, i) => ({ weekDay, date: weekStart.plus({ days: i }) }));
const items = planning?.items ?? [];
return (
<div className="planning-grid-wrapper">
<table className="planning-grid">
<thead>
<tr>
<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-date">{date.day}</span>
</th>
))}
</tr>
</thead>
<tbody>
{MEALS.map((meal) => (
<tr key={meal} className={BAND_END_MEALS.has(meal) ? "band-end" : undefined}>
<th>{t(`planning.meals.${meal}`)}</th>
{days.map(({ weekDay, date }) => (
<MealCell
key={weekDay}
isToday={date.hasSame(today, "day")}
recipes={items
.filter((item) => item.weekDay === weekDay && item.meal === meal)
.map((item) => ({ id: item.id, name: item.recipe.name }))}
/>
))}
</tr>
))}
</tbody>
</table>
</div>
);
}
/** One (day, meal) cell: the recipes already planned for it (as pills) plus the "+" to add another. */
function MealCell({
isToday,
recipes,
}: {
isToday: boolean;
recipes: { id: number; name: string }[];
}) {
const { t } = useTranslation();
return (
<td className={isToday ? "meal-cell today" : "meal-cell"}>
<div className="meal-cell__content">
{recipes.length > 0 && (
<div className="meal-cell__recipes">
{recipes.map((recipe) => (
<span key={recipe.id} className="recipe-chip">
<span className="recipe-chip__name">{recipe.name}</span>
<button
type="button"
className="recipe-chip__remove"
title={t("planning.grid.removeRecipe")}
>
</button>
</span>
))}
</div>
)}
<button type="button" className="add-recipe-btn" title={t("planning.grid.addRecipeSoon")}>
+
</button>
</div>
</td>
);
}

View file

@ -0,0 +1,356 @@
// =============================================================================
// Styles specific to PlanningPage colocated next to PlanningPage.tsx since
// nothing else uses these classes. Ported from the reviewed HTML mockup
// (see the plan file / PR description) onto the app's real design tokens —
// no light/dark duplication needed here, unlike the standalone mockup:
// every `var(--color-*)` below already resolves per-theme globally (see
// styles/_theme.scss).
// =============================================================================
// `.app-content` (AppLayout.scss) already stretches to the full viewport
// height (flex item of `.app-layout`, itself `min-height: 100vh` the
// same stretch the sidebar relies on to pin its footer at the bottom).
// `.planning-page` just needs to fill that box and lay out as a column so
// `.planning-grid-wrapper` can grow to fill whatever's left under the
// header, instead of the grid being only as tall as its content.
.planning-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);
}
}
// --- 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 --------------------------------------------------------
.planning-grid-wrapper {
flex: 1;
min-height: 0;
overflow: auto;
background: var(--color-surface);
border-radius: var(--radius-md);
box-shadow: var(--shadow-sm);
}
table.planning-grid {
width: 100%;
height: 100%;
min-width: 62rem;
border-collapse: collapse;
table-layout: fixed;
th,
td {
border: 1px solid var(--color-border);
vertical-align: top;
}
thead th {
padding: var(--space-sm) var(--space-md);
background: var(--color-surface-alt);
text-align: left;
&:first-child {
width: 9rem;
}
.day-name {
display: block;
font-size: var(--font-size-xs);
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-text-muted);
font-weight: 600;
}
.day-date {
display: block;
font-size: var(--font-size-md);
font-weight: 700;
margin-top: 2px;
color: var(--color-text);
}
&.today {
background: color-mix(in srgb, var(--color-primary) 10%, var(--color-surface-alt));
.day-date {
color: var(--color-primary);
}
}
}
tbody th {
padding: var(--space-sm) var(--space-md);
background: var(--color-surface-alt);
text-align: center;
vertical-align: middle;
font-size: var(--font-size-sm);
font-weight: 600;
}
td.today {
background: color-mix(in srgb, var(--color-primary) 4%, var(--color-surface));
}
// Repas groupés par moment de la journée (Matin / Midi / Après-midi /
// Soir) piloté uniquement via `border-bottom` (jamais `border-top`) :
// avec `border-collapse: collapse`, deux bordures différentes qui se
// rencontrent sur la même arête peuvent fusionner de façon ambiguë selon
// le navigateur en désactivant `border-top` sur tbody, chaque arête
// horizontale n'est plus définie que d'un seul côté, sans ambiguïté
// possible. Toutes les séparations entre repas sont pleines (même couleur
// que les séparations de jour) ; seule la frontière entre deux groupes
// ("band-end", la dernière ligne d'un groupe) se distingue par une
// épaisseur plus marquée.
tbody th,
tbody td {
border-top: none;
border-bottom: 1px solid var(--color-border);
}
tbody tr.band-end th,
tbody tr.band-end td {
border-bottom: 2px solid var(--color-border);
}
}
// --- Case : pastilles de recette + bouton "+" -------------------------------
.meal-cell {
padding: var(--space-sm);
}
.meal-cell__content {
display: flex;
flex-direction: column;
gap: var(--space-xs);
height: 100%;
}
.meal-cell__recipes {
display: flex;
flex-direction: column;
gap: var(--space-xs);
}
.recipe-chip {
display: flex;
align-items: center;
gap: var(--space-xs);
width: 100%;
box-sizing: border-box;
padding: 0.3rem var(--space-sm);
border-radius: var(--radius-pill);
background: var(--color-tag);
color: var(--color-tag-ink);
font-size: var(--font-size-xs);
font-weight: 600;
&__name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&__remove {
flex-shrink: 0;
width: 1rem;
height: 1rem;
display: grid;
place-items: center;
border: none;
background: none;
cursor: pointer;
color: inherit;
opacity: 0;
font-size: 0.65rem;
border-radius: 50%;
}
&:hover &__remove {
opacity: 0.7;
}
&__remove:hover {
opacity: 1;
background: rgba(0, 0, 0, 0.15);
}
}
// Pleine largeur, sans bordure (juste un fond au survol plus épuré qu'un
// contour pointillé), et toujours collé en haut de la case (juste sous la
// dernière recette s'il y en a), jamais centré au milieu d'une case vide.
.add-recipe-btn {
width: 100%;
box-sizing: border-box;
padding: 0.35rem;
display: flex;
align-items: center;
justify-content: center;
border: none;
border-radius: var(--radius-base);
background: none;
color: var(--color-text-muted);
font-size: var(--font-size-base);
line-height: 1;
cursor: pointer;
&:hover {
color: var(--color-primary);
background: color-mix(in srgb, var(--color-primary) 8%, transparent);
}
}

View file

@ -66,7 +66,9 @@ export function HouseholdSettingsPage() {
return ( return (
<div className="settings-page"> <div className="settings-page">
<h1>{t("household.title")}</h1> <h1>{t("household.title")}</h1>
<p className="settings-page__status settings-page__status--error">{t("home.error")}</p> <p className="settings-page__status settings-page__status--error">
{t("common.loadError")}
</p>
</div> </div>
); );
} }

View file

@ -129,7 +129,9 @@ export function PreferencesPage() {
return ( return (
<div className="settings-page"> <div className="settings-page">
<h1>{t("preferences.title")}</h1> <h1>{t("preferences.title")}</h1>
<p className="settings-page__status settings-page__status--error">{t("home.error")}</p> <p className="settings-page__status settings-page__status--error">
{t("common.loadError")}
</p>
</div> </div>
); );
} }

View file

@ -0,0 +1,26 @@
{
"name": "@batch-cooking/date-tools",
"version": "0.0.0",
"private": true,
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"scripts": {
"test": "echo \"no tests yet\" && exit 0",
"build": "tsc -p tsconfig.json",
"postinstall": "tsc -p tsconfig.json"
},
"devDependencies": {
"@types/luxon": "^3.4.2",
"typescript": "^5.7.2"
},
"dependencies": {
"luxon": "^3.5.0"
}
}

View file

@ -0,0 +1,47 @@
import { DateTime } from "luxon";
// A "date-only" value here always means UTC midnight — Prisma's `@db.Date`
// columns (`Planning.startDate`/`finishDate`, see apps/api's schema.prisma)
// carry no time-of-day, so every comparison/computation on them needs to be
// anchored the same way to stay meaningful. `DateTime` (Luxon) is the
// in-memory representation everywhere in this package; plain `Date`/ISO
// `string` only ever appear at the two boundaries that require them —
// Prisma (`Date`) and URLs/query strings (`string`).
/**
* Parses a strict `YYYY-MM-DD` string into a UTC-midnight {@link DateTime}.
* Returns `null` for anything that isn't a real calendar date including a
* value that's merely shaped right but impossible (e.g. `2026-02-30`),
* unlike native `Date` which would silently roll it over to March 2nd.
*/
export function parseDateOnly(iso: string): DateTime | null {
const parsed = DateTime.fromISO(iso, { zone: "utc" });
return parsed.isValid ? parsed.startOf("day") : null;
}
/**
* Formats a {@link DateTime} back to `YYYY-MM-DD`, the inverse of
* {@link parseDateOnly}.
*
* @throws if `date` is an invalid `DateTime` every `DateTime` produced by
* this package's own functions is always valid, so this only fires if a
* caller constructs one by hand incorrectly.
*/
export function formatDateOnly(date: DateTime): string {
const iso = date.toISODate();
if (iso === null) {
throw new Error("Cannot format an invalid DateTime as a date-only string");
}
return iso;
}
/**
* Normalizes a `Date` (e.g. a value read back from Prisma) or a
* `DateTime` in any zone/with any time-of-day to a UTC-midnight
* {@link DateTime} the common representation every other function in
* this package expects and returns.
*/
export function toDateOnly(date: Date | DateTime): DateTime {
const dateTime = date instanceof DateTime ? date : DateTime.fromJSDate(date, { zone: "utc" });
return dateTime.toUTC().startOf("day");
}

View file

@ -0,0 +1,12 @@
// Public entry point of the date-handling utilities shared between apps/api
// and apps/web — every date computation in the monorepo (parsing/formatting
// `YYYY-MM-DD` values, week/calendar math) goes through Luxon `DateTime` via
// this package rather than hand-rolled `Date` arithmetic or a second,
// differently-behaved date library creeping into one side only.
export * from "./date-only.js";
export * from "./week.js";
// Re-exported so a consumer never needs its own direct `luxon` dependency
// just to type a `DateTime` value passed to/from this package's functions.
export { DateTime } from "luxon";

View file

@ -0,0 +1,40 @@
import type { DateTime } from "luxon";
/**
* The Monday of the week containing `date` (UTC midnight, same time-of-day
* handling as `date-only.ts`). Luxon's `startOf("week")` is Monday-first by
* default (ISO 8601 week numbering) regardless of locale, which already
* matches the French week this app uses no locale option needed.
*/
export function getWeekStart(date: DateTime): DateTime {
return date.startOf("week");
}
/** Shifts `date` by `n` weeks (negative to go back) — `date` need not already be a week start. */
export function addWeeks(date: DateTime, n: number): DateTime {
return date.plus({ weeks: n });
}
/**
* Builds a fixed 6×7 (weeks × days, Monday-first) calendar grid covering
* `month`, the same shape every month-picker UI in this app should use
* always 6 rows regardless of how many weeks the month actually spans, so
* the grid never resizes/reflows switching between months. Leading/trailing
* days from the adjacent month are included (a caller distinguishes them
* with `day.hasSame(month, "month")`), not omitted.
*/
export function buildCalendarMonth(month: DateTime): DateTime[][] {
const gridStart = getWeekStart(month.startOf("month"));
const weeks: DateTime[][] = [];
let cursor = gridStart;
for (let week = 0; week < 6; week++) {
const days: DateTime[] = [];
for (let day = 0; day < 7; day++) {
days.push(cursor);
cursor = cursor.plus({ days: 1 });
}
weeks.push(days);
}
return weeks;
}

View file

@ -0,0 +1,11 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"module": "NodeNext",
"moduleResolution": "NodeNext",
"declaration": true,
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"]
}

View file

@ -7,6 +7,7 @@ export * from "./errors/error-codes.js";
export * from "./schemas/account.js"; export * from "./schemas/account.js";
export * from "./schemas/auth.js"; export * from "./schemas/auth.js";
export * from "./schemas/household.js"; export * from "./schemas/household.js";
export * from "./schemas/planning.js";
export * from "./schemas/profile.js"; export * from "./schemas/profile.js";
export * from "./tools/assert-is-never.js"; export * from "./tools/assert-is-never.js";
export * from "./types/household.js"; export * from "./types/household.js";

View file

@ -0,0 +1,17 @@
import { z } from "zod";
// See schemas/auth.ts for the shared client/server validation rationale.
/**
* Payload accepted by `GET /planning`'s `?date=` query param. Only checks
* the `YYYY-MM-DD` *shape* whether it's a real calendar date (e.g.
* rejecting `2026-02-30`) is checked service-side via
* `@batch-cooking/date-tools`'s `parseDateOnly`, not here: `packages/shared`
* has no runtime dependencies of its own, and pulling in a date library just
* for this one check isn't worth losing that.
*/
export const getPlanningByDateSchema = z.object({
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Date invalide"),
});
/** Inferred TS type for {@link getPlanningByDateSchema}'s validated output. */
export type GetPlanningByDateInput = z.infer<typeof getPlanningByDateSchema>;

View file

@ -1,3 +1,32 @@
/**
* The 7 values `PlanningItemView.weekDay` is expected to take lowercase,
* unaccented French day names. Not enforced by the database (`week_day` is
* a plain `String` column, see schema.prisma) or by any write endpoint yet
* (there isn't one), but this is the contract the planning grid
* (`apps/web`'s `PlanningPage`) reads against, and the one a future
* "add a recipe to a slot" endpoint should write.
*/
export const WEEK_DAYS = [
"lundi",
"mardi",
"mercredi",
"jeudi",
"vendredi",
"samedi",
"dimanche",
] as const;
/** Inferred TS type for one {@link WEEK_DAYS} member. */
export type WeekDay = (typeof WEEK_DAYS)[number];
/**
* The 5 values `PlanningItemView.meal` is expected to take, in day order
* same "documented but not enforced yet" status as {@link WEEK_DAYS}, same
* reason.
*/
export const MEALS = ["petit-dejeuner", "collation", "dejeuner", "gouter", "diner"] as const;
/** Inferred TS type for one {@link MEALS} member. */
export type Meal = (typeof MEALS)[number];
/** /**
* A single meal slot within a household's planning, with its recipe * A single meal slot within a household's planning, with its recipe
* resolved to just enough info for display (id + name) a caller needing * resolved to just enough info for display (id + name) a caller needing
@ -5,9 +34,9 @@
*/ */
export interface PlanningItemView { export interface PlanningItemView {
id: number; id: number;
/** Day of the week this item falls on (free-form for now — no enum exists yet, see schema.prisma). */ /** Day of the week this item falls on — see {@link WeekDay} (free-form for now — no enum exists yet, see schema.prisma). */
weekDay: string; weekDay: string;
/** Which meal of the day this item is for (free-form for now, same reason). */ /** Which meal of the day this item is for — see {@link Meal} (free-form for now, same reason). */
meal: string; meal: string;
recipe: { recipe: {
id: number; id: number;

View file

@ -17,6 +17,9 @@ importers:
apps/api: apps/api:
dependencies: dependencies:
'@batch-cooking/date-tools':
specifier: workspace:*
version: link:../../packages/date-tools
'@batch-cooking/error-tools': '@batch-cooking/error-tools':
specifier: workspace:* specifier: workspace:*
version: link:../../packages/error-tools version: link:../../packages/error-tools
@ -87,6 +90,9 @@ importers:
apps/web: apps/web:
dependencies: dependencies:
'@batch-cooking/date-tools':
specifier: workspace:*
version: link:../../packages/date-tools
'@batch-cooking/shared': '@batch-cooking/shared':
specifier: workspace:* specifier: workspace:*
version: link:../../packages/shared version: link:../../packages/shared
@ -137,6 +143,19 @@ importers:
specifier: ^5.4.11 specifier: ^5.4.11
version: 5.4.21(@types/node@22.20.1)(sass@1.102.0) version: 5.4.21(@types/node@22.20.1)(sass@1.102.0)
packages/date-tools:
dependencies:
luxon:
specifier: ^3.5.0
version: 3.7.2
devDependencies:
'@types/luxon':
specifier: ^3.4.2
version: 3.7.4
typescript:
specifier: ^5.7.2
version: 5.9.3
packages/error-tools: packages/error-tools:
dependencies: dependencies:
'@batch-cooking/shared': '@batch-cooking/shared':
@ -1055,6 +1074,9 @@ packages:
'@types/jsonwebtoken@9.0.10': '@types/jsonwebtoken@9.0.10':
resolution: {integrity: sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==, tarball: https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz} resolution: {integrity: sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==, tarball: https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz}
'@types/luxon@3.7.4':
resolution: {integrity: sha512-V536ZAd6ZJztrrBlLcDFaaZrXNAL2E5uGmssWf/dpSiLkmkLScXUYhUBnWPmtW+cIqnNHzf6//TCMpIc9SCRRQ==, tarball: https://registry.npmjs.org/@types/luxon/-/luxon-3.7.4.tgz}
'@types/methods@1.1.4': '@types/methods@1.1.4':
resolution: {integrity: sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==, tarball: https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz} resolution: {integrity: sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==, tarball: https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz}
@ -3617,6 +3639,8 @@ snapshots:
'@types/ms': 2.1.0 '@types/ms': 2.1.0
'@types/node': 22.20.1 '@types/node': 22.20.1
'@types/luxon@3.7.4': {}
'@types/methods@1.1.4': {} '@types/methods@1.1.4': {}
'@types/mime@1.3.5': {} '@types/mime@1.3.5': {}