From b930453878fdbd946c427f8f7b713d5ce838a2e0 Mon Sep 17 00:00:00 2001 From: Nicolas Date: Mon, 17 Aug 2026 14:17:21 +0200 Subject: [PATCH 1/5] =?UTF-8?q?Nouveau=20package=20date-tools=20(Luxon)=20?= =?UTF-8?q?+=20contrat=20jours/repas=20partag=C3=A9=20(step=201/4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - packages/date-tools: parseDateOnly/formatDateOnly/toDateOnly, getWeekStart/addWeeks/buildCalendarMonth, sur Luxon DateTime (UTC) - packages/shared: WEEK_DAYS/WeekDay, MEALS/Meal (contrat de valeurs documenté pour PlanningItemView.weekDay/.meal, pas encore enforcé en base), getPlanningByDateSchema (validation de forme de ?date=) --- packages/date-tools/package.json | 26 ++++++++++++++ packages/date-tools/src/date-only.ts | 47 +++++++++++++++++++++++++ packages/date-tools/src/index.ts | 12 +++++++ packages/date-tools/src/week.ts | 40 +++++++++++++++++++++ packages/date-tools/tsconfig.json | 11 ++++++ packages/shared/src/index.ts | 1 + packages/shared/src/schemas/planning.ts | 17 +++++++++ packages/shared/src/types/planning.ts | 33 +++++++++++++++-- pnpm-lock.yaml | 24 +++++++++++++ 9 files changed, 209 insertions(+), 2 deletions(-) create mode 100644 packages/date-tools/package.json create mode 100644 packages/date-tools/src/date-only.ts create mode 100644 packages/date-tools/src/index.ts create mode 100644 packages/date-tools/src/week.ts create mode 100644 packages/date-tools/tsconfig.json create mode 100644 packages/shared/src/schemas/planning.ts diff --git a/packages/date-tools/package.json b/packages/date-tools/package.json new file mode 100644 index 0000000..9d3ac09 --- /dev/null +++ b/packages/date-tools/package.json @@ -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" + } +} diff --git a/packages/date-tools/src/date-only.ts b/packages/date-tools/src/date-only.ts new file mode 100644 index 0000000..05099b9 --- /dev/null +++ b/packages/date-tools/src/date-only.ts @@ -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"); +} diff --git a/packages/date-tools/src/index.ts b/packages/date-tools/src/index.ts new file mode 100644 index 0000000..be425aa --- /dev/null +++ b/packages/date-tools/src/index.ts @@ -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"; diff --git a/packages/date-tools/src/week.ts b/packages/date-tools/src/week.ts new file mode 100644 index 0000000..02690bb --- /dev/null +++ b/packages/date-tools/src/week.ts @@ -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; +} diff --git a/packages/date-tools/tsconfig.json b/packages/date-tools/tsconfig.json new file mode 100644 index 0000000..fb47b20 --- /dev/null +++ b/packages/date-tools/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "module": "NodeNext", + "moduleResolution": "NodeNext", + "declaration": true, + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"] +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 95aa7b9..3a7f5e4 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -7,6 +7,7 @@ export * from "./errors/error-codes.js"; export * from "./schemas/account.js"; export * from "./schemas/auth.js"; export * from "./schemas/household.js"; +export * from "./schemas/planning.js"; export * from "./schemas/profile.js"; export * from "./tools/assert-is-never.js"; export * from "./types/household.js"; diff --git a/packages/shared/src/schemas/planning.ts b/packages/shared/src/schemas/planning.ts new file mode 100644 index 0000000..31a28ec --- /dev/null +++ b/packages/shared/src/schemas/planning.ts @@ -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; diff --git a/packages/shared/src/types/planning.ts b/packages/shared/src/types/planning.ts index 74a7c99..271b75e 100644 --- a/packages/shared/src/types/planning.ts +++ b/packages/shared/src/types/planning.ts @@ -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 * resolved to just enough info for display (id + name) — a caller needing @@ -5,9 +34,9 @@ */ export interface PlanningItemView { 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; - /** 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; recipe: { id: number; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b2efbb3..a842ad5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,9 @@ importers: apps/api: dependencies: + '@batch-cooking/date-tools': + specifier: workspace:* + version: link:../../packages/date-tools '@batch-cooking/error-tools': specifier: workspace:* version: link:../../packages/error-tools @@ -87,6 +90,9 @@ importers: apps/web: dependencies: + '@batch-cooking/date-tools': + specifier: workspace:* + version: link:../../packages/date-tools '@batch-cooking/shared': specifier: workspace:* version: link:../../packages/shared @@ -137,6 +143,19 @@ importers: specifier: ^5.4.11 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: dependencies: '@batch-cooking/shared': @@ -1055,6 +1074,9 @@ packages: '@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} + '@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': 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/node': 22.20.1 + '@types/luxon@3.7.4': {} + '@types/methods@1.1.4': {} '@types/mime@1.3.5': {} From 9b2b2c2e28e651fbe444c34ba9240e482d4c6335 Mon Sep 17 00:00:00 2001 From: Nicolas Date: Mon, 17 Aug 2026 14:17:46 +0200 Subject: [PATCH 2/5] API: GET /planning?date= remplace GET /planning/current (step 2/4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - getPlanningForDate(houseId, date: DateTime) — paramétré au lieu de toujours "aujourd'hui", même logique de recherche sinon - GET /planning?date=YYYY-MM-DD, validation de forme (zod) puis de validité calendaire (parseDateOnly, 400 VALIDATION_ERROR sinon) — un seul endpoint générique au lieu de deux qui se recouvrent - Tests Mocha + Cucumber adaptés, + cas date manquante/malformée/ impossible et "semaine différente d'aujourd'hui" --- .../step-definitions/planning.steps.ts | 4 +- apps/api/package.json | 1 + .../src/modules/planning/planning.routes.ts | 29 ++++-- .../src/modules/planning/planning.service.ts | 39 ++++---- apps/api/test/planning.test.ts | 99 ++++++++++++++++--- 5 files changed, 133 insertions(+), 39 deletions(-) diff --git a/apps/api/features/step-definitions/planning.steps.ts b/apps/api/features/step-definitions/planning.steps.ts index 12c36eb..952660a 100644 --- a/apps/api/features/step-definitions/planning.steps.ts +++ b/apps/api/features/step-definitions/planning.steps.ts @@ -1,10 +1,12 @@ import assert from "node:assert/strict"; +import { DateTime } from "@batch-cooking/date-tools"; import { Given, Then, When } from "@cucumber/cucumber"; import { prisma } from "../../src/db/prisma.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) { - 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) { diff --git a/apps/api/package.json b/apps/api/package.json index ddb49cb..e8bdb61 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -18,6 +18,7 @@ "seed": "tsx prisma/seed.ts" }, "dependencies": { + "@batch-cooking/date-tools": "workspace:*", "@batch-cooking/error-tools": "workspace:*", "@batch-cooking/express-tools": "workspace:*", "@batch-cooking/shared": "workspace:*", diff --git a/apps/api/src/modules/planning/planning.routes.ts b/apps/api/src/modules/planning/planning.routes.ts index 278cf2e..39eb8e2 100644 --- a/apps/api/src/modules/planning/planning.routes.ts +++ b/apps/api/src/modules/planning/planning.routes.ts @@ -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 { ErrorCode, getPlanningByDateSchema } from "@batch-cooking/shared"; import { Router } from "express"; 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. */ export const planningRouter = Router(); /** - * Returns the authenticated user's household's planning for today, or - * `null` if none exists yet — a valid, common response, not an error (see - * {@link getCurrentPlanning}). + * Returns the authenticated user's household's planning covering `?date=` + * (`YYYY-MM-DD`), or `null` if none exists yet — a valid, common response, + * 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( - "/current", + "/", requireAuth, - wrapAsyncHandler(async (_req, res) => { - const planning = await getCurrentPlanning(res.locals.userProfile.houseId); + wrapAsyncHandler(async (req, res) => { + 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); }), ); diff --git a/apps/api/src/modules/planning/planning.service.ts b/apps/api/src/modules/planning/planning.service.ts index 67eda25..380645e 100644 --- a/apps/api/src/modules/planning/planning.service.ts +++ b/apps/api/src/modules/planning/planning.service.ts @@ -1,35 +1,40 @@ +import { type DateTime, toDateOnly } from "@batch-cooking/date-tools"; import type { PlanningView } from "@batch-cooking/shared"; import { prisma } from "../../db/prisma.js"; /** - * Finds the household's planning that covers today's date and shapes it - * into a {@link PlanningView} (recipes resolved to `{id, name}`). + * Finds the household's planning that covers `date` and shapes it into a + * {@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` - * of `null` (a profile always gets a house at signup today, but the column - * is nullable) and "no planning row covers today" (the expected case until - * planning creation is built) — neither is an error, so both collapse to - * the same "nothing to show yet" result rather than throwing. + * Returns `null` for two distinct, both entirely normal states — a + * `houseId` of `null` (the profile has no household yet — households are no + * longer created automatically at signup, see `auth.service.ts`) and "no + * planning row covers this date" (the expected case until planning + * 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 { +export async function getPlanningForDate( + houseId: number | null, + date: DateTime, +): Promise { if (houseId === null) { return null; } // `startDate`/`finishDate` are `@db.Date` columns (no time-of-day - // component) — compare against today's date at UTC midnight so the - // comparison lines up with how Postgres stores/returns them, regardless - // of the server's local timezone. - const today = new Date(); - const todayDateOnly = new Date( - Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate()), - ); + // component) — comparing against a UTC-midnight JS `Date` lines up with + // how Postgres stores/returns them, regardless of the server's local + // timezone. + const dateOnly = toDateOnly(date).toJSDate(); const planning = await prisma.planning.findFirst({ where: { houseId, - startDate: { lte: todayDateOnly }, - finishDate: { gte: todayDateOnly }, + startDate: { lte: dateOnly }, + finishDate: { gte: dateOnly }, }, // A household should never have two plannings covering the same day, // but nothing in the schema enforces that yet — pick the most recently diff --git a/apps/api/test/planning.test.ts b/apps/api/test/planning.test.ts index 71fffe0..2dd885d 100644 --- a/apps/api/test/planning.test.ts +++ b/apps/api/test/planning.test.ts @@ -1,3 +1,4 @@ +import { DateTime } from "@batch-cooking/date-tools"; import { ErrorCode, type SignupInput } from "@batch-cooking/shared"; import { faker } from "@faker-js/faker"; 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", () => { const app = createApp(); @@ -29,63 +42,93 @@ describe("Planning", () => { await prisma.$disconnect(); }); - describe("GET /planning/current", () => { + describe("GET /planning", () => { 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.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); 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.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); 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: "Ratatouille" } }); - const today = new Date(); + const now = new Date(); const planning = await prisma.planning.create({ data: { houseId, startDate: new Date( - Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate() - 2), + Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() - 2), ), 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({ - 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.body.id).to.equal(planning.id); 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" }); }); - 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); await agent.post("/auth/signup").send(buildSignupPayload()); const houseRes = await agent.post("/house").send({ name: "Chez moi" }); 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({ data: { 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.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); + }); }); }); From 07c385195753b64581403e3d9d9b8a866464d423 Mon Sep 17 00:00:00 2001 From: Nicolas Date: Mon, 17 Aug 2026 14:19:16 +0200 Subject: [PATCH 3/5] =?UTF-8?q?Web:=20PlanningPage=20=E2=80=94=20grille=20?= =?UTF-8?q?hebdomadaire=20+=20navigation=20semaine=20(step=203/4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remplace HomePage (table jour unique) par PlanningPage : grille 7 jours × 5 repas, groupés Matin/Midi/Après-midi/Soir (séparateurs pleins, plus épais entre groupes), recettes en pastilles pleine largeur, bouton "+" pleine largeur sans bordure (pas encore branché — pas de catalogue de recettes côté API, tâche future) - WeekNavigator + CalendarPopover (sur date-tools) : flèches semaine précédente/suivante, popover calendrier (mois navigable, clic sur un jour → sa semaine), fermeture au clic extérieur - apiClient.getPlanningForWeek(date) remplace getCurrentPlanning() - i18n: namespace home → planning (+ nouvelles clés jours/repas/ calendrier), common.loadError factorisé (repris par les pages Foyer/Préférences qui réutilisaient l'ancien home.error) --- apps/web/package.json | 1 + apps/web/src/App.tsx | 4 +- apps/web/src/api/client.ts | 10 +- apps/web/src/locales/fr/translation.json | 40 +- apps/web/src/pages/HomePage.scss | 56 --- apps/web/src/pages/HomePage.tsx | 81 ---- apps/web/src/pages/PlanningPage.tsx | 318 ++++++++++++++++ apps/web/src/pages/planning-page.scss | 356 ++++++++++++++++++ .../pages/settings/HouseholdSettingsPage.tsx | 4 +- .../src/pages/settings/PreferencesPage.tsx | 4 +- 10 files changed, 722 insertions(+), 152 deletions(-) delete mode 100644 apps/web/src/pages/HomePage.scss delete mode 100644 apps/web/src/pages/HomePage.tsx create mode 100644 apps/web/src/pages/PlanningPage.tsx create mode 100644 apps/web/src/pages/planning-page.scss diff --git a/apps/web/package.json b/apps/web/package.json index 890c1fc..cf78bf5 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -13,6 +13,7 @@ "e2e": "start-server-and-test dev http://localhost:5173 cy:run" }, "dependencies": { + "@batch-cooking/date-tools": "workspace:*", "@batch-cooking/shared": "workspace:*", "i18next": "^26.3.6", "react": "^18.3.1", diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index ec6d75f..4abe841 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -2,8 +2,8 @@ import { Navigate, Route, Routes } from "react-router-dom"; import { RedirectIfAuthenticated } from "./features/auth/RedirectIfAuthenticated"; import { RequireAuth } from "./features/auth/RequireAuth"; import { AppLayout } from "./layouts/AppLayout"; -import { HomePage } from "./pages/HomePage"; import { LoginPage } from "./pages/LoginPage"; +import { PlanningPage } from "./pages/PlanningPage"; import { RecipesPage } from "./pages/RecipesPage"; import { ShoppingListPage } from "./pages/ShoppingListPage"; import { SignupPage } from "./pages/SignupPage"; @@ -45,7 +45,7 @@ export function App() { } > - } /> + } /> } /> } /> } /> diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts index 58489ca..99c979c 100644 --- a/apps/web/src/api/client.ts +++ b/apps/web/src/api/client.ts @@ -103,9 +103,13 @@ export class ApiClient { 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 { - return this.request("/planning/current"); + /** + * Fetches the current user's household's planning covering `date` + * (`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 { + return this.request(`/planning?date=${date}`); } /** Reference list of dietary regimes to pick from (signup wizard, `/foyer`). Public — no session required. */ diff --git a/apps/web/src/locales/fr/translation.json b/apps/web/src/locales/fr/translation.json index 15545fe..35a1e30 100644 --- a/apps/web/src/locales/fr/translation.json +++ b/apps/web/src/locales/fr/translation.json @@ -1,7 +1,8 @@ { "common": { "saving": "Enregistrement…", - "saved": "Enregistré ✓" + "saved": "Enregistré ✓", + "loadError": "Impossible de charger le planning, réessayez plus tard" }, "errors": { "VALIDATION_ERROR": "Erreur de validation", @@ -77,15 +78,38 @@ "greeting": "Bonjour {{firstName}} 👋", "logout": "Se déconnecter" }, - "home": { + "planning": { "title": "Planning de la semaine", "loading": "Chargement du planning…", - "error": "Impossible de charger le planning, réessayez plus tard", - "empty": "Aucun planning pour cette semaine.", - "table": { - "day": "Jour", - "meal": "Repas", - "recipe": "Recette" + "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", + "dejeuner": "Déjeuner", + "gouter": "Goûter", + "diner": "Dîner" + }, + "grid": { + "addRecipeSoon": "Recherche de recettes à venir", + "removeRecipe": "Retirer cette recette" } }, "recipes": { diff --git a/apps/web/src/pages/HomePage.scss b/apps/web/src/pages/HomePage.scss deleted file mode 100644 index 656e51c..0000000 --- a/apps/web/src/pages/HomePage.scss +++ /dev/null @@ -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; - } -} diff --git a/apps/web/src/pages/HomePage.tsx b/apps/web/src/pages/HomePage.tsx deleted file mode 100644 index 4181fa6..0000000 --- a/apps/web/src/pages/HomePage.tsx +++ /dev/null @@ -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({ 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 ( -
-

{t("home.title")}

- - {state.status === "loading" &&

{t("home.loading")}

} - - {state.status === "error" && ( -

{t("home.error")}

- )} - - {state.status === "loaded" && state.planning === null && ( -

{t("home.empty")}

- )} - - {state.status === "loaded" && state.planning !== null && ( - - - - - - - - - - {state.planning.items.map((item) => ( - - - - - - ))} - -
{t("home.table.day")}{t("home.table.meal")}{t("home.table.recipe")}
{item.weekDay}{item.meal}{item.recipe.name}
- )} -
- ); -} diff --git a/apps/web/src/pages/PlanningPage.tsx b/apps/web/src/pages/PlanningPage.tsx new file mode 100644 index 0000000..749d862 --- /dev/null +++ b/apps/web/src/pages/PlanningPage.tsx @@ -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 = 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(() => getWeekStart(DateTime.utc())); + const [state, setState] = useState({ 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 ( +
+
+

{t("planning.title")}

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

{t("planning.loading")}

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

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

+ )} + + {state.status === "loaded" && ( + + )} +
+ ); +} + +/** "17 au 23 août 2026" — collapses the month/year to just the end date when both ends of the week share it, spells it out on both ends otherwise (e.g. a week straddling two months). */ +function formatWeekRange(weekStart: DateTime): string { + const weekEnd = weekStart.plus({ days: 6 }); + const sameMonth = weekStart.hasSame(weekEnd, "month"); + const startLabel = weekStart.toLocaleString( + sameMonth ? { day: "numeric" } : { day: "numeric", month: "long" }, + { locale: "fr" }, + ); + const endLabel = weekEnd.toLocaleString( + { day: "numeric", month: "long", year: "numeric" }, + { locale: "fr" }, + ); + return `${startLabel} au ${endLabel}`; +} + +/** Arrows + clickable label opening {@link CalendarPopover} — the week-selection UI at the top of the page. */ +function WeekNavigator({ + weekStart, + onChangeWeek, +}: { + weekStart: DateTime; + onChangeWeek: (weekStart: DateTime) => void; +}) { + const { t } = useTranslation(); + const [isCalendarOpen, setIsCalendarOpen] = useState(false); + const isThisWeek = weekStart.hasSame(getWeekStart(DateTime.utc()), "day"); + + return ( +
+ + + + + + + {isCalendarOpen && ( + { + onChangeWeek(getWeekStart(day)); + setIsCalendarOpen(false); + }} + onClose={() => setIsCalendarOpen(false)} + /> + )} +
+ ); +} + +/** Month calendar letting the visitor jump to any week at once — selecting a day selects its whole (Monday-first) week. Closes itself on an outside click. */ +function CalendarPopover({ + selectedWeekStart, + onSelectDay, + onClose, +}: { + selectedWeekStart: DateTime; + onSelectDay: (day: DateTime) => void; + onClose: () => void; +}) { + const { t } = useTranslation(); + // Its own state: browsing to a different month to pick a week there + // shouldn't jump back every render — only re-anchors when the popover is + // first opened (`selectedWeekStart` at that point), not while it's open. + const [visibleMonth, setVisibleMonth] = useState(() => selectedWeekStart.startOf("month")); + const popoverRef = useRef(null); + + useEffect(() => { + function handleClickOutside(e: MouseEvent) { + if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) { + onClose(); + } + } + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, [onClose]); + + const today = toDateOnly(DateTime.utc()); + const selectedWeekEnd = selectedWeekStart.plus({ days: 6 }); + const weeks = buildCalendarMonth(visibleMonth); + + return ( +
+
+ + + {visibleMonth.toLocaleString({ month: "long", year: "numeric" }, { locale: "fr" })} + + +
+ +
+ {WEEK_DAYS.map((weekDay) => ( + + {t(`planning.days.${weekDay}`).charAt(0)} + + ))} + + {weeks.flat().map((day) => { + const classNames = ["calendar-grid__day"]; + if (!day.hasSame(visibleMonth, "month")) classNames.push("calendar-grid__day--muted"); + if (day >= selectedWeekStart && day <= selectedWeekEnd) { + classNames.push("calendar-grid__day--in-selected-week"); + } + if (day.hasSame(today, "day")) classNames.push("calendar-grid__day--today"); + + return ( + + ); + })} +
+
+ ); +} + +/** The week grid itself — 7 day columns × 5 meal rows. */ +function PlanningGrid({ + weekStart, + 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 ( +
+ + + + + ))} + + + + {MEALS.map((meal) => ( + + + {days.map(({ weekDay, date }) => ( + item.weekDay === weekDay && item.meal === meal) + .map((item) => ({ id: item.id, name: item.recipe.name }))} + /> + ))} + + ))} + +
+ {days.map(({ weekDay, date }) => ( + + {t(`planning.days.${weekDay}`)} + {date.day} +
{t(`planning.meals.${meal}`)}
+
+ ); +} + +/** 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 ( + +
+ {recipes.length > 0 && ( +
+ {recipes.map((recipe) => ( + + {recipe.name} + + + ))} +
+ )} + +
+ + ); +} diff --git a/apps/web/src/pages/planning-page.scss b/apps/web/src/pages/planning-page.scss new file mode 100644 index 0000000..f6c1431 --- /dev/null +++ b/apps/web/src/pages/planning-page.scss @@ -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); + } +} diff --git a/apps/web/src/pages/settings/HouseholdSettingsPage.tsx b/apps/web/src/pages/settings/HouseholdSettingsPage.tsx index 313a10d..1e37399 100644 --- a/apps/web/src/pages/settings/HouseholdSettingsPage.tsx +++ b/apps/web/src/pages/settings/HouseholdSettingsPage.tsx @@ -66,7 +66,9 @@ export function HouseholdSettingsPage() { return (

{t("household.title")}

-

{t("home.error")}

+

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

); } diff --git a/apps/web/src/pages/settings/PreferencesPage.tsx b/apps/web/src/pages/settings/PreferencesPage.tsx index 75f0249..6b2ef3f 100644 --- a/apps/web/src/pages/settings/PreferencesPage.tsx +++ b/apps/web/src/pages/settings/PreferencesPage.tsx @@ -129,7 +129,9 @@ export function PreferencesPage() { return (

{t("preferences.title")}

-

{t("home.error")}

+

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

); } From 9d4de338e386a7353890edf7600c5139db56c50c Mon Sep 17 00:00:00 2001 From: Nicolas Date: Mon, 17 Aug 2026 14:19:39 +0200 Subject: [PATCH 4/5] Tests: couverture Cypress pour la grille de planning (step 4/4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - home-planning.cy.ts → planning-page.cy.ts : nav sidebar reprise telle quelle, nouveaux cas pour la grille (case vide, recettes placées dans les bonnes cases, colonne du jour courant, navigation semaine précédente/suivante, popover calendrier) - cy.clock fige "aujourd'hui" (2026-08-17, un lundi) pour des assertions de date déterministes ; cy.viewport élargi (desktop-only, cf. décision produit) pour que les 7 colonnes tiennent sans scroll horizontal lors des assertions de visibilité --- apps/web/cypress/e2e/home-planning.cy.ts | 104 --------------- apps/web/cypress/e2e/planning-page.cy.ts | 163 +++++++++++++++++++++++ 2 files changed, 163 insertions(+), 104 deletions(-) delete mode 100644 apps/web/cypress/e2e/home-planning.cy.ts create mode 100644 apps/web/cypress/e2e/planning-page.cy.ts diff --git a/apps/web/cypress/e2e/home-planning.cy.ts b/apps/web/cypress/e2e/home-planning.cy.ts deleted file mode 100644 index 01e1d02..0000000 --- a/apps/web/cypress/e2e/home-planning.cy.ts +++ /dev/null @@ -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"); - }); -}); diff --git a/apps/web/cypress/e2e/planning-page.cy.ts b/apps/web/cypress/e2e/planning-page.cy.ts new file mode 100644 index 0000000..ab13c85 --- /dev/null +++ b/apps/web/cypress/e2e/planning-page.cy.ts @@ -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 24th–30th. + 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"); + }); +}); From db427a3f30fa26e06028eb9038620f107fa9d6df Mon Sep 17 00:00:00 2001 From: Nicolas Date: Mon, 17 Aug 2026 14:25:13 +0200 Subject: [PATCH 5/5] =?UTF-8?q?Ignore=20tmp-mockups/=20=E2=80=94=20maquett?= =?UTF-8?q?es=20HTML=20jetables=20utilis=C3=A9es=20pour=20la=20review=20de?= =?UTF-8?q?=20design?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index c2e1081..df6e0ba 100644 --- a/.gitignore +++ b/.gitignore @@ -144,3 +144,6 @@ vite.config.ts.timestamp-* # Project docs not meant to be committed Projet batch cooking.pdf + +# Throwaway HTML mockups used to review a design before implementing it +tmp-mockups/